Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 11 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# 🗃️ `docs-cache`

Deterministic local caching of external documentation for agents and tools
Deterministic local caching of external documentation for agents and developers

[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![npm version](https://img.shields.io/npm/v/docs-cache)](https://www.npmjs.com/package/docs-cache)
[![Audit](https://github.com/fbosch/docs-cache/actions/workflows/audit.yml/badge.svg)](https://github.com/fbosch/docs-cache/actions/workflows/audit.yml)

## Purpose

Provides agents and automation tools with local access to external documentation without committing it to the repository.
Provides agents and developers with local access to external documentation without committing it to the repository.

Documentation is cached in a gitignored location, exposed to agent and tool targets via links or copies, and updated through sync commands or postinstall hooks.

## Features

- **Local only**: Cache lives in the directory `.docs` (or a custom location) and _should_ be gitignored.
- **Local only**: Cache lives in the directory `.docs` (or a custom location) and can be gitignored.
- **Deterministic**: `docs-lock.json` pins commits and file metadata.
- **Fast**: Local cache avoids network roundtrips after sync.
- **Flexible**: Cache full repos or just the subdirectories you need.
Expand Down Expand Up @@ -78,21 +78,22 @@ npx docs-cache clean
| Field | Details | Required |
| ---------- | -------------------------------------- | -------- |
| `cacheDir` | Directory for cache. Default: `.docs`. | Optional |
| `sources` | List of repositories to sync. | Required |
| `defaults` | Default settings for all sources. | Optional |
| `sources` | List of repositories to sync. | Required |

<details>
<summary>Show default and source options</summary>

### Default options

All fields in `defaults` apply to all sources unless overridden per-source.
These fields can be set in `defaults` and are inherited by every source unless overridden per-source.

| Field | Details |
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `ref` | Branch, tag, or commit. Default: `"HEAD"`. |
| `mode` | Cache mode. Default: `"materialize"`. |
| `include` | Glob patterns to copy. Default: `["**/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc}"]`. |
| `exclude` | Glob patterns to skip. Default: `[]`. |
| `targetMode` | How to link or copy from the cache to the destination. Default: `"symlink"` on Unix, `"copy"` on Windows. |
| `required` | Whether missing sources should fail. Default: `true`. |
| `maxBytes` | Maximum total bytes to materialize. Default: `200000000` (200 MB). |
Expand All @@ -110,20 +111,11 @@ All fields in `defaults` apply to all sources unless overridden per-source.
| `repo` | Git URL. |
| `id` | Unique identifier for the source. |

#### Optional

| Field | Details |
| --------------------- | ----------------------------------------------------------------------------------------------- |
| `ref` | Branch, tag, or commit. |
| `include` | Glob patterns to copy. |
| `exclude` | Glob patterns to skip. |
| `targetDir` | Path where files should be symlinked/copied to, outside `.docs`. |
| `targetMode` | How to link or copy from the cache to the destination. |
| `required` | Whether missing sources should fail. |
| `maxBytes` | Maximum total bytes to materialize. |
| `maxFiles` | Maximum total files to materialize. |
| `toc` | Generate per-source `TOC.md`. Supports `true`, `false`, or a format (`"tree"`, `"compressed"`). |
| `unwrapSingleRootDir` | If the materialized output is nested under a single directory, unwrap it (recursively). |
#### Optional (source-only)

| Field | Details |
| ----------- | ---------------------------------------------------------------- |
| `targetDir` | Path where files should be symlinked/copied to, outside `.docs`. |

> **Note**: Sources are always downloaded to `.docs/<id>/`. If you provide a `targetDir`, `docs-cache` will create a symlink or copy pointing from the cache to that target directory. The target should be outside `.docs`. Git operation timeout is configured via the `--timeout-ms` CLI flag, not as a per-source configuration option.

Expand Down
19 changes: 9 additions & 10 deletions docs.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
"minLength": 1
}
},
"exclude": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"targetMode": {
"type": "string",
"enum": ["symlink", "copy"]
Expand All @@ -56,9 +63,6 @@
"minLength": 1
}
},
"unwrapSingleRootDir": {
"type": "boolean"
},
"toc": {
"anyOf": [
{
Expand All @@ -70,9 +74,8 @@
}
]
},
"tocFormat": {
"type": "string",
"enum": ["tree", "compressed"]
"unwrapSingleRootDir": {
"type": "boolean"
}
},
"additionalProperties": false
Expand Down Expand Up @@ -163,10 +166,6 @@
}
]
},
"tocFormat": {
"type": "string",
"enum": ["tree", "compressed"]
},
"unwrapSingleRootDir": {
"type": "boolean"
}
Expand Down
1 change: 1 addition & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const DefaultsSchema = z
ref: z.string().min(1),
mode: CacheModeSchema,
include: z.array(z.string().min(1)).min(1),
exclude: z.array(z.string().min(1)).optional(),
targetMode: TargetModeSchema.optional(),
required: z.boolean(),
maxBytes: z.number().min(1),
Expand Down
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface DocsCacheDefaults {
ref: string;
mode: CacheMode;
include: string[];
exclude?: string[];
targetMode?: "symlink" | "copy";
required: boolean;
maxBytes: number;
Expand Down Expand Up @@ -80,6 +81,7 @@ export const DEFAULT_CONFIG: DocsCacheConfig = {
ref: "HEAD",
mode: "materialize",
include: ["**/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc}"],
exclude: [],
targetMode: DEFAULT_TARGET_MODE,
required: true,
maxBytes: 200000000,
Expand Down Expand Up @@ -277,6 +279,10 @@ export const validateConfig = (input: unknown): DocsCacheConfig => {
defaultsInput.include !== undefined
? assertStringArray(defaultsInput.include, "defaults.include")
: defaultValues.include,
exclude:
defaultsInput.exclude !== undefined
? assertStringArray(defaultsInput.exclude, "defaults.exclude")
: defaultValues.exclude,
Comment thread
fbosch marked this conversation as resolved.
targetMode:
defaultsInput.targetMode !== undefined
? assertTargetMode(defaultsInput.targetMode, "defaults.targetMode")
Expand Down Expand Up @@ -434,7 +440,7 @@ export const resolveSources = (
ref: source.ref ?? defaults.ref,
mode: source.mode ?? defaults.mode,
include: source.include ?? defaults.include,
exclude: source.exclude,
exclude: source.exclude ?? defaults.exclude,
required: source.required ?? defaults.required,
maxBytes: source.maxBytes ?? defaults.maxBytes,
maxFiles: source.maxFiles ?? defaults.maxFiles,
Expand Down
2 changes: 1 addition & 1 deletion src/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const getSyncPlan = async (
filteredSources.map(async (source) => {
const lockEntry = lockData?.sources?.[source.id];
const include = source.include ?? defaults.include;
const exclude = source.exclude;
const exclude = source.exclude ?? defaults.exclude;
const rulesSha256 = computeRulesHash({
...source,
include,
Expand Down
14 changes: 14 additions & 0 deletions tests/edge-cases-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ test("defaults with all fields specified", async () => {
ref: "main",
mode: "materialize",
include: ["**/*.md"],
exclude: ["**/.cache/**"],
targetMode: "copy",
required: false,
maxBytes: 1000000,
Expand All @@ -411,5 +412,18 @@ test("defaults with all fields specified", async () => {
assert.equal(config.defaults.maxBytes, 1000000);
assert.equal(config.defaults.maxFiles, 100);
assert.deepEqual(config.defaults.allowHosts, ["github.com"]);
assert.deepEqual(config.defaults.exclude, ["**/.cache/**"]);
assert.equal(config.defaults.toc, true);
});

test("defaults exclude applies to sources", async () => {
const configPath = await writeConfig({
defaults: {
exclude: ["**/.cache/**"],
},
sources: [{ id: "test", repo: "https://github.com/example/repo.git" }],
});

const { sources } = await loadConfig(configPath);
assert.deepEqual(sources[0].exclude, ["**/.cache/**"]);
});
65 changes: 65 additions & 0 deletions tests/sync-include-exclude.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,71 @@ test("materialize respects include/exclude patterns", async () => {
assert.equal(await exists(path.join(docsRoot, "notes.txt")), false);
});

test("defaults exclude applies when source excludes are unset", async () => {
const tmpRoot = path.join(
tmpdir(),
`docs-cache-include-defaults-${Date.now().toString(36)}`,
);
const cacheDir = path.join(tmpRoot, ".docs");
const repoDir = path.join(tmpRoot, "repo");
const configPath = path.join(tmpRoot, "docs.config.json");

await mkdir(path.join(repoDir, "docs", ".cache"), { recursive: true });
await writeFile(path.join(repoDir, "README.md"), "readme", "utf8");
await writeFile(path.join(repoDir, "docs", "guide.md"), "guide", "utf8");
await writeFile(
path.join(repoDir, "docs", ".cache", "skip.md"),
"skip",
"utf8",
);

const config = {
$schema:
"https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json",
defaults: {
include: ["docs/**", "README.md"],
exclude: ["docs/**/.cache/**"],
},
sources: [
{
id: "local",
repo: "https://example.com/repo.git",
},
],
};
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");

await runSync(
{
configPath,
cacheDirOverride: cacheDir,
json: false,
lockOnly: false,
offline: false,
failOnMiss: false,
},
{
resolveRemoteCommit: async () => ({
repo: "https://example.com/repo.git",
ref: "HEAD",
resolvedCommit: "abc123",
}),
fetchSource: async () => ({
repoDir,
cleanup: async () => undefined,
}),
},
);

const docsRoot = path.join(cacheDir, "local");
assert.equal(await exists(path.join(docsRoot, "README.md")), true);
assert.equal(await exists(path.join(docsRoot, "docs", "guide.md")), true);
assert.equal(
await exists(path.join(docsRoot, "docs", ".cache", "skip.md")),
false,
);
});

test("exclude overrides include on overlap", async () => {
const tmpRoot = path.join(
tmpdir(),
Expand Down
Loading