From 2f9f7f457ebd0ecb4d4aafb94108b42f45d2b401 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:22:04 +0000 Subject: [PATCH 01/11] Initial plan From 1523d2eb7413b0a863c425d6408c43ff066109cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:28:50 +0000 Subject: [PATCH 02/11] Replace index feature with TOC feature Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- docs.config.schema.json | 5 +- src/add.ts | 4 +- src/config-schema.ts | 3 +- src/config.ts | 22 ++++-- src/init.ts | 18 ++--- src/paths.ts | 6 +- src/remove.ts | 4 +- src/sync.ts | 11 ++- src/toc.ts | 135 ++++++++++++++++++++++++++++++++++ tests/sync-index.test.js | 82 --------------------- tests/sync-toc.test.js | 153 +++++++++++++++++++++++++++++++++++++++ 11 files changed, 332 insertions(+), 111 deletions(-) create mode 100644 src/toc.ts delete mode 100644 tests/sync-index.test.js create mode 100644 tests/sync-toc.test.js diff --git a/docs.config.schema.json b/docs.config.schema.json index ab16933..4a70415 100644 --- a/docs.config.schema.json +++ b/docs.config.schema.json @@ -14,7 +14,7 @@ "type": "string", "enum": ["symlink", "copy"] }, - "index": { + "toc": { "type": "boolean" }, "defaults": { @@ -144,6 +144,9 @@ }, "required": ["type", "value"], "additionalProperties": false + }, + "toc": { + "type": "boolean" } }, "required": ["id", "repo"], diff --git a/src/add.ts b/src/add.ts index 5aac97b..e47ab2a 100644 --- a/src/add.ts +++ b/src/add.ts @@ -128,8 +128,8 @@ export const addSources = async (params: { if (rawConfig?.cacheDir) { nextConfig.cacheDir = rawConfig.cacheDir; } - if (rawConfig?.index !== undefined) { - nextConfig.index = rawConfig.index; + if (rawConfig?.toc !== undefined) { + nextConfig.toc = rawConfig.toc; } if (rawConfig?.defaults) { nextConfig.defaults = rawConfig.defaults; diff --git a/src/config-schema.ts b/src/config-schema.ts index 48f9fd6..61b72e7 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -38,6 +38,7 @@ export const SourceSchema = z maxBytes: z.number().min(1).optional(), maxFiles: z.number().min(1).optional(), integrity: IntegritySchema.optional(), + toc: z.boolean().optional(), }) .strict(); @@ -46,7 +47,7 @@ export const ConfigSchema = z $schema: z.string().min(1).optional(), cacheDir: z.string().min(1).optional(), targetMode: TargetModeSchema.optional(), - index: z.boolean().optional(), + toc: z.boolean().optional(), defaults: DefaultsSchema.partial().optional(), sources: z.array(SourceSchema), }) diff --git a/src/config.ts b/src/config.ts index eaa1e9a..7f594ba 100644 --- a/src/config.ts +++ b/src/config.ts @@ -39,13 +39,14 @@ export interface DocsCacheSource { maxBytes?: number; maxFiles?: number; integrity?: DocsCacheIntegrity; + toc?: boolean; } export interface DocsCacheConfig { $schema?: string; cacheDir?: string; targetMode?: "symlink" | "copy"; - index?: boolean; + toc?: boolean; defaults?: Partial; sources: DocsCacheSource[]; } @@ -64,6 +65,7 @@ export interface DocsCacheResolvedSource { maxBytes: number; maxFiles?: number; integrity?: DocsCacheIntegrity; + toc?: boolean; } export const DEFAULT_CONFIG_FILENAME = "docs.config.json"; @@ -72,7 +74,7 @@ const PACKAGE_JSON_FILENAME = "package.json"; const DEFAULT_TARGET_MODE = process.platform === "win32" ? "copy" : "symlink"; export const DEFAULT_CONFIG: DocsCacheConfig = { cacheDir: DEFAULT_CACHE_DIR, - index: false, + toc: false, defaults: { ref: "HEAD", mode: "materialize", @@ -144,7 +146,7 @@ export const stripDefaultConfigValues = ( const next: DocsCacheConfig = { $schema: pruned.$schema as DocsCacheConfig["$schema"], cacheDir: pruned.cacheDir as DocsCacheConfig["cacheDir"], - index: pruned.index as DocsCacheConfig["index"], + toc: pruned.toc as DocsCacheConfig["toc"], targetMode: pruned.targetMode as DocsCacheConfig["targetMode"], defaults: pruned.defaults as DocsCacheConfig["defaults"], sources: config.sources, @@ -247,10 +249,10 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { const cacheDir = input.cacheDir ? assertString(input.cacheDir, "cacheDir") : DEFAULT_CACHE_DIR; - const index = - input.index !== undefined - ? assertBoolean(input.index, "index") - : (DEFAULT_CONFIG.index ?? false); + const toc = + input.toc !== undefined + ? assertBoolean(input.toc, "toc") + : (DEFAULT_CONFIG.toc ?? false); const defaultsInput = input.defaults; const targetModeOverride = @@ -386,6 +388,9 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { `sources[${index}].integrity`, ); } + if (entry.toc !== undefined) { + source.toc = assertBoolean(entry.toc, `sources[${index}].toc`); + } return source; }); @@ -407,7 +412,7 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { return { cacheDir, targetMode: targetModeOverride, - index, + toc, defaults, sources, }; @@ -432,6 +437,7 @@ export const resolveSources = ( maxBytes: source.maxBytes ?? defaults.maxBytes, maxFiles: source.maxFiles ?? defaults.maxFiles, integrity: source.integrity, + toc: source.toc, })); }; diff --git a/src/init.ts b/src/init.ts index 887ec74..ad06ffb 100644 --- a/src/init.ts +++ b/src/init.ts @@ -93,12 +93,12 @@ export const initConfig = async ( throw new Error("Init cancelled."); } const cacheDirValue = cacheDirAnswer || DEFAULT_CACHE_DIR; - const indexAnswer = await confirm({ + const tocAnswer = await confirm({ message: - "Generate index.json (summary of cached sources + paths for tools)", + "Generate TOC.md (table of contents with links to all documentation)", initialValue: false, }); - if (isCancel(indexAnswer)) { + if (isCancel(tocAnswer)) { throw new Error("Init cancelled."); } const gitignoreStatus = await getGitignoreStatus(cwd, cacheDirValue); @@ -117,12 +117,12 @@ export const initConfig = async ( const answers = { configPath, cacheDir: cacheDirAnswer, - index: indexAnswer, + toc: tocAnswer, gitignore: gitignoreAnswer, } as { configPath: string; cacheDir: string; - index: boolean; + toc: boolean; gitignore: boolean; }; @@ -144,8 +144,8 @@ export const initConfig = async ( if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { baseConfig.cacheDir = resolvedCacheDir; } - if (answers.index) { - baseConfig.index = true; + if (answers.toc) { + baseConfig.toc = true; } pkg["docs-cache"] = stripDefaultConfigValues(baseConfig); await writeFile( @@ -178,8 +178,8 @@ export const initConfig = async ( if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { config.cacheDir = resolvedCacheDir; } - if (answers.index) { - config.index = true; + if (answers.toc) { + config.toc = true; } await writeConfig(resolvedConfigPath, config); diff --git a/src/paths.ts b/src/paths.ts index d712998..aabbd3d 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,7 +1,7 @@ import path from "node:path"; export const DEFAULT_LOCK_FILENAME = "docs.lock"; -export const DEFAULT_INDEX_FILENAME = "index.json"; +export const DEFAULT_TOC_FILENAME = "TOC.md"; export const toPosixPath = (value: string) => value.replace(/\\/g, "/"); @@ -40,10 +40,10 @@ export const resolveCacheDir = ( export const getCacheLayout = (cacheDir: string, sourceId: string) => { const _reposDir = path.join(cacheDir, "repos"); const sourceDir = path.join(cacheDir, sourceId); - const indexPath = path.join(cacheDir, DEFAULT_INDEX_FILENAME); + const tocPath = path.join(cacheDir, DEFAULT_TOC_FILENAME); return { cacheDir, sourceDir, - indexPath, + tocPath, }; }; diff --git a/src/remove.ts b/src/remove.ts index 4197152..aae6767 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -137,8 +137,8 @@ export const removeSources = async (params: { if (rawConfig?.cacheDir) { nextConfig.cacheDir = rawConfig.cacheDir; } - if (rawConfig?.index !== undefined) { - nextConfig.index = rawConfig.index; + if (rawConfig?.toc !== undefined) { + nextConfig.toc = rawConfig.toc; } if (rawConfig?.defaults) { nextConfig.defaults = rawConfig.defaults; diff --git a/src/sync.ts b/src/sync.ts index f557066..fc05ae3 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -11,12 +11,12 @@ import { } from "./config"; import { fetchSource } from "./git/fetch-source"; import { resolveRemoteCommit } from "./git/resolve-remote"; -import { writeIndex } from "./index"; import { readLock, resolveLockPath, writeLock } from "./lock"; import { MANIFEST_FILENAME } from "./manifest"; import { computeManifestHash, materializeSource } from "./materialize"; import { resolveCacheDir, resolveTargetDir } from "./paths"; import { applyTargetDir } from "./targets"; +import { writeToc } from "./toc"; import { verifyCache } from "./verify"; type SyncOptions = { @@ -482,12 +482,17 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { `${symbols.info} Completed in ${elapsedMs.toFixed(0)}ms · ${formatBytes(totalBytes)} · ${totalFiles} files${warningCount ? ` · ${warningCount} warning${warningCount === 1 ? "" : "s"}` : ""}`, ); } - if (plan.config.index) { - await writeIndex({ + // Check if any source has TOC enabled or global TOC is enabled + const anySourceHasToc = plan.sources.some((source) => source.toc === true); + const shouldWriteToc = plan.config.toc || anySourceHasToc; + + if (shouldWriteToc) { + await writeToc({ cacheDir: plan.cacheDir, configPath: plan.configPath, lock, sources: plan.sources, + globalToc: plan.config.toc ?? false, }); } plan.lockExists = true; diff --git a/src/toc.ts b/src/toc.ts new file mode 100644 index 0000000..e6ac307 --- /dev/null +++ b/src/toc.ts @@ -0,0 +1,135 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { DocsCacheResolvedSource } from "./config"; +import type { DocsCacheLock } from "./lock"; +import { resolveTargetDir, toPosixPath } from "./paths"; + +export const DEFAULT_TOC_FILENAME = "TOC.md"; + +type TocEntry = { + id: string; + repo: string; + ref: string; + resolvedCommit: string; + fileCount: number; + cachePath: string; + targetDir?: string; + files: string[]; +}; + +const generateGlobalToc = (entries: TocEntry[], cacheDir: string): string => { + const lines: string[] = []; + lines.push("# Documentation Cache - Table of Contents"); + lines.push(""); + lines.push(`Generated: ${new Date().toISOString()}`); + lines.push(""); + lines.push("## Cached Sources"); + lines.push(""); + + for (const entry of entries) { + lines.push(`### ${entry.id}`); + lines.push(""); + lines.push(`- **Repository**: ${entry.repo}`); + lines.push(`- **Ref**: ${entry.ref}`); + lines.push(`- **Commit**: ${entry.resolvedCommit}`); + lines.push(`- **Files**: ${entry.fileCount}`); + lines.push(`- **Cache Path**: ${entry.cachePath}`); + if (entry.targetDir) { + lines.push(`- **Target Directory**: ${entry.targetDir}`); + } + lines.push(`- **Source TOC**: [${entry.id}/TOC.md](${entry.id}/TOC.md)`); + lines.push(""); + } + + return lines.join("\n"); +}; + +const generateSourceToc = (entry: TocEntry): string => { + const lines: string[] = []; + lines.push(`# ${entry.id} - Documentation`); + lines.push(""); + lines.push(`- **Repository**: ${entry.repo}`); + lines.push(`- **Ref**: ${entry.ref}`); + lines.push(`- **Commit**: ${entry.resolvedCommit}`); + lines.push(""); + lines.push("## Files"); + lines.push(""); + + for (const file of entry.files.sort()) { + lines.push(`- [${file}](./${file})`); + } + lines.push(""); + + return lines.join("\n"); +}; + +const readManifest = async (sourceDir: string): Promise => { + const manifestPath = path.join(sourceDir, ".manifest.jsonl"); + try { + const raw = await readFile(manifestPath, "utf8"); + const files: string[] = []; + for (const line of raw.split("\n")) { + if (line.trim()) { + const entry = JSON.parse(line); + if (entry.path) { + files.push(entry.path); + } + } + } + return files; + } catch { + return []; + } +}; + +export const writeToc = async (params: { + cacheDir: string; + configPath: string; + lock: DocsCacheLock; + sources: DocsCacheResolvedSource[]; + globalToc: boolean; +}) => { + const sourcesById = new Map( + params.sources.map((source) => [source.id, source]), + ); + + const entries: TocEntry[] = []; + + for (const [id, lockEntry] of Object.entries(params.lock.sources)) { + const source = sourcesById.get(id); + const targetDir = source?.targetDir + ? toPosixPath(resolveTargetDir(params.configPath, source.targetDir)) + : undefined; + + const sourceDir = path.join(params.cacheDir, id); + const files = await readManifest(sourceDir); + + const entry: TocEntry = { + id, + repo: lockEntry.repo, + ref: lockEntry.ref, + resolvedCommit: lockEntry.resolvedCommit, + fileCount: lockEntry.fileCount, + cachePath: toPosixPath(path.join(params.cacheDir, id)), + targetDir, + files, + }; + + entries.push(entry); + + // Generate per-source TOC if the source has toc enabled or if global toc is enabled + const sourceToc = source?.toc ?? false; + if (sourceToc || params.globalToc) { + const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); + const sourceTocContent = generateSourceToc(entry); + await writeFile(sourceTocPath, sourceTocContent, "utf8"); + } + } + + // Generate global TOC if enabled + if (params.globalToc) { + const globalTocPath = path.join(params.cacheDir, DEFAULT_TOC_FILENAME); + const globalTocContent = generateGlobalToc(entries, params.cacheDir); + await writeFile(globalTocPath, globalTocContent, "utf8"); + } +}; diff --git a/tests/sync-index.test.js b/tests/sync-index.test.js deleted file mode 100644 index 6a97115..0000000 --- a/tests/sync-index.test.js +++ /dev/null @@ -1,82 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { test } from "node:test"; - -import { runSync } from "../dist/api.mjs"; - -const toPosix = (value) => value.split(path.sep).join("/"); - -test("sync writes index.json when enabled", async () => { - const tmpRoot = path.join( - tmpdir(), - `docs-cache-index-${Date.now().toString(36)}`, - ); - await mkdir(tmpRoot, { recursive: true }); - const cacheDir = path.join(tmpRoot, ".docs"); - const repoDir = path.join(tmpRoot, "repo"); - const configPath = path.join(tmpRoot, "docs.config.json"); - - await mkdir(repoDir, { recursive: true }); - await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); - - const config = { - $schema: - "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", - index: true, - sources: [ - { - id: "local", - repo: "https://example.com/repo.git", - targetDir: "./target-dir", - }, - ], - }; - 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, - }), - materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { - const outDir = path.join(cacheRoot, sourceId); - await mkdir(outDir, { recursive: true }); - await writeFile( - path.join(outDir, ".manifest.jsonl"), - `${JSON.stringify({ path: "README.md", size: 5 })}\n`, - ); - await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); - return { bytes: 5, fileCount: 1 }; - }, - }, - ); - - const indexPath = path.join(cacheDir, "index.json"); - const raw = await readFile(indexPath, "utf8"); - const index = JSON.parse(raw); - const entry = index.sources.local; - - assert.equal(entry.resolvedCommit, "abc123"); - assert.equal(entry.fileCount, 1); - assert.equal(entry.cachePath, toPosix(path.join(cacheDir, "local"))); - assert.equal( - entry.targetDir, - toPosix(path.resolve(path.dirname(configPath), "target-dir")), - ); -}); diff --git a/tests/sync-toc.test.js b/tests/sync-toc.test.js new file mode 100644 index 0000000..0bfca39 --- /dev/null +++ b/tests/sync-toc.test.js @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { runSync } from "../dist/api.mjs"; + +const toPosix = (value) => value.split(path.sep).join("/"); + +test("sync writes TOC.md when toc is enabled", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-toc-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + toc: true, + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + targetDir: "./target-dir", + }, + ], + }; + 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, + }), + materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { + const outDir = path.join(cacheRoot, sourceId); + await mkdir(outDir, { recursive: true }); + await writeFile( + path.join(outDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + ); + await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); + return { bytes: 5, fileCount: 1 }; + }, + }, + ); + + // Check global TOC exists + const globalTocPath = path.join(cacheDir, "TOC.md"); + const globalToc = await readFile(globalTocPath, "utf8"); + assert.ok(globalToc.includes("# Documentation Cache - Table of Contents")); + assert.ok(globalToc.includes("### local")); + assert.ok(globalToc.includes("**Repository**: https://example.com/repo.git")); + assert.ok(globalToc.includes("**Commit**: abc123")); + assert.ok(globalToc.includes("**Files**: 1")); + + // Check per-source TOC exists + const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); + const sourceToc = await readFile(sourceTocPath, "utf8"); + assert.ok(sourceToc.includes("# local - Documentation")); + assert.ok(sourceToc.includes("**Repository**: https://example.com/repo.git")); + assert.ok(sourceToc.includes("- [README.md](./README.md)")); +}); + +test("sync writes per-source TOC when source.toc is enabled", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-source-toc-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + await writeFile(path.join(repoDir, "guide.md"), "guide content", "utf8"); + + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + toc: true, + }, + ], + }; + 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, + }), + materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { + const outDir = path.join(cacheRoot, sourceId); + await mkdir(outDir, { recursive: true }); + await writeFile( + path.join(outDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n${JSON.stringify({ path: "guide.md", size: 13 })}\n`, + ); + await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); + await writeFile(path.join(outDir, "guide.md"), "guide content", "utf8"); + return { bytes: 18, fileCount: 2 }; + }, + }, + ); + + // Check per-source TOC exists + const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); + const sourceToc = await readFile(sourceTocPath, "utf8"); + assert.ok(sourceToc.includes("# local - Documentation")); + assert.ok(sourceToc.includes("- [README.md](./README.md)")); + assert.ok(sourceToc.includes("- [guide.md](./guide.md)")); +}); From 52c698196294fc33f8758bac4d1f16e2b5ba660f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:31:50 +0000 Subject: [PATCH 03/11] Update documentation and tests for TOC feature Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- README.md | 13 ++++--- src/index.ts | 60 ----------------------------- src/toc.ts | 2 +- tests/edge-cases-validation.test.js | 6 +-- tests/init.test.js | 16 ++++---- tests/sync-toc.test.js | 2 - 6 files changed, 19 insertions(+), 80 deletions(-) delete mode 100644 src/index.ts diff --git a/README.md b/README.md index b751312..2bbeb36 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,12 @@ npx docs-cache clean ### Options -| Field | Type | Description | -| ---------- | ------- | ---------------------------------------- | -| `cacheDir` | string | Directory for cache, defaults to `.docs` | -| `index` | boolean | Write `index.json` summary file | -| `sources` | array | List of repositories to sync | -| `defaults` | object | Default settings for all sources | +| Field | Type | Description | +| ---------- | ------- | ---------------------------------------------------- | +| `cacheDir` | string | Directory for cache, defaults to `.docs` | +| `toc` | boolean | Generate global `TOC.md` with links to all sources | +| `sources` | array | List of repositories to sync | +| `defaults` | object | Default settings for all sources | **Source Options:** @@ -91,6 +91,7 @@ npx docs-cache clean - `required`: Whether missing sources should fail in offline/strict runs - `maxBytes`: Maximum total bytes to materialize for the source - `maxFiles`: Maximum total files to materialize for the source +- `toc`: Generate per-source `TOC.md` listing all documentation files > **Note**: Sources are always downloaded to `.docs//`. 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`. diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 3d93537..0000000 --- a/src/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { writeFile } from "node:fs/promises"; -import path from "node:path"; -import type { DocsCacheResolvedSource } from "./config"; -import type { DocsCacheLock } from "./lock"; -import { DEFAULT_INDEX_FILENAME, resolveTargetDir, toPosixPath } from "./paths"; - -type IndexSource = { - repo: string; - ref: string; - resolvedCommit: string; - bytes: number; - fileCount: number; - manifestSha256: string; - updatedAt: string; - cachePath: string; - targetDir?: string; -}; - -type DocsCacheIndex = { - generatedAt: string; - cacheDir: string; - sources: Record; -}; - -export const writeIndex = async (params: { - cacheDir: string; - configPath: string; - lock: DocsCacheLock; - sources: DocsCacheResolvedSource[]; -}) => { - const sourcesById = new Map( - params.sources.map((source) => [source.id, source]), - ); - const sourceEntries: Record = {}; - for (const [id, entry] of Object.entries(params.lock.sources)) { - const source = sourcesById.get(id); - const targetDir = source?.targetDir - ? toPosixPath(resolveTargetDir(params.configPath, source.targetDir)) - : undefined; - sourceEntries[id] = { - repo: entry.repo, - ref: entry.ref, - resolvedCommit: entry.resolvedCommit, - bytes: entry.bytes, - fileCount: entry.fileCount, - manifestSha256: entry.manifestSha256, - updatedAt: entry.updatedAt, - cachePath: toPosixPath(path.join(params.cacheDir, id)), - ...(targetDir ? { targetDir } : {}), - }; - } - const index: DocsCacheIndex = { - generatedAt: new Date().toISOString(), - cacheDir: toPosixPath(params.cacheDir), - sources: sourceEntries, - }; - const indexPath = path.join(params.cacheDir, DEFAULT_INDEX_FILENAME); - const data = `${JSON.stringify(index, null, 2)}\n`; - await writeFile(indexPath, data, "utf8"); -}; diff --git a/src/toc.ts b/src/toc.ts index e6ac307..690c551 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -17,7 +17,7 @@ type TocEntry = { files: string[]; }; -const generateGlobalToc = (entries: TocEntry[], cacheDir: string): string => { +const generateGlobalToc = (entries: TocEntry[], _cacheDir: string): string => { const lines: string[] = []; lines.push("# Documentation Cache - Table of Contents"); lines.push(""); diff --git a/tests/edge-cases-validation.test.js b/tests/edge-cases-validation.test.js index d2236f0..06a5040 100644 --- a/tests/edge-cases-validation.test.js +++ b/tests/edge-cases-validation.test.js @@ -367,14 +367,14 @@ test("cacheDir with absolute path", async () => { assert.equal(config.cacheDir, "/tmp/docs-cache"); }); -test("index flag set to true", async () => { +test("toc flag set to true", async () => { const configPath = await writeConfig({ - index: true, + toc: true, sources: [{ id: "test", repo: "https://github.com/example/repo.git" }], }); const { config } = await loadConfig(configPath); - assert.equal(config.index, true); + assert.equal(config.toc, true); }); test("targetMode at root level", async () => { diff --git a/tests/init.test.js b/tests/init.test.js index 2c3da55..3c77ad3 100644 --- a/tests/init.test.js +++ b/tests/init.test.js @@ -8,8 +8,8 @@ import { initConfig } from "../dist/api.mjs"; const stubPrompts = (answers, callbacks = {}) => ({ confirm: async (options) => { - if (options.message?.startsWith("Generate index.json")) { - return answers.index; + if (options.message?.startsWith("Generate TOC.md")) { + return answers.toc; } if (options.message === "Add cache directory to .gitignore") { if (callbacks.onGitignorePrompt) { @@ -80,14 +80,14 @@ test("init writes docs.config.json when selected", async () => { stubPrompts({ location: "config", cacheDir: ".docs", - index: true, + toc: true, gitignore: true, }), ); const raw = await readFile(configPath, "utf8"); const config = JSON.parse(raw); - assert.equal(config.index, true); + assert.equal(config.toc, true); assert.equal(Array.isArray(config.sources), true); assert.equal(config.defaults, undefined); }); @@ -106,7 +106,7 @@ test("init writes package.json docs-cache when selected", async () => { stubPrompts({ location: "package", cacheDir: ".docs", - index: false, + toc: false, gitignore: false, }), ); @@ -114,7 +114,7 @@ test("init writes package.json docs-cache when selected", async () => { const raw = await readFile(packagePath, "utf8"); const pkg = JSON.parse(raw); assert.ok(pkg["docs-cache"]); - assert.equal(pkg["docs-cache"].index, undefined); + assert.equal(pkg["docs-cache"].toc, undefined); assert.equal(pkg["docs-cache"].cacheDir, undefined); assert.equal(pkg["docs-cache"].defaults, undefined); }); @@ -135,7 +135,7 @@ test("init writes .gitignore entry when missing", async () => { stubPrompts({ location: "config", cacheDir: ".docs", - index: false, + toc: false, gitignore: true, }), ); @@ -163,7 +163,7 @@ test("init skips gitignore prompt when entry exists", async () => { { location: "config", cacheDir: ".docs", - index: false, + toc: false, }, { onGitignorePrompt: () => { diff --git a/tests/sync-toc.test.js b/tests/sync-toc.test.js index 0bfca39..b0b8032 100644 --- a/tests/sync-toc.test.js +++ b/tests/sync-toc.test.js @@ -6,8 +6,6 @@ import { test } from "node:test"; import { runSync } from "../dist/api.mjs"; -const toPosix = (value) => value.split(path.sep).join("/"); - test("sync writes TOC.md when toc is enabled", async () => { const tmpRoot = path.join( tmpdir(), From 1a39625175112f178e6ade07818206ef5ca1fb25 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 18:33:36 +0000 Subject: [PATCH 04/11] Fix code review feedback: avoid array mutation and improve comments Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- src/toc.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/toc.ts b/src/toc.ts index 690c551..b8b4be7 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -55,7 +55,7 @@ const generateSourceToc = (entry: TocEntry): string => { lines.push("## Files"); lines.push(""); - for (const file of entry.files.sort()) { + for (const file of [...entry.files].sort()) { lines.push(`- [${file}](./${file})`); } lines.push(""); @@ -117,7 +117,7 @@ export const writeToc = async (params: { entries.push(entry); - // Generate per-source TOC if the source has toc enabled or if global toc is enabled + // Generate per-source TOC if the source has TOC enabled or if global TOC is enabled const sourceToc = source?.toc ?? false; if (sourceToc || params.globalToc) { const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); From dae06c3fc02934f2ba63946a54dca7c283d1eb2d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:17:23 +0000 Subject: [PATCH 05/11] Remove global TOC, make config.toc a default setting for sources Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- README.md | 33 ++++++++++++++++------ docs.config.schema.json | 6 ++-- src/add.ts | 3 -- src/config-schema.ts | 2 +- src/config.ts | 16 +++++------ src/init.ts | 4 +-- src/remove.ts | 3 -- src/sync.ts | 6 ++-- src/toc.ts | 43 ++--------------------------- tests/edge-cases-validation.test.js | 10 +++++-- tests/init.test.js | 6 ++-- tests/sync-toc.test.js | 23 +++++++-------- 12 files changed, 62 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index 2bbeb36..faee9ec 100644 --- a/README.md +++ b/README.md @@ -76,22 +76,37 @@ npx docs-cache clean | Field | Type | Description | | ---------- | ------- | ---------------------------------------------------- | | `cacheDir` | string | Directory for cache, defaults to `.docs` | -| `toc` | boolean | Generate global `TOC.md` with links to all sources | | `sources` | array | List of repositories to sync | | `defaults` | object | Default settings for all sources | +**Default Options:** + +All fields in `defaults` apply to all sources unless overridden per-source: + +- `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}"]`) +- `targetMode`: `"symlink"` or `"copy"` (default: `"symlink"` on Unix, `"copy"` on Windows) +- `depth`: Git clone depth (default: `1`) +- `required`: Whether missing sources should fail (default: `true`) +- `maxBytes`: Maximum total bytes to materialize (default: `200000000`) +- `maxFiles`: Maximum total files to materialize (optional) +- `allowHosts`: Allowed Git hosts (default: `["github.com", "gitlab.com"]`) +- `toc`: Generate per-source `TOC.md` listing all documentation files (default: `false`) + **Source Options:** -- `repo`: Git URL -- `ref`: Branch, tag, or commit -- `include`: Glob patterns to copy, defaults to `"**/*.{md,mdx,markdown,mkd,txt,rst,adoc,asciidoc}"`, +- `repo`: Git URL (required) +- `id`: Unique identifier for the source (required) +- `ref`: Branch, tag, or commit (overrides default) +- `include`: Glob patterns to copy (overrides default) - `exclude`: Glob patterns to skip - `targetDir`: Optional path where files should be symlinked/copied to, outside `.docs` -- `targetMode`: Defaults to `symlink` on Unix and `copy` on Windows -- `required`: Whether missing sources should fail in offline/strict runs -- `maxBytes`: Maximum total bytes to materialize for the source -- `maxFiles`: Maximum total files to materialize for the source -- `toc`: Generate per-source `TOC.md` listing all documentation files +- `targetMode`: `"symlink"` or `"copy"` (overrides default) +- `required`: Whether missing sources should fail (overrides default) +- `maxBytes`: Maximum total bytes to materialize (overrides default) +- `maxFiles`: Maximum total files to materialize (overrides default) +- `toc`: Generate per-source `TOC.md` listing all documentation files (overrides default) > **Note**: Sources are always downloaded to `.docs//`. 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`. diff --git a/docs.config.schema.json b/docs.config.schema.json index 4a70415..4ec49f4 100644 --- a/docs.config.schema.json +++ b/docs.config.schema.json @@ -14,9 +14,6 @@ "type": "string", "enum": ["symlink", "copy"] }, - "toc": { - "type": "boolean" - }, "defaults": { "type": "object", "properties": { @@ -62,6 +59,9 @@ "type": "string", "minLength": 1 } + }, + "toc": { + "type": "boolean" } }, "additionalProperties": false diff --git a/src/add.ts b/src/add.ts index e47ab2a..5c89cc1 100644 --- a/src/add.ts +++ b/src/add.ts @@ -128,9 +128,6 @@ export const addSources = async (params: { if (rawConfig?.cacheDir) { nextConfig.cacheDir = rawConfig.cacheDir; } - if (rawConfig?.toc !== undefined) { - nextConfig.toc = rawConfig.toc; - } if (rawConfig?.defaults) { nextConfig.defaults = rawConfig.defaults; } diff --git a/src/config-schema.ts b/src/config-schema.ts index 61b72e7..1cae358 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -20,6 +20,7 @@ export const DefaultsSchema = z maxBytes: z.number().min(1), maxFiles: z.number().min(1).optional(), allowHosts: z.array(z.string().min(1)).min(1), + toc: z.boolean().optional(), }) .strict(); @@ -47,7 +48,6 @@ export const ConfigSchema = z $schema: z.string().min(1).optional(), cacheDir: z.string().min(1).optional(), targetMode: TargetModeSchema.optional(), - toc: z.boolean().optional(), defaults: DefaultsSchema.partial().optional(), sources: z.array(SourceSchema), }) diff --git a/src/config.ts b/src/config.ts index 7f594ba..415d2c1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export interface DocsCacheDefaults { maxBytes: number; maxFiles?: number; allowHosts: string[]; + toc?: boolean; } export interface DocsCacheSource { @@ -46,7 +47,6 @@ export interface DocsCacheConfig { $schema?: string; cacheDir?: string; targetMode?: "symlink" | "copy"; - toc?: boolean; defaults?: Partial; sources: DocsCacheSource[]; } @@ -74,7 +74,6 @@ const PACKAGE_JSON_FILENAME = "package.json"; const DEFAULT_TARGET_MODE = process.platform === "win32" ? "copy" : "symlink"; export const DEFAULT_CONFIG: DocsCacheConfig = { cacheDir: DEFAULT_CACHE_DIR, - toc: false, defaults: { ref: "HEAD", mode: "materialize", @@ -84,6 +83,7 @@ export const DEFAULT_CONFIG: DocsCacheConfig = { required: true, maxBytes: 200000000, allowHosts: ["github.com", "gitlab.com"], + toc: false, }, sources: [], }; @@ -146,7 +146,6 @@ export const stripDefaultConfigValues = ( const next: DocsCacheConfig = { $schema: pruned.$schema as DocsCacheConfig["$schema"], cacheDir: pruned.cacheDir as DocsCacheConfig["cacheDir"], - toc: pruned.toc as DocsCacheConfig["toc"], targetMode: pruned.targetMode as DocsCacheConfig["targetMode"], defaults: pruned.defaults as DocsCacheConfig["defaults"], sources: config.sources, @@ -249,10 +248,6 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { const cacheDir = input.cacheDir ? assertString(input.cacheDir, "cacheDir") : DEFAULT_CACHE_DIR; - const toc = - input.toc !== undefined - ? assertBoolean(input.toc, "toc") - : (DEFAULT_CONFIG.toc ?? false); const defaultsInput = input.defaults; const targetModeOverride = @@ -302,6 +297,10 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { defaultsInput.allowHosts !== undefined ? assertStringArray(defaultsInput.allowHosts, "defaults.allowHosts") : defaultValues.allowHosts, + toc: + defaultsInput.toc !== undefined + ? assertBoolean(defaultsInput.toc, "defaults.toc") + : defaultValues.toc, }; } else if (targetModeOverride !== undefined) { defaults = { @@ -412,7 +411,6 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { return { cacheDir, targetMode: targetModeOverride, - toc, defaults, sources, }; @@ -437,7 +435,7 @@ export const resolveSources = ( maxBytes: source.maxBytes ?? defaults.maxBytes, maxFiles: source.maxFiles ?? defaults.maxFiles, integrity: source.integrity, - toc: source.toc, + toc: source.toc ?? defaults.toc, })); }; diff --git a/src/init.ts b/src/init.ts index ad06ffb..d6e5345 100644 --- a/src/init.ts +++ b/src/init.ts @@ -145,7 +145,7 @@ export const initConfig = async ( baseConfig.cacheDir = resolvedCacheDir; } if (answers.toc) { - baseConfig.toc = true; + baseConfig.defaults = { toc: true }; } pkg["docs-cache"] = stripDefaultConfigValues(baseConfig); await writeFile( @@ -179,7 +179,7 @@ export const initConfig = async ( config.cacheDir = resolvedCacheDir; } if (answers.toc) { - config.toc = true; + config.defaults = { toc: true }; } await writeConfig(resolvedConfigPath, config); diff --git a/src/remove.ts b/src/remove.ts index aae6767..f5a9526 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -137,9 +137,6 @@ export const removeSources = async (params: { if (rawConfig?.cacheDir) { nextConfig.cacheDir = rawConfig.cacheDir; } - if (rawConfig?.toc !== undefined) { - nextConfig.toc = rawConfig.toc; - } if (rawConfig?.defaults) { nextConfig.defaults = rawConfig.defaults; } diff --git a/src/sync.ts b/src/sync.ts index fc05ae3..d1cb420 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -482,17 +482,15 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { `${symbols.info} Completed in ${elapsedMs.toFixed(0)}ms · ${formatBytes(totalBytes)} · ${totalFiles} files${warningCount ? ` · ${warningCount} warning${warningCount === 1 ? "" : "s"}` : ""}`, ); } - // Check if any source has TOC enabled or global TOC is enabled + // Check if any source has TOC enabled const anySourceHasToc = plan.sources.some((source) => source.toc === true); - const shouldWriteToc = plan.config.toc || anySourceHasToc; - if (shouldWriteToc) { + if (anySourceHasToc) { await writeToc({ cacheDir: plan.cacheDir, configPath: plan.configPath, lock, sources: plan.sources, - globalToc: plan.config.toc ?? false, }); } plan.lockExists = true; diff --git a/src/toc.ts b/src/toc.ts index b8b4be7..efccae6 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -17,33 +17,6 @@ type TocEntry = { files: string[]; }; -const generateGlobalToc = (entries: TocEntry[], _cacheDir: string): string => { - const lines: string[] = []; - lines.push("# Documentation Cache - Table of Contents"); - lines.push(""); - lines.push(`Generated: ${new Date().toISOString()}`); - lines.push(""); - lines.push("## Cached Sources"); - lines.push(""); - - for (const entry of entries) { - lines.push(`### ${entry.id}`); - lines.push(""); - lines.push(`- **Repository**: ${entry.repo}`); - lines.push(`- **Ref**: ${entry.ref}`); - lines.push(`- **Commit**: ${entry.resolvedCommit}`); - lines.push(`- **Files**: ${entry.fileCount}`); - lines.push(`- **Cache Path**: ${entry.cachePath}`); - if (entry.targetDir) { - lines.push(`- **Target Directory**: ${entry.targetDir}`); - } - lines.push(`- **Source TOC**: [${entry.id}/TOC.md](${entry.id}/TOC.md)`); - lines.push(""); - } - - return lines.join("\n"); -}; - const generateSourceToc = (entry: TocEntry): string => { const lines: string[] = []; lines.push(`# ${entry.id} - Documentation`); @@ -87,14 +60,11 @@ export const writeToc = async (params: { configPath: string; lock: DocsCacheLock; sources: DocsCacheResolvedSource[]; - globalToc: boolean; }) => { const sourcesById = new Map( params.sources.map((source) => [source.id, source]), ); - const entries: TocEntry[] = []; - for (const [id, lockEntry] of Object.entries(params.lock.sources)) { const source = sourcesById.get(id); const targetDir = source?.targetDir @@ -115,21 +85,12 @@ export const writeToc = async (params: { files, }; - entries.push(entry); - - // Generate per-source TOC if the source has TOC enabled or if global TOC is enabled + // Generate per-source TOC if the source has TOC enabled const sourceToc = source?.toc ?? false; - if (sourceToc || params.globalToc) { + if (sourceToc) { const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); const sourceTocContent = generateSourceToc(entry); await writeFile(sourceTocPath, sourceTocContent, "utf8"); } } - - // Generate global TOC if enabled - if (params.globalToc) { - const globalTocPath = path.join(params.cacheDir, DEFAULT_TOC_FILENAME); - const globalTocContent = generateGlobalToc(entries, params.cacheDir); - await writeFile(globalTocPath, globalTocContent, "utf8"); - } }; diff --git a/tests/edge-cases-validation.test.js b/tests/edge-cases-validation.test.js index 06a5040..4cdf65a 100644 --- a/tests/edge-cases-validation.test.js +++ b/tests/edge-cases-validation.test.js @@ -367,14 +367,16 @@ test("cacheDir with absolute path", async () => { assert.equal(config.cacheDir, "/tmp/docs-cache"); }); -test("toc flag set to true", async () => { +test("toc flag set to true in defaults", async () => { const configPath = await writeConfig({ - toc: true, + defaults: { + toc: true, + }, sources: [{ id: "test", repo: "https://github.com/example/repo.git" }], }); const { config } = await loadConfig(configPath); - assert.equal(config.toc, true); + assert.equal(config.defaults.toc, true); }); test("targetMode at root level", async () => { @@ -399,6 +401,7 @@ test("defaults with all fields specified", async () => { maxBytes: 1000000, maxFiles: 100, allowHosts: ["github.com"], + toc: true, }, sources: [{ id: "test", repo: "https://github.com/example/repo.git" }], }); @@ -412,4 +415,5 @@ 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.equal(config.defaults.toc, true); }); diff --git a/tests/init.test.js b/tests/init.test.js index 3c77ad3..433d171 100644 --- a/tests/init.test.js +++ b/tests/init.test.js @@ -87,9 +87,8 @@ test("init writes docs.config.json when selected", async () => { const raw = await readFile(configPath, "utf8"); const config = JSON.parse(raw); - assert.equal(config.toc, true); + assert.equal(config.defaults?.toc, true); assert.equal(Array.isArray(config.sources), true); - assert.equal(config.defaults, undefined); }); test("init writes package.json docs-cache when selected", async () => { @@ -114,9 +113,8 @@ test("init writes package.json docs-cache when selected", async () => { const raw = await readFile(packagePath, "utf8"); const pkg = JSON.parse(raw); assert.ok(pkg["docs-cache"]); - assert.equal(pkg["docs-cache"].toc, undefined); + assert.equal(pkg["docs-cache"].defaults?.toc, undefined); assert.equal(pkg["docs-cache"].cacheDir, undefined); - assert.equal(pkg["docs-cache"].defaults, undefined); }); test("init writes .gitignore entry when missing", async () => { diff --git a/tests/sync-toc.test.js b/tests/sync-toc.test.js index b0b8032..4e667d1 100644 --- a/tests/sync-toc.test.js +++ b/tests/sync-toc.test.js @@ -6,7 +6,7 @@ import { test } from "node:test"; import { runSync } from "../dist/api.mjs"; -test("sync writes TOC.md when toc is enabled", async () => { +test("sync writes per-source TOC when defaults.toc is enabled", async () => { const tmpRoot = path.join( tmpdir(), `docs-cache-toc-${Date.now().toString(36)}`, @@ -22,7 +22,9 @@ test("sync writes TOC.md when toc is enabled", async () => { const config = { $schema: "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", - toc: true, + defaults: { + toc: true, + }, sources: [ { id: "local", @@ -65,21 +67,20 @@ test("sync writes TOC.md when toc is enabled", async () => { }, ); - // Check global TOC exists - const globalTocPath = path.join(cacheDir, "TOC.md"); - const globalToc = await readFile(globalTocPath, "utf8"); - assert.ok(globalToc.includes("# Documentation Cache - Table of Contents")); - assert.ok(globalToc.includes("### local")); - assert.ok(globalToc.includes("**Repository**: https://example.com/repo.git")); - assert.ok(globalToc.includes("**Commit**: abc123")); - assert.ok(globalToc.includes("**Files**: 1")); - // Check per-source TOC exists const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); const sourceToc = await readFile(sourceTocPath, "utf8"); assert.ok(sourceToc.includes("# local - Documentation")); assert.ok(sourceToc.includes("**Repository**: https://example.com/repo.git")); assert.ok(sourceToc.includes("- [README.md](./README.md)")); + + // Check global TOC does NOT exist + const globalTocPath = path.join(cacheDir, "TOC.md"); + await assert.rejects( + () => readFile(globalTocPath, "utf8"), + /ENOENT/, + "Global TOC should not exist", + ); }); test("sync writes per-source TOC when source.toc is enabled", async () => { From 6aa4ae7c3d855d3c71939e0eb1bdd64288d2a406 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:48:33 +0000 Subject: [PATCH 06/11] Remove duplicate DEFAULT_TOC_FILENAME and unused tocPath property Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- src/paths.ts | 2 -- src/toc.ts | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/paths.ts b/src/paths.ts index aabbd3d..ee4781f 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -40,10 +40,8 @@ export const resolveCacheDir = ( export const getCacheLayout = (cacheDir: string, sourceId: string) => { const _reposDir = path.join(cacheDir, "repos"); const sourceDir = path.join(cacheDir, sourceId); - const tocPath = path.join(cacheDir, DEFAULT_TOC_FILENAME); return { cacheDir, sourceDir, - tocPath, }; }; diff --git a/src/toc.ts b/src/toc.ts index efccae6..8019eb6 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -2,9 +2,7 @@ import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { DocsCacheResolvedSource } from "./config"; import type { DocsCacheLock } from "./lock"; -import { resolveTargetDir, toPosixPath } from "./paths"; - -export const DEFAULT_TOC_FILENAME = "TOC.md"; +import { DEFAULT_TOC_FILENAME, resolveTargetDir, toPosixPath } from "./paths"; type TocEntry = { id: string; From 210fa1daba8cef60bfde96b58788a93367059234 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:58:31 +0000 Subject: [PATCH 07/11] Change TOC default to true Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- src/config.ts | 2 +- src/init.ts | 2 +- src/toc.ts | 13 +++++++-- tests/sync-toc.test.js | 66 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 415d2c1..747a9b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -83,7 +83,7 @@ export const DEFAULT_CONFIG: DocsCacheConfig = { required: true, maxBytes: 200000000, allowHosts: ["github.com", "gitlab.com"], - toc: false, + toc: true, }, sources: [], }; diff --git a/src/init.ts b/src/init.ts index d6e5345..cb1e780 100644 --- a/src/init.ts +++ b/src/init.ts @@ -96,7 +96,7 @@ export const initConfig = async ( const tocAnswer = await confirm({ message: "Generate TOC.md (table of contents with links to all documentation)", - initialValue: false, + initialValue: true, }); if (isCancel(tocAnswer)) { throw new Error("Init cancelled."); diff --git a/src/toc.ts b/src/toc.ts index 8019eb6..d10cf64 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { access, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import type { DocsCacheResolvedSource } from "./config"; import type { DocsCacheLock } from "./lock"; @@ -70,6 +70,15 @@ export const writeToc = async (params: { : undefined; const sourceDir = path.join(params.cacheDir, id); + + // Check if source directory exists (might not exist for offline/missing optional sources) + try { + await access(sourceDir); + } catch { + // Source directory doesn't exist, skip TOC generation + continue; + } + const files = await readManifest(sourceDir); const entry: TocEntry = { @@ -84,7 +93,7 @@ export const writeToc = async (params: { }; // Generate per-source TOC if the source has TOC enabled - const sourceToc = source?.toc ?? false; + const sourceToc = source?.toc ?? true; if (sourceToc) { const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); const sourceTocContent = generateSourceToc(entry); diff --git a/tests/sync-toc.test.js b/tests/sync-toc.test.js index 4e667d1..efdd010 100644 --- a/tests/sync-toc.test.js +++ b/tests/sync-toc.test.js @@ -150,3 +150,69 @@ test("sync writes per-source TOC when source.toc is enabled", async () => { assert.ok(sourceToc.includes("- [README.md](./README.md)")); assert.ok(sourceToc.includes("- [guide.md](./guide.md)")); }); + +test("sync writes per-source TOC by default when no toc config is specified", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-default-toc-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + + // Config without any toc setting - should use default (true) + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + 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, + }), + materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { + const outDir = path.join(cacheRoot, sourceId); + await mkdir(outDir, { recursive: true }); + await writeFile( + path.join(outDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + ); + await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); + return { bytes: 5, fileCount: 1 }; + }, + }, + ); + + // Check per-source TOC exists (should be generated by default) + const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); + const sourceToc = await readFile(sourceTocPath, "utf8"); + assert.ok(sourceToc.includes("# local - Documentation")); + assert.ok(sourceToc.includes("**Repository**: https://example.com/repo.git")); + assert.ok(sourceToc.includes("- [README.md](./README.md)")); +}); From 890cd9e86899d3e93702a13e360e01696092b23e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:07:05 +0000 Subject: [PATCH 08/11] Fix init flow to properly handle TOC default being true Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- src/init.ts | 10 ++++++---- tests/init.test.js | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/init.ts b/src/init.ts index cb1e780..084c095 100644 --- a/src/init.ts +++ b/src/init.ts @@ -144,8 +144,9 @@ export const initConfig = async ( if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { baseConfig.cacheDir = resolvedCacheDir; } - if (answers.toc) { - baseConfig.defaults = { toc: true }; + // Since TOC defaults to true, only set it explicitly if user chose false + if (!answers.toc) { + baseConfig.defaults = { toc: false }; } pkg["docs-cache"] = stripDefaultConfigValues(baseConfig); await writeFile( @@ -178,8 +179,9 @@ export const initConfig = async ( if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { config.cacheDir = resolvedCacheDir; } - if (answers.toc) { - config.defaults = { toc: true }; + // Since TOC defaults to true, only set it explicitly if user chose false + if (!answers.toc) { + config.defaults = { toc: false }; } await writeConfig(resolvedConfigPath, config); diff --git a/tests/init.test.js b/tests/init.test.js index 433d171..c11f96b 100644 --- a/tests/init.test.js +++ b/tests/init.test.js @@ -87,7 +87,8 @@ test("init writes docs.config.json when selected", async () => { const raw = await readFile(configPath, "utf8"); const config = JSON.parse(raw); - assert.equal(config.defaults?.toc, true); + // When toc is true (default), it shouldn't be explicitly set + assert.equal(config.defaults?.toc, undefined); assert.equal(Array.isArray(config.sources), true); }); @@ -113,7 +114,8 @@ test("init writes package.json docs-cache when selected", async () => { const raw = await readFile(packagePath, "utf8"); const pkg = JSON.parse(raw); assert.ok(pkg["docs-cache"]); - assert.equal(pkg["docs-cache"].defaults?.toc, undefined); + // When toc is false (user disabled it), it should be explicitly set + assert.equal(pkg["docs-cache"].defaults?.toc, false); assert.equal(pkg["docs-cache"].cacheDir, undefined); }); From 6be191db0595c2ed4a34fc601ae54bf95d21eff3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:24:00 +0000 Subject: [PATCH 09/11] Remove TOC.md when toc setting is disabled Co-authored-by: fbosch <6979916+fbosch@users.noreply.github.com> --- src/sync.ts | 18 +++---- src/toc.ts | 12 ++++- tests/sync-toc.test.js | 107 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 13 deletions(-) diff --git a/src/sync.ts b/src/sync.ts index d1cb420..28b624e 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -482,17 +482,13 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { `${symbols.info} Completed in ${elapsedMs.toFixed(0)}ms · ${formatBytes(totalBytes)} · ${totalFiles} files${warningCount ? ` · ${warningCount} warning${warningCount === 1 ? "" : "s"}` : ""}`, ); } - // Check if any source has TOC enabled - const anySourceHasToc = plan.sources.some((source) => source.toc === true); - - if (anySourceHasToc) { - await writeToc({ - cacheDir: plan.cacheDir, - configPath: plan.configPath, - lock, - sources: plan.sources, - }); - } + // Always call writeToc to handle both generation and cleanup + await writeToc({ + cacheDir: plan.cacheDir, + configPath: plan.configPath, + lock, + sources: plan.sources, + }); plan.lockExists = true; return plan; }; diff --git a/src/toc.ts b/src/toc.ts index d10cf64..970b61d 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -1,4 +1,4 @@ -import { access, readFile, writeFile } from "node:fs/promises"; +import { access, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import type { DocsCacheResolvedSource } from "./config"; import type { DocsCacheLock } from "./lock"; @@ -94,10 +94,18 @@ export const writeToc = async (params: { // Generate per-source TOC if the source has TOC enabled const sourceToc = source?.toc ?? true; + const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); + if (sourceToc) { - const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); const sourceTocContent = generateSourceToc(entry); await writeFile(sourceTocPath, sourceTocContent, "utf8"); + } else { + // Remove TOC.md if it exists but toc is disabled + try { + await rm(sourceTocPath, { force: true }); + } catch { + // Ignore errors if file doesn't exist + } } } }; diff --git a/tests/sync-toc.test.js b/tests/sync-toc.test.js index efdd010..3b89fc1 100644 --- a/tests/sync-toc.test.js +++ b/tests/sync-toc.test.js @@ -216,3 +216,110 @@ test("sync writes per-source TOC by default when no toc config is specified", as assert.ok(sourceToc.includes("**Repository**: https://example.com/repo.git")); assert.ok(sourceToc.includes("- [README.md](./README.md)")); }); + +test("sync removes TOC.md when toc is disabled", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-toc-removal-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + + // First sync with TOC enabled (default) + const config1 = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + }, + ], + }; + await writeFile(configPath, `${JSON.stringify(config1, 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, + }), + materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { + const outDir = path.join(cacheRoot, sourceId); + await mkdir(outDir, { recursive: true }); + await writeFile( + path.join(outDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + ); + await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); + return { bytes: 5, fileCount: 1 }; + }, + }, + ); + + // Verify TOC.md was created + const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); + const tocContent = await readFile(sourceTocPath, "utf8"); + assert.ok(tocContent.includes("# local - Documentation")); + + // Second sync with TOC disabled + const config2 = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + sources: [ + { + id: "local", + repo: "https://example.com/repo.git", + toc: false, + }, + ], + }; + await writeFile(configPath, `${JSON.stringify(config2, null, 2)}\n`, "utf8"); + + await runSync( + { + configPath, + cacheDirOverride: cacheDir, + json: false, + lockOnly: false, + offline: true, + failOnMiss: false, + }, + { + resolveRemoteCommit: async () => ({ + repo: "https://example.com/repo.git", + ref: "HEAD", + resolvedCommit: "abc123", + }), + fetchSource: async () => ({ + repoDir, + cleanup: async () => undefined, + }), + }, + ); + + // Verify TOC.md was removed + await assert.rejects( + () => readFile(sourceTocPath, "utf8"), + /ENOENT/, + "TOC.md should have been removed", + ); +}); From 31060101c8c82a82ac2b3e4c6072d06edf620633 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:26:02 +0100 Subject: [PATCH 10/11] feat(toc): replace flat index with file tree --- src/sync.ts | 1 + src/toc.ts | 87 +++++++++++++++++++++++++++++++---- tests/sync-toc.test.js | 100 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 177 insertions(+), 11 deletions(-) diff --git a/src/sync.ts b/src/sync.ts index 28b624e..8b4097a 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -488,6 +488,7 @@ export const runSync = async (options: SyncOptions, deps: SyncDeps = {}) => { configPath: plan.configPath, lock, sources: plan.sources, + results: plan.results, }); plan.lockExists = true; return plan; diff --git a/src/toc.ts b/src/toc.ts index 970b61d..710b09e 100644 --- a/src/toc.ts +++ b/src/toc.ts @@ -15,20 +15,78 @@ type TocEntry = { files: string[]; }; +type TocFileEntry = { + name: string; + path: string; +}; + +type TocTree = { + dirs: Map; + files: TocFileEntry[]; +}; + +const createTocTree = (files: string[]): TocTree => { + const root: TocTree = { dirs: new Map(), files: [] }; + + for (const file of files) { + const parts = file.split("/").filter(Boolean); + if (parts.length === 0) { + continue; + } + + let node = root; + for (const part of parts.slice(0, -1)) { + let child = node.dirs.get(part); + if (!child) { + child = { dirs: new Map(), files: [] }; + node.dirs.set(part, child); + } + node = child; + } + + const name = parts[parts.length - 1]; + node.files.push({ name, path: file }); + } + + return root; +}; + +const renderTocTree = (tree: TocTree, depth: number, lines: string[]) => { + const indent = " ".repeat(depth); + const dirNames = Array.from(tree.dirs.keys()).sort(); + const files = [...tree.files].sort((a, b) => a.name.localeCompare(b.name)); + + for (const dirName of dirNames) { + lines.push(`${indent}- ${dirName}/`); + const child = tree.dirs.get(dirName); + if (child) { + renderTocTree(child, depth + 1, lines); + } + } + + for (const file of files) { + lines.push(`${indent}- [${file.name}](./${file.path})`); + } +}; + const generateSourceToc = (entry: TocEntry): string => { const lines: string[] = []; - lines.push(`# ${entry.id} - Documentation`); + lines.push("---"); + lines.push(`id: ${entry.id}`); + lines.push(`repository: ${entry.repo}`); + lines.push(`ref: ${entry.ref}`); + lines.push(`commit: ${entry.resolvedCommit}`); + if (entry.targetDir) { + lines.push(`targetDir: ${entry.targetDir}`); + } + lines.push("---"); lines.push(""); - lines.push(`- **Repository**: ${entry.repo}`); - lines.push(`- **Ref**: ${entry.ref}`); - lines.push(`- **Commit**: ${entry.resolvedCommit}`); + lines.push(`# ${entry.id} - Documentation`); lines.push(""); lines.push("## Files"); lines.push(""); - - for (const file of [...entry.files].sort()) { - lines.push(`- [${file}](./${file})`); - } + const tree = createTocTree(entry.files); + renderTocTree(tree, 0, lines); lines.push(""); return lines.join("\n"); @@ -58,10 +116,14 @@ export const writeToc = async (params: { configPath: string; lock: DocsCacheLock; sources: DocsCacheResolvedSource[]; + results?: Array<{ id: string; status: "up-to-date" | "changed" | "missing" }>; }) => { const sourcesById = new Map( params.sources.map((source) => [source.id, source]), ); + const resultsById = new Map( + (params.results ?? []).map((result) => [result.id, result]), + ); for (const [id, lockEntry] of Object.entries(params.lock.sources)) { const source = sourcesById.get(id); @@ -97,6 +159,15 @@ export const writeToc = async (params: { const sourceTocPath = path.join(sourceDir, DEFAULT_TOC_FILENAME); if (sourceToc) { + const result = resultsById.get(id); + if (result?.status === "up-to-date") { + try { + await access(sourceTocPath); + continue; + } catch { + // Missing TOC; regenerate below. + } + } const sourceTocContent = generateSourceToc(entry); await writeFile(sourceTocPath, sourceTocContent, "utf8"); } else { diff --git a/tests/sync-toc.test.js b/tests/sync-toc.test.js index 3b89fc1..f44886f 100644 --- a/tests/sync-toc.test.js +++ b/tests/sync-toc.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -71,7 +71,7 @@ test("sync writes per-source TOC when defaults.toc is enabled", async () => { const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); const sourceToc = await readFile(sourceTocPath, "utf8"); assert.ok(sourceToc.includes("# local - Documentation")); - assert.ok(sourceToc.includes("**Repository**: https://example.com/repo.git")); + assert.ok(sourceToc.includes("repository: https://example.com/repo.git")); assert.ok(sourceToc.includes("- [README.md](./README.md)")); // Check global TOC does NOT exist @@ -147,6 +147,7 @@ test("sync writes per-source TOC when source.toc is enabled", async () => { const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); const sourceToc = await readFile(sourceTocPath, "utf8"); assert.ok(sourceToc.includes("# local - Documentation")); + assert.ok(sourceToc.includes("---")); assert.ok(sourceToc.includes("- [README.md](./README.md)")); assert.ok(sourceToc.includes("- [guide.md](./guide.md)")); }); @@ -213,7 +214,7 @@ test("sync writes per-source TOC by default when no toc config is specified", as const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); const sourceToc = await readFile(sourceTocPath, "utf8"); assert.ok(sourceToc.includes("# local - Documentation")); - assert.ok(sourceToc.includes("**Repository**: https://example.com/repo.git")); + assert.ok(sourceToc.includes("repository: https://example.com/repo.git")); assert.ok(sourceToc.includes("- [README.md](./README.md)")); }); @@ -323,3 +324,96 @@ test("sync removes TOC.md when toc is disabled", async () => { "TOC.md should have been removed", ); }); + +test("sync does not rewrite TOC.md when commit matches", async () => { + const tmpRoot = path.join( + tmpdir(), + `docs-cache-toc-stable-${Date.now().toString(36)}`, + ); + await mkdir(tmpRoot, { recursive: true }); + const cacheDir = path.join(tmpRoot, ".docs"); + const repoDir = path.join(tmpRoot, "repo"); + const configPath = path.join(tmpRoot, "docs.config.json"); + + await mkdir(repoDir, { recursive: true }); + await writeFile(path.join(repoDir, "README.md"), "hello", "utf8"); + + const config = { + $schema: + "https://raw.githubusercontent.com/fbosch/docs-cache/main/docs.config.schema.json", + 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, + }), + materializeSource: async ({ cacheDir: cacheRoot, sourceId }) => { + const outDir = path.join(cacheRoot, sourceId); + await mkdir(outDir, { recursive: true }); + await writeFile( + path.join(outDir, ".manifest.jsonl"), + `${JSON.stringify({ path: "README.md", size: 5 })}\n`, + ); + await writeFile(path.join(outDir, "README.md"), "hello", "utf8"); + return { bytes: 5, fileCount: 1 }; + }, + }, + ); + + const sourceTocPath = path.join(cacheDir, "local", "TOC.md"); + const before = await stat(sourceTocPath); + + 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, + }), + materializeSource: async () => { + throw new Error("materialize should not run"); + }, + }, + ); + + const after = await stat(sourceTocPath); + assert.equal( + before.mtimeMs, + after.mtimeMs, + "TOC.md should not be rewritten when commit matches", + ); +}); From c706656c359eaa42d1ccf5e45b86784df57aeee5 Mon Sep 17 00:00:00 2001 From: Frederik Bosch <6979916+fbosch@users.noreply.github.com> Date: Sun, 1 Feb 2026 23:38:52 +0100 Subject: [PATCH 11/11] docs: README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index faee9ec..2cd2b96 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ All fields in `defaults` apply to all sources unless overridden per-source: - `maxBytes`: Maximum total bytes to materialize (default: `200000000`) - `maxFiles`: Maximum total files to materialize (optional) - `allowHosts`: Allowed Git hosts (default: `["github.com", "gitlab.com"]`) -- `toc`: Generate per-source `TOC.md` listing all documentation files (default: `false`) +- `toc`: Generate per-source `TOC.md` listing all documentation files (default: `true`) **Source Options:**