@zvndev/darkmown 1.5.0 → 2.0.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 (95) hide show
  1. package/AGENTS.md +18 -4
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +49 -19
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +21 -7
  7. package/package.json +3 -2
  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 +5 -1
  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 +2 -1
  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.md +1 -1
  24. package/site/pages/blog/zero-js-by-default.skin +127 -0
  25. package/site/pages/carousel/index.wd +1 -0
  26. package/site/pages/compass/index.wd +1 -0
  27. package/site/pages/data.wd +3 -0
  28. package/site/pages/docs/-relative-note.wd +4 -0
  29. package/site/pages/docs/index.skin +27 -0
  30. package/site/pages/docs/index.wd +85 -14
  31. package/site/pages/folio/-style.wd +4 -0
  32. package/site/pages/folio/cart.wd +1 -0
  33. package/site/pages/folio/index.wd +1 -0
  34. package/site/pages/forge/index.wd +1 -0
  35. package/site/pages/highlight/index.wd +1 -0
  36. package/site/pages/index.js +19 -0
  37. package/site/pages/index.skin +46 -1
  38. package/site/pages/index.wd +6 -3
  39. package/site/pages/interactions/index.wd +1 -0
  40. package/site/pages/ledger/index.wd +1 -0
  41. package/site/pages/loops.wd +1 -0
  42. package/site/pages/media/index.wd +1 -0
  43. package/site/pages/pulse/index.wd +1 -0
  44. package/site/pages/reactive.wd +1 -0
  45. package/site/pages/scoped/index.wd +1 -0
  46. package/site/pages/showcase/index.wd +2 -1
  47. package/site/pages/store.wd +1 -0
  48. package/site/pages/theme.wd +1 -0
  49. package/src/builder.js +444 -65
  50. package/src/cli.js +96 -18
  51. package/src/compiler/actions.js +303 -0
  52. package/src/compiler/body.js +36 -24
  53. package/src/compiler/context.js +65 -6
  54. package/src/compiler/directives.js +31 -1470
  55. package/src/compiler/fetch.js +159 -0
  56. package/src/compiler/format.js +17 -4
  57. package/src/compiler/forms.js +456 -0
  58. package/src/compiler/frontmatter.js +10 -5
  59. package/src/compiler/includes.js +75 -4
  60. package/src/compiler/index.js +15 -3
  61. package/src/compiler/loops.js +85 -23
  62. package/src/compiler/markdown.js +65 -15
  63. package/src/compiler/media.js +133 -0
  64. package/src/compiler/page.js +88 -77
  65. package/src/compiler/state.js +168 -0
  66. package/src/compiler/structure.js +399 -0
  67. package/src/dev.js +76 -0
  68. package/src/headers.js +56 -17
  69. package/src/router.js +12 -1
  70. package/src/runtime.js +5 -3
  71. package/src/statics.js +63 -12
  72. package/src/templates/blog/README.md +13 -4
  73. package/src/templates/blog/site/_/nav.wd +7 -6
  74. package/src/templates/blog/site/pages/index.skin +10 -4
  75. package/src/templates/blog/site/pages/index.wd +11 -6
  76. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  77. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  78. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  79. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  80. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  81. package/src/templates/dashboard/site/_/nav.wd +7 -6
  82. package/src/templates/dashboard/site/pages/index.skin +7 -4
  83. package/src/templates/dashboard/site/pages/index.wd +2 -2
  84. package/src/templates/landing/site/_/nav.wd +7 -6
  85. package/src/templates/landing/site/pages/index.skin +13 -4
  86. package/src/templates/landing/site/pages/index.wd +22 -8
  87. package/src/templates/starter/site/_/nav.wd +8 -7
  88. package/src/templates/starter/site/pages/about.md +8 -2
  89. package/src/templates/starter/site/pages/about.skin +44 -0
  90. package/src/templates/starter/site/pages/index.skin +10 -6
  91. package/src/templates/starter/site/pages/index.wd +2 -2
  92. package/src/templates/store/site/_/nav.wd +7 -6
  93. package/src/templates/store/site/pages/index.skin +7 -4
  94. package/src/templates/store/site/pages/index.wd +2 -2
  95. package/src/templates/blog/site/_/posts.json +0 -16
@@ -0,0 +1,133 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Media — :video / :audio / :embed. Compile-time only (zero runtime): hardened
3
+ // HTML5 media and privacy-friendly, lazy iframe embeds. The extension stays the
4
+ // feature gate; these emit no `data-wd-*`, so a media-only page ships zero JS.
5
+ //
6
+ // Every handler receives the 0-based line `index` so its malformed/invalid
7
+ // errors report `file:line` (via `at`) — matching the unclosed-block errors —
8
+ // while keeping the message text and `Use:` hints intact.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import { at } from "./context.js";
12
+ import { validateFetchUrl } from "./fetch.js";
13
+ import { escapeHtml, stripQuotes } from "./interpolation.js";
14
+
15
+ /**
16
+ * @typedef {import("./context.js").Ctx} Ctx
17
+ */
18
+
19
+ /** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
20
+ const MEDIA_SPEC = {
21
+ video: {
22
+ flags: ["controls", "autoplay", "loop", "muted", "playsinline"],
23
+ attrs: ["poster", "width", "height", "preload"]
24
+ },
25
+ audio: { flags: ["controls", "autoplay", "loop", "muted"], attrs: ["preload"] }
26
+ };
27
+
28
+ /**
29
+ * `:video /clip.mp4 [poster=…] [width=…] [height=…] [preload=…] [controls] [autoplay]
30
+ * [loop] [muted] [playsinline]` and `:audio /track.mp3 [preload=…] [controls] …` →
31
+ * a hardened `<video>`/`<audio>`. Defaults: `preload="metadata"`, and `controls`
32
+ * unless the clip is an `autoplay` background (autoplay also implies `muted`, which
33
+ * browsers require). Author flags/attrs always win. URLs use the `:fetch` scheme
34
+ * guard (relative or http(s)).
35
+ * @param {string} line
36
+ * @param {"video" | "audio"} kind
37
+ * @param {Ctx} ctx
38
+ * @param {number} index 0-based line index for `file:line` errors.
39
+ * @returns {string}
40
+ */
41
+ export function handleMedia(line, kind, ctx, index) {
42
+ const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
43
+ if (!match) {
44
+ throw new Error(
45
+ `Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: :${kind} /clip [controls] [autoplay] [loop] [muted]`
46
+ );
47
+ }
48
+ const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
49
+ const spec = MEDIA_SPEC[kind];
50
+ /** @type {Set<string>} */
51
+ const flags = new Set();
52
+ /** @type {Map<string, string>} */
53
+ const attrs = new Map();
54
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
55
+ for (const token of (match[2] || "").matchAll(re)) {
56
+ if (token[3]) {
57
+ if (!spec.flags.includes(token[3]))
58
+ throw new Error(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`);
59
+ flags.add(token[3]);
60
+ } else {
61
+ if (!spec.attrs.includes(token[1]))
62
+ throw new Error(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`);
63
+ attrs.set(token[1], stripQuotes(token[2]));
64
+ }
65
+ }
66
+ // Sensible defaults; author input always wins.
67
+ if (flags.has("autoplay")) flags.add("muted"); // browsers block unmuted autoplay
68
+ if (kind === "audio" || !flags.has("autoplay")) flags.add("controls");
69
+ const parts = [`src="${escapeHtml(src)}"`];
70
+ if (attrs.has("poster"))
71
+ parts.push(
72
+ `poster="${escapeHtml(validateFetchUrl(/** @type {string} */ (attrs.get("poster")), ctx, `:${kind} poster`))}"`
73
+ );
74
+ for (const name of ["width", "height"]) {
75
+ if (attrs.has(name))
76
+ parts.push(`${name}="${escapeHtml(/** @type {string} */ (attrs.get(name)))}"`);
77
+ }
78
+ parts.push(`preload="${escapeHtml(attrs.get("preload") || "metadata")}"`);
79
+ for (const flag of spec.flags) if (flags.has(flag)) parts.push(flag);
80
+ return `<${kind} ${parts.join(" ")}></${kind}>`;
81
+ }
82
+
83
+ /**
84
+ * `:embed <url> [title="…"]` → a lazy, privacy-friendly responsive iframe. A
85
+ * YouTube or Vimeo URL is rewritten to its no-cookie / player embed; any other
86
+ * http(s) URL becomes a generic 16:9 iframe. Inline styles keep the wrapper
87
+ * self-contained (no framework CSS), so an embed-only page stays zero-JS/zero-CSS.
88
+ * @param {string} line
89
+ * @param {Ctx} ctx
90
+ * @param {number} index 0-based line index for `file:line` errors.
91
+ * @returns {string}
92
+ */
93
+ export function handleEmbed(line, ctx, index) {
94
+ const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
95
+ if (!match) {
96
+ throw new Error(
97
+ `Malformed :embed in ${at(ctx, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
98
+ );
99
+ }
100
+ const raw = stripQuotes(match[1]);
101
+ let title = "";
102
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
103
+ for (const token of (match[2] || "").matchAll(re)) {
104
+ if (token[1] !== "title")
105
+ throw new Error(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`);
106
+ title = stripQuotes(token[2]);
107
+ }
108
+ const { url, label } = resolveEmbed(raw, ctx);
109
+ const iframe =
110
+ `<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
111
+ `referrerpolicy="strict-origin-when-cross-origin" ` +
112
+ `allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" ` +
113
+ `allowfullscreen style="width:100%;height:100%;border:0"></iframe>`;
114
+ return `<div class="wd-embed" style="aspect-ratio:16/9">${iframe}</div>`;
115
+ }
116
+
117
+ /**
118
+ * Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
119
+ * player; anything else is validated as a generic http(s) iframe source.
120
+ * @param {string} raw
121
+ * @param {Ctx} ctx
122
+ * @returns {{ url: string, label: string }}
123
+ */
124
+ function resolveEmbed(raw, ctx) {
125
+ const youtube = raw.match(
126
+ /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([\w-]{6,})/
127
+ );
128
+ if (youtube)
129
+ return { url: `https://www.youtube-nocookie.com/embed/${youtube[1]}`, label: "YouTube video" };
130
+ const vimeo = raw.match(/vimeo\.com\/(?:video\/)?(\d+)/);
131
+ if (vimeo) return { url: `https://player.vimeo.com/video/${vimeo[1]}`, label: "Vimeo video" };
132
+ return { url: validateFetchUrl(raw, ctx, ":embed"), label: "Embedded content" };
133
+ }
@@ -12,7 +12,12 @@ import { htmlHasHighlight } from "../highlight.js";
12
12
  import { compileBody } from "./body.js";
13
13
  import { createCompilation, createScope } from "./context.js";
14
14
  import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
15
- import { collectColocatedAssets, scanMarkdownHints, scopedSkinFor } from "./includes.js";
15
+ import {
16
+ collectColocatedAssets,
17
+ scanMarkdownHints,
18
+ scopedSkinFor,
19
+ stampScope
20
+ } from "./includes.js";
16
21
  import { escapeHtml } from "./interpolation.js";
17
22
  import { selectMd } from "./markdown.js";
18
23
 
@@ -26,6 +31,13 @@ import { selectMd } from "./markdown.js";
26
31
  * @typedef {import("./context.js").CompiledPage} CompiledPage
27
32
  */
28
33
 
34
+ // Skip-to-content link: the first focusable element on every page, visually
35
+ // hidden until keyboard focus reveals it. Styled inline (not via a skin) so it
36
+ // behaves on pages with no stylesheet at all; it rides ahead of page skins in
37
+ // the head, so a project skin can restyle `.wd-skip-link` freely.
38
+ const SKIP_LINK_STYLE =
39
+ "<style>.wd-skip-link{position:absolute;top:0;left:0;z-index:100;padding:.55rem 1.1rem;background:#18221d;color:#f7f3ea;font:600 .9rem/1.2 system-ui,sans-serif;text-decoration:none;border-radius:0 0 8px 0;transform:translateY(-150%)}.wd-skip-link:focus{transform:none}</style>";
40
+
29
41
  /**
30
42
  * Compile a page source file into a full HTML document plus its assets.
31
43
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
@@ -42,6 +54,12 @@ export function compilePage(file, context, options = {}) {
42
54
  const compiled = compileDocument(file, context, [], options.vars, options.collections);
43
55
  const title = compiled.meta.title || "Darkmown";
44
56
  const description = compiled.meta.description || "";
57
+ // Per-page document language for the `<html lang>` attribute (`lang: fr` in
58
+ // frontmatter). Defaults to English.
59
+ const lang =
60
+ typeof compiled.meta.lang === "string" && compiled.meta.lang.trim()
61
+ ? compiled.meta.lang.trim()
62
+ : "en";
45
63
  // Optional `image:` frontmatter sets the social-share preview (absolute URL).
46
64
  const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
47
65
  /** @type {string[]} */
@@ -68,10 +86,8 @@ export function compilePage(file, context, options = {}) {
68
86
  // The framework highlight stylesheet rides ahead of page skins so a project's
69
87
  // own `$code-*` tokens / overrides still cascade over it. Pay-for-what-you-use:
70
88
  // linked only on pages with a highlighted code block (`hasCode`).
71
- const highlightLink = compiled.assets.hasCode
72
- ? [`<link rel="stylesheet" href="/__wd/highlight.css">`]
73
- : [];
74
- const cssLinks = [...highlightLink, ...compiled.assets.skins]
89
+ const highlightHref = compiled.assets.hasCode ? ["/__wd/highlight.css"] : [];
90
+ const cssLinks = [...highlightHref, ...compiled.assets.skins]
75
91
  .map((href) => `<link rel="stylesheet" href="${href}">`)
76
92
  .join("\n");
77
93
  // RSS feed discovery: when the site emits an `rss.xml` (home `site_url` set +
@@ -143,20 +159,25 @@ export function compilePage(file, context, options = {}) {
143
159
  const stamped =
144
160
  scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
145
161
  const body = enhanceImages(stamped, context);
162
+ const { content, target } = ensureMainLandmark(body);
146
163
 
147
164
  return {
148
165
  meta: compiled.meta,
166
+ deps: compiled.deps,
167
+ collectionsUsed: compiled.collectionsUsed,
149
168
  html: `<!doctype html>
150
- <html lang="en">
169
+ <html lang="${escapeHtml(lang)}">
151
170
  <head>
152
171
  <meta charset="utf-8">
153
172
  <meta name="viewport" content="width=device-width, initial-scale=1">
154
173
  <title>${escapeHtml(title)}</title>${descriptionTag}
155
174
  <link rel="icon" href="${favicon}">${feedLink}
175
+ ${SKIP_LINK_STYLE}
156
176
  ${cssLinks}${transitions}${speculation}
157
177
  </head>
158
178
  <body>
159
- ${body}
179
+ <a class="wd-skip-link" href="#${escapeHtml(target)}">Skip to content</a>
180
+ ${content}
160
181
  ${scripts}
161
182
  </body>
162
183
  </html>`,
@@ -166,6 +187,25 @@ ${scripts}
166
187
  };
167
188
  }
168
189
 
190
+ /**
191
+ * Guarantee the page body exposes a main-content landmark the skip link can
192
+ * target. A body that already has a `<main>` keeps it: an author-supplied id
193
+ * wins as the skip target; a `<main>` without one gets `id="main"` stamped on.
194
+ * A body with no `<main>` at all is wrapped whole. Pure compile-time string
195
+ * pass (like {@link enhanceImages}) — a static page stays static.
196
+ * @param {string} html Assembled page body HTML.
197
+ * @returns {{ content: string, target: string }}
198
+ */
199
+ function ensureMainLandmark(html) {
200
+ const open = html.match(/<main\b[^>]*>/i);
201
+ if (!open) return { content: `<main id="main">\n${html}\n</main>`, target: "main" };
202
+ // The id attribute must start after whitespace (or a closing quote — sloppy
203
+ // but browser-parsed) so `data-id="…"` never masquerades as the real id.
204
+ const id = open[0].match(/[\s"']id\s*=\s*["']([^"']*)["']/i);
205
+ if (id) return { content: html, target: id[1] || "main" };
206
+ return { content: html.replace(open[0], `${open[0].slice(0, -1)} id="main">`), target: "main" };
207
+ }
208
+
169
209
  /**
170
210
  * Harden every `<img>` in the assembled page body — compile-time only, zero
171
211
  * runtime JS. Bare markdown images reflow the page as they decode (cumulative
@@ -203,70 +243,6 @@ export function enhanceImages(html, paths) {
203
243
  });
204
244
  }
205
245
 
206
- /**
207
- * Raw-text (`script`, `style`) and escapable-raw-text (`textarea`, `title`)
208
- * elements per the HTML spec: their CONTENT is not markup, so `<`/`>` inside them
209
- * are plain characters and must never be treated as tags. The content runs to the
210
- * first matching close tag.
211
- * @type {Set<string>}
212
- */
213
- const RAW_TEXT_ELEMENTS = new Set(["script", "style", "textarea", "title"]);
214
-
215
- /**
216
- * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
217
- * half of compile-time scoped styles, mirroring {@link enhanceImages}: a
218
- * compile-time post-pass over assembled HTML, zero runtime JS. Every element
219
- * start tag that doesn't already carry a scope attribute gets one, so the scoped
220
- * stylesheet (whose selectors end in `[data-wd-scope="<id>"]`) matches inside
221
- * this subtree and nowhere else. Void/self-closing tags are stamped too (the
222
- * attribute is valid on any element). Closing tags, comments, doctype, and the
223
- * `<!`/`<?` families are left alone. A tag that ALREADY has a `data-wd-scope`
224
- * (e.g. a nested include with its own scope) keeps its own — first stamp wins.
225
- *
226
- * Raw-text / escapable-raw-text elements (`script`, `style`, `textarea`,
227
- * `title`) are special-cased: their OPENING tag is stamped (valid), but their
228
- * BODY is left byte-intact — `<`/`>` inside a `<script>` are JavaScript, not
229
- * markup, so stamping there would corrupt the content. A small tokenizer walks
230
- * tag-by-tag (quote-aware in attribute lists) and, on entering a raw-text
231
- * element, copies through verbatim until the matching close tag.
232
- * @param {string} html Subtree HTML to scope.
233
- * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
234
- * @returns {string}
235
- */
236
- export function stampScope(html, scopeId) {
237
- // Opening tag: name + (quote-aware) attribute list + optional self-close slash.
238
- const openTag = /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g;
239
- let out = "";
240
- let cursor = 0;
241
- for (let m = openTag.exec(html); m; m = openTag.exec(html)) {
242
- const [tag, name, attrs, slash] = m;
243
- out += html.slice(cursor, m.index); // text/markup before this opening tag
244
- const stamped = /\bdata-wd-scope\s*=/.test(attrs)
245
- ? tag // first stamp wins — a pre-stamped tag keeps its own scope
246
- : `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
247
- out += stamped;
248
- cursor = m.index + tag.length;
249
-
250
- // Raw-text element (not self-closed): copy its body through untouched and
251
- // resume scanning after the matching close tag, so nothing inside is stamped.
252
- if (!slash && RAW_TEXT_ELEMENTS.has(name.toLowerCase())) {
253
- const rest = html.slice(cursor);
254
- const closeMatch = new RegExp(`</${name}\\s*>`, "i").exec(rest);
255
- if (!closeMatch) {
256
- out += rest; // unterminated raw-text element — copy the remainder verbatim
257
- cursor = html.length;
258
- break;
259
- }
260
- const through = closeMatch.index + closeMatch[0].length; // body + close tag
261
- out += rest.slice(0, through); // byte-intact
262
- cursor += through;
263
- openTag.lastIndex = cursor; // skip the body the scanner would otherwise re-walk
264
- }
265
- }
266
- out += html.slice(cursor); // trailing text after the last opening tag
267
- return out;
268
- }
269
-
270
246
  /**
271
247
  * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
272
248
  * @param {string} tag
@@ -317,13 +293,25 @@ function measureImage(src, paths) {
317
293
  export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
318
294
  const comp = createCompilation();
319
295
  comp.collections = collections;
320
- const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
296
+ const result = compileFile(
297
+ file,
298
+ context,
299
+ stack,
300
+ createScope(null, vars),
301
+ comp,
302
+ [],
303
+ null,
304
+ 0,
305
+ null
306
+ );
321
307
  return {
322
308
  meta: result.meta,
323
309
  html: result.html,
324
310
  assets: comp.assets,
325
311
  warnings: comp.warnings,
326
- pagination: comp.pagination
312
+ pagination: comp.pagination,
313
+ deps: comp.deps,
314
+ collectionsUsed: comp.collectionsUsed
327
315
  };
328
316
  }
329
317
 
@@ -336,9 +324,24 @@ export function compileDocument(file, context, stack = [], vars = {}, collection
336
324
  * @param {Compilation} comp
337
325
  * @param {string[]} sections
338
326
  * @param {string | null} loopItem
327
+ * @param {number} [reactiveDepth] Enclosing REACTIVE `@loop` levels, threaded in
328
+ * from the including page so an include's body can't bypass the two-level
329
+ * reactive-nesting limit at the `@include` boundary.
330
+ * @param {{ at: string, line: string } | null} [loopOpener] The enclosing `@loop`
331
+ * opener, threaded in so a depth error inside the include names the right opener.
339
332
  * @returns {{ meta: Meta, html: string }}
340
333
  */
341
- export function compileFile(file, context, stack, scope, comp, sections, loopItem) {
334
+ export function compileFile(
335
+ file,
336
+ context,
337
+ stack,
338
+ scope,
339
+ comp,
340
+ sections,
341
+ loopItem,
342
+ reactiveDepth = 0,
343
+ loopOpener = null
344
+ ) {
342
345
  const real = fs.realpathSync(file);
343
346
  if (stack.includes(real)) {
344
347
  throw new Error(
@@ -347,13 +350,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
347
350
  }
348
351
 
349
352
  const raw = fs.readFileSync(file, "utf8");
350
- const { meta, body } = parseFrontmatter(raw, file);
353
+ const { meta, body, bodyLine } = parseFrontmatter(raw, file);
351
354
  warnLikelyFrontmatter(raw, file, comp);
355
+ comp.deps.add(file);
352
356
  collectColocatedAssets(file, context, comp.assets);
353
357
 
354
358
  if (path.extname(file) === ".md") {
355
359
  scanMarkdownHints(body, file, comp);
356
- const html = selectMd(meta).render(body, {});
360
+ // Share the compilation's heading-slug counters (as renderProse does), so a
361
+ // `.md` included from a `.wd` page still dedupes anchors document-wide.
362
+ const html = selectMd(meta).render(body, { headingSlugs: comp.headingSlugs });
357
363
  if (htmlHasHighlight(html)) comp.assets.hasCode = true;
358
364
  return { meta, html };
359
365
  }
@@ -363,13 +369,18 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
363
369
  /** @type {Ctx} */
364
370
  const ctx = {
365
371
  file,
372
+ bodyLine,
366
373
  context,
367
374
  stack: [...stack, real],
368
375
  scope: createScope(scope, { meta }),
369
376
  comp,
370
377
  sections,
371
378
  loopItem,
372
- md: selectMd(meta)
379
+ reactiveDepth,
380
+ ...(loopOpener ? { loopOpener } : {}),
381
+ md: selectMd(meta),
382
+ compileBody,
383
+ compileFile
373
384
  };
374
385
  const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
375
386
  if (htmlHasHighlight(html)) comp.assets.hasCode = true;
@@ -0,0 +1,168 @@
1
+ // ---------------------------------------------------------------------------
2
+ // State declarations: the `:state` / `:store` / `:computed` / `:theme` handlers
3
+ // plus the shared declare* helpers (declareState/declareStore/declareErrorState)
4
+ // the `:fetch` and form handlers lean on to register their auto-declared keys.
5
+ //
6
+ // Every handler receives the 0-based line `index` so its malformed/invalid
7
+ // errors report `file:line` (via `at`) — matching the unclosed-block errors —
8
+ // while keeping the message text and `Use:` hints intact.
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import { at } from "./context.js";
12
+ import { FORMATTERS } from "./format.js";
13
+ import { escapeHtml, getPath, parseStateValue, safeScriptJson } from "./interpolation.js";
14
+ import { compileComputedExpr } from "./predicates.js";
15
+
16
+ /**
17
+ * @typedef {import("./context.js").Ctx} Ctx
18
+ */
19
+
20
+ /**
21
+ * @param {string} line
22
+ * @param {Ctx} ctx
23
+ * @param {number} index 0-based line index for `file:line` errors.
24
+ * @returns {string}
25
+ */
26
+ export function handleState(line, ctx, index) {
27
+ const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
28
+ if (!match) throw new Error(`Malformed :state in ${at(ctx, index)}: ${line}`);
29
+ const value = parseStateValue(match[2], at(ctx, index));
30
+ const key = declareState(match[1], value, ctx);
31
+ const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
32
+ return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
33
+ }
34
+
35
+ /**
36
+ * Register a state key in the current section scope, enabling the runtime.
37
+ * @param {string} name
38
+ * @param {unknown} value
39
+ * @param {Ctx} ctx
40
+ * @returns {string} The fully-qualified state key.
41
+ */
42
+ export function declareState(name, value, ctx) {
43
+ if (ctx.loopItem)
44
+ throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
45
+ if (ctx.comp.stores.has(name))
46
+ throw new Error(
47
+ `State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
48
+ );
49
+ const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
50
+ if (ctx.comp.state.has(key))
51
+ throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
52
+ ctx.comp.state.set(key, value);
53
+ ctx.comp.assets.runtime = true;
54
+ return key;
55
+ }
56
+
57
+ /**
58
+ * @param {string} line
59
+ * @param {Ctx} ctx
60
+ * @param {number} index 0-based line index for `file:line` errors.
61
+ * @returns {string}
62
+ */
63
+ export function handleStore(line, ctx, index) {
64
+ const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
65
+ if (!match)
66
+ throw new Error(
67
+ `Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral]`
68
+ );
69
+ const value = parseStateValue(match[2], at(ctx, index));
70
+ const name = declareStore(match[1], value, ctx);
71
+ const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
72
+ return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
73
+ }
74
+
75
+ /**
76
+ * Register a page-global store. The bare name is added to `comp.state` so every
77
+ * resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
78
+ * in `comp.stores` for collision checks. Never section-scoped.
79
+ * @param {string} name
80
+ * @param {unknown} value
81
+ * @param {Ctx} ctx
82
+ * @returns {string} The store name (also its bare state key).
83
+ */
84
+ export function declareStore(name, value, ctx) {
85
+ if (ctx.comp.stores.has(name))
86
+ throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
87
+ if (ctx.comp.state.has(name))
88
+ throw new Error(
89
+ `Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`
90
+ );
91
+ ctx.comp.stores.add(name);
92
+ ctx.comp.state.set(name, value);
93
+ ctx.comp.assets.runtime = true;
94
+ return name;
95
+ }
96
+
97
+ /**
98
+ * Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
99
+ * round-trip :form so error fallbacks have a key to bind.
100
+ * @param {string} key
101
+ * @param {Ctx} ctx
102
+ * @returns {void}
103
+ */
104
+ export function declareErrorState(key, ctx) {
105
+ const errorKey = `${key}_error`;
106
+ if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
107
+ }
108
+
109
+ /**
110
+ * @param {string} line
111
+ * @param {Ctx} ctx
112
+ * @param {number} index 0-based line index for `file:line` errors.
113
+ * @returns {string}
114
+ */
115
+ export function handleComputed(line, ctx, index) {
116
+ const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
117
+ if (!match)
118
+ throw new Error(
119
+ `Malformed :computed in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
120
+ );
121
+ const expr = compileComputedExpr(match[2].trim(), ctx);
122
+ /** @type {unknown} */
123
+ let initial;
124
+ try {
125
+ /** @param {string} key @param {string} [path] */
126
+ const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
127
+ // Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
128
+ // has the same initial value the runtime will recompute to.
129
+ const agg = (
130
+ /** @type {string} */ name,
131
+ /** @type {any} */ list,
132
+ /** @type {string} */ field
133
+ ) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
134
+ initial = new Function("S", "A", `return (${expr});`)(read, agg);
135
+ } catch {
136
+ console.warn(
137
+ `:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
138
+ );
139
+ initial = null;
140
+ }
141
+ const key = declareState(match[1], initial ?? null, ctx);
142
+ return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
143
+ }
144
+
145
+ /**
146
+ * `:theme [name] [= "auto"]` declares a durable `:store` (default name `theme`,
147
+ * seed `"auto"`) and reflects its value onto `<html data-theme>`. This layers a
148
+ * manual light/dark switch over the OS preference: `"auto"` follows the skin's
149
+ * `tokens dark` media query, while `"light"`/`"dark"` force the matching
150
+ * `:root[data-theme]` scope. Wire a switch with ordinary store actions —
151
+ * `:button "Dark" -> theme = "dark"`. Durable, so the choice survives reloads.
152
+ * @param {string} line
153
+ * @param {Ctx} ctx
154
+ * @param {number} index 0-based line index for `file:line` errors.
155
+ * @returns {string}
156
+ */
157
+ export function handleTheme(line, ctx, index) {
158
+ const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
159
+ if (!match) {
160
+ throw new Error(
161
+ `Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto")`
162
+ );
163
+ }
164
+ const name = match[1] || "theme";
165
+ const seed = match[2] != null ? parseStateValue(match[2], at(ctx, index)) : "auto";
166
+ const storeName = declareStore(name, seed, ctx);
167
+ return `<script type="application/json" data-wd-store="${storeName}">${safeScriptJson(seed)}</script><span data-wd-theme="${storeName}" hidden></span>`;
168
+ }