@zvndev/darkmown 2.9.0 → 2.10.1

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 (54) hide show
  1. package/AGENTS.md +23 -12
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +105 -6
  4. package/SECURITY.md +1 -0
  5. package/docs/cli.md +1 -1
  6. package/docs/spec-alignment.md +16 -2
  7. package/grammar/wd-directives.gbnf +1 -1
  8. package/package.json +8 -4
  9. package/site/pages/benchmarks/index.skin +67 -0
  10. package/site/pages/benchmarks/index.wd +126 -0
  11. package/site/pages/docs/index.wd +47 -2
  12. package/site/pages/index.wd +1 -0
  13. package/site/pages/playground/index.js +58 -54
  14. package/src/api-runner.js +80 -8
  15. package/src/builder.js +70 -15
  16. package/src/catalog.js +13 -4
  17. package/src/cli.js +18 -6
  18. package/src/compiler/body.js +30 -1
  19. package/src/compiler/context.js +13 -1
  20. package/src/compiler/includes.js +44 -1
  21. package/src/compiler/media.js +25 -0
  22. package/src/compiler/page.js +6 -0
  23. package/src/compiler/state.js +75 -0
  24. package/src/compiler/structure.js +272 -4
  25. package/src/errors.js +74 -3
  26. package/src/font-subset-worker.js +131 -0
  27. package/src/font-subset.js +475 -0
  28. package/src/grammar.js +3 -2
  29. package/src/headers.js +28 -1
  30. package/src/image-variant-resize.js +4 -1
  31. package/src/image-variants.js +9 -4
  32. package/src/router.js +16 -0
  33. package/src/skin.js +111 -45
  34. package/src/statics.js +22 -14
  35. package/src/templates/dashboard/README.md +3 -1
  36. package/src/templates/landing/site/pages/index.skin +7 -0
  37. package/src/templates/landing/site/pages/index.wd +10 -3
  38. package/src/templates/store/README.md +5 -2
  39. package/src/templates/store/site/_/card.wd +19 -0
  40. package/src/templates/store/site/_/products.json +6 -6
  41. package/src/templates/store/site/pages/index.skin +17 -2
  42. package/src/templates/store/site/pages/index.wd +3 -12
  43. package/types/api-runner.d.ts +20 -4
  44. package/types/builder.d.ts +25 -1
  45. package/types/catalog.d.ts +3 -2
  46. package/types/compiler/context.d.ts +28 -0
  47. package/types/compiler/includes.d.ts +23 -0
  48. package/types/compiler/structure.d.ts +53 -1
  49. package/types/errors.d.ts +6 -4
  50. package/types/font-subset.d.ts +144 -0
  51. package/types/headers.d.ts +14 -0
  52. package/types/image-variants.d.ts +7 -3
  53. package/types/router.d.ts +5 -0
  54. package/types/skin.d.ts +9 -4
@@ -228,21 +228,26 @@ export function generateImageVariants(paths, options = {}) {
228
228
  * build is synchronous; sharp is not), communicating through temp JSON files
229
229
  * rather than stdio so a large job list never hits a pipe buffer. The worker
230
230
  * always writes a results file — a missing or unparsable one means the child
231
- * itself died, which is reported as `unavailable` (the build then behaves as
232
- * if sharp were absent: a hint, no variants, no failure).
231
+ * itself died (crashed, or killed by the spawn timeout), which is reported as
232
+ * `unavailable` (the build then behaves as if sharp were absent: a hint, no
233
+ * variants, no failure).
233
234
  * @param {ResizeJob[]} jobs
234
235
  * @param {(cmd: string, args: string[], opts: object) => unknown} [spawn]
235
236
  * Injectable for tests; the real one is `spawnSync`.
237
+ * @param {number} [timeoutMs] The worker is killed after this long (default
238
+ * two minutes), so a hung sharp call cannot stall a build forever. The kill
239
+ * lands in the same `unavailable` path as any other dead child.
236
240
  * @returns {ResizeResult}
237
241
  */
238
- export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync)) {
242
+ export function runResizeJobs(jobs, spawn = /** @type {any} */ (spawnSync), timeoutMs = 120000) {
239
243
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wd-img-variants-"));
240
244
  try {
241
245
  const jobsFile = path.join(dir, "jobs.json");
242
246
  const resultsFile = path.join(dir, "results.json");
243
247
  fs.writeFileSync(jobsFile, JSON.stringify(jobs));
244
248
  spawn(process.execPath, [WORKER_PATH, jobsFile, resultsFile], {
245
- stdio: ["ignore", "ignore", "inherit"]
249
+ stdio: ["ignore", "ignore", "inherit"],
250
+ timeout: timeoutMs
246
251
  });
247
252
  if (!fs.existsSync(resultsFile)) return { unavailable: true, failed: [] };
248
253
  try {
package/src/router.js CHANGED
@@ -88,6 +88,11 @@ export function isDraft(meta) {
88
88
 
89
89
  /**
90
90
  * Derive a public route path from a source file path.
91
+ *
92
+ * Fail-closed on control/whitespace characters: the route string flows RAW
93
+ * into generated config (`dist/_headers` blocks, llms.txt lines), where a
94
+ * newline in a filename would inject whole config lines. Rejecting here, at
95
+ * the one route-derivation chokepoint, protects every downstream sink at once.
91
96
  * @param {string} routesRoot Absolute path to `site/pages`.
92
97
  * @param {string} file Absolute path to the source file.
93
98
  * @returns {string}
@@ -98,9 +103,20 @@ export function routeFromFile(routesRoot, file) {
98
103
  const parts = rel.split(path.sep);
99
104
  if (parts.at(-1) === "index") parts.pop();
100
105
  const route = `/${parts.join("/")}`;
106
+ if (ROUTE_UNSAFE_CHARS.test(route)) {
107
+ throw wdError(
108
+ `Refusing to route ${file} — a page filename must not contain whitespace or ` +
109
+ "control characters: the route is written verbatim into generated config " +
110
+ "(dist/_headers, llms.txt), where such a character would corrupt the file.",
111
+ { code: "WD908", file }
112
+ );
113
+ }
101
114
  return route === "/" ? "/" : `${route}/`;
102
115
  }
103
116
 
117
+ /** Whitespace (`\s`) and control characters (`\p{Cc}`: C0, DEL, C1) a route string must never carry. */
118
+ const ROUTE_UNSAFE_CHARS = /[\s\p{Cc}]/u;
119
+
104
120
  /**
105
121
  * Map a public route to the `index.html` output path under `distRoot`.
106
122
  *
package/src/skin.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import crypto from "node:crypto";
2
2
  import { wdError } from "./compiler/context.js";
3
3
 
4
+ // Concrete, compilable skins for the at-rule error hints + the error registry
5
+ // (src/errors.js). Multi-line because both at-rules are block constructs; a
6
+ // test in tests/skin-atrules.test.js compiles each verbatim.
7
+ export const FONT_FACE_EXAMPLE = '@font-face\n font-family "Newsreader"';
8
+ export const KEYFRAMES_EXAMPLE = "@keyframes fade-in\n to\n opacity 1";
9
+
4
10
  const aliases = new Map([
5
11
  ["radius", "border-radius"],
6
12
  ["shadow", "box-shadow"],
@@ -70,28 +76,37 @@ const PAGE_LEVEL_SELECTORS = new Set(["page", "*", "html", "body", "::selection"
70
76
  * selectors), they are ALWAYS global (never scope-stamped, same contract as
71
77
  * `tokens`), and their declaration values use the same alias/`$token` pipeline.
72
78
  * @param {string} source
73
- * @param {{ scope?: string, subjects?: Set<string> }} [opts] `scope`: the scope
74
- * id enabling scoped mode. `subjects`: an optional set the compiler fills with
75
- * every scoped subject token (`.card`, `h2`, `#id`) for the unused-selector
76
- * warning, so the caller need not re-parse the source.
79
+ * @param {{ scope?: string, subjects?: Set<string>, file?: string }} [opts]
80
+ * `scope`: the scope id enabling scoped mode. `subjects`: an optional set the
81
+ * compiler fills with every scoped subject token (`.card`, `h2`, `#id`) for
82
+ * the unused-selector warning, so the caller need not re-parse the source.
83
+ * `file`: the skin's path, threaded into author-facing errors so they report
84
+ * `file:line` (the builder always passes it; a bare call reports `on line N`).
77
85
  * @returns {string}
78
86
  */
79
87
  export function compileSkin(source, opts = {}) {
80
88
  const scope = opts.scope;
81
89
  const subjects = opts.subjects;
90
+ const file = opts.file;
82
91
  const lines = source
83
92
  .replace(/\r\n?/g, "\n")
84
- .replace(/\/\*[\s\S]*?\*\//g, "") // strip /* … */ block comments (any span)
93
+ // Strip /* … */ block comments (any span), KEEPING their newlines so the
94
+ // `line` recorded below stays the true file line even past a multi-line
95
+ // comment.
96
+ .replace(/\/\*[\s\S]*?\*\//g, (comment) => comment.replace(/[^\n]/g, ""))
85
97
  .split("\n")
86
- .filter((raw) => {
87
- const t = raw.trim();
88
- if (!t || t.startsWith("//")) return false;
98
+ .map((raw, index) => ({
99
+ indent: (raw.match(/^\s*/)?.[0] ?? "").length,
100
+ text: raw.trim(),
101
+ line: index + 1
102
+ }))
103
+ .filter(({ text }) => {
104
+ if (!text || text.startsWith("//")) return false;
89
105
  // Decorative divider lines (----, ====, * * *, ~~~) carry no rule and would
90
106
  // otherwise parse as a bogus selector/declaration. Anything with a letter or
91
107
  // digit is real content; punctuation-only lines are skipped.
92
- return /[A-Za-z0-9]/.test(t);
93
- })
94
- .map((raw) => ({ indent: (raw.match(/^\s*/)?.[0] ?? "").length, text: raw.trim() }));
108
+ return /[A-Za-z0-9]/.test(text);
109
+ });
95
110
 
96
111
  // The opt-in marker: `scoped` must be the FIRST meaningful line (the filter
97
112
  // above has already dropped comments, blanks, and dividers). Anywhere else it
@@ -181,7 +196,7 @@ export function compileSkin(source, opts = {}) {
181
196
  const atRule = /^@(font-face|keyframes)(?:\s+(.+))?$/.exec(text);
182
197
  if (atRule) {
183
198
  const media = stack.find((frame) => frame.media)?.media;
184
- const block = collectAtRule(atRule[1], atRule[2], lines, i);
199
+ const block = collectAtRule(atRule[1], atRule[2], lines, i, file);
185
200
  css.push(media ? `${media} {\n${block.css}\n}` : block.css);
186
201
  i = block.end;
187
202
  continue;
@@ -367,17 +382,19 @@ function normalizeSelector(text, parent) {
367
382
  const KEYFRAME_SELECTOR = /^(?:from|to|\d+(?:\.\d+)?%)(?:\s*,\s*(?:from|to|\d+(?:\.\d+)?%))*$/;
368
383
 
369
384
  /**
370
- * A plain Error, no WD code: the code registry (`src/errors.js`) and its drift
371
- * guard (`tests/error-codes.test.js`) are owned by a parallel change, so a
372
- * `code: "WD8xx"` here would fail the "every code thrown is registered" gate
373
- * and an inline throw would fail the "no author-facing throw bypasses wdError"
374
- * scan. Messages still lead with the construct and end with a corrective
375
- * example, registry-style.
376
- * @param {string} message
377
- * @returns {Error}
385
+ * An author-facing at-rule error, routed through `wdError` like every other
386
+ * compile error: it carries a registered WD8xx code (src/errors.js), the true
387
+ * source line, and a corrective `Use:` hint. The file is named when the caller
388
+ * threaded one (the builder always does); a bare `compileSkin` call has no
389
+ * path, so the location falls back to `on line N`.
390
+ * @param {string} summary What is wrong, naming the offending text.
391
+ * @param {import("./compiler/context.js").WdErrorInfo} wd The structured mirror;
392
+ * `code` is required (every at-rule error is registered in src/errors.js).
393
+ * @returns {Error & { wd: import("./compiler/context.js").WdErrorInfo }}
378
394
  */
379
- function skinError(message) {
380
- return new Error(message);
395
+ function skinError(summary, wd) {
396
+ const where = wd.file ? ` in ${wd.file}:${wd.line}` : ` on line ${wd.line}`;
397
+ return wdError(`${summary}${where}. Use: ${wd.hint}`, wd);
381
398
  }
382
399
 
383
400
  /**
@@ -403,21 +420,28 @@ function declaration(text) {
403
420
  * `50%, 75%`), each with its own indented declarations; a keyframe selector is
404
421
  * validated against KEYFRAME_SELECTOR so it can never be misread as a
405
422
  * declaration or an element selector, and declarations cannot nest further.
423
+ * Every rejection is a coded WD8xx error pointing at the offending line.
406
424
  * @param {string} kind `font-face` or `keyframes`.
407
425
  * @param {string | undefined} rest Text after the at-rule keyword (the
408
426
  * keyframes name; invalid on `@font-face`).
409
- * @param {{ indent: number, text: string }[]} lines Preprocessed skin lines.
427
+ * @param {{ indent: number, text: string, line: number }[]} lines Preprocessed
428
+ * skin lines, each carrying its true 1-based file line.
410
429
  * @param {number} start Index of the at-rule line.
430
+ * @param {string | undefined} file The skin's path, when the caller knows it.
411
431
  * @returns {{ css: string, end: number }} `end` = last consumed line index.
412
432
  */
413
- function collectAtRule(kind, rest, lines, start) {
433
+ function collectAtRule(kind, rest, lines, start, file) {
414
434
  const indent = lines[start].indent;
415
435
 
416
436
  if (kind === "font-face") {
417
437
  if (rest)
418
- throw skinError(
419
- `@font-face takes no name, got "${rest}" — write the descriptors indented beneath a bare @font-face line, e.g. font-family "Newsreader"`
420
- );
438
+ throw skinError(`@font-face takes no name, got "${rest}"`, {
439
+ code: "WD804",
440
+ file,
441
+ line: lines[start].line,
442
+ hint: `write the descriptors indented beneath a bare @font-face line — e.g. font-family "Newsreader"`,
443
+ example: FONT_FACE_EXAMPLE
444
+ });
421
445
  /** @type {string[]} */
422
446
  const decls = [];
423
447
  let i = start;
@@ -425,52 +449,94 @@ function collectAtRule(kind, rest, lines, start) {
425
449
  i++;
426
450
  if (i + 1 < lines.length && lines[i + 1].indent > lines[i].indent)
427
451
  throw skinError(
428
- `@font-face only takes declarations — "${lines[i].text}" opens a nested block, which is not valid inside @font-face`
452
+ `@font-face only takes declarations — "${lines[i].text}" opens a nested block, which is not valid inside @font-face`,
453
+ {
454
+ code: "WD804",
455
+ file,
456
+ line: lines[i].line,
457
+ hint: `keep every @font-face child a flat prop value declaration — e.g. font-family "Newsreader"`,
458
+ example: FONT_FACE_EXAMPLE
459
+ }
429
460
  );
430
461
  decls.push(` ${declaration(lines[i].text)}`);
431
462
  }
432
463
  if (!decls.length)
433
- throw skinError(
434
- '@font-face has no declarations — indent its descriptors beneath it, e.g. font-family "Newsreader"'
435
- );
464
+ throw skinError("@font-face has no declarations", {
465
+ code: "WD804",
466
+ file,
467
+ line: lines[start].line,
468
+ hint: `indent its descriptors beneath it — e.g. font-family "Newsreader"`,
469
+ example: FONT_FACE_EXAMPLE
470
+ });
436
471
  return { css: `@font-face {\n${decls.join("\n")}\n}`, end: i };
437
472
  }
438
473
 
439
- if (!rest) throw skinError("@keyframes needs a name — e.g. @keyframes fade-in");
474
+ if (!rest)
475
+ throw skinError("@keyframes needs a name", {
476
+ code: "WD805",
477
+ file,
478
+ line: lines[start].line,
479
+ hint: "@keyframes <name> — e.g. @keyframes fade-in",
480
+ example: KEYFRAMES_EXAMPLE
481
+ });
440
482
  if (!/^-?[A-Za-z_][\w-]*$/.test(rest))
441
- throw skinError(
442
- `invalid @keyframes name "${rest}" — use a plain CSS identifier, e.g. @keyframes fade-in`
443
- );
483
+ throw skinError(`invalid @keyframes name "${rest}"`, {
484
+ code: "WD805",
485
+ file,
486
+ line: lines[start].line,
487
+ hint: "name it with a plain CSS identifier — e.g. @keyframes fade-in",
488
+ example: KEYFRAMES_EXAMPLE
489
+ });
444
490
  /** @type {string[]} */
445
491
  const stops = [];
446
492
  let i = start;
447
493
  while (i + 1 < lines.length && lines[i + 1].indent > indent) {
448
494
  i++;
449
495
  const selector = lines[i].text;
496
+ const selectorLine = lines[i].line;
450
497
  const selectorIndent = lines[i].indent;
451
498
  if (!KEYFRAME_SELECTOR.test(selector))
452
- throw skinError(
453
- `"${selector}" is not a keyframe selector in @keyframes ${rest} — use from, to, or a percentage (50%, or a list like 50%, 75%)`
454
- );
499
+ throw skinError(`"${selector}" is not a keyframe selector in @keyframes ${rest}`, {
500
+ code: "WD806",
501
+ file,
502
+ line: selectorLine,
503
+ hint: "use from, to, or a percentage (50%, or a list like 50%, 75%)",
504
+ example: KEYFRAMES_EXAMPLE
505
+ });
455
506
  /** @type {string[]} */
456
507
  const decls = [];
457
508
  while (i + 1 < lines.length && lines[i + 1].indent > selectorIndent) {
458
509
  i++;
459
510
  if (i + 1 < lines.length && lines[i + 1].indent > lines[i].indent)
460
511
  throw skinError(
461
- `@keyframes declarations cannot nest — "${lines[i].text}" opens a block inside keyframe "${selector}"`
512
+ `@keyframes declarations cannot nest — "${lines[i].text}" opens a block inside keyframe "${selector}"`,
513
+ {
514
+ code: "WD807",
515
+ file,
516
+ line: lines[i].line,
517
+ hint: "keep each keyframe's declarations flat, one level beneath its selector — e.g. opacity 1",
518
+ example: KEYFRAMES_EXAMPLE
519
+ }
462
520
  );
463
521
  decls.push(` ${declaration(lines[i].text)}`);
464
522
  }
465
523
  if (!decls.length)
466
- throw skinError(
467
- `keyframe "${selector}" in @keyframes ${rest} has no declarations — indent them beneath it`
468
- );
524
+ throw skinError(`keyframe "${selector}" in @keyframes ${rest} has no declarations`, {
525
+ code: "WD807",
526
+ file,
527
+ line: selectorLine,
528
+ hint: "indent the declarations beneath the keyframe selector — e.g. opacity 1",
529
+ example: KEYFRAMES_EXAMPLE
530
+ });
469
531
  stops.push(` ${selector} {\n${decls.join("\n")}\n }`);
470
532
  }
471
533
  if (!stops.length)
472
- throw skinError(
473
- `@keyframes ${rest} has no keyframe selectors — indent from/to/percentage blocks beneath it`
474
- );
534
+ throw skinError(`@keyframes ${rest} has no keyframe selectors`, {
535
+ code: "WD807",
536
+ file,
537
+ line: lines[start].line,
538
+ hint: "indent from/to/percentage blocks beneath it — e.g. from",
539
+ example: KEYFRAMES_EXAMPLE
540
+ });
475
541
  return { css: `@keyframes ${rest} {\n${stops.join("\n")}\n}`, end: i };
476
542
  }
package/src/statics.js CHANGED
@@ -2,14 +2,16 @@ import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import zlib from "node:zlib";
5
- import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
5
+ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, routeCsp } from "./headers.js";
6
6
 
7
7
  /**
8
8
  * The security headers (including the right CSP variant) for an HTML response.
9
9
  * Mirrors what production ships. Since 2.1 both variants are eval-free (the
10
10
  * reactive runtime walks a validated AST, not `new Function`), so a reactive and a
11
- * static route resolve to the same CSP — the per-route selection is kept for parity
12
- * with the `_headers` / `vercel.json` structure the same build writes. Anything not
11
+ * static route resolve to the same base CSP — the per-route selection is kept for
12
+ * parity with the `_headers` / `vercel.json` structure the same build writes, and
13
+ * it is what lets a route's own `:embed` hosts (recorded on its manifest entry as
14
+ * `assets.embedHosts`) widen `frame-src` for exactly that route. Anything not
13
15
  * found in `routes.json` (an unknown route, a 404, a missing manifest) falls back
14
16
  * to the reactive CSP, exactly like the production `_headers` catch-all block.
15
17
  * @param {string} distRoot Absolute path to the build output directory.
@@ -17,9 +19,12 @@ import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
17
19
  * @returns {Record<string, string>}
18
20
  */
19
21
  function htmlSecurityHeaders(distRoot, url) {
22
+ const entry = routeEntry(distRoot, routeOf(url));
20
23
  return {
21
24
  ...BASE_SECURITY_HEADERS,
22
- "Content-Security-Policy": routeIsReactive(distRoot, routeOf(url)) ? REACTIVE_CSP : STATIC_CSP
25
+ "Content-Security-Policy": entry
26
+ ? routeCsp(Boolean(entry.assets?.runtime), entry.assets?.embedHosts ?? [])
27
+ : REACTIVE_CSP
23
28
  };
24
29
  }
25
30
 
@@ -43,24 +48,23 @@ function routeOf(url) {
43
48
  }
44
49
 
45
50
  /**
46
- * Whether the route ships the reactive runtime, per the build's `routes.json`.
47
- * Defaults to `true` (reactive) when the route is absent or the manifest can't be
48
- * read — matching the production `_headers` catch-all, so an unknown route is
49
- * never accidentally served the stricter policy.
51
+ * The route's own manifest entry from the build's `routes.json`, or null when
52
+ * the route is absent or the manifest can't be read — in which case the caller
53
+ * falls back to the reactive CSP, matching the production `_headers` catch-all,
54
+ * so an unknown route is never accidentally served the stricter policy.
50
55
  * @param {string} distRoot
51
56
  * @param {string | null} route
52
- * @returns {boolean}
57
+ * @returns {{ route: string, assets?: { runtime?: boolean, embedHosts?: string[] } } | null}
53
58
  */
54
- function routeIsReactive(distRoot, route) {
55
- if (!route) return true;
59
+ function routeEntry(distRoot, route) {
60
+ if (!route) return null;
56
61
  try {
57
62
  const manifest = JSON.parse(fs.readFileSync(path.join(distRoot, "routes.json"), "utf8"));
58
- const entry = manifest.find((/** @type {{ route: string }} */ e) => e.route === route);
59
- if (entry) return Boolean(entry.assets?.runtime);
63
+ return manifest.find((/** @type {{ route: string }} */ e) => e.route === route) ?? null;
60
64
  } catch {
61
65
  /* no/unreadable manifest → reactive default (the catch-all policy) */
66
+ return null;
62
67
  }
63
- return true;
64
68
  }
65
69
 
66
70
  /**
@@ -256,6 +260,10 @@ export function resolvePublicFile(distRoot, url) {
256
260
  } catch {
257
261
  return null;
258
262
  }
263
+ // A decoded NUL (%00) is a valid string here but every fs call on the
264
+ // resulting path throws ERR_INVALID_ARG_VALUE out of the request handler,
265
+ // killing the server. It can never name a built file, so it is a miss.
266
+ if (cleanUrl.includes("\0")) return null;
259
267
  const base = cleanUrl.startsWith("/__wd/")
260
268
  ? cleanUrl
261
269
  : path.join(cleanUrl, path.extname(cleanUrl) ? "" : "index.html");
@@ -12,7 +12,9 @@ in `darkmown dev` and on every host. Loading and error states are handled inline
12
12
 
13
13
  - `npm run dev` — live compiler + local `/api/*` runner on http://localhost:5173.
14
14
  - `npm run build` — compile to `dist/`.
15
- - `npm run preview` — serve the built `dist/` locally (static only; use `dev` for `/api`).
15
+ - `npm run preview`: serve the built `dist/` locally. Since Darkmown 2.8.0 this
16
+ runs your `api/` functions too, so the dashboard fetches live data in preview
17
+ exactly like in `dev`.
16
18
 
17
19
  ## Deploy
18
20
 
@@ -164,6 +164,13 @@ input:focus-visible, textarea:focus-visible, select:focus-visible
164
164
  radius 12px
165
165
  padding 1.25rem
166
166
 
167
+ .icon
168
+ color $accent
169
+ margin-bottom .35rem
170
+
171
+ .icon svg
172
+ display block
173
+
167
174
  .feature h3
168
175
  margin .25rem 0 .5rem
169
176
 
@@ -2,6 +2,7 @@
2
2
  title: Acme — ship faster
3
3
  description: A marketing one-pager built with Darkmown. Zero framework JS on the static parts, with a testimonials carousel.
4
4
  transitions: true
5
+ html: true
5
6
  ---
6
7
 
7
8
  @include /nav.wd
@@ -20,15 +21,21 @@ The all-in-one toolkit your team actually enjoys using.
20
21
 
21
22
  ::: features
22
23
  ::: feature
23
- ### ⚡ Fast
24
+ <div class="icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/></svg></div>
25
+
26
+ ### Fast
24
27
  Static pages, zero framework JavaScript. Instant loads, perfect Lighthouse.
25
28
  :::
26
29
  ::: feature
27
- ### 🧩 Simple
30
+ <div class="icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/></svg></div>
31
+
32
+ ### Simple
28
33
  Write Markdown. Add a directive only on the pages that need interactivity.
29
34
  :::
30
35
  ::: feature
31
- ### 🚀 Deploy anywhere
36
+ <div class="icon"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15V3"/><path d="m6 9 6-6 6 6"/><path d="M4 21h16"/></svg></div>
37
+
38
+ ### Deploy anywhere
32
39
  One command to Vercel or Cloudflare — your API comes along for free.
33
40
  :::
34
41
  :::
@@ -3,7 +3,9 @@
3
3
  A storefront in Markdown: a product grid with live search, a shared `:store` cart,
4
4
  and a checkout that POSTs to a serverless function.
5
5
 
6
- - **Static catalog** — `site/pages/index.wd` loops `site/_/products.json`.
6
+ - **Static catalog**: `site/pages/index.wd` loops `site/_/products.json`, rendering
7
+ each product through the `site/_/card.wd` include (a `defaults:` fallback plus a
8
+ `{ children }` slot for the add-to-cart button).
7
9
  - **Cart** — a `:store` shared across the page; add/remove with `:button`.
8
10
  - **Checkout** — `:form action="/api/checkout"` posts to `api/checkout.js`, a plain
9
11
  Web-standard `(request) => Response` function that runs in `darkmown dev` and on
@@ -13,7 +15,8 @@ and a checkout that POSTs to a serverless function.
13
15
 
14
16
  - `npm run dev` — live compiler + the local `/api/*` runner on http://localhost:5173.
15
17
  - `npm run build` — compile to `dist/`.
16
- - `npm run preview` — serve the built `dist/` locally (static only; use `dev` for `/api`).
18
+ - `npm run preview`: serve the built `dist/` locally. Since Darkmown 2.8.0 this
19
+ runs your `api/` functions too, so checkout works in preview exactly like in `dev`.
17
20
 
18
21
  ## Deploy
19
22
 
@@ -0,0 +1,19 @@
1
+ ---
2
+ defaults: [note="Ships in 2 days"]
3
+ ---
4
+
5
+ ::: card .product
6
+ ::: tile
7
+ { p.initial }
8
+ :::
9
+ ::: pname
10
+ { p.name }
11
+ :::
12
+ ::: price
13
+ { p.price | money }
14
+ :::
15
+ ::: note
16
+ { note }
17
+ :::
18
+ { children }
19
+ :::
@@ -1,8 +1,8 @@
1
1
  [
2
- { "name": "Ceramic Mug", "emoji": "☕️", "price": 18 },
3
- { "name": "Linen Tote", "emoji": "👜", "price": 32 },
4
- { "name": "Pocket Notebook", "emoji": "📓", "price": 12 },
5
- { "name": "Desk Lamp", "emoji": "💡", "price": 64 },
6
- { "name": "Plant Pot", "emoji": "🪴", "price": 24 },
7
- { "name": "Wall Clock", "emoji": "🕰️", "price": 48 }
2
+ { "name": "Ceramic Mug", "initial": "C", "price": 18 },
3
+ { "name": "Linen Tote", "initial": "L", "price": 32 },
4
+ { "name": "Pocket Notebook", "initial": "P", "price": 12 },
5
+ { "name": "Desk Lamp", "initial": "D", "price": 64 },
6
+ { "name": "Plant Pot", "initial": "P", "price": 24 },
7
+ { "name": "Wall Clock", "initial": "W", "price": 48 }
8
8
  ]
@@ -93,13 +93,28 @@ input:focus-visible, select:focus-visible, textarea:focus-visible
93
93
  border-color $accent
94
94
  box-shadow 0 12px 26px -16px rgba(26,31,43,.4)
95
95
 
96
- .emoji
97
- font-size 2.25rem
96
+ .tile
97
+ width 3rem
98
+ height 3rem
99
+ margin 0 auto
100
+ display grid
101
+ place-items center
102
+ font-size 1.3rem
103
+ font-weight 700
104
+ color $accent-deep
105
+ bg $paper
106
+ border 1px solid $line
107
+ radius 10px
98
108
 
99
109
  .pname
100
110
  font-weight 600
101
111
  margin .35rem 0 .15rem
102
112
 
113
+ .note
114
+ color $muted
115
+ font-size .82rem
116
+ margin-bottom .6rem
117
+
103
118
  .price
104
119
  color $accent
105
120
  font-weight 600
@@ -23,18 +23,9 @@ transitions: true
23
23
 
24
24
  ::: catalog
25
25
  @loop /products.json into p where p.name contains q
26
- ::: card .product
27
- ::: emoji
28
- { p.emoji }
29
- :::
30
- ::: pname
31
- { p.name }
32
- :::
33
- ::: price
34
- { p.price | money }
35
- :::
26
+ @include /card.wd
36
27
  :button "Add to cart" -> cart += p
37
- :::
28
+ @endinclude
38
29
  @empty
39
30
  ::: card .empty
40
31
  Nothing matches "**{ q }**".
@@ -46,7 +37,7 @@ Nothing matches "**{ q }**".
46
37
 
47
38
  @loop cart into item
48
39
  ::: card .line
49
- { item.emoji } **{ item.name }** — { item.price | money }
40
+ **{ item.name }** — { item.price | money }
50
41
  :button "Remove" -> cart remove item
51
42
  :::
52
43
  @empty
@@ -38,18 +38,25 @@ export function discoverApiRoutes(apiDir: string): ApiRoute[];
38
38
  /**
39
39
  * Match a request pathname against discovered routes. The pathname is expected
40
40
  * to start with `/api`; anything else returns `null` (caller serves static).
41
+ * A dynamic segment whose percent-encoding is malformed (`%E0%A4%A`) makes
42
+ * `decodeURIComponent` throw — that is reported as `{ malformed: true }` so the
43
+ * caller answers a clean 400 instead of crashing the request handler.
41
44
  * @param {ApiRoute[]} routes
42
45
  * @param {string} pathname Request pathname (no query string).
43
- * @returns {{ file: string, params: Record<string, string> } | null}
46
+ * @returns {{ file: string, params: Record<string, string> } | { malformed: true } | null}
44
47
  */
45
48
  export function matchApiRoute(routes: ApiRoute[], pathname: string): {
46
49
  file: string;
47
50
  params: Record<string, string>;
51
+ } | {
52
+ malformed: true;
48
53
  } | null;
49
54
  /**
50
55
  * Build a Web-standard `Request` from a Node `IncomingMessage`. The body (for
51
- * methods that carry one) is buffered fully — dev endpoints are small and this
52
- * avoids the `duplex: "half"` streaming dance.
56
+ * methods that carry one) is buffered up to {@link MAX_BODY_BYTES} — dev and
57
+ * preview endpoints are small and this avoids the `duplex: "half"` streaming
58
+ * dance. A larger body rejects with {@link BodyTooLargeError}, which
59
+ * `handleApiRequest` answers as a 413.
53
60
  * @param {import("node:http").IncomingMessage} req
54
61
  * @returns {Promise<Request>}
55
62
  */
@@ -75,19 +82,28 @@ export function writeWebResponse(response: Response, res: import("node:http").Se
75
82
  * the handler succeeded or threw), `false` when nothing matched so the caller
76
83
  * should fall through to static serving.
77
84
  *
85
+ * Error disclosure: a handler throw is answered as JSON either way, but the
86
+ * `error` field carries the real message only when `discloseErrors` is set
87
+ * (the dev server's default — a documented dev convenience). The preview
88
+ * server passes `false` and the client gets the generic "Internal error",
89
+ * matching the shape the generated Cloudflare worker ships.
90
+ *
78
91
  * @param {object} io
79
92
  * @param {string} io.apiDir Absolute path to the project's `api/` directory.
80
93
  * @param {import("node:http").IncomingMessage} io.req
81
94
  * @param {import("node:http").ServerResponse} io.res
82
95
  * @param {string | string[] | undefined} [io.acceptEncoding] The request's
83
96
  * Accept-Encoding header, when the caller negotiates compression (preview).
97
+ * @param {boolean} [io.discloseErrors] Echo the thrown message to the client
98
+ * (default true, the dev behavior). Pass false on any production-shaped path.
84
99
  * @returns {Promise<boolean>}
85
100
  */
86
- export function handleApiRequest({ apiDir, req, res, acceptEncoding }: {
101
+ export function handleApiRequest({ apiDir, req, res, acceptEncoding, discloseErrors }: {
87
102
  apiDir: string;
88
103
  req: import("node:http").IncomingMessage;
89
104
  res: import("node:http").ServerResponse;
90
105
  acceptEncoding?: string | string[] | undefined;
106
+ discloseErrors?: boolean | undefined;
91
107
  }): Promise<boolean>;
92
108
  /**
93
109
  * A discovered API route.