@zvndev/darkmown 0.19.0 → 1.0.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.
Files changed (69) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +115 -5
  4. package/docs/spec-alignment.md +16 -3
  5. package/package.json +22 -4
  6. package/site/_/base.skin +372 -0
  7. package/site/_/base.wd +4 -0
  8. package/site/_/footer.wd +14 -0
  9. package/site/_/metrics.json +13 -0
  10. package/site/_/nav.skin +35 -12
  11. package/site/_/nav.wd +8 -10
  12. package/site/_/products.json +10 -0
  13. package/site/pages/app.wd +1 -1
  14. package/site/pages/carousel/index.js +82 -0
  15. package/site/pages/carousel/index.skin +185 -0
  16. package/site/pages/carousel/index.wd +109 -0
  17. package/site/pages/carousel/slide-1.jpg +0 -0
  18. package/site/pages/carousel/slide-2.jpg +0 -0
  19. package/site/pages/carousel/slide-3.jpg +0 -0
  20. package/site/pages/carousel/slide-4.jpg +0 -0
  21. package/site/pages/carousel/slide-5.jpg +0 -0
  22. package/site/pages/compass/index.skin +247 -0
  23. package/site/pages/compass/index.wd +194 -0
  24. package/site/pages/docs/index.skin +48 -28
  25. package/site/pages/docs/index.wd +71 -5
  26. package/site/pages/folio/-style.skin +375 -0
  27. package/site/pages/folio/-style.wd +6 -0
  28. package/site/pages/folio/cart.skin +309 -0
  29. package/site/pages/folio/cart.wd +201 -0
  30. package/site/pages/folio/index.skin +24 -0
  31. package/site/pages/folio/index.wd +167 -0
  32. package/site/pages/forge/index.skin +676 -0
  33. package/site/pages/forge/index.wd +330 -0
  34. package/site/pages/index.skin +135 -144
  35. package/site/pages/index.wd +17 -5
  36. package/site/pages/ledger/index.skin +257 -0
  37. package/site/pages/ledger/index.wd +174 -0
  38. package/site/pages/media/clip-poster.jpg +0 -0
  39. package/site/pages/media/clip.mp4 +0 -0
  40. package/site/pages/media/index.skin +82 -0
  41. package/site/pages/media/index.wd +84 -0
  42. package/site/pages/media/tone.mp3 +0 -0
  43. package/site/pages/pulse/index.skin +434 -0
  44. package/site/pages/pulse/index.wd +178 -0
  45. package/site/pages/showcase/index.skin +130 -0
  46. package/site/pages/showcase/index.wd +168 -0
  47. package/src/builder.js +20 -14
  48. package/src/cli.js +216 -77
  49. package/src/compiler/body.js +457 -0
  50. package/src/compiler/context.js +160 -0
  51. package/src/compiler/directives.js +1345 -0
  52. package/src/compiler/format.js +244 -0
  53. package/src/compiler/frontmatter.js +117 -0
  54. package/src/compiler/includes.js +116 -0
  55. package/src/compiler/index.js +22 -0
  56. package/src/compiler/interpolation.js +223 -0
  57. package/src/compiler/loops.js +715 -0
  58. package/src/compiler/markdown.js +242 -0
  59. package/src/compiler/page.js +267 -0
  60. package/src/compiler/predicates.js +314 -0
  61. package/src/compiler.js +26 -2932
  62. package/src/dev.js +3 -1
  63. package/src/headers.js +8 -4
  64. package/src/index.js +22 -0
  65. package/src/router.js +3 -1
  66. package/src/runtime.js +168 -14
  67. package/src/scaffold.js +158 -131
  68. package/src/skin.js +11 -2
  69. package/site/_/og.png +0 -0
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
+ }