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.*"],
},
},