@zvndev/darkmown 0.8.2 → 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,13 +2,90 @@ 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
 
8
79
  // Raw HTML in markdown passes through by default (a documented design choice).
9
80
  // Pages can opt out with frontmatter `html: false` for untrusted content; the
10
81
  // stricter instance is built lazily so the default path stays a single instance.
82
+ /** @type {MarkdownIt | null} */
11
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
+ */
12
89
  function selectMd(meta) {
13
90
  // Frontmatter scalars stay strings (no coercion), so accept both forms.
14
91
  if (meta?.html !== false && meta?.html !== "false") return md;
@@ -21,6 +98,12 @@ function selectMd(meta) {
21
98
 
22
99
  const pageIncludeExtensions = [".md", ".wd"];
23
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
+ */
24
107
  export function compilePage(file, context) {
25
108
  const compiled = compileDocument(file, context);
26
109
  const title = compiled.meta.title || "Darkmown";
@@ -62,12 +145,21 @@ ${scripts}
62
145
  };
63
146
  }
64
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
+ */
65
156
  export function compileDocument(file, context, stack = [], vars = {}) {
66
157
  const comp = createCompilation();
67
158
  const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
68
159
  return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
69
160
  }
70
161
 
162
+ /** @returns {Compilation} */
71
163
  function createCompilation() {
72
164
  return {
73
165
  assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
@@ -77,10 +169,26 @@ function createCompilation() {
77
169
  };
78
170
  }
79
171
 
172
+ /**
173
+ * @param {Scope | null} parent
174
+ * @param {Record<string, unknown>} [vars]
175
+ * @returns {Scope}
176
+ */
80
177
  function createScope(parent, vars = {}) {
81
178
  return { parent, vars: { ...vars } };
82
179
  }
83
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
+ */
84
192
  function compileFile(file, context, stack, scope, comp, sections, loopItem) {
85
193
  const real = fs.realpathSync(file);
86
194
  if (stack.includes(real)) {
@@ -98,10 +206,17 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
98
206
 
99
207
  // Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
100
208
  // `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
209
+ /** @type {Ctx} */
101
210
  const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem, md: selectMd(meta) };
102
211
  return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
103
212
  }
104
213
 
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
+ */
105
220
  export function parseFrontmatter(raw, file) {
106
221
  if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
107
222
  const end = raw.indexOf("\n---", 3);
@@ -111,6 +226,7 @@ export function parseFrontmatter(raw, file) {
111
226
  }
112
227
  const front = raw.slice(4, end).trim();
113
228
  const body = raw.slice(end + 4).replace(/^\n/, "");
229
+ /** @type {Meta} */
114
230
  const meta = {};
115
231
  for (const line of front.split("\n")) {
116
232
  const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
@@ -122,15 +238,24 @@ export function parseFrontmatter(raw, file) {
122
238
  // Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
123
239
  // Block sequences (`- item` on following lines) are intentionally out of scope —
124
240
  // the parser stays single-pass and line-based.
241
+ /**
242
+ * @param {string} raw
243
+ * @returns {FrontmatterValue}
244
+ */
125
245
  function parseFrontmatterValue(raw) {
126
246
  const value = raw.trim();
127
247
  if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
128
248
  return stripQuotes(value);
129
249
  }
130
250
 
251
+ /**
252
+ * @param {string} raw
253
+ * @returns {string[]}
254
+ */
131
255
  function parseInlineArray(raw) {
132
256
  const inner = raw.slice(1, -1);
133
257
  if (inner.trim() === "") return [];
258
+ /** @type {string[]} */
134
259
  const items = [];
135
260
  let buf = "";
136
261
  let quote = null;
@@ -162,8 +287,16 @@ function parseInlineArray(raw) {
162
287
  // Block parser: directives + prose segments
163
288
  // ---------------------------------------------------------------------------
164
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
+ */
165
296
  function compileBody(lines, ctx) {
297
+ /** @type {string[]} */
166
298
  const out = [];
299
+ /** @type {string[]} */
167
300
  let prose = [];
168
301
  let fence = null;
169
302
 
@@ -280,7 +413,17 @@ function compileBody(lines, ctx) {
280
413
  return out.join("\n");
281
414
  }
282
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
+ */
283
425
  function scanBlock(lines, start, openRe, endToken, file) {
426
+ /** @type {string[]} */
284
427
  const body = [];
285
428
  let depth = 0;
286
429
  let fence = null;
@@ -307,7 +450,15 @@ function scanBlock(lines, start, openRe, endToken, file) {
307
450
  throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
308
451
  }
309
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
+ */
310
460
  function scanContainer(lines, start, file) {
461
+ /** @type {string[]} */
311
462
  const body = [];
312
463
  let depth = 0;
313
464
  let fence = null;
@@ -339,8 +490,17 @@ function scanContainer(lines, start, file) {
339
490
  throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
340
491
  }
341
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
+ */
342
500
  function scanConditional(lines, start, file) {
501
+ /** @type {string[]} */
343
502
  const truthy = [];
503
+ /** @type {string[]} */
344
504
  const falsy = [];
345
505
  let current = truthy;
346
506
  let depth = 0;
@@ -376,6 +536,11 @@ function scanConditional(lines, start, file) {
376
536
  // Directive handlers
377
537
  // ---------------------------------------------------------------------------
378
538
 
539
+ /**
540
+ * @param {string} line
541
+ * @param {Ctx} ctx
542
+ * @returns {string}
543
+ */
379
544
  function handleInclude(line, ctx) {
380
545
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
381
546
  if (!match) throw new Error(`Malformed @include in ${ctx.file}: ${line}`);
@@ -386,7 +551,13 @@ function handleInclude(line, ctx) {
386
551
  return child.html;
387
552
  }
388
553
 
554
+ /**
555
+ * @param {string} raw
556
+ * @param {Ctx} ctx
557
+ * @returns {Record<string, unknown>}
558
+ */
389
559
  function parseIncludeArgs(raw, ctx) {
560
+ /** @type {Record<string, unknown>} */
390
561
  const args = {};
391
562
  const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
392
563
  for (const match of raw.matchAll(re)) {
@@ -405,12 +576,19 @@ function parseIncludeArgs(raw, ctx) {
405
576
  return args;
406
577
  }
407
578
 
579
+ /**
580
+ * @param {string} header
581
+ * @param {string[]} bodyLines
582
+ * @param {Ctx} ctx
583
+ * @returns {string}
584
+ */
408
585
  function handleContainer(header, bodyLines, ctx) {
409
586
  const tokens = header.split(/\s+/);
410
587
  let tag = "section";
588
+ /** @type {string[]} */
411
589
  let extraClass = [];
412
590
  let id = "";
413
- 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";
414
592
  if (nameToken !== "section") {
415
593
  tag = "div";
416
594
  extraClass.push(nameToken);
@@ -435,6 +613,11 @@ function handleContainer(header, bodyLines, ctx) {
435
613
  return `<${tag}${idAttr}${classAttr}>\n${inner}\n</${tag}>`;
436
614
  }
437
615
 
616
+ /**
617
+ * @param {string} line
618
+ * @param {Ctx} ctx
619
+ * @returns {string}
620
+ */
438
621
  function handleState(line, ctx) {
439
622
  const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
440
623
  if (!match) throw new Error(`Malformed :state in ${ctx.file}: ${line}`);
@@ -444,6 +627,13 @@ function handleState(line, ctx) {
444
627
  return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
445
628
  }
446
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
+ */
447
637
  function declareState(name, value, ctx) {
448
638
  if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
449
639
  const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
@@ -453,6 +643,11 @@ function declareState(name, value, ctx) {
453
643
  return key;
454
644
  }
455
645
 
646
+ /**
647
+ * @param {string} line
648
+ * @param {Ctx} ctx
649
+ * @returns {string}
650
+ */
456
651
  function handleFetch(line, ctx) {
457
652
  const match = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+?)(\s+when=visible)?\s*$/);
458
653
  if (!match) {
@@ -465,17 +660,29 @@ function handleFetch(line, ctx) {
465
660
  return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
466
661
  }
467
662
 
663
+ /**
664
+ * @param {string} key
665
+ * @param {Ctx} ctx
666
+ * @returns {void}
667
+ */
468
668
  function declareErrorState(key, ctx) {
469
669
  const errorKey = `${key}_error`;
470
670
  if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
471
671
  }
472
672
 
673
+ /**
674
+ * @param {string} line
675
+ * @param {Ctx} ctx
676
+ * @returns {string}
677
+ */
473
678
  function handleComputed(line, ctx) {
474
679
  const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
475
680
  if (!match) throw new Error(`Malformed :computed in ${ctx.file}: ${line}. Use: :computed total = items.length * 4`);
476
681
  const expr = compileComputedExpr(match[2].trim(), ctx);
682
+ /** @type {unknown} */
477
683
  let initial;
478
684
  try {
685
+ /** @param {string} key @param {string} [path] */
479
686
  const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
480
687
  initial = new Function("S", `return (${expr});`)(read);
481
688
  } catch {
@@ -486,7 +693,14 @@ function handleComputed(line, ctx) {
486
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>`;
487
694
  }
488
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
+ */
489
702
  function compileComputedExpr(raw, ctx) {
703
+ /** @type {string[]} */
490
704
  const strings = [];
491
705
  let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
492
706
  strings.push(`"${literal.slice(1, -1)}"`);
@@ -520,6 +734,12 @@ function compileComputedExpr(raw, ctx) {
520
734
  return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
521
735
  }
522
736
 
737
+ /**
738
+ * @param {string} line
739
+ * @param {string[]} bodyLines
740
+ * @param {Ctx} ctx
741
+ * @returns {string}
742
+ */
523
743
  function handleForm(line, bodyLines, ctx) {
524
744
  let rest = line.slice(":form".length).trim();
525
745
  const action = rest.match(/action="([^"]+)"/)?.[1];
@@ -545,6 +765,11 @@ function handleForm(line, bodyLines, ctx) {
545
765
  return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
546
766
  }
547
767
 
768
+ /**
769
+ * @param {string} line
770
+ * @param {Ctx} ctx
771
+ * @returns {string}
772
+ */
548
773
  function handleInput(line, ctx) {
549
774
  const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
550
775
  if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
@@ -575,6 +800,11 @@ function handleInput(line, ctx) {
575
800
  // :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
576
801
  // declared :state. Updates state on every keystroke; reflects state back when
577
802
  // not focused. The state must be declared first (with :state).
803
+ /**
804
+ * @param {string} line
805
+ * @param {Ctx} ctx
806
+ * @returns {string}
807
+ */
578
808
  function handleBind(line, ctx) {
579
809
  const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
580
810
  if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
@@ -604,12 +834,22 @@ function handleBind(line, ctx) {
604
834
  return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
605
835
  }
606
836
 
837
+ /**
838
+ * @param {string} line
839
+ * @param {Ctx} ctx
840
+ * @returns {string}
841
+ */
607
842
  function handleSubmit(line, ctx) {
608
843
  const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
609
844
  if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
610
845
  return `<button type="submit">${escapeHtml(match[1])}</button>`;
611
846
  }
612
847
 
848
+ /**
849
+ * @param {string} line
850
+ * @param {Ctx} ctx
851
+ * @returns {string}
852
+ */
613
853
  function handleButton(line, ctx) {
614
854
  const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
615
855
  if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
@@ -619,6 +859,13 @@ function handleButton(line, ctx) {
619
859
  return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
620
860
  }
621
861
 
862
+ /**
863
+ * @param {string} line
864
+ * @param {string[]} truthyLines
865
+ * @param {string[]} falsyLines
866
+ * @param {Ctx} ctx
867
+ * @returns {string}
868
+ */
622
869
  function handleIf(line, truthyLines, falsyLines, ctx) {
623
870
  const match = line.match(/^:if\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
624
871
  if (!match) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" with a :state or in-scope value.`);
@@ -653,6 +900,12 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
653
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>`;
654
901
  }
655
902
 
903
+ /**
904
+ * @param {string} line
905
+ * @param {string[]} bodyLines
906
+ * @param {Ctx} ctx
907
+ * @returns {string}
908
+ */
656
909
  function handleLoop(line, bodyLines, ctx) {
657
910
  const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
658
911
  if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
@@ -688,6 +941,21 @@ function handleLoop(line, bodyLines, ctx) {
688
941
  // A static source (JSON file / in-scope value). No `where`, or a `where` that
689
942
  // only reads the loop item → filter at build time and stay zero-JS. A `where`
690
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
+ */
691
959
  function loopOverData(rows, itemName, bodyLines, ctx, where) {
692
960
  if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
693
961
  if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
@@ -697,8 +965,15 @@ function loopOverData(rows, itemName, bodyLines, ctx, where) {
697
965
  // Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
698
966
  // Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
699
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
+ */
700
974
  function compilePredicate(raw, itemName, ctx) {
701
975
  const parts = raw.split(/\s+(and|or)\s+/i);
976
+ /** @type {string[]} */
702
977
  const pieces = [];
703
978
  let refsState = false;
704
979
  for (let i = 0; i < parts.length; i++) {
@@ -710,6 +985,12 @@ function compilePredicate(raw, itemName, ctx) {
710
985
  return { body: pieces.join(" "), refsState };
711
986
  }
712
987
 
988
+ /**
989
+ * @param {string} cond
990
+ * @param {string} itemName
991
+ * @param {Ctx} ctx
992
+ * @returns {{ expr: string, usesState: boolean }}
993
+ */
713
994
  function compileCondition(cond, itemName, ctx) {
714
995
  const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
715
996
  if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
@@ -721,6 +1002,12 @@ function compileCondition(cond, itemName, ctx) {
721
1002
  return { expr: `${left.code} ${op} ${right.code}`, usesState };
722
1003
  }
723
1004
 
1005
+ /**
1006
+ * @param {string} tok
1007
+ * @param {string} itemName
1008
+ * @param {Ctx} ctx
1009
+ * @returns {{ code: string, usesState: boolean }}
1010
+ */
724
1011
  function compileOperand(tok, itemName, ctx) {
725
1012
  if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
726
1013
  if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
@@ -741,11 +1028,21 @@ function compileOperand(tok, itemName, ctx) {
741
1028
  return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
742
1029
  }
743
1030
 
1031
+ /** @param {unknown} a @param {unknown} b @returns {boolean} */
744
1032
  const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
745
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
+ */
746
1041
  function evalPredicate(body, item, ctx) {
747
1042
  try {
1043
+ /** @param {string} [p] */
748
1044
  const I = (p) => getPath(item, p ? p.split(".") : []);
1045
+ /** @param {string} k @param {string} [r] */
749
1046
  const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
750
1047
  return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
751
1048
  } catch {
@@ -754,6 +1051,13 @@ function evalPredicate(body, item, ctx) {
754
1051
  }
755
1052
  }
756
1053
 
1054
+ /**
1055
+ * @param {unknown[]} rows
1056
+ * @param {string} itemName
1057
+ * @param {string[]} bodyLines
1058
+ * @param {Ctx} ctx
1059
+ * @returns {string}
1060
+ */
757
1061
  function staticUnroll(rows, itemName, bodyLines, ctx) {
758
1062
  const out = [];
759
1063
  for (const row of rows) {
@@ -763,9 +1067,19 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
763
1067
  return out.join("\n");
764
1068
  }
765
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
+ */
766
1079
  function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
767
1080
  ctx.comp.assets.runtime = true;
768
- 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) };
769
1083
  const templateHtml = compileBody(bodyLines, templateCtx).trim();
770
1084
 
771
1085
  let wrapperTag = "div";
@@ -780,11 +1094,14 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
780
1094
 
781
1095
  const where = opts?.where || null;
782
1096
  const baked = opts?.data || null; // rows for a static source filtered by state
783
- const allRows = key ? (Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : []) : (baked || []);
784
- 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>} */
785
1102
  const counts = new Map();
786
1103
  const initial = rows
787
- .map((item) => {
1104
+ .map((/** @type {unknown} */ item) => {
788
1105
  const itemKey = loopKeyOf(item, counts);
789
1106
  return fillTemplateString(withLoopKey(itemTemplate, itemKey), item);
790
1107
  })
@@ -795,10 +1112,20 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
795
1112
  return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
796
1113
  }
797
1114
 
1115
+ /**
1116
+ * @param {string} template
1117
+ * @param {string} itemKey
1118
+ * @returns {string}
1119
+ */
798
1120
  function withLoopKey(template, itemKey) {
799
1121
  return template.replace(/^<([a-zA-Z][a-zA-Z0-9-]*)/, `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`);
800
1122
  }
801
1123
 
1124
+ /**
1125
+ * @param {string} template
1126
+ * @param {unknown} item
1127
+ * @returns {string}
1128
+ */
802
1129
  function fillTemplateString(template, item) {
803
1130
  // Resolve per-item :if regions first (recursively, so nested conditionals are
804
1131
  // pre-rendered for the initial paint), then fill the plain text bindings.
@@ -808,6 +1135,11 @@ function fillTemplateString(template, item) {
808
1135
  // Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
809
1136
  // chosen branch is recursed so nested conditionals resolve too. The <template>
810
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
+ */
811
1143
  function fillEachIfRegions(str, item) {
812
1144
  const marker = '<span data-wd-each-if ';
813
1145
  let result = "";
@@ -822,8 +1154,13 @@ function fillEachIfRegions(str, item) {
822
1154
  }
823
1155
  }
824
1156
 
1157
+ /**
1158
+ * @param {string} region
1159
+ * @param {unknown} item
1160
+ * @returns {string}
1161
+ */
825
1162
  function fillOneEachIf(region, item) {
826
- 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];
827
1164
  const trueStart = region.indexOf("<template data-wd-if-true>");
828
1165
  const trueEnd = matchElement(region, trueStart, "template");
829
1166
  const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
@@ -837,6 +1174,11 @@ function fillOneEachIf(region, item) {
837
1174
  return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
838
1175
  }
839
1176
 
1177
+ /**
1178
+ * @param {string} str
1179
+ * @param {unknown} item
1180
+ * @returns {string}
1181
+ */
840
1182
  function fillEachText(str, item) {
841
1183
  return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
842
1184
  const value = p ? getPath(item, p.split(".")) : item;
@@ -848,6 +1190,12 @@ function fillEachText(str, item) {
848
1190
  // Return the index just past the balanced close of the element of `tag` that
849
1191
  // begins at `start`. Counts nested same-tag opens/closes so regions that embed
850
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
+ */
851
1199
  function matchElement(str, start, tag) {
852
1200
  const openPrefix = `<${tag}`;
853
1201
  const closeTag = `</${tag}>`;
@@ -869,16 +1217,27 @@ function matchElement(str, start, tag) {
869
1217
  return str.length;
870
1218
  }
871
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
+ */
872
1226
  export function loopKeyOf(item, counts) {
873
1227
  const base =
874
1228
  item && typeof item === "object"
875
- ? 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))
876
1230
  : String(item);
877
1231
  const seen = counts.get(base) || 0;
878
1232
  counts.set(base, seen + 1);
879
1233
  return seen ? `${base}#${seen}` : base;
880
1234
  }
881
1235
 
1236
+ /**
1237
+ * Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
1238
+ * @param {string} line
1239
+ * @returns {string}
1240
+ */
882
1241
  function renderDemoDirective(line) {
883
1242
  const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
884
1243
  if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
@@ -896,10 +1255,21 @@ function renderDemoDirective(line) {
896
1255
  // Interpolation: one syntax, { name } / { name.path }
897
1256
  // ---------------------------------------------------------------------------
898
1257
 
1258
+ /**
1259
+ * @param {string} text
1260
+ * @param {Ctx} ctx
1261
+ * @returns {string}
1262
+ */
899
1263
  function renderProse(text, ctx) {
900
- return (ctx.md ?? md).render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
1264
+ return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
901
1265
  }
902
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
+ */
903
1273
  function bindingPlugin(mdInstance) {
904
1274
  mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
905
1275
  if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
@@ -917,6 +1287,13 @@ function bindingPlugin(mdInstance) {
917
1287
  });
918
1288
  }
919
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
+ */
920
1297
  function resolveBindingHtml(expr, ctx) {
921
1298
  const segs = expr.split(".");
922
1299
  const head = segs[0];
@@ -929,8 +1306,7 @@ function resolveBindingHtml(expr, ctx) {
929
1306
  const staticValue = lookupVar(ctx.scope, head);
930
1307
  if (staticValue.found) {
931
1308
  const resolved = getPath(staticValue.value, segs.slice(1));
932
- if (Array.isArray(resolved)) return escapeHtml(resolved.join(", "));
933
- return escapeHtml(resolved ?? "");
1309
+ return escapeHtml(interpolateLeaf(resolved, expr, ctx));
934
1310
  }
935
1311
 
936
1312
  const key = resolveStateKey(head, ctx);
@@ -939,19 +1315,51 @@ function resolveBindingHtml(expr, ctx) {
939
1315
  const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
940
1316
  const rest = segs.slice(1).join(".");
941
1317
  const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
942
- return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(initial ?? "")}</span>`;
1318
+ return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(interpolateLeaf(initial, expr, ctx))}</span>`;
943
1319
  }
944
1320
 
945
1321
  return null;
946
1322
  }
947
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
+ */
948
1348
  function lookupVar(scope, name) {
949
- for (let current = scope; current; current = current.parent) {
1349
+ /** @type {Scope | null} */
1350
+ let current = scope;
1351
+ for (; current; current = current.parent) {
950
1352
  if (name in current.vars) return { found: true, value: current.vars[name] };
951
1353
  }
952
1354
  return { found: false };
953
1355
  }
954
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
+ */
955
1363
  function lookupPath(expr, ctx) {
956
1364
  const segs = expr.split(".");
957
1365
  const head = lookupVar(ctx.scope, segs[0]);
@@ -959,7 +1367,14 @@ function lookupPath(expr, ctx) {
959
1367
  return { found: true, value: getPath(head.value, segs.slice(1)) };
960
1368
  }
961
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
+ */
962
1376
  function getPath(value, segments) {
1377
+ /** @type {any} */
963
1378
  let current = value;
964
1379
  for (const segment of segments) {
965
1380
  if (current == null) return undefined;
@@ -969,6 +1384,12 @@ function getPath(value, segments) {
969
1384
  return current;
970
1385
  }
971
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
+ */
972
1393
  function resolveStateKey(name, ctx) {
973
1394
  for (let i = ctx.sections.length - 1; i >= 0; i--) {
974
1395
  const key = `${ctx.sections[i]}:${name}`;
@@ -981,8 +1402,23 @@ function resolveStateKey(name, ctx) {
981
1402
  // Actions
982
1403
  // ---------------------------------------------------------------------------
983
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
+ */
984
1419
  function parseAction(raw, ctx) {
985
1420
  const expression = raw.trim();
1421
+ /** @param {string} name @returns {string} */
986
1422
  const resolveTarget = (name) => {
987
1423
  const key = resolveStateKey(name, ctx);
988
1424
  if (!key) {
@@ -1011,7 +1447,7 @@ function parseAction(raw, ctx) {
1011
1447
  const value = parseActionLiteral(rhs);
1012
1448
  if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
1013
1449
  if (typeof value === "number") return { op: "add", target, value };
1014
- 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]} = []".`);
1015
1451
  }
1016
1452
  // Per-row: remove the current row from the list being looped — `todos remove todo`.
1017
1453
  const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
@@ -1029,9 +1465,13 @@ function parseAction(raw, ctx) {
1029
1465
  }
1030
1466
  const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
1031
1467
  if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
1032
- 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).`);
1033
1469
  }
1034
1470
 
1471
+ /**
1472
+ * @param {string} raw
1473
+ * @returns {unknown}
1474
+ */
1035
1475
  function parseActionLiteral(raw) {
1036
1476
  const value = raw.trim();
1037
1477
  if (/^["'].*["']$/.test(value)) return stripQuotes(value);
@@ -1053,6 +1493,13 @@ function parseActionLiteral(raw) {
1053
1493
  // Includes / assets
1054
1494
  // ---------------------------------------------------------------------------
1055
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
+ */
1056
1503
  function collectColocatedAssets(file, context, assets) {
1057
1504
  const ext = path.extname(file);
1058
1505
  const stem = file.slice(0, -ext.length);
@@ -1068,6 +1515,14 @@ function collectColocatedAssets(file, context, assets) {
1068
1515
  }
1069
1516
  }
1070
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
+ */
1071
1526
  function resolveInclude(spec, fromFile, context, allowAny = false) {
1072
1527
  const clean = stripQuotes(spec);
1073
1528
  const candidates = [];
@@ -1089,6 +1544,11 @@ function resolveInclude(spec, fromFile, context, allowAny = false) {
1089
1544
  throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
1090
1545
  }
1091
1546
 
1547
+ /**
1548
+ * @param {string} file
1549
+ * @param {Paths} context
1550
+ * @returns {boolean}
1551
+ */
1092
1552
  function isAllowedInclude(file, context) {
1093
1553
  const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
1094
1554
  return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
@@ -1098,6 +1558,13 @@ function isAllowedInclude(file, context) {
1098
1558
  // Plain .md hints
1099
1559
  // ---------------------------------------------------------------------------
1100
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
+ */
1101
1568
  function scanMarkdownHints(body, file, comp) {
1102
1569
  let fence = null;
1103
1570
  for (const line of body.split("\n")) {
@@ -1124,10 +1591,19 @@ function scanMarkdownHints(body, file, comp) {
1124
1591
  // Literals
1125
1592
  // ---------------------------------------------------------------------------
1126
1593
 
1594
+ /**
1595
+ * JSON-encode a value for an inline `<script>`, escaping `<` to stay HTML-safe.
1596
+ * @param {unknown} value
1597
+ * @returns {string}
1598
+ */
1127
1599
  function safeScriptJson(value) {
1128
1600
  return JSON.stringify(value).replaceAll("<", "\\u003c");
1129
1601
  }
1130
1602
 
1603
+ /**
1604
+ * @param {string} raw
1605
+ * @returns {unknown}
1606
+ */
1131
1607
  function parseStateValue(raw) {
1132
1608
  const value = raw.trim();
1133
1609
  if (/^["'].*["']$/.test(value)) return stripQuotes(value);
@@ -1142,6 +1618,10 @@ function parseStateValue(raw) {
1142
1618
  }
1143
1619
  }
1144
1620
 
1621
+ /**
1622
+ * @param {string} raw
1623
+ * @returns {string | number | boolean}
1624
+ */
1145
1625
  function parseScalar(raw) {
1146
1626
  const trimmed = raw.trim();
1147
1627
  if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
@@ -1151,10 +1631,19 @@ function parseScalar(raw) {
1151
1631
  return trimmed;
1152
1632
  }
1153
1633
 
1634
+ /**
1635
+ * @param {string} [value]
1636
+ * @returns {string}
1637
+ */
1154
1638
  function stripQuotes(value) {
1155
1639
  return value?.replace(/^["']|["']$/g, "") ?? "";
1156
1640
  }
1157
1641
 
1642
+ /**
1643
+ * Escape a value for safe inclusion in HTML text and attribute contexts.
1644
+ * @param {unknown} value
1645
+ * @returns {string}
1646
+ */
1158
1647
  export function escapeHtml(value) {
1159
1648
  return String(value)
1160
1649
  .replaceAll("&", "&amp;")