@zvndev/darkmown 0.8.1 → 0.9.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.
package/src/compiler.js CHANGED
@@ -2,11 +2,108 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import MarkdownIt from "markdown-it";
4
4
 
5
+ /**
6
+ * @typedef {import("./config.js").Paths} Paths
7
+ */
8
+
9
+ /**
10
+ * Frontmatter values are scalars or inline arrays of scalars.
11
+ * @typedef {string | number | boolean | null | Array<string | number | boolean | null>} FrontmatterValue
12
+ */
13
+
14
+ /**
15
+ * Parsed page frontmatter, keyed by field name.
16
+ * @typedef {Record<string, FrontmatterValue>} Meta
17
+ */
18
+
19
+ /**
20
+ * Collected output assets for a compiled document.
21
+ * @typedef {object} Assets
22
+ * @property {Set<string>} skins Public hrefs of compiled skin stylesheets.
23
+ * @property {Set<string>} scripts Public hrefs of colocated page scripts.
24
+ * @property {Map<string, string>} files Source path → public href for emitted assets.
25
+ * @property {boolean} runtime Whether the reactive runtime is required.
26
+ */
27
+
28
+ /**
29
+ * Per-document compilation accumulator shared across includes/sections.
30
+ * @typedef {object} Compilation
31
+ * @property {Assets} assets
32
+ * @property {Map<string, unknown>} state Declared state keys → initial values.
33
+ * @property {string[]} warnings Non-fatal authoring hints.
34
+ * @property {number} sectionCounter Counter for auto-generated section ids.
35
+ */
36
+
37
+ /**
38
+ * A lexical scope chain for static interpolation values (include args, loop vars).
39
+ * @typedef {object} Scope
40
+ * @property {Scope | null} parent
41
+ * @property {Record<string, unknown>} vars
42
+ */
43
+
44
+ /**
45
+ * Per-file compile context threaded through the directive handlers.
46
+ * @typedef {object} Ctx
47
+ * @property {string} file Absolute path to the file being compiled.
48
+ * @property {Paths} context Resolved project paths.
49
+ * @property {string[]} stack Include stack (real paths) for cycle detection.
50
+ * @property {Scope} scope Static value scope chain.
51
+ * @property {Compilation} comp Shared compilation accumulator.
52
+ * @property {string[]} sections Active section-id scope chain.
53
+ * @property {string | null} loopItem Name of the current reactive loop item, if any.
54
+ * @property {string} [loopKey] State key of the list being looped, if any.
55
+ * @property {MarkdownIt} [md] Markdown-it instance selected for this file.
56
+ */
57
+
58
+ /**
59
+ * A compiled page document.
60
+ * @typedef {object} CompiledDocument
61
+ * @property {Meta} meta
62
+ * @property {string} html
63
+ * @property {Assets} assets
64
+ * @property {string[]} warnings
65
+ */
66
+
67
+ /**
68
+ * A compiled page: the document plus its full HTML shell.
69
+ * @typedef {object} CompiledPage
70
+ * @property {Meta} meta
71
+ * @property {string} html
72
+ * @property {Assets} assets
73
+ * @property {string[]} warnings
74
+ */
75
+
5
76
  const md = new MarkdownIt({ html: true });
6
77
  md.use(bindingPlugin);
7
78
 
79
+ // Raw HTML in markdown passes through by default (a documented design choice).
80
+ // Pages can opt out with frontmatter `html: false` for untrusted content; the
81
+ // stricter instance is built lazily so the default path stays a single instance.
82
+ /** @type {MarkdownIt | null} */
83
+ let mdNoHtml = null;
84
+ /**
85
+ * Pick the markdown-it instance for a page (raw HTML on by default, off with `html: false`).
86
+ * @param {Meta} [meta]
87
+ * @returns {MarkdownIt}
88
+ */
89
+ function selectMd(meta) {
90
+ // Frontmatter scalars stay strings (no coercion), so accept both forms.
91
+ if (meta?.html !== false && meta?.html !== "false") return md;
92
+ if (!mdNoHtml) {
93
+ mdNoHtml = new MarkdownIt({ html: false });
94
+ mdNoHtml.use(bindingPlugin);
95
+ }
96
+ return mdNoHtml;
97
+ }
98
+
8
99
  const pageIncludeExtensions = [".md", ".wd"];
9
100
 
101
+ /**
102
+ * Compile a page source file into a full HTML document plus its assets.
103
+ * @param {string} file Absolute path to the source `.md`/`.wd` file.
104
+ * @param {Paths} context Resolved project paths.
105
+ * @returns {CompiledPage}
106
+ */
10
107
  export function compilePage(file, context) {
11
108
  const compiled = compileDocument(file, context);
12
109
  const title = compiled.meta.title || "Darkmown";
@@ -48,12 +145,21 @@ ${scripts}
48
145
  };
49
146
  }
50
147
 
148
+ /**
149
+ * Compile a source file into its body HTML, frontmatter, and assets (no page shell).
150
+ * @param {string} file Absolute path to the source file.
151
+ * @param {Paths} context Resolved project paths.
152
+ * @param {string[]} [stack] Include stack for cycle detection.
153
+ * @param {Record<string, unknown>} [vars] Initial static scope variables.
154
+ * @returns {CompiledDocument}
155
+ */
51
156
  export function compileDocument(file, context, stack = [], vars = {}) {
52
157
  const comp = createCompilation();
53
158
  const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
54
159
  return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
55
160
  }
56
161
 
162
+ /** @returns {Compilation} */
57
163
  function createCompilation() {
58
164
  return {
59
165
  assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
@@ -63,10 +169,26 @@ function createCompilation() {
63
169
  };
64
170
  }
65
171
 
172
+ /**
173
+ * @param {Scope | null} parent
174
+ * @param {Record<string, unknown>} [vars]
175
+ * @returns {Scope}
176
+ */
66
177
  function createScope(parent, vars = {}) {
67
178
  return { parent, vars: { ...vars } };
68
179
  }
69
180
 
181
+ /**
182
+ * Compile a single file: parse frontmatter, collect assets, render body.
183
+ * @param {string} file
184
+ * @param {Paths} context
185
+ * @param {string[]} stack
186
+ * @param {Scope} scope
187
+ * @param {Compilation} comp
188
+ * @param {string[]} sections
189
+ * @param {string | null} loopItem
190
+ * @returns {{ meta: Meta, html: string }}
191
+ */
70
192
  function compileFile(file, context, stack, scope, comp, sections, loopItem) {
71
193
  const real = fs.realpathSync(file);
72
194
  if (stack.includes(real)) {
@@ -74,26 +196,37 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
74
196
  }
75
197
 
76
198
  const raw = fs.readFileSync(file, "utf8");
77
- const { meta, body } = parseFrontmatter(raw);
199
+ const { meta, body } = parseFrontmatter(raw, file);
78
200
  collectColocatedAssets(file, context, comp.assets);
79
201
 
80
202
  if (path.extname(file) === ".md") {
81
203
  scanMarkdownHints(body, file, comp);
82
- return { meta, html: md.render(body, {}) };
204
+ return { meta, html: selectMd(meta).render(body, {}) };
83
205
  }
84
206
 
85
207
  // Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
86
208
  // `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
87
- const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem };
209
+ /** @type {Ctx} */
210
+ const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem, md: selectMd(meta) };
88
211
  return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
89
212
  }
90
213
 
91
- export function parseFrontmatter(raw) {
214
+ /**
215
+ * Split a raw file into its frontmatter `meta` and `body`.
216
+ * @param {string} raw Full file contents.
217
+ * @param {string} [file] Source path, used only for error messages.
218
+ * @returns {{ meta: Meta, body: string }}
219
+ */
220
+ export function parseFrontmatter(raw, file) {
92
221
  if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
93
- const end = raw.indexOf("\n---", 4);
94
- if (end === -1) return { meta: {}, body: raw };
222
+ const end = raw.indexOf("\n---", 3);
223
+ if (end === -1) {
224
+ const where = file ? ` in ${file}` : "";
225
+ throw new Error(`Unterminated frontmatter${where}: opening "---" has no closing "---". Use: --- on its own line to open and another --- to close, then the page body.`);
226
+ }
95
227
  const front = raw.slice(4, end).trim();
96
228
  const body = raw.slice(end + 4).replace(/^\n/, "");
229
+ /** @type {Meta} */
97
230
  const meta = {};
98
231
  for (const line of front.split("\n")) {
99
232
  const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
@@ -105,15 +238,24 @@ export function parseFrontmatter(raw) {
105
238
  // Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
106
239
  // Block sequences (`- item` on following lines) are intentionally out of scope —
107
240
  // the parser stays single-pass and line-based.
241
+ /**
242
+ * @param {string} raw
243
+ * @returns {FrontmatterValue}
244
+ */
108
245
  function parseFrontmatterValue(raw) {
109
246
  const value = raw.trim();
110
247
  if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
111
248
  return stripQuotes(value);
112
249
  }
113
250
 
251
+ /**
252
+ * @param {string} raw
253
+ * @returns {string[]}
254
+ */
114
255
  function parseInlineArray(raw) {
115
256
  const inner = raw.slice(1, -1);
116
257
  if (inner.trim() === "") return [];
258
+ /** @type {string[]} */
117
259
  const items = [];
118
260
  let buf = "";
119
261
  let quote = null;
@@ -145,8 +287,16 @@ function parseInlineArray(raw) {
145
287
  // Block parser: directives + prose segments
146
288
  // ---------------------------------------------------------------------------
147
289
 
290
+ /**
291
+ * Parse a `.wd` body line-by-line into HTML, mixing directives and prose.
292
+ * @param {string[]} lines
293
+ * @param {Ctx} ctx
294
+ * @returns {string}
295
+ */
148
296
  function compileBody(lines, ctx) {
297
+ /** @type {string[]} */
149
298
  const out = [];
299
+ /** @type {string[]} */
150
300
  let prose = [];
151
301
  let fence = null;
152
302
 
@@ -263,7 +413,17 @@ function compileBody(lines, ctx) {
263
413
  return out.join("\n");
264
414
  }
265
415
 
416
+ /**
417
+ * Capture lines from an opener until a matching close token, honoring nesting and fences.
418
+ * @param {string[]} lines
419
+ * @param {number} start Index of the opening line.
420
+ * @param {RegExp} openRe Pattern that re-opens a nested block.
421
+ * @param {string} endToken Literal closing token (e.g. `@endloop`).
422
+ * @param {string} file Source path for errors.
423
+ * @returns {{ body: string[], end: number }}
424
+ */
266
425
  function scanBlock(lines, start, openRe, endToken, file) {
426
+ /** @type {string[]} */
267
427
  const body = [];
268
428
  let depth = 0;
269
429
  let fence = null;
@@ -290,7 +450,15 @@ function scanBlock(lines, start, openRe, endToken, file) {
290
450
  throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
291
451
  }
292
452
 
453
+ /**
454
+ * Capture the body of a `:::` container up to its matching close, honoring nesting.
455
+ * @param {string[]} lines
456
+ * @param {number} start Index of the opening line.
457
+ * @param {string} file Source path for errors.
458
+ * @returns {{ body: string[], end: number }}
459
+ */
293
460
  function scanContainer(lines, start, file) {
461
+ /** @type {string[]} */
294
462
  const body = [];
295
463
  let depth = 0;
296
464
  let fence = null;
@@ -322,8 +490,17 @@ function scanContainer(lines, start, file) {
322
490
  throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
323
491
  }
324
492
 
493
+ /**
494
+ * Split an `:if … :else … :endif` block into its two branches.
495
+ * @param {string[]} lines
496
+ * @param {number} start Index of the `:if` line.
497
+ * @param {string} file Source path for errors.
498
+ * @returns {{ truthy: string[], falsy: string[], end: number }}
499
+ */
325
500
  function scanConditional(lines, start, file) {
501
+ /** @type {string[]} */
326
502
  const truthy = [];
503
+ /** @type {string[]} */
327
504
  const falsy = [];
328
505
  let current = truthy;
329
506
  let depth = 0;
@@ -359,6 +536,11 @@ function scanConditional(lines, start, file) {
359
536
  // Directive handlers
360
537
  // ---------------------------------------------------------------------------
361
538
 
539
+ /**
540
+ * @param {string} line
541
+ * @param {Ctx} ctx
542
+ * @returns {string}
543
+ */
362
544
  function handleInclude(line, ctx) {
363
545
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
364
546
  if (!match) throw new Error(`Malformed @include in ${ctx.file}: ${line}`);
@@ -369,7 +551,13 @@ function handleInclude(line, ctx) {
369
551
  return child.html;
370
552
  }
371
553
 
554
+ /**
555
+ * @param {string} raw
556
+ * @param {Ctx} ctx
557
+ * @returns {Record<string, unknown>}
558
+ */
372
559
  function parseIncludeArgs(raw, ctx) {
560
+ /** @type {Record<string, unknown>} */
373
561
  const args = {};
374
562
  const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
375
563
  for (const match of raw.matchAll(re)) {
@@ -388,12 +576,19 @@ function parseIncludeArgs(raw, ctx) {
388
576
  return args;
389
577
  }
390
578
 
579
+ /**
580
+ * @param {string} header
581
+ * @param {string[]} bodyLines
582
+ * @param {Ctx} ctx
583
+ * @returns {string}
584
+ */
391
585
  function handleContainer(header, bodyLines, ctx) {
392
586
  const tokens = header.split(/\s+/);
393
587
  let tag = "section";
588
+ /** @type {string[]} */
394
589
  let extraClass = [];
395
590
  let id = "";
396
- let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() : "section";
591
+ let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() ?? "section" : "section";
397
592
  if (nameToken !== "section") {
398
593
  tag = "div";
399
594
  extraClass.push(nameToken);
@@ -418,6 +613,11 @@ function handleContainer(header, bodyLines, ctx) {
418
613
  return `<${tag}${idAttr}${classAttr}>\n${inner}\n</${tag}>`;
419
614
  }
420
615
 
616
+ /**
617
+ * @param {string} line
618
+ * @param {Ctx} ctx
619
+ * @returns {string}
620
+ */
421
621
  function handleState(line, ctx) {
422
622
  const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
423
623
  if (!match) throw new Error(`Malformed :state in ${ctx.file}: ${line}`);
@@ -427,6 +627,13 @@ function handleState(line, ctx) {
427
627
  return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
428
628
  }
429
629
 
630
+ /**
631
+ * Register a state key in the current section scope, enabling the runtime.
632
+ * @param {string} name
633
+ * @param {unknown} value
634
+ * @param {Ctx} ctx
635
+ * @returns {string} The fully-qualified state key.
636
+ */
430
637
  function declareState(name, value, ctx) {
431
638
  if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
432
639
  const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
@@ -436,6 +643,11 @@ function declareState(name, value, ctx) {
436
643
  return key;
437
644
  }
438
645
 
646
+ /**
647
+ * @param {string} line
648
+ * @param {Ctx} ctx
649
+ * @returns {string}
650
+ */
439
651
  function handleFetch(line, ctx) {
440
652
  const match = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+?)(\s+when=visible)?\s*$/);
441
653
  if (!match) {
@@ -448,27 +660,47 @@ function handleFetch(line, ctx) {
448
660
  return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
449
661
  }
450
662
 
663
+ /**
664
+ * @param {string} key
665
+ * @param {Ctx} ctx
666
+ * @returns {void}
667
+ */
451
668
  function declareErrorState(key, ctx) {
452
669
  const errorKey = `${key}_error`;
453
670
  if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
454
671
  }
455
672
 
673
+ /**
674
+ * @param {string} line
675
+ * @param {Ctx} ctx
676
+ * @returns {string}
677
+ */
456
678
  function handleComputed(line, ctx) {
457
679
  const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
458
680
  if (!match) throw new Error(`Malformed :computed in ${ctx.file}: ${line}. Use: :computed total = items.length * 4`);
459
681
  const expr = compileComputedExpr(match[2].trim(), ctx);
682
+ /** @type {unknown} */
460
683
  let initial;
461
684
  try {
685
+ /** @param {string} key @param {string} [path] */
462
686
  const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
463
687
  initial = new Function("S", `return (${expr});`)(read);
464
688
  } catch {
689
+ console.warn(`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`);
465
690
  initial = null;
466
691
  }
467
692
  const key = declareState(match[1], initial ?? null, ctx);
468
693
  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>`;
469
694
  }
470
695
 
696
+ /**
697
+ * Compile a `:computed` expression into safe JS over the `S(key, path)` reader.
698
+ * @param {string} raw
699
+ * @param {Ctx} ctx
700
+ * @returns {string}
701
+ */
471
702
  function compileComputedExpr(raw, ctx) {
703
+ /** @type {string[]} */
472
704
  const strings = [];
473
705
  let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
474
706
  strings.push(`"${literal.slice(1, -1)}"`);
@@ -502,6 +734,12 @@ function compileComputedExpr(raw, ctx) {
502
734
  return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
503
735
  }
504
736
 
737
+ /**
738
+ * @param {string} line
739
+ * @param {string[]} bodyLines
740
+ * @param {Ctx} ctx
741
+ * @returns {string}
742
+ */
505
743
  function handleForm(line, bodyLines, ctx) {
506
744
  let rest = line.slice(":form".length).trim();
507
745
  const action = rest.match(/action="([^"]+)"/)?.[1];
@@ -527,6 +765,11 @@ function handleForm(line, bodyLines, ctx) {
527
765
  return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
528
766
  }
529
767
 
768
+ /**
769
+ * @param {string} line
770
+ * @param {Ctx} ctx
771
+ * @returns {string}
772
+ */
530
773
  function handleInput(line, ctx) {
531
774
  const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
532
775
  if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
@@ -557,6 +800,11 @@ function handleInput(line, ctx) {
557
800
  // :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
558
801
  // declared :state. Updates state on every keystroke; reflects state back when
559
802
  // not focused. The state must be declared first (with :state).
803
+ /**
804
+ * @param {string} line
805
+ * @param {Ctx} ctx
806
+ * @returns {string}
807
+ */
560
808
  function handleBind(line, ctx) {
561
809
  const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
562
810
  if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
@@ -586,12 +834,22 @@ function handleBind(line, ctx) {
586
834
  return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
587
835
  }
588
836
 
837
+ /**
838
+ * @param {string} line
839
+ * @param {Ctx} ctx
840
+ * @returns {string}
841
+ */
589
842
  function handleSubmit(line, ctx) {
590
843
  const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
591
844
  if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
592
845
  return `<button type="submit">${escapeHtml(match[1])}</button>`;
593
846
  }
594
847
 
848
+ /**
849
+ * @param {string} line
850
+ * @param {Ctx} ctx
851
+ * @returns {string}
852
+ */
595
853
  function handleButton(line, ctx) {
596
854
  const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
597
855
  if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
@@ -601,6 +859,13 @@ function handleButton(line, ctx) {
601
859
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
602
860
  }
603
861
 
862
+ /**
863
+ * @param {string} line
864
+ * @param {string[]} truthyLines
865
+ * @param {string[]} falsyLines
866
+ * @param {Ctx} ctx
867
+ * @returns {string}
868
+ */
604
869
  function handleIf(line, truthyLines, falsyLines, ctx) {
605
870
  const match = line.match(/^:if\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
606
871
  if (!match) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" with a :state or in-scope value.`);
@@ -635,6 +900,12 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
635
900
  return `<div data-wd-if="${key}"${pathAttr} 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>`;
636
901
  }
637
902
 
903
+ /**
904
+ * @param {string} line
905
+ * @param {string[]} bodyLines
906
+ * @param {Ctx} ctx
907
+ * @returns {string}
908
+ */
638
909
  function handleLoop(line, bodyLines, ctx) {
639
910
  const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
640
911
  if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
@@ -670,6 +941,21 @@ function handleLoop(line, bodyLines, ctx) {
670
941
  // A static source (JSON file / in-scope value). No `where`, or a `where` that
671
942
  // only reads the loop item → filter at build time and stay zero-JS. A `where`
672
943
  // that reads :state → becomes a reactive filtered loop with the rows baked in.
944
+ /**
945
+ * A compiled `@loop … where` predicate.
946
+ * @typedef {object} Predicate
947
+ * @property {string} body Safe JS boolean expression over `I()`/`S()`/`C()`.
948
+ * @property {boolean} refsState Whether the predicate reads declared state.
949
+ */
950
+
951
+ /**
952
+ * @param {unknown[]} rows
953
+ * @param {string} itemName
954
+ * @param {string[]} bodyLines
955
+ * @param {Ctx} ctx
956
+ * @param {Predicate | null} where
957
+ * @returns {string}
958
+ */
673
959
  function loopOverData(rows, itemName, bodyLines, ctx, where) {
674
960
  if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
675
961
  if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
@@ -679,8 +965,15 @@ function loopOverData(rows, itemName, bodyLines, ctx, where) {
679
965
  // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
680
966
  // Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
681
967
  // paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
968
+ /**
969
+ * @param {string} raw
970
+ * @param {string} itemName
971
+ * @param {Ctx} ctx
972
+ * @returns {Predicate}
973
+ */
682
974
  function compilePredicate(raw, itemName, ctx) {
683
975
  const parts = raw.split(/\s+(and|or)\s+/i);
976
+ /** @type {string[]} */
684
977
  const pieces = [];
685
978
  let refsState = false;
686
979
  for (let i = 0; i < parts.length; i++) {
@@ -692,6 +985,12 @@ function compilePredicate(raw, itemName, ctx) {
692
985
  return { body: pieces.join(" "), refsState };
693
986
  }
694
987
 
988
+ /**
989
+ * @param {string} cond
990
+ * @param {string} itemName
991
+ * @param {Ctx} ctx
992
+ * @returns {{ expr: string, usesState: boolean }}
993
+ */
695
994
  function compileCondition(cond, itemName, ctx) {
696
995
  const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
697
996
  if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
@@ -703,6 +1002,12 @@ function compileCondition(cond, itemName, ctx) {
703
1002
  return { expr: `${left.code} ${op} ${right.code}`, usesState };
704
1003
  }
705
1004
 
1005
+ /**
1006
+ * @param {string} tok
1007
+ * @param {string} itemName
1008
+ * @param {Ctx} ctx
1009
+ * @returns {{ code: string, usesState: boolean }}
1010
+ */
706
1011
  function compileOperand(tok, itemName, ctx) {
707
1012
  if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
708
1013
  if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
@@ -723,18 +1028,36 @@ function compileOperand(tok, itemName, ctx) {
723
1028
  return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
724
1029
  }
725
1030
 
1031
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
726
1032
  const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
727
1033
 
1034
+ /**
1035
+ * Evaluate a compiled predicate against a row at build time.
1036
+ * @param {string} body
1037
+ * @param {unknown} item
1038
+ * @param {Ctx} ctx
1039
+ * @returns {boolean}
1040
+ */
728
1041
  function evalPredicate(body, item, ctx) {
729
1042
  try {
1043
+ /** @param {string} [p] */
730
1044
  const I = (p) => getPath(item, p ? p.split(".") : []);
1045
+ /** @param {string} k @param {string} [r] */
731
1046
  const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
732
1047
  return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
733
1048
  } catch {
1049
+ console.warn(`@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`);
734
1050
  return false;
735
1051
  }
736
1052
  }
737
1053
 
1054
+ /**
1055
+ * @param {unknown[]} rows
1056
+ * @param {string} itemName
1057
+ * @param {string[]} bodyLines
1058
+ * @param {Ctx} ctx
1059
+ * @returns {string}
1060
+ */
738
1061
  function staticUnroll(rows, itemName, bodyLines, ctx) {
739
1062
  const out = [];
740
1063
  for (const row of rows) {
@@ -744,9 +1067,19 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
744
1067
  return out.join("\n");
745
1068
  }
746
1069
 
1070
+ /**
1071
+ * Emit a reactive loop region (template + initial rows) for the runtime.
1072
+ * @param {string | null} key State key of the list, or null for baked data.
1073
+ * @param {string} itemName
1074
+ * @param {string[]} bodyLines
1075
+ * @param {Ctx} ctx
1076
+ * @param {{ where?: Predicate, data?: unknown[] } | null} [opts]
1077
+ * @returns {string}
1078
+ */
747
1079
  function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
748
1080
  ctx.comp.assets.runtime = true;
749
- const templateCtx = { ...ctx, loopItem: itemName, loopKey: key, scope: createScope(ctx.scope) };
1081
+ /** @type {Ctx} */
1082
+ const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, scope: createScope(ctx.scope) };
750
1083
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
751
1084
 
752
1085
  let wrapperTag = "div";
@@ -761,11 +1094,14 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
761
1094
 
762
1095
  const where = opts?.where || null;
763
1096
  const baked = opts?.data || null; // rows for a static source filtered by state
764
- const allRows = key ? (Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : []) : (baked || []);
765
- const rows = where ? allRows.filter((row) => evalPredicate(where.body, row, ctx)) : allRows;
1097
+ const stateRows = key ? ctx.comp.state.get(key) : null;
1098
+ /** @type {unknown[]} */
1099
+ const allRows = key ? (Array.isArray(stateRows) ? stateRows : []) : (baked || []);
1100
+ const rows = where ? allRows.filter((/** @type {unknown} */ row) => evalPredicate(where.body, row, ctx)) : allRows;
1101
+ /** @type {Map<string, number>} */
766
1102
  const counts = new Map();
767
1103
  const initial = rows
768
- .map((item) => {
1104
+ .map((/** @type {unknown} */ item) => {
769
1105
  const itemKey = loopKeyOf(item, counts);
770
1106
  return fillTemplateString(withLoopKey(itemTemplate, itemKey), item);
771
1107
  })
@@ -776,10 +1112,20 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
776
1112
  return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
777
1113
  }
778
1114
 
1115
+ /**
1116
+ * @param {string} template
1117
+ * @param {string} itemKey
1118
+ * @returns {string}
1119
+ */
779
1120
  function withLoopKey(template, itemKey) {
780
1121
  return template.replace(/^<([a-zA-Z][a-zA-Z0-9-]*)/, `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`);
781
1122
  }
782
1123
 
1124
+ /**
1125
+ * @param {string} template
1126
+ * @param {unknown} item
1127
+ * @returns {string}
1128
+ */
783
1129
  function fillTemplateString(template, item) {
784
1130
  // Resolve per-item :if regions first (recursively, so nested conditionals are
785
1131
  // pre-rendered for the initial paint), then fill the plain text bindings.
@@ -789,6 +1135,11 @@ function fillTemplateString(template, item) {
789
1135
  // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
790
1136
  // chosen branch is recursed so nested conditionals resolve too. The <template>
791
1137
  // markup is left pristine so the runtime can keep toggling branches.
1138
+ /**
1139
+ * @param {string} str
1140
+ * @param {unknown} item
1141
+ * @returns {string}
1142
+ */
792
1143
  function fillEachIfRegions(str, item) {
793
1144
  const marker = '<span data-wd-each-if ';
794
1145
  let result = "";
@@ -803,8 +1154,13 @@ function fillEachIfRegions(str, item) {
803
1154
  }
804
1155
  }
805
1156
 
1157
+ /**
1158
+ * @param {string} region
1159
+ * @param {unknown} item
1160
+ * @returns {string}
1161
+ */
806
1162
  function fillOneEachIf(region, item) {
807
- const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || [, ""])[1];
1163
+ const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
808
1164
  const trueStart = region.indexOf("<template data-wd-if-true>");
809
1165
  const trueEnd = matchElement(region, trueStart, "template");
810
1166
  const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
@@ -818,6 +1174,11 @@ function fillOneEachIf(region, item) {
818
1174
  return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
819
1175
  }
820
1176
 
1177
+ /**
1178
+ * @param {string} str
1179
+ * @param {unknown} item
1180
+ * @returns {string}
1181
+ */
821
1182
  function fillEachText(str, item) {
822
1183
  return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
823
1184
  const value = p ? getPath(item, p.split(".")) : item;
@@ -829,6 +1190,12 @@ function fillEachText(str, item) {
829
1190
  // Return the index just past the balanced close of the element of `tag` that
830
1191
  // begins at `start`. Counts nested same-tag opens/closes so regions that embed
831
1192
  // their own spans/templates (nested :if) match correctly.
1193
+ /**
1194
+ * @param {string} str
1195
+ * @param {number} start
1196
+ * @param {string} tag
1197
+ * @returns {number}
1198
+ */
832
1199
  function matchElement(str, start, tag) {
833
1200
  const openPrefix = `<${tag}`;
834
1201
  const closeTag = `</${tag}>`;
@@ -850,16 +1217,27 @@ function matchElement(str, start, tag) {
850
1217
  return str.length;
851
1218
  }
852
1219
 
1220
+ /**
1221
+ * Stable per-render key for a loop row, disambiguating duplicates with `#n`.
1222
+ * @param {unknown} item
1223
+ * @param {Map<string, number>} counts Mutable seen-count accumulator.
1224
+ * @returns {string}
1225
+ */
853
1226
  export function loopKeyOf(item, counts) {
854
1227
  const base =
855
1228
  item && typeof item === "object"
856
- ? String(item.id ?? item.key ?? JSON.stringify(item))
1229
+ ? String(/** @type {Record<string, unknown>} */ (item).id ?? /** @type {Record<string, unknown>} */ (item).key ?? JSON.stringify(item))
857
1230
  : String(item);
858
1231
  const seen = counts.get(base) || 0;
859
1232
  counts.set(base, seen + 1);
860
1233
  return seen ? `${base}#${seen}` : base;
861
1234
  }
862
1235
 
1236
+ /**
1237
+ * Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
1238
+ * @param {string} line
1239
+ * @returns {string}
1240
+ */
863
1241
  function renderDemoDirective(line) {
864
1242
  const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
865
1243
  if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
@@ -877,10 +1255,21 @@ function renderDemoDirective(line) {
877
1255
  // Interpolation: one syntax, { name } / { name.path }
878
1256
  // ---------------------------------------------------------------------------
879
1257
 
1258
+ /**
1259
+ * @param {string} text
1260
+ * @param {Ctx} ctx
1261
+ * @returns {string}
1262
+ */
880
1263
  function renderProse(text, ctx) {
881
- return md.render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
1264
+ return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
882
1265
  }
883
1266
 
1267
+ /**
1268
+ * markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
1269
+ * whose content comes from the env's `resolveBinding` callback.
1270
+ * @param {MarkdownIt} mdInstance
1271
+ * @returns {void}
1272
+ */
884
1273
  function bindingPlugin(mdInstance) {
885
1274
  mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
886
1275
  if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
@@ -898,6 +1287,13 @@ function bindingPlugin(mdInstance) {
898
1287
  });
899
1288
  }
900
1289
 
1290
+ /**
1291
+ * Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
1292
+ * a reactive bind span. Returns null when nothing in scope matches.
1293
+ * @param {string} expr
1294
+ * @param {Ctx} ctx
1295
+ * @returns {string | null}
1296
+ */
901
1297
  function resolveBindingHtml(expr, ctx) {
902
1298
  const segs = expr.split(".");
903
1299
  const head = segs[0];
@@ -910,8 +1306,7 @@ function resolveBindingHtml(expr, ctx) {
910
1306
  const staticValue = lookupVar(ctx.scope, head);
911
1307
  if (staticValue.found) {
912
1308
  const resolved = getPath(staticValue.value, segs.slice(1));
913
- if (Array.isArray(resolved)) return escapeHtml(resolved.join(", "));
914
- return escapeHtml(resolved ?? "");
1309
+ return escapeHtml(interpolateLeaf(resolved, expr, ctx));
915
1310
  }
916
1311
 
917
1312
  const key = resolveStateKey(head, ctx);
@@ -920,19 +1315,51 @@ function resolveBindingHtml(expr, ctx) {
920
1315
  const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
921
1316
  const rest = segs.slice(1).join(".");
922
1317
  const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
923
- return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(initial ?? "")}</span>`;
1318
+ return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(interpolateLeaf(initial, expr, ctx))}</span>`;
924
1319
  }
925
1320
 
926
1321
  return null;
927
1322
  }
928
1323
 
1324
+ /**
1325
+ * Render an interpolated value as text. Arrays join with ", " (matching the
1326
+ * documented `{ meta.tags }` behavior); a bare object is a mistake — fail
1327
+ * loudly with a fix rather than emitting "[object Object]".
1328
+ * @param {unknown} value
1329
+ * @param {string} expr
1330
+ * @param {Ctx} ctx
1331
+ * @returns {string}
1332
+ */
1333
+ function interpolateLeaf(value, expr, ctx) {
1334
+ if (value === null || value === undefined) return "";
1335
+ if (Array.isArray(value)) return value.join(", ");
1336
+ if (typeof value === "object") {
1337
+ throw new Error(`Cannot interpolate the object value "{ ${expr} }" in ${ctx.file}. Interpolate a field instead — e.g. { ${expr}.title } — or iterate it with @loop ${expr} into item.`);
1338
+ }
1339
+ return String(value);
1340
+ }
1341
+
1342
+ /**
1343
+ * Walk the scope chain for a top-level variable.
1344
+ * @param {Scope} scope
1345
+ * @param {string} name
1346
+ * @returns {{ found: true, value: unknown } | { found: false }}
1347
+ */
929
1348
  function lookupVar(scope, name) {
930
- for (let current = scope; current; current = current.parent) {
1349
+ /** @type {Scope | null} */
1350
+ let current = scope;
1351
+ for (; current; current = current.parent) {
931
1352
  if (name in current.vars) return { found: true, value: current.vars[name] };
932
1353
  }
933
1354
  return { found: false };
934
1355
  }
935
1356
 
1357
+ /**
1358
+ * Resolve a dotted path against the static scope chain.
1359
+ * @param {string} expr
1360
+ * @param {Ctx} ctx
1361
+ * @returns {{ found: true, value: unknown } | { found: false }}
1362
+ */
936
1363
  function lookupPath(expr, ctx) {
937
1364
  const segs = expr.split(".");
938
1365
  const head = lookupVar(ctx.scope, segs[0]);
@@ -940,7 +1367,14 @@ function lookupPath(expr, ctx) {
940
1367
  return { found: true, value: getPath(head.value, segs.slice(1)) };
941
1368
  }
942
1369
 
1370
+ /**
1371
+ * Safely read a dotted path off a value, rejecting prototype-pollution segments.
1372
+ * @param {unknown} value
1373
+ * @param {string[]} segments
1374
+ * @returns {unknown}
1375
+ */
943
1376
  function getPath(value, segments) {
1377
+ /** @type {any} */
944
1378
  let current = value;
945
1379
  for (const segment of segments) {
946
1380
  if (current == null) return undefined;
@@ -950,6 +1384,12 @@ function getPath(value, segments) {
950
1384
  return current;
951
1385
  }
952
1386
 
1387
+ /**
1388
+ * Resolve a bare state name to its fully-qualified key, walking section scopes.
1389
+ * @param {string} name
1390
+ * @param {Ctx} ctx
1391
+ * @returns {string | null}
1392
+ */
953
1393
  function resolveStateKey(name, ctx) {
954
1394
  for (let i = ctx.sections.length - 1; i >= 0; i--) {
955
1395
  const key = `${ctx.sections[i]}:${name}`;
@@ -962,8 +1402,23 @@ function resolveStateKey(name, ctx) {
962
1402
  // Actions
963
1403
  // ---------------------------------------------------------------------------
964
1404
 
1405
+ /**
1406
+ * A parsed `:button` action.
1407
+ * @typedef {object} Action
1408
+ * @property {string} op Runtime operation (inc/dec/add/append/set/remove/append-row).
1409
+ * @property {string} target State key the action mutates.
1410
+ * @property {unknown} [value] Literal value for value-carrying ops.
1411
+ */
1412
+
1413
+ /**
1414
+ * Parse a `:button` action expression into a validated `{ op, target, value }`.
1415
+ * @param {string} raw
1416
+ * @param {Ctx} ctx
1417
+ * @returns {Action}
1418
+ */
965
1419
  function parseAction(raw, ctx) {
966
1420
  const expression = raw.trim();
1421
+ /** @param {string} name @returns {string} */
967
1422
  const resolveTarget = (name) => {
968
1423
  const key = resolveStateKey(name, ctx);
969
1424
  if (!key) {
@@ -992,7 +1447,7 @@ function parseAction(raw, ctx) {
992
1447
  const value = parseActionLiteral(rhs);
993
1448
  if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
994
1449
  if (typeof value === "number") return { op: "add", target, value };
995
- throw new Error(`Unsupported button action "${raw}". += with non-number values requires a list state target.`);
1450
+ throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`);
996
1451
  }
997
1452
  // Per-row: remove the current row from the list being looped — `todos remove todo`.
998
1453
  const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
@@ -1010,9 +1465,13 @@ function parseAction(raw, ctx) {
1010
1465
  }
1011
1466
  const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
1012
1467
  if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
1013
- throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
1468
+ throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
1014
1469
  }
1015
1470
 
1471
+ /**
1472
+ * @param {string} raw
1473
+ * @returns {unknown}
1474
+ */
1016
1475
  function parseActionLiteral(raw) {
1017
1476
  const value = raw.trim();
1018
1477
  if (/^["'].*["']$/.test(value)) return stripQuotes(value);
@@ -1034,6 +1493,13 @@ function parseActionLiteral(raw) {
1034
1493
  // Includes / assets
1035
1494
  // ---------------------------------------------------------------------------
1036
1495
 
1496
+ /**
1497
+ * Register a page's colocated `.skin`/`.js` siblings as emitted assets.
1498
+ * @param {string} file
1499
+ * @param {Paths} context
1500
+ * @param {Assets} assets
1501
+ * @returns {void}
1502
+ */
1037
1503
  function collectColocatedAssets(file, context, assets) {
1038
1504
  const ext = path.extname(file);
1039
1505
  const stem = file.slice(0, -ext.length);
@@ -1049,6 +1515,14 @@ function collectColocatedAssets(file, context, assets) {
1049
1515
  }
1050
1516
  }
1051
1517
 
1518
+ /**
1519
+ * Resolve an include spec to an absolute path inside `site/pages` or `site/_`.
1520
+ * @param {string} spec Include target (may be quoted).
1521
+ * @param {string} fromFile File requesting the include.
1522
+ * @param {Paths} context
1523
+ * @param {boolean} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
1524
+ * @returns {string}
1525
+ */
1052
1526
  function resolveInclude(spec, fromFile, context, allowAny = false) {
1053
1527
  const clean = stripQuotes(spec);
1054
1528
  const candidates = [];
@@ -1070,6 +1544,11 @@ function resolveInclude(spec, fromFile, context, allowAny = false) {
1070
1544
  throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
1071
1545
  }
1072
1546
 
1547
+ /**
1548
+ * @param {string} file
1549
+ * @param {Paths} context
1550
+ * @returns {boolean}
1551
+ */
1073
1552
  function isAllowedInclude(file, context) {
1074
1553
  const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
1075
1554
  return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
@@ -1079,6 +1558,13 @@ function isAllowedInclude(file, context) {
1079
1558
  // Plain .md hints
1080
1559
  // ---------------------------------------------------------------------------
1081
1560
 
1561
+ /**
1562
+ * Warn when a plain `.md` file contains `.wd`-only syntax that stays inert.
1563
+ * @param {string} body
1564
+ * @param {string} file
1565
+ * @param {Compilation} comp
1566
+ * @returns {void}
1567
+ */
1082
1568
  function scanMarkdownHints(body, file, comp) {
1083
1569
  let fence = null;
1084
1570
  for (const line of body.split("\n")) {
@@ -1105,10 +1591,19 @@ function scanMarkdownHints(body, file, comp) {
1105
1591
  // Literals
1106
1592
  // ---------------------------------------------------------------------------
1107
1593
 
1594
+ /**
1595
+ * JSON-encode a value for an inline `<script>`, escaping `<` to stay HTML-safe.
1596
+ * @param {unknown} value
1597
+ * @returns {string}
1598
+ */
1108
1599
  function safeScriptJson(value) {
1109
1600
  return JSON.stringify(value).replaceAll("<", "\\u003c");
1110
1601
  }
1111
1602
 
1603
+ /**
1604
+ * @param {string} raw
1605
+ * @returns {unknown}
1606
+ */
1112
1607
  function parseStateValue(raw) {
1113
1608
  const value = raw.trim();
1114
1609
  if (/^["'].*["']$/.test(value)) return stripQuotes(value);
@@ -1123,6 +1618,10 @@ function parseStateValue(raw) {
1123
1618
  }
1124
1619
  }
1125
1620
 
1621
+ /**
1622
+ * @param {string} raw
1623
+ * @returns {string | number | boolean}
1624
+ */
1126
1625
  function parseScalar(raw) {
1127
1626
  const trimmed = raw.trim();
1128
1627
  if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
@@ -1132,14 +1631,24 @@ function parseScalar(raw) {
1132
1631
  return trimmed;
1133
1632
  }
1134
1633
 
1634
+ /**
1635
+ * @param {string} [value]
1636
+ * @returns {string}
1637
+ */
1135
1638
  function stripQuotes(value) {
1136
1639
  return value?.replace(/^["']|["']$/g, "") ?? "";
1137
1640
  }
1138
1641
 
1642
+ /**
1643
+ * Escape a value for safe inclusion in HTML text and attribute contexts.
1644
+ * @param {unknown} value
1645
+ * @returns {string}
1646
+ */
1139
1647
  export function escapeHtml(value) {
1140
1648
  return String(value)
1141
1649
  .replaceAll("&", "&amp;")
1142
1650
  .replaceAll("<", "&lt;")
1143
1651
  .replaceAll(">", "&gt;")
1144
- .replaceAll('"', "&quot;");
1652
+ .replaceAll('"', "&quot;")
1653
+ .replaceAll("'", "&#39;");
1145
1654
  }