@zvndev/darkmown 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 0.20.0 — 2026-06-26
6
+
7
+ Engineering hardening: a real programmatic API, plus a gold-standard pass on tooling, tests, and internals. No behavior change for existing sites — the runtime is byte-for-byte the same (~5.8 KB gzipped).
8
+
9
+ - **Programmatic API.** `@zvndev/darkmown` now ships a proper entry point: `import { buildSite, compilePage, compileDocument, parseFrontmatter, escapeHtml, … } from "@zvndev/darkmown"` resolves through an `exports` map and a `src/index.js` barrel that mirrors the public surface already declared in `types/index.d.ts`. The package was CLI-only before; the `darkmown` CLI is unchanged.
10
+ - **Compiler internals split.** The ~2,900-line `src/compiler.js` is now a `src/compiler/` folder along its existing seams (page shell, frontmatter, includes, interpolation, predicates, markdown plugin, loops, directives, body). Public exports and output are identical — `src/compiler.js` stays as a thin re-export barrel. Purely internal.
11
+ - **Sharper compile errors.** Malformed-directive errors now report `file:line` (matching the unclosed-block errors that already did), and action-literal failures name the offending page. Easier to trace a mistake back to its source.
12
+ - **Lint + format with Biome.** Added Biome (one binary, lint + format) with `npm run lint` / `npm run format:check`, a `lint` CI job, and a husky + lint-staged pre-commit that checks staged files. `src/runtime.js` is excluded from the formatter so the byte budget is never moved by a reformat. Style commands are documented in CONTRIBUTING.md.
13
+ - **100% line coverage, CI-gated.** The test suite now covers every line of `src/`, and the coverage gate is raised from 90% to 100%. Covering the CLI and dev-server paths surfaced and fixed two real bugs: symlinked-bin entry-point detection and a dev-server response that wrote headers before reading the file.
14
+ - **Runtime size trend.** A committed `.size-snapshot.json` baseline lets CI report the gzipped-runtime delta on every change, alongside the existing hard < 6 KB gate.
15
+ - **Fixed a production-breaking `vercel.json`.** An invalid `comment` property was rejected by Vercel's strict schema and failed every deploy before install; removed, with a regression test that rejects any non-allowlisted key in a `headers[]` rule.
16
+
5
17
  ## 0.19.0 — 2026-06-25
6
18
 
7
19
  Post-review sprint: deeper reactive loops, hardened deploys, and OS-aware theming.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  **[darkmown.com](https://darkmown.com)** · Markdown that runs.
9
9
 
10
- Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~5.7 KB gzipped, CI-enforced under 6 KB.
10
+ Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~5.8 KB gzipped, CI-enforced under 6 KB.
11
11
 
12
12
  ## Quick start
13
13
 
@@ -52,7 +52,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
52
52
  - Files or folders starting with `.`, `-`, or `_` are hidden from routing.
53
53
  - `site/_` is the include shelf for `@include /name.wd`.
54
54
  - Matching `page.skin` and `page.js` colocate styling and behavior by basename.
55
- - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.7 KB gzipped, CI-enforced under 6 KB).
55
+ - Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.8 KB gzipped, CI-enforced under 6 KB).
56
56
  - Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
57
57
 
58
58
  ## Interpolation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,26 +10,41 @@
10
10
  "bin": {
11
11
  "darkmown": "./src/cli.js"
12
12
  },
13
+ "main": "./src/index.js",
14
+ "exports": {
15
+ ".": "./src/index.js",
16
+ "./package.json": "./package.json"
17
+ },
13
18
  "types": "./types/index.d.ts",
14
19
  "scripts": {
15
20
  "build": "node src/cli.js build",
16
21
  "runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 6144)'); if (size >= 6144) process.exit(1)\"",
17
22
  "dev": "node src/cli.js dev",
18
23
  "test": "node --test tests/*.test.js",
19
- "test:cov": "node scripts/test-coverage-gate.mjs 90",
24
+ "test:cov": "node scripts/test-coverage-gate.mjs 100",
20
25
  "test:e2e": "playwright test",
21
26
  "test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
22
27
  "pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
23
28
  "typecheck": "tsc --noEmit",
24
29
  "types": "tsc -p tsconfig.types.json",
25
30
  "smoke": "node scripts/smoke-consumer.mjs",
31
+ "lint": "biome check .",
32
+ "format": "biome format --write .",
33
+ "format:check": "biome format .",
34
+ "size:check": "node scripts/size-check.mjs",
35
+ "prepare": "husky || true",
26
36
  "prepublish:check": "npm run typecheck && npm run test:cov && npm run build && npm run runtime:size && npm audit --audit-level=high && npm run smoke",
27
- "prepublishOnly": "npm run prepublish:check"
37
+ "prepublishOnly": "npm run prepublish:check",
38
+ "og": "node scripts/og-image.mjs"
39
+ },
40
+ "lint-staged": {
41
+ "*.{js,json}": "biome check --no-errors-on-unmatched --files-ignore-unknown=true"
28
42
  },
29
43
  "files": [
30
44
  "src",
31
45
  "types",
32
46
  "site",
47
+ "!site/_/og.png",
33
48
  "docs/cli.md",
34
49
  "docs/spec-alignment.md",
35
50
  "AGENTS.md",
@@ -46,9 +61,12 @@
46
61
  "markdown-it": "^14.2.0"
47
62
  },
48
63
  "devDependencies": {
64
+ "@biomejs/biome": "^2.5.1",
49
65
  "@playwright/test": "^1.49.0",
50
66
  "@types/markdown-it": "^14.1.2",
51
67
  "@types/node": "^26.0.0",
68
+ "husky": "^9.1.7",
69
+ "lint-staged": "^16.4.0",
52
70
  "typescript": "^6.0.3"
53
71
  },
54
72
  "repository": {
package/src/builder.js CHANGED
@@ -60,7 +60,9 @@ export function buildSite(cwd = process.cwd()) {
60
60
  file: path.relative(cwd, route.file).replaceAll(path.sep, "/"),
61
61
  assets: {
62
62
  skins: [...page.assets.skins],
63
- scripts: page.assets.runtime ? ["/__wd/runtime.js", ...page.assets.scripts] : [...page.assets.scripts],
63
+ scripts: page.assets.runtime
64
+ ? ["/__wd/runtime.js", ...page.assets.scripts]
65
+ : [...page.assets.scripts],
64
66
  runtime: page.assets.runtime
65
67
  }
66
68
  });
@@ -101,13 +103,15 @@ export function buildSite(cwd = process.cwd()) {
101
103
  * @returns {string}
102
104
  */
103
105
  export function stripRuntimeComments(source) {
104
- return source
105
- // JSDoc blocks — type annotations the browser never needs.
106
- .replace(/\/\*\*[\s\S]*?\*\//g, "")
107
- // Whole-line `//` comments — developer notes that don't belong in the download.
108
- .replace(/^[ \t]*\/\/.*$/gm, "")
109
- // Drop the now blank/whitespace-only lines the removals leave behind.
110
- .replace(/^[ \t]*\n/gm, "");
106
+ return (
107
+ source
108
+ // JSDoc blocks — type annotations the browser never needs.
109
+ .replace(/\/\*\*[\s\S]*?\*\//g, "")
110
+ // Whole-line `//` comments — developer notes that don't belong in the download.
111
+ .replace(/^[ \t]*\/\/.*$/gm, "")
112
+ // Drop the now blank/whitespace-only lines the removals leave behind.
113
+ .replace(/^[ \t]*\n/gm, "")
114
+ );
111
115
  }
112
116
 
113
117
  /**
@@ -126,10 +130,10 @@ export function stripRuntimeComments(source) {
126
130
  * @returns {string}
127
131
  */
128
132
  export function renderCloudflareHeaders(manifest) {
129
- const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(([name, value]) => ` ${name}: ${value}`);
130
- const blocks = [
131
- ["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]
132
- ];
133
+ const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(
134
+ ([name, value]) => ` ${name}: ${value}`
135
+ );
136
+ const blocks = [["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]];
133
137
 
134
138
  for (const entry of manifest) {
135
139
  if (entry.assets.runtime) continue; // reactive routes keep the catch-all (relaxed) CSP
@@ -169,7 +173,7 @@ function defaultNotFoundHtml() {
169
173
  "<body>",
170
174
  "<main>",
171
175
  "<h1>Page not found</h1>",
172
- "<p>That route does not exist. <a href=\"/\">Back to home</a>.</p>",
176
+ '<p>That route does not exist. <a href="/">Back to home</a>.</p>',
173
177
  "</main>",
174
178
  "</body>",
175
179
  "</html>",
@@ -243,7 +247,9 @@ function emitPageAssets(paths) {
243
247
  if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
244
248
  const out = path.join(paths.distRoot, rel);
245
249
  if (fs.existsSync(out)) {
246
- console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
250
+ console.warn(
251
+ `hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`
252
+ );
247
253
  continue;
248
254
  }
249
255
  fs.mkdirSync(path.dirname(out), { recursive: true });
package/src/cli.js CHANGED
@@ -10,38 +10,101 @@ import { initProject } from "./scaffold.js";
10
10
  import { contentType, resolvePublicFile, serve } from "./statics.js";
11
11
 
12
12
  const cliPath = fileURLToPath(import.meta.url);
13
- // Bare `darkmown` prints help (discovery) rather than silently building — a new
14
- // user typing the command alone expects to see what it can do, not a no-op build.
15
- const command = process.argv[2] || "help";
16
-
17
- if (command === "help" || command === "--help" || command === "-h") {
18
- printHelp();
19
- } else if (command === "version" || command === "--version" || command === "-v") {
20
- const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
21
- console.log(pkg.version);
22
- } else if (command === "init") {
23
- try {
24
- const target = process.argv[3] || ".";
25
- const result = initProject(path.resolve(process.cwd(), target));
26
- const relativeRoot = path.relative(process.cwd(), result.root) || ".";
27
- console.log(`Created Darkmown project at ${relativeRoot}`);
28
- console.log(`Next: ${nextStep(relativeRoot)}`);
29
- } catch (error) {
30
- failClean(error);
13
+
14
+ /**
15
+ * Run the Darkmown CLI. Extracted from the top-level script so every command
16
+ * branch is unit-testable in-process: it never reads `process.argv` or calls
17
+ * `process.exit` directly. Instead it takes the argv tail and an injectable
18
+ * environment, RETURNS a value (and a server/close handle for `dev`/`serve`),
19
+ * and throws `CliError` for the failure paths the binary maps to exit code 1.
20
+ *
21
+ * The thin top-level wrapper below preserves identical binary behavior: it reads
22
+ * `process.argv`, calls `run`, prints `CliError` messages the same way the old
23
+ * inline `failClean`/`console.error` did, and exits with the right code.
24
+ *
25
+ * @typedef {object} RunEnv
26
+ * @property {string} [cwd] Working directory (defaults to `process.cwd()`).
27
+ * @property {(message?: unknown, ...rest: unknown[]) => void} [log] stdout sink.
28
+ * @property {(message?: unknown, ...rest: unknown[]) => void} [warn] stderr (warn) sink.
29
+ * @property {(message?: unknown, ...rest: unknown[]) => void} [error] stderr (error) sink.
30
+ *
31
+ * @param {string[]} argv The argv tail (i.e. `process.argv.slice(2)`).
32
+ * @param {RunEnv} [env]
33
+ * @returns {Promise<{ command: string, server?: import("node:http").Server, close?: () => Promise<void> }>}
34
+ */
35
+ export async function run(argv, env = {}) {
36
+ const cwd = env.cwd ?? process.cwd();
37
+ const log = env.log ?? console.log;
38
+ const warn = env.warn ?? console.warn;
39
+ const error = env.error ?? console.error;
40
+ // Bare `darkmown` prints help (discovery) rather than silently building — a new
41
+ // user typing the command alone expects to see what it can do, not a no-op build.
42
+ const command = argv[0] || "help";
43
+
44
+ if (command === "help" || command === "--help" || command === "-h") {
45
+ log(helpText());
46
+ return { command: "help" };
31
47
  }
32
- } else if (command === "build") {
33
- try {
34
- const result = buildSite();
35
- console.log(`Built ${result.routes.length} routes into ${path.relative(process.cwd(), result.distRoot)}`);
36
- } catch (error) {
37
- // A compile error has a clear, file-pathed message — print just that, not a
38
- // Node stack trace, so a typo reads as "fix your page", not "the tool crashed".
39
- failClean(error);
48
+ if (command === "version" || command === "--version" || command === "-v") {
49
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
50
+ log(pkg.version);
51
+ return { command: "version" };
52
+ }
53
+ if (command === "init") {
54
+ const target = argv[1] || ".";
55
+ const result = initProject(path.resolve(cwd, target));
56
+ const relativeRoot = path.relative(cwd, result.root) || ".";
57
+ log(`Created Darkmown project at ${relativeRoot}`);
58
+ log(`Next: ${nextStep(relativeRoot)}`);
59
+ return { command: "init" };
60
+ }
61
+ if (command === "build") {
62
+ const result = buildSite(cwd);
63
+ log(`Built ${result.routes.length} routes into ${path.relative(cwd, result.distRoot)}`);
64
+ return { command: "build" };
65
+ }
66
+ if (command === "dev") {
67
+ return startDevServer({ cwd, log, warn, error });
68
+ }
69
+ if (command === "serve") {
70
+ return startPreviewServer({ cwd, log, error });
71
+ }
72
+ // No command matched: report it, show help, and fail like the binary always
73
+ // did (exit 1). `silent` keeps `main` from re-printing a `✗` line on top.
74
+ error(`Unknown command: ${command}`);
75
+ log(helpText());
76
+ throw new CliError(`Unknown command: ${command}`, { silent: true });
77
+ }
78
+
79
+ /**
80
+ * An error whose message is already user-facing. The binary prints it (prefixed
81
+ * with `✗` unless `silent`) and exits 1 — never a Node stack trace.
82
+ */
83
+ export class CliError extends Error {
84
+ /**
85
+ * @param {string} message
86
+ * @param {{ silent?: boolean }} [options] `silent` skips the `✗`-prefixed print
87
+ * (used when the message was already shown, e.g. unknown command + help).
88
+ */
89
+ constructor(message, options = {}) {
90
+ super(message);
91
+ this.name = "CliError";
92
+ this.silent = Boolean(options.silent);
40
93
  }
41
- } else if (command === "dev") {
94
+ }
95
+
96
+ /**
97
+ * Start the live-compiler dev server. Builds once (recording, not crashing on, a
98
+ * broken initial build), watches `site/` and `src/`, and serves dist with the
99
+ * dev client + SSE reload channel injected. Returns the server and a clean
100
+ * `close()` that tears down the watchers and the rebuild debounce timer.
101
+ * @param {{ cwd: string, log: typeof console.log, warn: typeof console.warn, error: typeof console.error }} io
102
+ * @returns {Promise<{ command: "dev", server: import("node:http").Server, close: () => Promise<void> }>}
103
+ */
104
+ function startDevServer({ cwd, log, warn, error }) {
42
105
  const port = Number(process.env.PORT || 5173);
43
106
  const host = process.env.HOST || "127.0.0.1";
44
- const distRoot = path.join(process.cwd(), "dist");
107
+ const distRoot = path.join(cwd, "dist");
45
108
  /** @type {Set<import("node:http").ServerResponse>} */
46
109
  const clients = new Set();
47
110
  /** @type {ReturnType<typeof setTimeout> | undefined} */
@@ -53,10 +116,10 @@ if (command === "help" || command === "--help" || command === "-h") {
53
116
  // and thus the error overlay — comes up. Record the error so new SSE clients
54
117
  // see it on connect; the next successful rebuild clears it via `reload`.
55
118
  try {
56
- buildSite();
57
- } catch (error) {
58
- lastBuildError = (error instanceof Error ? error.message : String(error)).trim();
59
- console.error(lastBuildError);
119
+ buildSite(cwd);
120
+ } catch (err) {
121
+ lastBuildError = (err instanceof Error ? err.message : String(err)).trim();
122
+ error(lastBuildError);
60
123
  }
61
124
 
62
125
  // Rebuild in a child process so changes to Darkmown's own src/ always load
@@ -65,26 +128,32 @@ if (command === "help" || command === "--help" || command === "-h") {
65
128
  clearTimeout(timer);
66
129
  timer = setTimeout(() => {
67
130
  const started = performance.now();
68
- execFile(process.execPath, [cliPath, "build"], { cwd: process.cwd() }, (error, stdout, stderr) => {
69
- if (error) {
70
- const message = (stderr || stdout || String(error)).trim();
131
+ execFile(process.execPath, [cliPath, "build"], { cwd }, (err, stdout, stderr) => {
132
+ if (err) {
133
+ const message = (stderr || stdout || String(err)).trim();
71
134
  lastBuildError = message;
72
- console.error(message);
73
- broadcast(clients, `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`);
135
+ error(message);
136
+ broadcast(
137
+ clients,
138
+ `event: builderror\ndata: ${JSON.stringify({ message: message.slice(0, 4000) })}\n\n`
139
+ );
74
140
  return;
75
141
  }
76
142
  lastBuildError = undefined;
77
143
  const elapsed = Math.round(performance.now() - started);
78
- if (stderr.trim()) console.warn(stderr.trim());
144
+ if (stderr.trim()) warn(stderr.trim());
79
145
  broadcast(clients, `event: reload\ndata: ${JSON.stringify({ elapsed })}\n\n`);
80
- console.log(`${stdout.trim().split("\n").at(-1)} (${elapsed}ms)`);
146
+ log(`${stdout.trim().split("\n").at(-1)} (${elapsed}ms)`);
81
147
  });
82
148
  }, 30);
83
149
  };
84
150
 
151
+ /** @type {import("node:fs").FSWatcher[]} */
152
+ const watchers = [];
85
153
  for (const dir of ["site", "src"]) {
86
- if (fs.existsSync(dir)) {
87
- fs.watch(dir, { recursive: true }, rebuild);
154
+ const abs = path.join(cwd, dir);
155
+ if (fs.existsSync(abs)) {
156
+ watchers.push(fs.watch(abs, { recursive: true }, rebuild));
88
157
  }
89
158
  }
90
159
 
@@ -102,7 +171,9 @@ if (command === "help" || command === "--help" || command === "-h") {
102
171
  // Replay a broken build to clients that connect while it's still failing,
103
172
  // so the overlay shows even after a startup or pre-connect compile error.
104
173
  if (lastBuildError) {
105
- res.write(`event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`);
174
+ res.write(
175
+ `event: builderror\ndata: ${JSON.stringify({ message: lastBuildError.slice(0, 4000) })}\n\n`
176
+ );
106
177
  }
107
178
  req.on("close", () => clients.delete(res));
108
179
  return;
@@ -117,39 +188,67 @@ if (command === "help" || command === "--help" || command === "-h") {
117
188
  req.on("data", (chunk) => (body += chunk));
118
189
  req.on("end", () => {
119
190
  res.writeHead(200, { "content-type": "application/json" });
120
- res.end(JSON.stringify({
121
- ok: true,
122
- received: Object.fromEntries(new URLSearchParams(body)),
123
- at: new Date().toISOString()
124
- }));
191
+ res.end(
192
+ JSON.stringify({
193
+ ok: true,
194
+ received: Object.fromEntries(new URLSearchParams(body)),
195
+ at: new Date().toISOString()
196
+ })
197
+ );
125
198
  });
126
199
  return;
127
200
  }
128
201
  serveDev(distRoot, url, res);
129
- } catch (error) {
202
+ } catch (err) {
130
203
  res.writeHead(500, { "content-type": "text/plain" });
131
- res.end((error instanceof Error && error.stack) || String(error));
204
+ res.end((err instanceof Error && err.stack) || String(err));
132
205
  }
133
206
  });
134
- server.listen(port, host, () => {
135
- console.log(`Darkmown dev server ready at http://${host}:${port}`);
136
- console.log(`Live compiler watching site/ and src/`);
207
+
208
+ return new Promise((resolve) => {
209
+ server.listen(port, host, () => {
210
+ log(`Darkmown dev server ready at http://${host}:${port}`);
211
+ log(`Live compiler watching site/ and src/`);
212
+ resolve({
213
+ command: "dev",
214
+ server,
215
+ close: () => {
216
+ clearTimeout(timer);
217
+ for (const watcher of watchers) watcher.close();
218
+ for (const client of clients) client.end();
219
+ clients.clear();
220
+ return new Promise((done) => server.close(() => done()));
221
+ }
222
+ });
223
+ });
137
224
  });
138
- } else if (command === "serve") {
225
+ }
226
+
227
+ /**
228
+ * Start the static preview server for an existing `dist/`. Throws `CliError`
229
+ * when there is nothing built to serve.
230
+ * @param {{ cwd: string, log: typeof console.log, error: typeof console.error }} io
231
+ * @returns {Promise<{ command: "serve", server: import("node:http").Server, close: () => Promise<void> }>}
232
+ */
233
+ function startPreviewServer({ cwd, log, error }) {
139
234
  const port = Number(process.env.PORT || 4173);
140
235
  const host = process.env.HOST || "127.0.0.1";
141
- const distRoot = path.join(process.cwd(), "dist");
236
+ const distRoot = path.join(cwd, "dist");
142
237
  if (!fs.existsSync(distRoot)) {
143
- console.error("No dist directory found. Run `darkmown build` first.");
144
- process.exit(1);
238
+ error("No dist directory found. Run `darkmown build` first.");
239
+ throw new CliError("No dist directory found. Run `darkmown build` first.", { silent: true });
145
240
  }
146
- http.createServer((req, res) => serve(distRoot, req.url || "/", res)).listen(port, host, () => {
147
- console.log(`Darkmown preview of dist at http://${host}:${port}`);
241
+ const server = http.createServer((req, res) => serve(distRoot, req.url || "/", res));
242
+ return new Promise((resolve) => {
243
+ server.listen(port, host, () => {
244
+ log(`Darkmown preview of dist at http://${host}:${port}`);
245
+ resolve({
246
+ command: "serve",
247
+ server,
248
+ close: () => new Promise((done) => server.close(() => done()))
249
+ });
250
+ });
148
251
  });
149
- } else {
150
- console.error(`Unknown command: ${command}`);
151
- printHelp();
152
- process.exit(1);
153
252
  }
154
253
 
155
254
  /**
@@ -165,8 +264,12 @@ function serveDev(distRoot, url, res) {
165
264
  return;
166
265
  }
167
266
  if (file.endsWith(".html")) {
267
+ // Read BEFORE writing headers: if the read throws (e.g. the path is a
268
+ // directory), the handler's catch can still send a clean 500 instead of
269
+ // failing to overwrite an already-sent 200.
270
+ const html = injectDevClient(fs.readFileSync(file, "utf8"));
168
271
  res.writeHead(200, { "content-type": "text/html" });
169
- res.end(injectDevClient(fs.readFileSync(file, "utf8")));
272
+ res.end(html);
170
273
  return;
171
274
  }
172
275
  res.writeHead(200, { "content-type": contentType(file) });
@@ -183,18 +286,11 @@ function broadcast(clients, message) {
183
286
  }
184
287
 
185
288
  /**
186
- * Print a compile/CLI error as a single clean line and exit non-zero — no Node
187
- * stack trace. The thrown Error already carries the file path + corrective hint.
188
- * @param {unknown} error
189
- * @returns {never}
289
+ * The CLI help text. Returned (not printed) so `run` and tests share one source.
290
+ * @returns {string}
190
291
  */
191
- function failClean(error) {
192
- console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
193
- process.exit(1);
194
- }
195
-
196
- function printHelp() {
197
- console.log(`Darkmown
292
+ export function helpText() {
293
+ return `Darkmown
198
294
 
199
295
  Usage:
200
296
  darkmown init [dir] Scaffold a new Darkmown project
@@ -210,15 +306,58 @@ Authoring:
210
306
  @loop x into item Loop over JSON files, in-scope values, or :state lists
211
307
  *.skin Colocated indentation-based CSS
212
308
  *.js Colocated page behavior
213
- `);
309
+ `;
214
310
  }
215
311
 
216
-
217
312
  /**
218
313
  * @param {string} relativeRoot
219
314
  * @returns {string}
220
315
  */
221
- function nextStep(relativeRoot) {
316
+ export function nextStep(relativeRoot) {
222
317
  if (relativeRoot === ".") return "npm install && npm run dev";
223
318
  return `cd ${relativeRoot} && npm install && npm run dev`;
224
319
  }
320
+
321
+ /**
322
+ * Binary entry point: read argv, dispatch, and map failures to a clean
323
+ * `✗`-prefixed line + exit code 1 — never a Node stack trace. The thrown Error
324
+ * already carries the file path + corrective hint.
325
+ * @returns {Promise<void>}
326
+ */
327
+ async function main() {
328
+ try {
329
+ await run(process.argv.slice(2));
330
+ } catch (error) {
331
+ if (!(error instanceof CliError && error.silent)) {
332
+ // A compile error has a clear, file-pathed message — print just that.
333
+ console.error(`✗ ${error instanceof Error ? error.message : String(error)}`);
334
+ }
335
+ process.exit(1);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Whether `entryArg` (typically `process.argv[1]`) points at THIS module — i.e.
341
+ * the binary is being executed, not imported by a test. npm installs the
342
+ * `darkmown` bin as a SYMLINK in node_modules/.bin, so the entry arg is the link
343
+ * path while `import.meta.url` resolves to the real file; comparing realpaths
344
+ * lets the binary still self-execute. Exported so the symlink/missing-path
345
+ * branches are unit-testable.
346
+ * @param {string | undefined} entryArg
347
+ * @returns {boolean}
348
+ */
349
+ export function isEntryPoint(entryArg) {
350
+ if (!entryArg) return false;
351
+ if (entryArg === cliPath) return true;
352
+ try {
353
+ // Resolve symlinks on BOTH sides: npm's .bin/darkmown is a link to this file.
354
+ return fs.realpathSync(entryArg) === fs.realpathSync(cliPath);
355
+ } catch {
356
+ return false; // a non-existent entry path can never be this module
357
+ }
358
+ }
359
+
360
+ // Auto-run only when this file is the process entry point.
361
+ if (isEntryPoint(process.argv[1])) {
362
+ main();
363
+ }