diff --git a/README.md b/README.md index b751312..2cd2b96 100644 --- a/README.md +++ b/README.md @@ -73,24 +73,40 @@ 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` | +| `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: `true`) **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 +- `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 ab16933..4ec49f4 100644 --- a/docs.config.schema.json +++ b/docs.config.schema.json @@ -14,9 +14,6 @@ "type": "string", "enum": ["symlink", "copy"] }, - "index": { - "type": "boolean" - }, "defaults": { "type": "object", "properties": { @@ -62,6 +59,9 @@ "type": "string", "minLength": 1 } + }, + "toc": { + "type": "boolean" } }, "additionalProperties": false @@ -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..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?.index !== undefined) { - nextConfig.index = rawConfig.index; - } if (rawConfig?.defaults) { nextConfig.defaults = rawConfig.defaults; } diff --git a/src/config-schema.ts b/src/config-schema.ts index 48f9fd6..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(); @@ -38,6 +39,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 +48,6 @@ export const ConfigSchema = z $schema: z.string().min(1).optional(), cacheDir: z.string().min(1).optional(), targetMode: TargetModeSchema.optional(), - index: z.boolean().optional(), defaults: DefaultsSchema.partial().optional(), sources: z.array(SourceSchema), }) diff --git a/src/config.ts b/src/config.ts index eaa1e9a..747a9b9 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 { @@ -39,13 +40,13 @@ export interface DocsCacheSource { maxBytes?: number; maxFiles?: number; integrity?: DocsCacheIntegrity; + toc?: boolean; } export interface DocsCacheConfig { $schema?: string; cacheDir?: string; targetMode?: "symlink" | "copy"; - index?: 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,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, - index: false, defaults: { ref: "HEAD", mode: "materialize", @@ -82,6 +83,7 @@ export const DEFAULT_CONFIG: DocsCacheConfig = { required: true, maxBytes: 200000000, allowHosts: ["github.com", "gitlab.com"], + toc: true, }, sources: [], }; @@ -144,7 +146,6 @@ export const stripDefaultConfigValues = ( const next: DocsCacheConfig = { $schema: pruned.$schema as DocsCacheConfig["$schema"], cacheDir: pruned.cacheDir as DocsCacheConfig["cacheDir"], - index: pruned.index as DocsCacheConfig["index"], targetMode: pruned.targetMode as DocsCacheConfig["targetMode"], defaults: pruned.defaults as DocsCacheConfig["defaults"], sources: config.sources, @@ -247,10 +248,6 @@ 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 defaultsInput = input.defaults; const targetModeOverride = @@ -300,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 = { @@ -386,6 +387,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 +411,6 @@ export const validateConfig = (input: unknown): DocsCacheConfig => { return { cacheDir, targetMode: targetModeOverride, - index, defaults, sources, }; @@ -432,6 +435,7 @@ export const resolveSources = ( maxBytes: source.maxBytes ?? defaults.maxBytes, maxFiles: source.maxFiles ?? defaults.maxFiles, integrity: source.integrity, + toc: source.toc ?? defaults.toc, })); }; 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/init.ts b/src/init.ts index 887ec74..084c095 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)", - initialValue: false, + "Generate TOC.md (table of contents with links to all documentation)", + initialValue: true, }); - 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,9 @@ export const initConfig = async ( if (resolvedCacheDir !== DEFAULT_CACHE_DIR) { baseConfig.cacheDir = resolvedCacheDir; } - if (answers.index) { - baseConfig.index = 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.index) { - config.index = 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/src/paths.ts b/src/paths.ts index d712998..ee4781f 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,8 @@ 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); return { cacheDir, sourceDir, - indexPath, }; }; diff --git a/src/remove.ts b/src/remove.ts index 4197152..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?.index !== undefined) { - nextConfig.index = rawConfig.index; - } if (rawConfig?.defaults) { nextConfig.defaults = rawConfig.defaults; } diff --git a/src/sync.ts b/src/sync.ts index f557066..8b4097a 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,14 +482,14 @@ 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({ - 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, + results: plan.results, + }); plan.lockExists = true; return plan; }; diff --git a/src/toc.ts b/src/toc.ts new file mode 100644 index 0000000..710b09e --- /dev/null +++ b/src/toc.ts @@ -0,0 +1,182 @@ +import { access, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { DocsCacheResolvedSource } from "./config"; +import type { DocsCacheLock } from "./lock"; +import { DEFAULT_TOC_FILENAME, resolveTargetDir, toPosixPath } from "./paths"; + +type TocEntry = { + id: string; + repo: string; + ref: string; + resolvedCommit: string; + fileCount: number; + cachePath: string; + targetDir?: string; + 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("---"); + 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(`# ${entry.id} - Documentation`); + lines.push(""); + lines.push("## Files"); + lines.push(""); + const tree = createTocTree(entry.files); + renderTocTree(tree, 0, lines); + 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[]; + 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); + const targetDir = source?.targetDir + ? toPosixPath(resolveTargetDir(params.configPath, source.targetDir)) + : 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 = { + id, + repo: lockEntry.repo, + ref: lockEntry.ref, + resolvedCommit: lockEntry.resolvedCommit, + fileCount: lockEntry.fileCount, + cachePath: toPosixPath(path.join(params.cacheDir, id)), + targetDir, + files, + }; + + // 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 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 { + // 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/edge-cases-validation.test.js b/tests/edge-cases-validation.test.js index d2236f0..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("index flag set to true", async () => { +test("toc flag set to true in defaults", async () => { const configPath = await writeConfig({ - index: true, + defaults: { + 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.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 2c3da55..c11f96b 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,16 +80,16 @@ 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); + // When toc is true (default), it shouldn't be explicitly set + assert.equal(config.defaults?.toc, undefined); assert.equal(Array.isArray(config.sources), true); - assert.equal(config.defaults, undefined); }); test("init writes package.json docs-cache when selected", async () => { @@ -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,9 +114,9 @@ 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); + // 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); - assert.equal(pkg["docs-cache"].defaults, undefined); }); test("init writes .gitignore entry when missing", async () => { @@ -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-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..f44886f --- /dev/null +++ b/tests/sync-toc.test.js @@ -0,0 +1,419 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, stat, 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"; + +test("sync writes per-source TOC when defaults.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", + defaults: { + 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 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 () => { + 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("---")); + 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)")); +}); + +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", + ); +}); + +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", + ); +});