@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
@@ -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
@@ -323,7 +299,9 @@ export function compileDocument(file, context, stack = [], vars = {}, collection
323
299
  html: result.html,
324
300
  assets: comp.assets,
325
301
  warnings: comp.warnings,
326
- pagination: comp.pagination
302
+ pagination: comp.pagination,
303
+ deps: comp.deps,
304
+ collectionsUsed: comp.collectionsUsed
327
305
  };
328
306
  }
329
307
 
@@ -347,13 +325,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
347
325
  }
348
326
 
349
327
  const raw = fs.readFileSync(file, "utf8");
350
- const { meta, body } = parseFrontmatter(raw, file);
328
+ const { meta, body, bodyLine } = parseFrontmatter(raw, file);
351
329
  warnLikelyFrontmatter(raw, file, comp);
330
+ comp.deps.add(file);
352
331
  collectColocatedAssets(file, context, comp.assets);
353
332
 
354
333
  if (path.extname(file) === ".md") {
355
334
  scanMarkdownHints(body, file, comp);
356
- 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 });
357
338
  if (htmlHasHighlight(html)) comp.assets.hasCode = true;
358
339
  return { meta, html };
359
340
  }
@@ -363,13 +344,16 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
363
344
  /** @type {Ctx} */
364
345
  const ctx = {
365
346
  file,
347
+ bodyLine,
366
348
  context,
367
349
  stack: [...stack, real],
368
350
  scope: createScope(scope, { meta }),
369
351
  comp,
370
352
  sections,
371
353
  loopItem,
372
- md: selectMd(meta)
354
+ md: selectMd(meta),
355
+ compileBody,
356
+ compileFile
373
357
  };
374
358
  const html = compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx);
375
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
+ }