@zvndev/darkmown 1.4.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 (106) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +114 -17
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +31 -4
  7. package/package.json +1 -1
  8. package/site/_/base.skin +239 -18
  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 +5 -1
  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.skin +27 -5
  17. package/site/pages/app.wd +1 -0
  18. package/site/pages/blog/_schema.wd +15 -0
  19. package/site/pages/blog/collections-and-pagination.md +38 -0
  20. package/site/pages/blog/collections-and-pagination.skin +127 -0
  21. package/site/pages/blog/drafts-that-never-leak.md +2 -0
  22. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  23. package/site/pages/blog/index.skin +144 -0
  24. package/site/pages/blog/index.wd +74 -0
  25. package/site/pages/blog/shipping-feeds.md +1 -0
  26. package/site/pages/blog/shipping-feeds.skin +127 -0
  27. package/site/pages/blog/typed-frontmatter.md +45 -0
  28. package/site/pages/blog/typed-frontmatter.skin +127 -0
  29. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  30. package/site/pages/blog/zero-js-by-default.md +31 -0
  31. package/site/pages/blog/zero-js-by-default.skin +127 -0
  32. package/site/pages/carousel/index.wd +1 -0
  33. package/site/pages/compass/index.wd +1 -0
  34. package/site/pages/data.skin +50 -9
  35. package/site/pages/data.wd +3 -0
  36. package/site/pages/docs/-relative-note.wd +4 -0
  37. package/site/pages/docs/index.skin +27 -0
  38. package/site/pages/docs/index.wd +115 -11
  39. package/site/pages/folio/-style.wd +4 -0
  40. package/site/pages/folio/cart.wd +1 -0
  41. package/site/pages/folio/index.wd +1 -0
  42. package/site/pages/forge/index.wd +1 -0
  43. package/site/pages/highlight/index.wd +1 -0
  44. package/site/pages/index.wd +1 -0
  45. package/site/pages/interactions/index.wd +1 -0
  46. package/site/pages/ledger/index.wd +1 -0
  47. package/site/pages/loops.skin +43 -6
  48. package/site/pages/loops.wd +1 -0
  49. package/site/pages/media/index.wd +1 -0
  50. package/site/pages/pulse/index.wd +1 -0
  51. package/site/pages/reactive.skin +38 -6
  52. package/site/pages/reactive.wd +1 -0
  53. package/site/pages/scoped/index.wd +1 -0
  54. package/site/pages/showcase/index.wd +1 -0
  55. package/site/pages/store.skin +43 -6
  56. package/site/pages/store.wd +1 -0
  57. package/site/pages/theme.wd +1 -0
  58. package/src/builder.js +535 -48
  59. package/src/cli.js +96 -18
  60. package/src/compiler/actions.js +303 -0
  61. package/src/compiler/body.js +36 -24
  62. package/src/compiler/collections.js +316 -0
  63. package/src/compiler/context.js +83 -6
  64. package/src/compiler/directives.js +31 -1470
  65. package/src/compiler/fetch.js +159 -0
  66. package/src/compiler/format.js +17 -4
  67. package/src/compiler/forms.js +456 -0
  68. package/src/compiler/frontmatter.js +10 -5
  69. package/src/compiler/includes.js +66 -1
  70. package/src/compiler/index.js +15 -3
  71. package/src/compiler/loops.js +181 -31
  72. package/src/compiler/markdown.js +65 -15
  73. package/src/compiler/media.js +133 -0
  74. package/src/compiler/page.js +78 -41
  75. package/src/compiler/state.js +168 -0
  76. package/src/compiler/structure.js +377 -0
  77. package/src/dev.js +76 -0
  78. package/src/headers.js +56 -17
  79. package/src/router.js +12 -1
  80. package/src/runtime.js +5 -3
  81. package/src/statics.js +63 -12
  82. package/src/templates/blog/README.md +13 -4
  83. package/src/templates/blog/site/_/nav.wd +4 -0
  84. package/src/templates/blog/site/pages/index.skin +87 -0
  85. package/src/templates/blog/site/pages/index.wd +10 -4
  86. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  87. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  88. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  89. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  90. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  91. package/src/templates/dashboard/site/_/nav.wd +4 -0
  92. package/src/templates/dashboard/site/pages/index.skin +84 -0
  93. package/src/templates/dashboard/site/pages/index.wd +1 -0
  94. package/src/templates/landing/site/_/nav.wd +4 -0
  95. package/src/templates/landing/site/pages/index.skin +113 -6
  96. package/src/templates/landing/site/pages/index.wd +1 -0
  97. package/src/templates/starter/site/_/nav.wd +4 -0
  98. package/src/templates/starter/site/pages/about.md +18 -1
  99. package/src/templates/starter/site/pages/about.skin +69 -0
  100. package/src/templates/starter/site/pages/index.skin +81 -3
  101. package/src/templates/starter/site/pages/index.wd +1 -0
  102. package/src/templates/store/site/_/nav.wd +4 -0
  103. package/src/templates/store/site/pages/index.skin +93 -11
  104. package/src/templates/store/site/pages/index.wd +1 -0
  105. package/site/pages/blog/index.md +0 -13
  106. 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,19 +31,35 @@ 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.
32
44
  * @param {Paths} context Resolved project paths.
33
- * @param {{ feed?: { href: string, title: string } }} [options] When a site-wide
34
- * RSS feed is emitted (the home page set `site_url` and the site has dated
35
- * posts), `feed` carries its absolute href + title so every page links it.
45
+ * @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown> }} [options]
46
+ * When a site-wide RSS feed is emitted (the home page set `site_url` and the
47
+ * site has dated posts), `feed` carries its absolute href + title so every page
48
+ * links it. `collections` is the build-time collection index a bare-name
49
+ * `@loop` resolves against; `vars` seeds the document scope (the pager `page`
50
+ * object on a paginated route).
36
51
  * @returns {CompiledPage}
37
52
  */
38
53
  export function compilePage(file, context, options = {}) {
39
- const compiled = compileDocument(file, context);
54
+ const compiled = compileDocument(file, context, [], options.vars, options.collections);
40
55
  const title = compiled.meta.title || "Darkmown";
41
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";
42
63
  // Optional `image:` frontmatter sets the social-share preview (absolute URL).
43
64
  const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
44
65
  /** @type {string[]} */
@@ -65,10 +86,8 @@ export function compilePage(file, context, options = {}) {
65
86
  // The framework highlight stylesheet rides ahead of page skins so a project's
66
87
  // own `$code-*` tokens / overrides still cascade over it. Pay-for-what-you-use:
67
88
  // linked only on pages with a highlighted code block (`hasCode`).
68
- const highlightLink = compiled.assets.hasCode
69
- ? [`<link rel="stylesheet" href="/__wd/highlight.css">`]
70
- : [];
71
- const cssLinks = [...highlightLink, ...compiled.assets.skins]
89
+ const highlightHref = compiled.assets.hasCode ? ["/__wd/highlight.css"] : [];
90
+ const cssLinks = [...highlightHref, ...compiled.assets.skins]
72
91
  .map((href) => `<link rel="stylesheet" href="${href}">`)
73
92
  .join("\n");
74
93
  // RSS feed discovery: when the site emits an `rss.xml` (home `site_url` set +
@@ -140,28 +159,53 @@ export function compilePage(file, context, options = {}) {
140
159
  const stamped =
141
160
  scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
142
161
  const body = enhanceImages(stamped, context);
162
+ const { content, target } = ensureMainLandmark(body);
143
163
 
144
164
  return {
145
165
  meta: compiled.meta,
166
+ deps: compiled.deps,
167
+ collectionsUsed: compiled.collectionsUsed,
146
168
  html: `<!doctype html>
147
- <html lang="en">
169
+ <html lang="${escapeHtml(lang)}">
148
170
  <head>
149
171
  <meta charset="utf-8">
150
172
  <meta name="viewport" content="width=device-width, initial-scale=1">
151
173
  <title>${escapeHtml(title)}</title>${descriptionTag}
152
174
  <link rel="icon" href="${favicon}">${feedLink}
175
+ ${SKIP_LINK_STYLE}
153
176
  ${cssLinks}${transitions}${speculation}
154
177
  </head>
155
178
  <body>
156
- ${body}
179
+ <a class="wd-skip-link" href="#${escapeHtml(target)}">Skip to content</a>
180
+ ${content}
157
181
  ${scripts}
158
182
  </body>
159
183
  </html>`,
160
184
  assets: compiled.assets,
161
- warnings: compiled.warnings
185
+ warnings: compiled.warnings,
186
+ pagination: compiled.pagination
162
187
  };
163
188
  }
164
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
+
165
209
  /**
166
210
  * Harden every `<img>` in the assembled page body — compile-time only, zero
167
211
  * runtime JS. Bare markdown images reflow the page as they decode (cumulative
@@ -199,30 +243,6 @@ export function enhanceImages(html, paths) {
199
243
  });
200
244
  }
201
245
 
202
- /**
203
- * Stamp `data-wd-scope="<id>"` onto every opening tag of a subtree — the HTML
204
- * half of compile-time scoped styles, mirroring {@link enhanceImages}: a regex
205
- * post-pass over assembled HTML, zero runtime JS. Every element start tag that
206
- * doesn't already carry a scope attribute gets one, so the scoped stylesheet
207
- * (whose selectors end in `[data-wd-scope="<id>"]`) matches inside this subtree
208
- * and nowhere else. Void/self-closing tags are stamped too (the attribute is
209
- * valid on any element). Closing tags, comments, doctype, and the `<!`/`<?`
210
- * families are left alone. A tag that ALREADY has a `data-wd-scope` (e.g. a
211
- * nested include with its own scope) keeps its own — first stamp wins.
212
- * @param {string} html Subtree HTML to scope.
213
- * @param {string} scopeId Scope id (from {@link import("../skin.js").scopeIdFor}).
214
- * @returns {string}
215
- */
216
- export function stampScope(html, scopeId) {
217
- return html.replace(
218
- /<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/g,
219
- (tag, name, attrs, slash) => {
220
- if (/\bdata-wd-scope\s*=/.test(attrs)) return tag;
221
- return `<${name}${attrs} data-wd-scope="${scopeId}"${slash ? " /" : ""}>`;
222
- }
223
- );
224
- }
225
-
226
246
  /**
227
247
  * Extract the `src` attribute value from an `<img>` tag, or "" if absent.
228
248
  * @param {string} tag
@@ -266,12 +286,23 @@ function measureImage(src, paths) {
266
286
  * @param {Paths} context Resolved project paths.
267
287
  * @param {string[]} [stack] Include stack for cycle detection.
268
288
  * @param {Record<string, unknown>} [vars] Initial static scope variables.
289
+ * @param {Map<string, import("./collections.js").CollectionRow[]>} [collections]
290
+ * Build-time collection index a bare-name `@loop` resolves against.
269
291
  * @returns {CompiledDocument}
270
292
  */
271
- export function compileDocument(file, context, stack = [], vars = {}) {
293
+ export function compileDocument(file, context, stack = [], vars = {}, collections = new Map()) {
272
294
  const comp = createCompilation();
295
+ comp.collections = collections;
273
296
  const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
274
- return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
297
+ return {
298
+ meta: result.meta,
299
+ html: result.html,
300
+ assets: comp.assets,
301
+ warnings: comp.warnings,
302
+ pagination: comp.pagination,
303
+ deps: comp.deps,
304
+ collectionsUsed: comp.collectionsUsed
305
+ };
275
306
  }
276
307
 
277
308
  /**
@@ -294,13 +325,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
294
325
  }
295
326
 
296
327
  const raw = fs.readFileSync(file, "utf8");
297
- const { meta, body } = parseFrontmatter(raw, file);
328
+ const { meta, body, bodyLine } = parseFrontmatter(raw, file);
298
329
  warnLikelyFrontmatter(raw, file, comp);
330
+ comp.deps.add(file);
299
331
  collectColocatedAssets(file, context, comp.assets);
300
332
 
301
333
  if (path.extname(file) === ".md") {
302
334
  scanMarkdownHints(body, file, comp);
303
- const html = selectMd(meta).render(body, {});
335
+ // Share the compilation's heading-slug counters (as renderProse does), so a
336
+ // `.md` included from a `.wd` page still dedupes anchors document-wide.
337
+ const html = selectMd(meta).render(body, { headingSlugs: comp.headingSlugs });
304
338
  if (htmlHasHighlight(html)) comp.assets.hasCode = true;
305
339
  return { meta, html };
306
340
  }
@@ -310,13 +344,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
310
344
  /** @type {Ctx} */
311
345
  const ctx = {
312
346
  file,
347
+ bodyLine,
313
348
  context,
314
349
  stack: [...stack, real],
315
350
  scope: createScope(scope, { meta }),
316
351
  comp,
317
352
  sections,
318
353
  loopItem,
319
- md: selectMd(meta)
354
+ md: selectMd(meta),
355
+ compileBody,
356
+ compileFile
320
357
  };
321
358
  const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
322
359
  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
+ }