diff --git a/package-lock.json b/package-lock.json index aac3c18..f1ed0ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "chokidar": "^5.0.0", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0" }, @@ -1287,9 +1288,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.9.tgz", - "integrity": "sha512-dSC6tJeOJxbZrPzPbv5mMd6CMiQ1ugaVXXPRad2fXUSsy1kstFn9XQWemV9VW7Y7kpxgQ/4WMoZfwdH8XSU48w==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1335,16 +1336,15 @@ } }, "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "license": "MIT", "dependencies": { - "readdirp": "^4.0.1" + "readdirp": "^5.0.0" }, "engines": { - "node": ">= 14.16.0" + "node": ">= 20.19.0" }, "funding": { "url": "https://paulmillr.com/funding/" @@ -1921,13 +1921,12 @@ } }, "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", "license": "MIT", "engines": { - "node": ">= 14.18.0" + "node": ">= 20.19.0" }, "funding": { "type": "individual", @@ -2210,6 +2209,36 @@ } } }, + "node_modules/tsup/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/tsup/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index a0917cb..e79a4e0 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "lint:ci": "biome ci" }, "dependencies": { + "chokidar": "^5.0.0", "highlight.js": "^11.11.1", "markdown-it": "^14.1.0" }, @@ -46,9 +47,9 @@ "@biomejs/biome": "2.3.10", "@types/markdown-it": "^14.1.2", "@types/node": "^25.0.3", + "@vitest/coverage-v8": "^4.0.16", "tsup": "^8.5.1", "typescript": "^5.9.3", - "@vitest/coverage-v8": "^4.0.16", "vitest": "^4.0.16" } } diff --git a/src/cli.ts b/src/cli.ts index 36b291d..cd23422 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { listPresets, runInit } from "./init.js"; import { collectMarkdownFiles, ensureDir, removeDir, toOutPath } from "./io.js"; import { loadTemplates } from "./templates.js"; import { transformMarkdownToHtml } from "./transform.js"; +import { startWatch } from "./watch.js"; type CliOptions = { templateDir: string; @@ -19,15 +20,22 @@ type CliOptions = { allowHtml: boolean; }; +type WatchCliOptions = CliOptions & { + debounceMs: number; + once: boolean; +}; + function printHelp(): void { console.log( ` Usage: md-xformer -o [-t ] [--ext html] [--clean] [--dry-run] [--verbose] [--allow-html] + md-xformer watch -o [-t ] [--ext html] [--verbose] [--allow-html] [--debounce-ms 100] [--once] md-xformer init [--preset ] [--dir ] [--force] [--dry-run] Commands: (default) Convert Markdown to HTML + watch Watch for changes and rebuild automatically init Scaffold a new project with templates and sample content Transform options: @@ -40,6 +48,10 @@ Transform options: --verbose Verbose logs --allow-html Allow raw HTML in Markdown input (unsafe for untrusted input) +Watch options: + --debounce-ms Debounce delay in ms before rebuild (default: 100) + --once Build once and exit (useful for testing) + Init options: --preset Scaffold preset (default: example) Available: ${listPresets().join(", ")} @@ -92,6 +104,189 @@ async function handleInitCommand(argv: string[]): Promise { }); } +type BuildOnceParams = { + inputAbs: string; + templateAbs: string; + outAbs: string; + ext: string; + verbose: boolean; + allowHtml: boolean; + dryRun: boolean; +}; + +async function buildOnce( + params: BuildOnceParams, +): Promise<{ ok: number; ng: number }> { + const { inputAbs, templateAbs, outAbs, ext, verbose, allowHtml, dryRun } = + params; + const cwd = process.cwd(); + + const templates = await loadTemplates(templateAbs); + const mdFiles = await collectMarkdownFiles(inputAbs); + + if (mdFiles.length === 0) { + throw new CliError(`ERROR: no markdown files found under: ${inputAbs}`); + } + + if (verbose) { + console.log(`[input] ${inputAbs}`); + console.log(`[templates] ${templateAbs}`); + console.log(`[out] ${outAbs}`); + console.log(`[files] ${mdFiles.length}`); + } + + let ok = 0; + let ng = 0; + + for (const mdFileAbs of mdFiles) { + try { + const md = await fs.readFile(mdFileAbs, "utf-8"); + const html = transformMarkdownToHtml(md, templates, { + verbose, + allowHtml, + }); + + const outFileAbs = toOutPath(mdFileAbs, cwd, outAbs, ext); + + if (verbose) { + console.log( + `[emit] ${path.relative(cwd, mdFileAbs)} -> ${path.relative(cwd, outFileAbs)}`, + ); + } + + if (!dryRun) { + await ensureDir(path.dirname(outFileAbs)); + await fs.writeFile(outFileAbs, html, "utf-8"); + } + + ok++; + } catch (e) { + ng++; + console.error(`[error] ${mdFileAbs}`); + console.error(e); + } + } + + if (verbose) { + console.log(`[done] ok=${ok} ng=${ng}`); + } + + return { ok, ng }; +} + +async function handleWatchCommand(argv: string[]): Promise { + const { values, positionals } = parseArgs({ + args: argv, + options: { + "template-dir": { type: "string", short: "t" }, + "out-dir": { type: "string", short: "o" }, + ext: { type: "string" }, + verbose: { type: "boolean" }, + "allow-html": { type: "boolean" }, + "debounce-ms": { type: "string" }, + once: { type: "boolean" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + }); + + if (values.help) { + printHelp(); + return 0; + } + + const input = positionals[0]; + if (!input) die("ERROR: is required. Use --help."); + + const opts: WatchCliOptions = { + templateDir: values["template-dir"] ?? "template", + outDir: values["out-dir"] ?? "", + ext: values.ext ?? "html", + clean: false, // watch does not support --clean + dryRun: false, // watch does not support --dry-run + verbose: Boolean(values.verbose), + allowHtml: Boolean(values["allow-html"]), + debounceMs: Number.parseInt(values["debounce-ms"] ?? "100", 10), + once: Boolean(values.once), + }; + + if (!opts.outDir) die("ERROR: --out-dir (-o) is required."); + if (Number.isNaN(opts.debounceMs) || opts.debounceMs < 0) { + die("ERROR: --debounce-ms must be a non-negative integer."); + } + + const inputAbs = path.resolve(process.cwd(), input); + const outAbs = path.resolve(process.cwd(), opts.outDir); + const templateAbs = path.resolve(process.cwd(), opts.templateDir); + + if (!existsSync(templateAbs)) { + die(`ERROR: template directory not found: ${templateAbs}`); + } + + if (!existsSync(inputAbs)) { + die(`ERROR: input path not found: ${inputAbs}`); + } + + // Ensure output directory exists + await ensureDir(outAbs); + + if (opts.verbose) { + console.log(`[watch] input: ${inputAbs}`); + console.log(`[watch] templates: ${templateAbs}`); + console.log(`[watch] output: ${outAbs}`); + console.log(`[watch] debounce: ${opts.debounceMs}ms`); + } + + const buildParams: BuildOnceParams = { + inputAbs, + templateAbs, + outAbs, + ext: opts.ext, + verbose: opts.verbose, + allowHtml: opts.allowHtml, + dryRun: false, + }; + + // `--once` is intentionally implemented without starting a watcher. + // This keeps tests stable and ensures we return a correct exit code. + if (opts.once) { + const result = await buildOnce(buildParams); + return result.ng > 0 ? 1 : 0; + } + + // Initial build on start (watch continues even if build has errors). + const initial = await buildOnce(buildParams); + if (initial.ng > 0) { + process.exitCode = 1; + } + + return new Promise((resolve) => { + const watcher = startWatch({ + inputAbs, + templateAbs, + outAbs, + debounceMs: opts.debounceMs, + verbose: opts.verbose, + once: false, + onBuild: async () => { + const result = await buildOnce(buildParams); + if (result.ng > 0) process.exitCode = 1; + }, + }); + + watcher.on("close", () => { + resolve(0); + }); + + process.on("SIGINT", () => { + if (opts.verbose) { + console.log("\n[watch] shutting down..."); + } + watcher.close(); + }); + }); +} + export async function runCli(argv: string[]): Promise { try { return await mainWithArgs(argv); @@ -112,6 +307,11 @@ async function mainWithArgs(argv: string[]): Promise { return await handleInitCommand(argv.slice(1)); } + // Check if first positional is "watch" command + if (argv[0] === "watch") { + return await handleWatchCommand(argv.slice(1)); + } + const { values, positionals } = parseArgs({ args: argv, options: { @@ -155,6 +355,10 @@ async function mainWithArgs(argv: string[]): Promise { die(`ERROR: template directory not found: ${templateAbs}`); } + if (!existsSync(inputAbs)) { + die(`ERROR: input path not found: ${inputAbs}`); + } + if (opts.clean && existsSync(outAbs)) { if (opts.verbose) console.log(`[clean] ${outAbs}`); if (!opts.dryRun) await removeDir(outAbs); @@ -162,67 +366,21 @@ async function mainWithArgs(argv: string[]): Promise { if (!opts.dryRun) await ensureDir(outAbs); - const templates = await loadTemplates(templateAbs); - - // Collect inputs - const mdFiles = await collectMarkdownFiles(inputAbs); - if (mdFiles.length === 0) { - die(`ERROR: no markdown files found under: ${inputAbs}`); - } - - if (opts.verbose) { - console.log(`[input] ${inputAbs}`); - console.log(`[templates] ${templateAbs}`); - console.log(`[out] ${outAbs}`); - console.log(`[files] ${mdFiles.length}`); - } - - const cwd = process.cwd(); - - let ok = 0; - let ng = 0; - - for (const mdFileAbs of mdFiles) { - try { - const md = await fs.readFile(mdFileAbs, "utf-8"); - const html = transformMarkdownToHtml(md, templates, { - verbose: opts.verbose, - allowHtml: opts.allowHtml, - }); - - const outFileAbs = toOutPath(mdFileAbs, cwd, outAbs, opts.ext); - - if (opts.verbose) { - console.log( - `[emit] ${path.relative(cwd, mdFileAbs)} -> ${path.relative( - cwd, - outFileAbs, - )}`, - ); - } - - if (!opts.dryRun) { - await ensureDir(path.dirname(outFileAbs)); - await fs.writeFile(outFileAbs, html, "utf-8"); - } - - ok++; - } catch (e) { - ng++; - console.error(`[error] ${mdFileAbs}`); - console.error(e); - } - } + const result = await buildOnce({ + inputAbs, + templateAbs, + outAbs, + ext: opts.ext, + verbose: opts.verbose, + allowHtml: opts.allowHtml, + dryRun: opts.dryRun, + }); - if (ng > 0) { + if (result.ng > 0) { process.exitCode = 1; } - if (opts.verbose) { - console.log(`[done] ok=${ok} ng=${ng}`); - } - - return ng > 0 ? 1 : 0; + return result.ng > 0 ? 1 : 0; } const invoked = process.argv[1]; diff --git a/src/watch.ts b/src/watch.ts new file mode 100644 index 0000000..85f8359 --- /dev/null +++ b/src/watch.ts @@ -0,0 +1,139 @@ +import path from "node:path"; +import { watch as chokidarWatch, type FSWatcher } from "chokidar"; + +export type WatchOptions = { + inputAbs: string; + templateAbs: string; + outAbs: string; + debounceMs: number; + verbose: boolean; + once: boolean; + onBuild: () => Promise; +}; + +const IGNORED_DIRS = ["node_modules", ".git", "dist"]; + +function isUnderDir(filePath: string, dirAbs: string): boolean { + return filePath === dirAbs || filePath.startsWith(dirAbs + path.sep); +} + +function buildIgnored(outAbs: string): (filePath: string) => boolean { + const ignoredDirRegexes = IGNORED_DIRS.map( + (dir) => new RegExp(`(^|[\\\\/])${dir}([\\\\/]|$)`), + ); + + return (filePath: string) => { + if (isUnderDir(filePath, outAbs)) return true; + return ignoredDirRegexes.some((re) => re.test(filePath)); + }; +} + +export function startWatch(opts: WatchOptions): FSWatcher { + const { inputAbs, templateAbs, outAbs, debounceMs, verbose, once, onBuild } = + opts; + + const ignored = buildIgnored(outAbs); + + // Watched paths: input (*.md) + template dir (*.template.html, non-recursive) + const watchedPaths = [inputAbs, templateAbs]; + + const watcher = chokidarWatch(watchedPaths, { + ignored, + persistent: !once, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 10, + }, + }); + + let debounceTimer: ReturnType | null = null; + let building = false; + + const scheduleBuild = (eventPath: string, eventType: string) => { + // Filter: only react to .md files for input, .template.html for templates + const isMd = eventPath.toLowerCase().endsWith(".md"); + const isTemplate = eventPath.toLowerCase().endsWith(".template.html"); + + // Check if path is under template directory (non-recursive) + const isInTemplateDir = + path.dirname(eventPath) === templateAbs && isTemplate; + // Check if path is under input (could be nested) + const isInInput = + (eventPath.startsWith(inputAbs + path.sep) || eventPath === inputAbs) && + isMd; + + if (!isInTemplateDir && !isInInput) { + return; + } + + if (verbose) { + console.log(`[watch] ${eventType}: ${eventPath}`); + } + + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + debounceTimer = setTimeout(async () => { + debounceTimer = null; + + if (building) { + if (verbose) { + console.log("[watch] build already in progress, skipping"); + } + return; + } + + building = true; + try { + if (verbose) { + console.log("[watch] rebuilding..."); + } + await onBuild(); + if (verbose) { + console.log("[watch] rebuild complete"); + } + } catch (e) { + console.error("[watch] build error (will retry on next change):"); + console.error(e); + } finally { + building = false; + } + + if (once) { + watcher.close(); + } + }, debounceMs); + }; + + watcher.on("add", (p) => scheduleBuild(p, "add")); + watcher.on("change", (p) => scheduleBuild(p, "change")); + watcher.on("unlink", (p) => scheduleBuild(p, "unlink")); + + watcher.on("ready", async () => { + if (verbose) { + console.log("[watch] watching for changes..."); + } + + // For --once mode, run initial build immediately + if (once) { + building = true; + try { + await onBuild(); + } catch (e) { + console.error("[watch] initial build error:"); + console.error(e); + } finally { + building = false; + watcher.close(); + } + } + }); + + watcher.on("error", (error) => { + console.error("[watch] watcher error:", error); + }); + + return watcher; +} diff --git a/tests/e2e/cli.e2e.test.ts b/tests/e2e/cli.e2e.test.ts index 749a253..97810a9 100644 --- a/tests/e2e/cli.e2e.test.ts +++ b/tests/e2e/cli.e2e.test.ts @@ -635,4 +635,113 @@ describe("CLI (dist)", () => { expect(html).toContain("Sample Article"); expect(html).toContain('id="introduction"'); }); + + it("watch --once builds files and exits", async () => { + const cli = distCliPath(); + expect(existsSync(cli)).toBe(true); + + const root = await makeTempDir("md-xformer-e2e-watch-once-"); + created.push(root); + + const inputDir = path.join(root, "input"); + const outDir = path.join(root, "out"); + const templateDir = path.join(root, "template"); + + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(templateDir, { recursive: true }); + + await fs.writeFile( + path.join(templateDir, "h2.template.html"), + '

{{ h2 }}

', + ); + await fs.writeFile( + path.join(templateDir, "p.template.html"), + '

{{ p }}

', + ); + + await fs.writeFile( + path.join(inputDir, "test.md"), + "## Watch Test\n\nThis is a test.\n", + ); + + const res = spawnSync( + process.execPath, + [cli, "watch", "input", "-o", "out", "-t", "template", "--once"], + { + cwd: root, + encoding: "utf-8", + timeout: 10000, + }, + ); + + expect(res.status).toBe(0); + + const outFile = path.join(outDir, "input", "test.html"); + expect(existsSync(outFile)).toBe(true); + + const html = await fs.readFile(outFile, "utf-8"); + expect(html).toContain('

Watch Test

'); + expect(html).toContain('

This is a test.

'); + }); + + it("watch --once with --verbose shows watch logs", async () => { + const cli = distCliPath(); + expect(existsSync(cli)).toBe(true); + + const root = await makeTempDir("md-xformer-e2e-watch-verbose-"); + created.push(root); + + const inputDir = path.join(root, "input"); + const outDir = path.join(root, "out"); + const templateDir = path.join(root, "template"); + + await fs.mkdir(inputDir, { recursive: true }); + await fs.mkdir(templateDir, { recursive: true }); + + await fs.writeFile( + path.join(templateDir, "p.template.html"), + "

{{ p }}

", + ); + await fs.writeFile(path.join(inputDir, "a.md"), "Hello\n"); + + const res = spawnSync( + process.execPath, + [ + cli, + "watch", + "input", + "-o", + "out", + "-t", + "template", + "--once", + "--verbose", + ], + { + cwd: root, + encoding: "utf-8", + timeout: 10000, + }, + ); + + expect(res.status).toBe(0); + expect(res.stdout).toContain("[watch]"); + + const outFile = path.join(outDir, "input", "a.html"); + expect(existsSync(outFile)).toBe(true); + }); + + it("help includes watch command", () => { + const cli = distCliPath(); + expect(existsSync(cli)).toBe(true); + + const res = spawnSync(process.execPath, [cli, "--help"], { + encoding: "utf-8", + }); + + expect(res.status).toBe(0); + expect(res.stdout).toContain("watch"); + expect(res.stdout).toContain("--debounce-ms"); + expect(res.stdout).toContain("--once"); + }); }); diff --git a/tests/unit/watch.test.ts b/tests/unit/watch.test.ts new file mode 100644 index 0000000..be2f8bf --- /dev/null +++ b/tests/unit/watch.test.ts @@ -0,0 +1,300 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type WatchCall = { + paths: unknown; + options: any; +}; + +type Handler = (...args: any[]) => unknown; + +type MockState = { + watchCalls: WatchCall[]; + handlers: Map; + watcher: { + on: (event: string, cb: Handler) => unknown; + close: ReturnType; + }; + reset: () => void; + emit: (event: string, ...args: any[]) => void; + emitAsync: (event: string, ...args: any[]) => Promise; +}; + +const state = vi.hoisted(() => { + const watchCalls: WatchCall[] = []; + const handlers = new Map(); + + const watcher: MockState["watcher"] = { + on: (event, cb) => { + const list = handlers.get(event) ?? []; + list.push(cb); + handlers.set(event, list); + return watcher; + }, + close: vi.fn(), + }; + + const reset = () => { + watchCalls.length = 0; + handlers.clear(); + watcher.close.mockClear(); + }; + + const emit = (event: string, ...args: any[]) => { + for (const cb of handlers.get(event) ?? []) { + cb(...args); + } + }; + + const emitAsync = async (event: string, ...args: any[]) => { + const list = handlers.get(event) ?? []; + await Promise.all(list.map((cb) => cb(...args))); + }; + + return { watchCalls, handlers, watcher, reset, emit, emitAsync }; +}); + +vi.mock("chokidar", () => { + return { + watch: (paths: unknown, options: unknown) => { + state.watchCalls.push({ paths, options }); + return state.watcher; + }, + }; +}); + +describe("startWatch", () => { + beforeEach(() => { + vi.resetModules(); + state.reset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("calls chokidar.watch with expected paths and options", async () => { + const { startWatch } = await import("../../src/watch.js"); + + const inputAbs = "/repo/input"; + const templateAbs = "/repo/template"; + const outAbs = "/repo/out"; + + startWatch({ + inputAbs, + templateAbs, + outAbs, + debounceMs: 100, + verbose: false, + once: false, + onBuild: async () => {}, + }); + + expect(state.watchCalls).toHaveLength(1); + + const call = state.watchCalls[0]; + expect(call.paths).toEqual([inputAbs, templateAbs]); + + expect(call.options).toMatchObject({ + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 50, + pollInterval: 10, + }, + }); + + expect(typeof call.options.ignored).toBe("function"); + }); + + it("sets persistent=false when once=true", async () => { + const { startWatch } = await import("../../src/watch.js"); + + startWatch({ + inputAbs: "/repo/input", + templateAbs: "/repo/template", + outAbs: "/repo/out", + debounceMs: 0, + verbose: false, + once: true, + onBuild: async () => {}, + }); + + expect(state.watchCalls).toHaveLength(1); + expect(state.watchCalls[0]?.options?.persistent).toBe(false); + }); + + it("ignores output directory subtree and common directories", async () => { + const { startWatch } = await import("../../src/watch.js"); + + const outAbs = "/repo/out"; + + startWatch({ + inputAbs: "/repo/input", + templateAbs: "/repo/template", + outAbs, + debounceMs: 0, + verbose: false, + once: false, + onBuild: async () => {}, + }); + + const ignored = state.watchCalls[0]?.options?.ignored as + | ((p: string) => boolean) + | undefined; + + expect(ignored).toBeTypeOf("function"); + if (!ignored) return; + + expect(ignored(outAbs)).toBe(true); + expect(ignored(path.join(outAbs, "a.html"))).toBe(true); + + expect(ignored("/x/node_modules/y/file.md")).toBe(true); + expect(ignored("/x/.git/config")).toBe(true); + expect(ignored("/x/dist/bundle.js")).toBe(true); + + // Windows separators should also match + expect(ignored("C:\\proj\\node_modules\\a.md")).toBe(true); + + // Should not accidentally ignore similar names + expect(ignored("/x/distillery/file.md")).toBe(false); + }); + + it("filters events to only input .md and template/*.template.html (non-recursive)", async () => { + vi.useFakeTimers(); + + const { startWatch } = await import("../../src/watch.js"); + + const inputAbs = "/repo/input"; + const templateAbs = "/repo/template"; + const outAbs = "/repo/out"; + + const onBuild = vi.fn(async () => {}); + + startWatch({ + inputAbs, + templateAbs, + outAbs, + debounceMs: 100, + verbose: false, + once: false, + onBuild, + }); + + state.emit("add", path.join(inputAbs, "a.md")); + state.emit("change", path.join(inputAbs, "b.MD")); + state.emit("change", path.join(inputAbs, "c.txt")); + + state.emit("change", path.join(templateAbs, "h2.template.html")); + state.emit("change", path.join(templateAbs, "sub", "h2.template.html")); + + await vi.runAllTimersAsync(); + + expect(onBuild).toHaveBeenCalledTimes(1); + }); + + it("debounces multiple events into a single build", async () => { + vi.useFakeTimers(); + + const { startWatch } = await import("../../src/watch.js"); + + const inputAbs = "/repo/input"; + const templateAbs = "/repo/template"; + const outAbs = "/repo/out"; + + const onBuild = vi.fn(async () => {}); + + startWatch({ + inputAbs, + templateAbs, + outAbs, + debounceMs: 100, + verbose: false, + once: false, + onBuild, + }); + + state.emit("add", path.join(inputAbs, "a.md")); + await vi.advanceTimersByTimeAsync(50); + + // Another event within the debounce window should reset the timer + state.emit("change", path.join(inputAbs, "a.md")); + + await vi.advanceTimersByTimeAsync(100); + + expect(onBuild).toHaveBeenCalledTimes(1); + }); + + it("does not start a second build while one is in progress", async () => { + vi.useFakeTimers(); + + const { startWatch } = await import("../../src/watch.js"); + + const inputAbs = "/repo/input"; + const templateAbs = "/repo/template"; + const outAbs = "/repo/out"; + + let resolveFirst!: () => void; + const firstBuild = new Promise((resolve) => { + resolveFirst = resolve; + }); + + let calls = 0; + const onBuild = vi.fn(() => { + calls++; + if (calls === 1) return firstBuild; + return Promise.resolve(); + }); + + startWatch({ + inputAbs, + templateAbs, + outAbs, + debounceMs: 100, + verbose: false, + once: false, + onBuild, + }); + + state.emit("change", path.join(inputAbs, "a.md")); + await vi.advanceTimersByTimeAsync(100); + + // Build is now in-progress (firstBuild unresolved) + expect(onBuild).toHaveBeenCalledTimes(1); + + state.emit("change", path.join(inputAbs, "b.md")); + await vi.advanceTimersByTimeAsync(100); + + // Should be skipped due to "building" guard + expect(onBuild).toHaveBeenCalledTimes(1); + + resolveFirst(); + await Promise.resolve(); + + state.emit("change", path.join(inputAbs, "c.md")); + await vi.advanceTimersByTimeAsync(100); + + expect(onBuild).toHaveBeenCalledTimes(2); + }); + + it("runs initial build and closes when once=true on ready", async () => { + const { startWatch } = await import("../../src/watch.js"); + + const onBuild = vi.fn(async () => {}); + + startWatch({ + inputAbs: "/repo/input", + templateAbs: "/repo/template", + outAbs: "/repo/out", + debounceMs: 0, + verbose: false, + once: true, + onBuild, + }); + + await state.emitAsync("ready"); + + expect(onBuild).toHaveBeenCalledTimes(1); + expect(state.watcher.close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 12f6cfa..4e00ca1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ provider: "v8", reporter: ["text", "html"], reportsDirectory: "coverage", + include: ["src/**/*.ts"], exclude: ["dist/**", "tests/**", "**/*.d.ts", "**/*.config.*"], }, },