@zvndev/darkmown 1.5.0 → 2.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 (88) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +28 -0
  3. package/README.md +44 -16
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +19 -6
  7. package/package.json +1 -1
  8. package/site/_/base.skin +8 -0
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +4 -0
  12. package/site/_/nav.wd +4 -0
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.wd +1 -0
  17. package/site/pages/blog/collections-and-pagination.skin +127 -0
  18. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  19. package/site/pages/blog/index.wd +1 -0
  20. package/site/pages/blog/shipping-feeds.skin +127 -0
  21. package/site/pages/blog/typed-frontmatter.skin +127 -0
  22. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  23. package/site/pages/blog/zero-js-by-default.skin +127 -0
  24. package/site/pages/carousel/index.wd +1 -0
  25. package/site/pages/compass/index.wd +1 -0
  26. package/site/pages/data.wd +3 -0
  27. package/site/pages/docs/-relative-note.wd +4 -0
  28. package/site/pages/docs/index.skin +27 -0
  29. package/site/pages/docs/index.wd +70 -13
  30. package/site/pages/folio/-style.wd +4 -0
  31. package/site/pages/folio/cart.wd +1 -0
  32. package/site/pages/folio/index.wd +1 -0
  33. package/site/pages/forge/index.wd +1 -0
  34. package/site/pages/highlight/index.wd +1 -0
  35. package/site/pages/index.wd +1 -0
  36. package/site/pages/interactions/index.wd +1 -0
  37. package/site/pages/ledger/index.wd +1 -0
  38. package/site/pages/loops.wd +1 -0
  39. package/site/pages/media/index.wd +1 -0
  40. package/site/pages/pulse/index.wd +1 -0
  41. package/site/pages/reactive.wd +1 -0
  42. package/site/pages/scoped/index.wd +1 -0
  43. package/site/pages/showcase/index.wd +1 -0
  44. package/site/pages/store.wd +1 -0
  45. package/site/pages/theme.wd +1 -0
  46. package/src/builder.js +444 -65
  47. package/src/cli.js +96 -18
  48. package/src/compiler/actions.js +303 -0
  49. package/src/compiler/body.js +36 -24
  50. package/src/compiler/context.js +56 -6
  51. package/src/compiler/directives.js +31 -1470
  52. package/src/compiler/fetch.js +159 -0
  53. package/src/compiler/format.js +17 -4
  54. package/src/compiler/forms.js +456 -0
  55. package/src/compiler/frontmatter.js +10 -5
  56. package/src/compiler/includes.js +66 -1
  57. package/src/compiler/index.js +15 -3
  58. package/src/compiler/loops.js +53 -21
  59. package/src/compiler/markdown.js +65 -15
  60. package/src/compiler/media.js +133 -0
  61. package/src/compiler/page.js +59 -75
  62. package/src/compiler/state.js +168 -0
  63. package/src/compiler/structure.js +377 -0
  64. package/src/dev.js +76 -0
  65. package/src/headers.js +56 -17
  66. package/src/router.js +12 -1
  67. package/src/runtime.js +5 -3
  68. package/src/statics.js +63 -12
  69. package/src/templates/blog/README.md +13 -4
  70. package/src/templates/blog/site/_/nav.wd +4 -0
  71. package/src/templates/blog/site/pages/index.skin +3 -0
  72. package/src/templates/blog/site/pages/index.wd +10 -4
  73. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  74. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  75. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  76. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  77. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  78. package/src/templates/dashboard/site/_/nav.wd +4 -0
  79. package/src/templates/dashboard/site/pages/index.wd +1 -0
  80. package/src/templates/landing/site/_/nav.wd +4 -0
  81. package/src/templates/landing/site/pages/index.wd +1 -0
  82. package/src/templates/starter/site/_/nav.wd +4 -0
  83. package/src/templates/starter/site/pages/about.md +18 -1
  84. package/src/templates/starter/site/pages/about.skin +69 -0
  85. package/src/templates/starter/site/pages/index.wd +1 -0
  86. package/src/templates/store/site/_/nav.wd +4 -0
  87. package/src/templates/store/site/pages/index.wd +1 -0
  88. package/src/templates/blog/site/_/posts.json +0 -16
@@ -0,0 +1,377 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Structure: the directives that shape a page's tree — `@include` (sub-file
3
+ // compile + scoped-skin stamp), `::: container` sections, `:if` conditional
4
+ // regions, the `:carousel` block — plus the documentation-demo directives
5
+ // (`:try`/`:note`/`:sprint`). Nested block bodies recurse through
6
+ // `ctx.compileBody` (and `@include` through `ctx.compileFile`), threaded in by
7
+ // the per-file compile so the module graph stays an import-cycle-free DAG.
8
+ //
9
+ // Every handler receives the 0-based line `index` so its malformed/invalid
10
+ // errors report `file:line` (via `at`) — matching the unclosed-block errors —
11
+ // while keeping the message text and `Use:` hints intact.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ import { at, createScope, LOOP_META, nestedCtx } from "./context.js";
15
+ import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
16
+ import {
17
+ escapeHtml,
18
+ getPath,
19
+ lookupPath,
20
+ lookupVar,
21
+ parseScalar,
22
+ resolveStateKey
23
+ } from "./interpolation.js";
24
+ import { compileWhen, evalPredicate } from "./predicates.js";
25
+
26
+ /**
27
+ * @typedef {import("./context.js").Ctx} Ctx
28
+ */
29
+
30
+ /**
31
+ * @param {string} line
32
+ * @param {Ctx} ctx
33
+ * @param {number} index 0-based line index for `file:line` errors.
34
+ * @returns {string}
35
+ */
36
+ export function handleInclude(line, ctx, index) {
37
+ const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
38
+ if (!match) throw new Error(`Malformed @include in ${at(ctx, index)}: ${line}`);
39
+ const target = resolveInclude(match[1], ctx.file, ctx.context);
40
+ const args = parseIncludeArgs(match[2] || "", ctx);
41
+ const childScope = createScope(ctx.scope, args);
42
+ const child = ctx.compileFile(
43
+ target,
44
+ ctx.context,
45
+ ctx.stack,
46
+ childScope,
47
+ ctx.comp,
48
+ ctx.sections,
49
+ ctx.loopItem
50
+ );
51
+ // An include with its OWN scoped colocated skin stamps just its returned
52
+ // subtree — so a scoped `.card` in the include can't collide with a `.card`
53
+ // anywhere else on the page, and the scope never leaks to the include's
54
+ // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
55
+ // the same path-derived id, so attribute and stylesheet line up.
56
+ const scopedSkin = scopedSkinFor(target, ctx.context);
57
+ return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
58
+ }
59
+
60
+ /**
61
+ * @param {string} raw
62
+ * @param {Ctx} ctx
63
+ * @returns {Record<string, unknown>}
64
+ */
65
+ function parseIncludeArgs(raw, ctx) {
66
+ /** @type {Record<string, unknown>} */
67
+ const args = {};
68
+ const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
69
+ for (const match of raw.matchAll(re)) {
70
+ const value = match[2];
71
+ if (value.startsWith("{")) {
72
+ const expr = value.slice(1, -1).trim();
73
+ const resolved = lookupPath(expr, ctx);
74
+ if (!resolved.found) {
75
+ throw new Error(
76
+ `@include argument ${match[1]}={ ${expr} } in ${ctx.file} does not match any value in scope`
77
+ );
78
+ }
79
+ args[match[1]] = resolved.value;
80
+ } else {
81
+ args[match[1]] = parseScalar(value);
82
+ }
83
+ }
84
+ return args;
85
+ }
86
+
87
+ /**
88
+ * @param {string} header
89
+ * @param {string[]} bodyLines
90
+ * @param {Ctx} ctx
91
+ * @param {number} index 0-based line index for `file:line` errors.
92
+ * @returns {string}
93
+ */
94
+ export function handleContainer(header, bodyLines, ctx, index) {
95
+ let rest = header.trim();
96
+ let tag = "section";
97
+ /** @type {string[]} Static classes baked into class="". */
98
+ const extraClass = [];
99
+ /** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
100
+ const eachClasses = [];
101
+ /** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
102
+ const stateClasses = [];
103
+ let id = "";
104
+ // Leading tag/name token (anything not starting with . or #). "section" keeps
105
+ // the <section> tag; any other name becomes a <div> and also a class.
106
+ const lead = rest.match(/^([^\s.#]\S*)/);
107
+ let nameToken = "section";
108
+ if (lead) {
109
+ nameToken = lead[1];
110
+ rest = rest.slice(lead[0].length).trim();
111
+ }
112
+ if (nameToken !== "section") {
113
+ tag = "div";
114
+ extraClass.push(nameToken);
115
+ }
116
+ while (rest.length) {
117
+ if (rest.startsWith("#")) {
118
+ const m = rest.match(/^#(\S+)/);
119
+ id = m ? m[1] : "";
120
+ rest = rest.slice((m ? m[0] : rest).length).trim();
121
+ continue;
122
+ }
123
+ const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
124
+ if (!cm)
125
+ throw new Error(
126
+ `Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${at(ctx, index)}`
127
+ );
128
+ const cls = cm[1];
129
+ rest = rest.slice(cm[0].length).trim();
130
+ // Optional `when <predicate>` makes the class reactive. The predicate runs to
131
+ // the next ` .`/` #` token or the end of the header.
132
+ const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
133
+ if (whenMatch) {
134
+ rest = rest.slice(whenMatch[0].length).trim();
135
+ const compiled = compileWhen(whenMatch[1].trim(), ctx);
136
+ if (compiled.static) {
137
+ if (compiled.value) extraClass.push(cls);
138
+ } else if (compiled.item) eachClasses.push([cls, compiled.body]);
139
+ else stateClasses.push([cls, compiled.body]);
140
+ } else {
141
+ extraClass.push(cls);
142
+ }
143
+ }
144
+ const explicitId = Boolean(id);
145
+ if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
146
+
147
+ ctx.sections.push(id);
148
+ let inner;
149
+ try {
150
+ inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1));
151
+ } finally {
152
+ ctx.sections.pop();
153
+ }
154
+ const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
155
+ const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
156
+ let classBind = "";
157
+ if (eachClasses.length) {
158
+ ctx.comp.assets.runtime = true;
159
+ classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`;
160
+ }
161
+ if (stateClasses.length) {
162
+ ctx.comp.assets.runtime = true;
163
+ classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`;
164
+ }
165
+ return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
166
+ }
167
+
168
+ /**
169
+ * `:carousel [autoplay=N]` … `:endcarousel` — a horizontally scroll-snapping
170
+ * carousel. Contract: each DIRECT child element of the track is one slide, so put
171
+ * each slide in its own block (e.g. a `::: slide` container) and give that block
172
+ * the slide sizing in your skin — loose prose lines would each count as a slide.
173
+ * Registers the `carousel` behavior (prev/next, dot nav, optional autoplay, mouse
174
+ * drag); native CSS scroll-snap + the page skin handle layout and touch swipe.
175
+ * `autoplay` is suppressed under `prefers-reduced-motion`. No runtime required.
176
+ * @param {string} line
177
+ * @param {string[]} bodyLines
178
+ * @param {Ctx} ctx
179
+ * @param {number} index 0-based line index for `file:line` errors.
180
+ * @returns {string}
181
+ */
182
+ export function handleCarousel(line, bodyLines, ctx, index) {
183
+ const match = line.match(/^:carousel\s*(.*)$/);
184
+ const rest = (match?.[1] || "").trim();
185
+ let autoplayAttr = "";
186
+ if (rest) {
187
+ const auto = rest.match(/^autoplay=(\d+)$/);
188
+ if (!auto)
189
+ throw new Error(
190
+ `Malformed :carousel in ${at(ctx, index)}: ${line}. Use: :carousel [autoplay=3000] … :endcarousel`
191
+ );
192
+ autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
193
+ }
194
+ ctx.comp.assets.behaviors.add("carousel");
195
+ const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1));
196
+ return `<div class="wd-carousel" data-wd-carousel${autoplayAttr}>\n<div class="wd-carousel-track" data-wd-carousel-track>\n${inner}\n</div>\n</div>`;
197
+ }
198
+
199
+ /**
200
+ * Compile-time announcements for `:fetch` lifecycle regions: a bare
201
+ * `:if <key>_loading` over a fetch-declared key becomes a `role="status"`
202
+ * (polite) live region and `:if <key>_error` a `role="alert"` one, so the
203
+ * runtime's existing show/hide flip is read by assistive tech with zero extra
204
+ * runtime JS. Author-supplied `role`/`aria-live` inside the region always wins
205
+ * (see handleInput) — nothing is added over it.
206
+ *
207
+ * The author-aria check reads the region's own RAW SOURCE lines, not its compiled
208
+ * branch HTML: in the documented chained pattern (`:if x_loading` / `:else if
209
+ * x_error` / `:else`) the loading region's falsy branch *contains* the nested
210
+ * error region's framework-added `role="alert"`, and matching that would falsely
211
+ * suppress the loading region's own `role="status"`. Framework-added attributes
212
+ * never appear in source, so only genuine author `role`/`aria-live` can match.
213
+ * @param {string} key Resolved state key of the `:if` region.
214
+ * @param {string[]} sourceLines The region's own truthy + falsy source lines.
215
+ * @param {Ctx} ctx
216
+ * @returns {string}
217
+ */
218
+ function fetchLiveAttr(key, sourceLines, ctx) {
219
+ const match = key.match(/^(.*)_(loading|error)$/);
220
+ if (!match || !ctx.comp.fetchKeys.has(match[1])) return "";
221
+ if (/\b(?:role|aria-live)\s*=/.test(sourceLines.join("\n"))) return "";
222
+ return match[2] === "loading" ? ' role="status" aria-live="polite"' : ' role="alert"';
223
+ }
224
+
225
+ /**
226
+ * @param {string} line
227
+ * @param {string[]} truthyLines
228
+ * @param {string[]} falsyLines
229
+ * @param {Ctx} ctx
230
+ * @param {number} index 0-based line index for `file:line` errors.
231
+ * @param {number} [falsyStart] 0-based index the falsy body starts at in the
232
+ * current slice, so nested errors in that branch report the true file line.
233
+ * @returns {string}
234
+ */
235
+ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart = index + 1) {
236
+ const truthyCtx = nestedCtx(ctx, index + 1);
237
+ const falsyCtx = nestedCtx(ctx, falsyStart);
238
+ const condition = line.replace(/^:if\s+/, "").trim();
239
+ // Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
240
+ // existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
241
+ // `not`, …) falls through to the predicate path below.
242
+ const match = condition.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/);
243
+ if (match) {
244
+ const segs = match[1].split(".");
245
+ const head = segs[0];
246
+
247
+ // Per-row meta vars in :if — only valid inside a loop.
248
+ if (LOOP_META[head]) {
249
+ if (!ctx.loopMeta)
250
+ throw new Error(
251
+ `":if ${match[1]}" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Use it inside a loop body.`
252
+ );
253
+ if (ctx.loopItem) {
254
+ const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
255
+ const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
256
+ return `<span data-wd-each-if data-wd-meta="${LOOP_META[head]}"><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
257
+ }
258
+ // static: the meta boolean is in scope — fall through to the static branch below.
259
+ }
260
+
261
+ const staticValue = lookupVar(ctx.scope, head);
262
+ if (staticValue.found) {
263
+ const active = Boolean(getPath(staticValue.value, segs.slice(1)));
264
+ return ctx.compileBody(active ? truthyLines : falsyLines, active ? truthyCtx : falsyCtx);
265
+ }
266
+
267
+ if (ctx.loopItem && head === ctx.loopItem) {
268
+ const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
269
+ const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
270
+ const rest = segs.slice(1).join(".");
271
+ const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
272
+ return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
273
+ }
274
+
275
+ const key = resolveStateKey(head, ctx);
276
+ if (!key) {
277
+ throw new Error(
278
+ `:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`
279
+ );
280
+ }
281
+ ctx.comp.assets.runtime = true;
282
+ const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
283
+ const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
284
+ const restPath = segs.slice(1).join(".");
285
+ const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
286
+ const liveAttr = restPath ? "" : fetchLiveAttr(key, [...truthyLines, ...falsyLines], ctx);
287
+ const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
288
+ const active = initialTruthy ? truthy : falsy;
289
+ return `<div data-wd-if="${key}"${pathAttr}${liveAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
290
+ }
291
+
292
+ // Predicate path: a comparison / logical condition. Compiles through the same
293
+ // whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
294
+ // build when static, drives a per-row each-if when it reads the loop item, and
295
+ // a global if-region (evaluated each render) when it reads state. No raw eval.
296
+ if (!condition)
297
+ throw new Error(
298
+ `Malformed :if in ${at(ctx, index)}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`
299
+ );
300
+ const compiled = compileWhen(condition, ctx, '":if"');
301
+ if (compiled.static)
302
+ return ctx.compileBody(
303
+ compiled.value ? truthyLines : falsyLines,
304
+ compiled.value ? truthyCtx : falsyCtx
305
+ );
306
+ ctx.comp.assets.runtime = true;
307
+ const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
308
+ const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
309
+ const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
310
+ if (compiled.item) {
311
+ return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
312
+ }
313
+ const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
314
+ const active = initialTruthy ? truthy : falsy;
315
+ return `<div data-wd-if=""${exprAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
316
+ }
317
+
318
+ /**
319
+ * Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
320
+ * @param {string} line
321
+ * @param {Ctx} ctx
322
+ * @returns {string}
323
+ */
324
+ export function renderDemoDirective(line, ctx) {
325
+ const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
326
+ if (tryMatch) {
327
+ const href = validateDemoHref(tryMatch[2], ctx);
328
+ return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
329
+ }
330
+ const note = line.match(/^:note\s+"([^"]+)"$/);
331
+ if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
332
+ const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
333
+ if (sprint) {
334
+ const roles = sprint[3]
335
+ .split(",")
336
+ .map((role) => role.trim())
337
+ .filter(Boolean);
338
+ return `<section class="sprint-board" data-min="${sprint[1]}" data-max="${sprint[2]}">${roles.map((role) => `<article><strong>${escapeHtml(role)}</strong><span>active lane</span></article>`).join("")}</section>`;
339
+ }
340
+ return "";
341
+ }
342
+
343
+ /**
344
+ * Validate demo-card links without adding arbitrary URL passthrough.
345
+ * @param {string} href
346
+ * @param {Ctx} ctx
347
+ * @returns {string}
348
+ */
349
+ function validateDemoHref(href, ctx) {
350
+ const value = href.trim();
351
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
352
+ if (value !== href || /[\u0000-\u001F\u007F]/.test(value)) {
353
+ throw new Error(
354
+ `Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`
355
+ );
356
+ }
357
+ if (value.startsWith("//")) {
358
+ throw new Error(
359
+ `Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http: or https: explicitly.`
360
+ );
361
+ }
362
+ if (
363
+ value.startsWith("/") ||
364
+ value.startsWith("./") ||
365
+ value.startsWith("../") ||
366
+ value.startsWith("#")
367
+ ) {
368
+ return value;
369
+ }
370
+ const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
371
+ if (scheme && ["http", "https", "mailto"].includes(scheme[1].toLowerCase())) {
372
+ return value;
373
+ }
374
+ throw new Error(
375
+ `Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`
376
+ );
377
+ }
package/src/dev.js CHANGED
@@ -1,6 +1,54 @@
1
+ import { escapeHtml } from "./compiler.js";
2
+
1
3
  export const devClientPath = "/__wd/dev-client.js";
2
4
  export const devEventsPath = "/__wd/dev-events";
3
5
 
6
+ /**
7
+ * Serialize the dev server's rebuilds: at most ONE child build runs at a time.
8
+ * Every build read-modify-writes the dependency map (`dist/.wd-dev-deps.json`),
9
+ * so overlapping children could persist a stale map missing deps a concurrent
10
+ * build just recorded — after which the affected routes silently stop
11
+ * rebuilding. Changes debounce into a batch; changes arriving MID-build
12
+ * accumulate into the next batch (duplicate paths coalesce via the Set, and a
13
+ * `null` — a `src/` change or an unattributable event, forcing a full rebuild —
14
+ * swallows the whole batch into one full build).
15
+ * @param {(changed: string[]) => Promise<void> | void} runBuild Runs one child
16
+ * build over the changed `site/` paths (`[]` = full rebuild). Build failures
17
+ * are its own concern (report + resolve); a rejection still frees the queue.
18
+ * @param {number} [debounceMs] Debounce window for batching change events.
19
+ * @returns {{ change: (path: string | null) => void, close: () => void }}
20
+ */
21
+ export function createRebuildQueue(runBuild, debounceMs = 30) {
22
+ /** @type {Set<string | null>} */
23
+ const pending = new Set();
24
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
25
+ let timer;
26
+ let running = false;
27
+ const flush = () => {
28
+ if (running || pending.size === 0) return;
29
+ const changed = pending.has(null) ? [] : /** @type {string[]} */ ([...pending]);
30
+ pending.clear();
31
+ running = true;
32
+ Promise.resolve()
33
+ .then(() => runBuild(changed))
34
+ .catch(() => {}) // runBuild reports its own failures; never wedge the queue
35
+ .then(() => {
36
+ running = false;
37
+ flush(); // drain changes that arrived mid-build (already waited a build long)
38
+ });
39
+ };
40
+ return {
41
+ change(path) {
42
+ pending.add(path);
43
+ clearTimeout(timer);
44
+ timer = setTimeout(flush, debounceMs);
45
+ },
46
+ close() {
47
+ clearTimeout(timer);
48
+ }
49
+ };
50
+ }
51
+
4
52
  /**
5
53
  * Inject the dev-client `<script>` before `</body>` (or append it if absent),
6
54
  * plus — for a `draft: true` page — an inline draft-banner script. Both are
@@ -42,6 +90,34 @@ document.addEventListener("DOMContentLoaded", () => document.body.prepend(banner
42
90
  `;
43
91
  }
44
92
 
93
+ /**
94
+ * The page the dev server serves for a route with no dist output while the last
95
+ * build is FAILED — the honest alternative to the production 404 copy, which
96
+ * would claim the route "is hidden or has not been created" when the real cause
97
+ * is a compile error. The message renders inline (works without JS) and the
98
+ * injected dev client replays the `builderror` overlay on connect, then reloads
99
+ * the real page on the next successful build.
100
+ * @param {string} message The recorded build failure.
101
+ * @returns {string}
102
+ */
103
+ export function buildFailedPage(message) {
104
+ return injectDevClient(
105
+ `<!doctype html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="utf-8">
109
+ <meta name="viewport" content="width=device-width, initial-scale=1">
110
+ <title>Darkmown build failed</title>
111
+ </head>
112
+ <body>
113
+ <h1>Darkmown build failed</h1>
114
+ <pre>${escapeHtml(message)}</pre>
115
+ <p>This route has no built output because the last build failed. Fix the file above and this page reloads on the next successful build.</p>
116
+ </body>
117
+ </html>`
118
+ );
119
+ }
120
+
45
121
  /**
46
122
  * The dev-client browser script that opens the SSE channel for live reload.
47
123
  * @returns {string}
package/src/headers.js CHANGED
@@ -12,11 +12,21 @@
12
12
  * The CSP is derived from what the framework's own output actually emits
13
13
  * (verify against built `dist/` HTML):
14
14
  * - External script `/__wd/runtime.js` (same-origin) on reactive pages.
15
- * - Inline `<script type="application/json" data-wd-state>` (state seed) and
16
- * inline `<script type="speculationrules">` (prerender) — JSON/speculation
17
- * blocks can't be nonce'd cleanly, so `script-src 'unsafe-inline'`.
15
+ * - Inline `<script type="application/json" data-wd-state>` (state seed) —
16
+ * a non-executable data block, which `script-src` does not govern, so it
17
+ * needs no source at all.
18
+ * - Inline `<script type="speculationrules">` (prerender, `transitions: true`)
19
+ * — the one inline script `script-src` does gate. Its body is a single
20
+ * build-time constant, so a `'sha256-…'` hash source authorizes exactly it
21
+ * (plus the `'inline-speculation-rules'` keyword for browsers that check
22
+ * that instead of the hash) and `'unsafe-inline'` is dropped entirely. A
23
+ * raw `<script>` an author writes into `html: true` markdown is therefore
24
+ * blocked by the shipped CSP — use a colocated `.js` file (same-origin,
25
+ * covered by `'self'`) or widen `script-src` deliberately.
18
26
  * - The reactive runtime compiles validated expressions via `new Function`,
19
- * so reactive pages additionally need `script-src 'unsafe-eval'`.
27
+ * so reactive pages additionally need `script-src 'unsafe-eval'`. That
28
+ * keyword cannot be hashed away: it gates eval-family APIs, not inline
29
+ * script elements.
20
30
  * - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
21
31
  * - `data:` favicon and remote images — `img-src 'self' data: https:`.
22
32
  * - `:video`/`:audio` allow relative or `http(s)` sources — `media-src 'self' https:`.
@@ -26,11 +36,40 @@
26
36
  * authorizes it (and blocks a form exfiltrating to a third-party origin). Apps
27
37
  * posting to a remote backend widen this alongside `connect-src`.
28
38
  *
29
- * Static pages (zero framework JS) still emit inline `<style>` and an inline
30
- * `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
39
+ * Static pages (zero framework JS) share the same hash-based `script-src` but
31
40
  * never call `new Function`, so their CSP drops `'unsafe-eval'`.
32
41
  */
33
42
 
43
+ import { createHash } from "node:crypto";
44
+
45
+ /**
46
+ * The exact body of the inline `<script type="speculationrules">` block that
47
+ * `compilePage` emits for `transitions: true` pages. Kept in sync with
48
+ * `src/compiler/page.js` by a drift-guard test that hashes the block out of a
49
+ * real build — if the emitted rules change, that test fails before the CSP
50
+ * silently starts blocking prerender.
51
+ * @type {string}
52
+ */
53
+ export const SPECULATION_RULES_JSON =
54
+ '{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}';
55
+
56
+ /**
57
+ * CSP hash source for the speculationrules block — computed from the constant
58
+ * above at module load, never hand-maintained.
59
+ * @type {string}
60
+ */
61
+ export const SPECULATION_RULES_HASH = `'sha256-${createHash("sha256")
62
+ .update(SPECULATION_RULES_JSON)
63
+ .digest("base64")}'`;
64
+
65
+ /**
66
+ * The `script-src` both CSP variants share: same-origin scripts, plus the two
67
+ * grants for the inline speculationrules block (hash + keyword). No
68
+ * `'unsafe-inline'` — the state seed is a data block and needs nothing.
69
+ * @type {string}
70
+ */
71
+ const SCRIPT_SRC = `script-src 'self' ${SPECULATION_RULES_HASH} 'inline-speculation-rules'`;
72
+
34
73
  /**
35
74
  * `connect-src` for the demo is `'self'`: the framework's own pages only
36
75
  * `:fetch` same-origin endpoints. Apps whose `:fetch` targets a remote host
@@ -42,6 +81,10 @@ const CONNECT_SRC = "connect-src 'self'";
42
81
 
43
82
  /**
44
83
  * Directives shared by both the static and reactive CSP. Order is cosmetic.
84
+ * `img-src`/`media-src` deliberately allow any `https:` host — remote images
85
+ * and media in markdown are legitimate everywhere. Sites that only serve their
86
+ * own assets can tighten both to `'self'` (plus `data:` for the favicon) in
87
+ * their deploy config; see SECURITY.md "Tightening the policy".
45
88
  * @type {string[]}
46
89
  */
47
90
  const COMMON_CSP_DIRECTIVES = [
@@ -59,24 +102,20 @@ const COMMON_CSP_DIRECTIVES = [
59
102
  ];
60
103
 
61
104
  /**
62
- * Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`, the
63
- * inline state/speculationrules JSON needs `'unsafe-inline'`.
105
+ * Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`; the
106
+ * inline speculationrules block rides on the shared hash source, so no
107
+ * `'unsafe-inline'`.
64
108
  * @type {string}
65
109
  */
66
- export const REACTIVE_CSP = [
67
- "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
68
- ...COMMON_CSP_DIRECTIVES
69
- ].join("; ");
110
+ export const REACTIVE_CSP = [`${SCRIPT_SRC} 'unsafe-eval'`, ...COMMON_CSP_DIRECTIVES].join("; ");
70
111
 
71
112
  /**
72
113
  * Stricter CSP for static pages: no runtime means no `new Function`, so
73
- * `'unsafe-eval'` is dropped. Inline `<style>`/`<script type=speculationrules>`
74
- * keep `'unsafe-inline'` on script/style.
114
+ * `'unsafe-eval'` is dropped too. Inline `<style>` (view transitions) keeps
115
+ * `'unsafe-inline'` on style-src only.
75
116
  * @type {string}
76
117
  */
77
- export const STATIC_CSP = ["script-src 'self' 'unsafe-inline'", ...COMMON_CSP_DIRECTIVES].join(
78
- "; "
79
- );
118
+ export const STATIC_CSP = [SCRIPT_SRC, ...COMMON_CSP_DIRECTIVES].join("; ");
80
119
 
81
120
  /**
82
121
  * Baseline security headers applied to every HTML response, independent of the
package/src/router.js CHANGED
@@ -101,11 +101,22 @@ export function routeFromFile(routesRoot, file) {
101
101
 
102
102
  /**
103
103
  * Map a public route to the `index.html` output path under `distRoot`.
104
+ *
105
+ * Defense in depth: routes come from `routeFromFile` walking `site/pages`, so
106
+ * they can't normally carry traversal segments — but any future caller passing
107
+ * an unvetted route must not be able to write outside the build output. The
108
+ * resolved path is asserted to stay inside `distRoot`.
104
109
  * @param {string} distRoot Absolute path to the build output directory.
105
110
  * @param {string} route Public route path.
106
111
  * @returns {string}
107
112
  */
108
113
  export function outputPathForRoute(distRoot, route) {
109
114
  const clean = route === "/" ? "" : route.replace(/^\/|\/$/g, "");
110
- return path.join(distRoot, clean, "index.html");
115
+ const out = path.join(distRoot, clean, "index.html");
116
+ if (!path.resolve(out).startsWith(path.resolve(distRoot) + path.sep)) {
117
+ throw new Error(
118
+ `Route "${route}" resolves outside the build output directory ${distRoot} — routes must not contain traversal segments`
119
+ );
120
+ }
121
+ return out;
111
122
  }
package/src/runtime.js CHANGED
@@ -142,6 +142,8 @@ const fmtToDate = (v) => {
142
142
  };
143
143
  /** @param {Date} d @param {any} opts @param {any} fb */
144
144
  const fmtDate = (d, opts, fb) => (Number.isNaN(d.getTime()) ? String(fb) : new Intl.DateTimeFormat(undefined, opts).format(d));
145
+ /** Date-only strings (no time) format in UTC so the calendar date is stable. @param {any} v */
146
+ const fmtTZ = (v) => (typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v.trim()) ? { timeZone: "UTC" } : undefined);
145
147
 
146
148
  /** @type {Record<string, (value: any, args: any[]) => any>} */
147
149
  const FMT = {
@@ -149,9 +151,9 @@ const FMT = {
149
151
  number: (v, [d]) => new Intl.NumberFormat(undefined, d == null ? {} : { minimumFractionDigits: +d, maximumFractionDigits: +d }).format(fmtNum(v)),
150
152
  percent: (v, [d = 0]) => new Intl.NumberFormat(undefined, { style: "percent", minimumFractionDigits: +d, maximumFractionDigits: +d }).format(fmtNum(v)),
151
153
  round: (v, [d = 0]) => { const p = 10 ** +d; return Math.round(fmtNum(v) * p) / p; },
152
- date: (v, [s = "medium"]) => fmtDate(fmtToDate(v), { dateStyle: s }, v),
153
- time: (v, [s = "short"]) => fmtDate(fmtToDate(v), { timeStyle: s }, v),
154
- datetime: (v, [ds = "medium", ts = "short"]) => fmtDate(fmtToDate(v), { dateStyle: ds, timeStyle: ts }, v),
154
+ date: (v, [s = "medium"]) => fmtDate(fmtToDate(v), { dateStyle: s, ...fmtTZ(v) }, v),
155
+ time: (v, [s = "short"]) => fmtDate(fmtToDate(v), { timeStyle: s, ...fmtTZ(v) }, v),
156
+ datetime: (v, [ds = "medium", ts = "short"]) => fmtDate(fmtToDate(v), { dateStyle: ds, timeStyle: ts, ...fmtTZ(v) }, v),
155
157
  upper: (v) => String(v ?? "").toUpperCase(),
156
158
  lower: (v) => String(v ?? "").toLowerCase(),
157
159
  capitalize: (v) => { const s = String(v ?? ""); return s ? s[0].toUpperCase() + s.slice(1) : s; },