@zvndev/darkmown 2.6.1-next.11.g82bb8d7 → 2.7.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 (73) hide show
  1. package/AGENTS.md +392 -77
  2. package/CHANGELOG.md +47 -0
  3. package/README.md +316 -49
  4. package/docs/cli.md +51 -17
  5. package/docs/spec-alignment.md +25 -4
  6. package/grammar/wd-directives.gbnf +13 -5
  7. package/package.json +7 -3
  8. package/site/_/base.skin +1 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/pages/api-errors/index.wd +81 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/attr-binding/index.wd +84 -0
  13. package/site/pages/blog/darkmown-2-7-0.md +176 -0
  14. package/site/pages/blog/darkmown-2-7-0.skin +127 -0
  15. package/site/pages/blog/no-unsafe-eval.md +66 -0
  16. package/site/pages/blog/no-unsafe-eval.skin +127 -0
  17. package/site/pages/blog/zero-js-by-default.md +1 -1
  18. package/site/pages/branch-state/index.wd +68 -0
  19. package/site/pages/docs/index.wd +320 -19
  20. package/site/pages/index.wd +3 -1
  21. package/site/pages/playground/index.js +63 -56
  22. package/site/pages/pulse/index.wd +1 -1
  23. package/site/pages/settings/index.wd +97 -0
  24. package/site/pages/showcase/index.wd +37 -1
  25. package/site/pages/upload/index.wd +63 -0
  26. package/site/pages/url-state/index.wd +70 -0
  27. package/src/builder.js +222 -34
  28. package/src/catalog.js +217 -21
  29. package/src/cli.js +6 -3
  30. package/src/compiler/actions.js +80 -12
  31. package/src/compiler/body.js +104 -28
  32. package/src/compiler/collections.js +29 -13
  33. package/src/compiler/context.js +9 -2
  34. package/src/compiler/expr-ast.js +14 -0
  35. package/src/compiler/fetch.js +8 -4
  36. package/src/compiler/forms.js +133 -14
  37. package/src/compiler/frontmatter.js +28 -7
  38. package/src/compiler/includes.js +46 -6
  39. package/src/compiler/interpolation.js +56 -1
  40. package/src/compiler/loops.js +489 -35
  41. package/src/compiler/markdown.js +457 -44
  42. package/src/compiler/page.js +8 -3
  43. package/src/compiler/predicates.js +84 -14
  44. package/src/compiler/state.js +167 -18
  45. package/src/compiler/structure.js +159 -29
  46. package/src/errors.js +111 -3
  47. package/src/feeds.js +140 -7
  48. package/src/grammar.js +53 -10
  49. package/src/runtime.js +509 -130
  50. package/src/runtime.min.js +2 -0
  51. package/src/runtime.min.js.map +1 -0
  52. package/src/statics.js +46 -2
  53. package/src/templates/blog/README.md +2 -0
  54. package/src/templates/dashboard/README.md +2 -0
  55. package/src/templates/landing/README.md +2 -0
  56. package/src/templates/starter/README.md +1 -1
  57. package/src/templates/store/README.md +2 -0
  58. package/src/tools/validate.js +14 -4
  59. package/types/builder.d.ts +6 -2
  60. package/types/catalog.d.ts +12 -0
  61. package/types/compiler/actions.d.ts +1 -1
  62. package/types/compiler/body.d.ts +0 -3
  63. package/types/compiler/forms.d.ts +12 -1
  64. package/types/compiler/frontmatter.d.ts +8 -1
  65. package/types/compiler/includes.d.ts +16 -2
  66. package/types/compiler/interpolation.d.ts +23 -0
  67. package/types/compiler/loops.d.ts +47 -0
  68. package/types/compiler/markdown.d.ts +63 -1
  69. package/types/compiler/predicates.d.ts +7 -1
  70. package/types/compiler/state.d.ts +8 -2
  71. package/types/compiler/structure.d.ts +32 -1
  72. package/types/feeds.d.ts +68 -2
  73. package/types/statics.d.ts +25 -0
@@ -39,6 +39,49 @@ export const BIND_EXAMPLE = ':bind query placeholder="Search"';
39
39
  export const SLIDER_EXAMPLE = ":slider volume = 50 min=0 max=100 step=1";
40
40
  const INPUT_USE = `Use: :input name [type=…] [placeholder="…"] [required] — e.g. ${INPUT_EXAMPLE}`;
41
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // A FIELD DIRECTIVE MEANS TWO DIFFERENT THINGS, AND ONLY THE FORM KNOWS WHICH.
44
+ //
45
+ // Inside a `:form`, `:select topic` names a FORM FIELD: the label goes in the
46
+ // submitted payload under that name and nothing is reactive. Outside one, the
47
+ // same line is a bound CONTROL over a declared `:state`, exactly like `:bind`
48
+ // and `:slider` — changing it writes the state, changing the state moves it.
49
+ //
50
+ // The two cannot be told apart from the line alone, and inferring it from
51
+ // "does a state of that name happen to exist?" would mean declaring an
52
+ // unrelated `:state topic` elsewhere on the page silently rewires a form. So
53
+ // the enclosing `:form` says so: `handleForm` flags the context it compiles its
54
+ // body with, and `nestedCtx` spreads that flag into every nested block, so a
55
+ // field inside a `:::` inside the form still sees it.
56
+ //
57
+ // The flag rides on the context object rather than the `Ctx` typedef because
58
+ // `context.js` is the root of the module DAG and knows nothing about forms.
59
+ // (Known limit: an `@include` compiles its target with a fresh context, so a
60
+ // field directive inside an included partial is always the standalone form.)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** @param {Ctx} ctx @returns {boolean} */
64
+ const inForm = (ctx) => Boolean(/** @type {any} */ (ctx).inForm);
65
+
66
+ /**
67
+ * Resolve a standalone field's state key, or explain how to declare it.
68
+ * @param {string} name
69
+ * @param {string} directive `:select` / `:checkbox` / `:radio`.
70
+ * @param {Ctx} ctx
71
+ * @param {number} index 0-based line index for `file:line`.
72
+ * @returns {string} The fully-qualified state key.
73
+ */
74
+ function boundKey(name, directive, ctx, index) {
75
+ const key = resolveStateKey(name, ctx);
76
+ if (key) return key;
77
+ const seed = directive === ":checkbox" ? "false" : '"…"';
78
+ const hint = `declare the state first — :state ${name} = ${seed} — or move ${directive} inside a :form, where the name is a form field instead`;
79
+ throw wdError(
80
+ `${directive} ${name} in ${at(ctx, index)} is outside a :form and has no matching state. Use: ${hint} — e.g. ${SELECT_EXAMPLE}`,
81
+ { code: "WD450", file: ctx.file, line: lineOf(ctx, index), hint, example: SELECT_EXAMPLE }
82
+ );
83
+ }
84
+
42
85
  /**
43
86
  * @param {string} line
44
87
  * @param {string[]} bodyLines
@@ -67,9 +110,30 @@ export function handleForm(line, bodyLines, ctx, index) {
67
110
  });
68
111
  }
69
112
  const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
70
- const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1)).trim();
113
+ // See the `inForm` note above: the body's context carries the flag that makes
114
+ // a field directive a FORM FIELD rather than a bound control.
115
+ const bodyCtx = /** @type {any} */ (nestedCtx(ctx, index + 1));
116
+ bodyCtx.inForm = true;
117
+ const inner = ctx.compileBody(bodyLines, bodyCtx).trim();
118
+ // A file cannot be urlencoded, so a form carrying one has to be multipart —
119
+ // in the browser's native submit (this attribute) and in the runtime's
120
+ // round-trip (which posts the FormData itself). A GET form has no body at
121
+ // all, so the file would be dropped and only its NAME would travel: that is
122
+ // the silent kind of failure, so it is a compile error instead.
123
+ const hasFile = /<input[^>]*\btype="file"/i.test(inner);
124
+ if (hasFile && method.toLowerCase() === "get") {
125
+ const hint = `drop method="get" (a :form posts by default) — a GET request has no body, so the file would never be sent — e.g. ${FORM_EXAMPLE}`;
126
+ throw wdError(`:form in ${at(ctx, index)} has a file field and method="get". Use: ${hint}`, {
127
+ code: "WD452",
128
+ file: ctx.file,
129
+ line: lineOf(ctx, index),
130
+ hint,
131
+ example: FORM_EXAMPLE
132
+ });
133
+ }
134
+ const enctype = hasFile ? ` enctype="multipart/form-data"` : "";
71
135
  if (!into) {
72
- return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
136
+ return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}"${enctype}>${inner}</form>`;
73
137
  }
74
138
  const key = declareState(into, null, ctx);
75
139
  declareErrorState(key, ctx);
@@ -80,9 +144,9 @@ export function handleForm(line, bodyLines, ctx, index) {
80
144
  detail: `:form action="${rawAction}" into ${into}`
81
145
  });
82
146
  const actionAttrs = action
83
- ? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"`
147
+ ? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"${enctype}`
84
148
  : "";
85
- return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
149
+ return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${escapeHtml(key)}"${actionAttrs}>${inner}</form>`;
86
150
  }
87
151
 
88
152
  /**
@@ -230,7 +294,7 @@ export function handleBind(line, ctx, index) {
230
294
  const initial = ctx.comp.state.get(key);
231
295
  const valueAttr =
232
296
  initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
233
- return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
297
+ return `<input type="${escapeHtml(type)}" data-wd-bind-input="${escapeHtml(key)}"${valueAttr} ${attrs.join(" ")}>`;
234
298
  }
235
299
 
236
300
  /**
@@ -318,7 +382,7 @@ export function handleSlider(line, ctx, index) {
318
382
  { code: "WD413", file: ctx.file, line: lineOf(ctx, index) }
319
383
  );
320
384
  key = declareState(name, value, ctx);
321
- const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
385
+ const persistAttr = persist ? ` data-wd-persist="${escapeHtml(key)}"` : "";
322
386
  seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
323
387
  } else {
324
388
  if (persist)
@@ -340,7 +404,7 @@ export function handleSlider(line, ctx, index) {
340
404
  initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
341
405
  const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
342
406
  recordSymbol(ctx, index, { kind: "field", name, target: key, detail: `:slider ${name}` });
343
- return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
407
+ return `${seed}<input type="range" data-wd-bind-input="${escapeHtml(key)}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
344
408
  }
345
409
 
346
410
  /**
@@ -437,7 +501,13 @@ export function handleTextarea(line, ctx, index) {
437
501
  /**
438
502
  * `:select name [required]` followed by `- Label` list lines → a `<select>` with
439
503
  * one `<option>` per label (value === label). Derives an aria-label from the
440
- * humanized name when none is given. Captured by FormData like the other fields.
504
+ * humanized name when none is given.
505
+ *
506
+ * Inside a `:form` it is a form field captured by FormData, exactly as before.
507
+ * Outside one it is a control two-way bound to a declared `:state` of that name
508
+ * (see the `inForm` note at the top of this file): the seed selects the matching
509
+ * option, changing the control writes the state, and changing the state moves
510
+ * the control.
441
511
  * @param {string} line
442
512
  * @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
443
513
  * @param {Ctx} ctx
@@ -456,8 +526,17 @@ export function handleSelect(line, optionLines, ctx, index) {
456
526
  example: SELECT_EXAMPLE
457
527
  });
458
528
  }
459
- const attrs = [`name="${escapeHtml(match[1])}"`];
460
- recordSymbol(ctx, index, { kind: "field", name: match[1], detail: `:select ${match[1]}` });
529
+ const bound = inForm(ctx) ? null : boundKey(match[1], ":select", ctx, index);
530
+ const attrs = bound
531
+ ? [`data-wd-bind-input="${escapeHtml(bound)}"`]
532
+ : [`name="${escapeHtml(match[1])}"`];
533
+ if (bound) ctx.comp.assets.runtime = true;
534
+ recordSymbol(ctx, index, {
535
+ kind: "field",
536
+ name: match[1],
537
+ ...(bound ? { target: bound } : {}),
538
+ detail: `:select ${match[1]}`
539
+ });
461
540
  let hasAria = false;
462
541
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
463
542
  for (const token of (match[2] || "").matchAll(re)) {
@@ -493,8 +572,14 @@ export function handleSelect(line, optionLines, ctx, index) {
493
572
  { code: "WD423", file: ctx.file, line: lineOf(ctx, index) }
494
573
  );
495
574
  }
575
+ // A bound select opens on the state's current value, so the first paint agrees
576
+ // with what the runtime will reflect a moment later.
577
+ const selected = bound ? String(ctx.comp.state.get(bound) ?? "") : null;
496
578
  const opts = options
497
- .map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`)
579
+ .map(
580
+ (o) =>
581
+ `<option value="${escapeHtml(o)}"${o === selected ? " selected" : ""}>${escapeHtml(o)}</option>`
582
+ )
498
583
  .join("");
499
584
  return `<select ${attrs.join(" ")}>${opts}</select>`;
500
585
  }
@@ -509,6 +594,11 @@ export function handleSelect(line, optionLines, ctx, index) {
509
594
  * groups capture a single value like `:input`. Flags: `required` (radio → the
510
595
  * group; not emitted on checkboxes, where "at least one" has no native HTML form),
511
596
  * `disabled` (whole group), `autofocus` (first control).
597
+ *
598
+ * OUTSIDE a `:form` (see the `inForm` note at the top of this file) both bind to
599
+ * a declared `:state` of that name instead: a `:radio` group binds the STRING of
600
+ * the chosen option, and a `:checkbox` binds a BOOLEAN, so it takes exactly one
601
+ * option — the label to show beside it.
512
602
  * @param {string} line
513
603
  * @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
514
604
  * @param {Ctx} ctx
@@ -535,7 +625,14 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
535
625
  });
536
626
  }
537
627
  const name = match[1];
538
- recordSymbol(ctx, index, { kind: "field", name, detail: `:${kind} ${name}` });
628
+ const bound = inForm(ctx) ? null : boundKey(name, directive, ctx, index);
629
+ if (bound) ctx.comp.assets.runtime = true;
630
+ recordSymbol(ctx, index, {
631
+ kind: "field",
632
+ name,
633
+ ...(bound ? { target: bound } : {}),
634
+ detail: `:${kind} ${name}`
635
+ });
539
636
  const flags = [];
540
637
  let ariaLabel = null;
541
638
  let ariaDescribedby = null;
@@ -570,19 +667,41 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
570
667
  );
571
668
  }
572
669
  const isCheckbox = kind === "checkbox";
670
+ // A bound checkbox is one boolean, so a list of options has no meaning: the
671
+ // author is reaching for a multi-value control that would need every box to
672
+ // write the same key.
673
+ if (bound && isCheckbox && options.length > 1) {
674
+ const hint = `a bound :checkbox is a single true/false, so give it one "- Label" line. For a list of choices, use a :radio group (one value) or drive an array with :button "…" -> ${name} toggle "Label"`;
675
+ throw wdError(
676
+ `:checkbox ${name} in ${at(ctx, index)} is bound to state and has ${options.length} options. Use: ${hint} — e.g. ${CHECKBOX_EXAMPLE}`,
677
+ { code: "WD451", file: ctx.file, line: lineOf(ctx, index), hint, example: CHECKBOX_EXAMPLE }
678
+ );
679
+ }
573
680
  const groupAttrs = [
574
681
  `class="wd-${isCheckbox ? "checkboxes" : "radios"}"`,
575
682
  `role="${isCheckbox ? "group" : "radiogroup"}"`
576
683
  ];
577
- if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
684
+ // `data-wd-multi` tells the form submit handler to collect every checked box
685
+ // into an array. A bound checkbox is not in a form and is not an array.
686
+ if (isCheckbox && !bound) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
578
687
  groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
579
688
  if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
580
689
  const disabled = flags.includes("disabled");
581
690
  const required = flags.includes("required");
582
691
  const autofocus = flags.includes("autofocus");
692
+ const current = bound ? ctx.comp.state.get(bound) : undefined;
583
693
  const controls = options
584
694
  .map((opt, idx) => {
585
- const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
695
+ const a = [
696
+ `type="${kind}"`,
697
+ `name="${escapeHtml(bound || name)}"`,
698
+ `value="${escapeHtml(opt)}"`
699
+ ];
700
+ // A radio group still needs its shared `name` to be mutually exclusive in
701
+ // the browser, so the bound form keys it by the state key.
702
+ if (bound) a.push(`data-wd-bind-input="${escapeHtml(bound)}"`);
703
+ if (bound && (isCheckbox ? Boolean(current) : String(current ?? "") === opt))
704
+ a.push("checked");
586
705
  if (disabled) a.push("disabled");
587
706
  if (required && !isCheckbox) a.push("required");
588
707
  if (autofocus && idx === 0) a.push("autofocus");
@@ -16,9 +16,15 @@ import { stripQuotes } from "./interpolation.js";
16
16
  * Split a raw file into its frontmatter `meta` and `body`. `bodyLine` is the
17
17
  * 0-based file line index the body starts on (the number of lines the
18
18
  * frontmatter block consumed) so compile errors can report true file lines.
19
+ *
20
+ * `quotedKeys` names the fields the author wrote with quotes. Quotes are syntax,
21
+ * not content, so `meta` never carries them — but "the author quoted it" is the
22
+ * only signal that `sku: "007"` is the TEXT 007 and not the number 7, and the
23
+ * typed-collection coercion needs it. Additive: existing destructuring of
24
+ * `{ meta, body, bodyLine }` is unaffected.
19
25
  * @param {string} raw Full file contents.
20
26
  * @param {string} [file] Source path, used only for error messages.
21
- * @returns {{ meta: Meta, body: string, bodyLine: number }}
27
+ * @returns {{ meta: Meta, body: string, bodyLine: number, quotedKeys: Set<string> }}
22
28
  */
23
29
  export function parseFrontmatter(raw, file) {
24
30
  // A file authored on Windows (or checked out with git's autocrlf) arrives
@@ -28,7 +34,7 @@ export function parseFrontmatter(raw, file) {
28
34
  // in the reader keeps the direct callers (builder.js reads route frontmatter
29
35
  // straight off disk for feeds) on the same footing. Idempotent on LF input.
30
36
  raw = normalizeNewlines(raw);
31
- if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0 };
37
+ if (!raw.startsWith("---\n")) return { meta: {}, body: raw, bodyLine: 0, quotedKeys: new Set() };
32
38
  const end = raw.indexOf("\n---", 3);
33
39
  if (end === -1) {
34
40
  const where = file ? ` in ${file}` : "";
@@ -44,11 +50,16 @@ export function parseFrontmatter(raw, file) {
44
50
  const bodyLine = raw.slice(0, bodyStart).split("\n").length - 1;
45
51
  /** @type {Meta} */
46
52
  const meta = {};
53
+ /** @type {Set<string>} */
54
+ const quotedKeys = new Set();
47
55
  for (const line of front.split("\n")) {
48
56
  const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
49
- if (match) meta[match[1]] = parseFrontmatterValue(match[2]);
57
+ if (!match) continue;
58
+ const parsed = parseFrontmatterValue(match[2]);
59
+ meta[match[1]] = parsed.value;
60
+ if (parsed.quoted) quotedKeys.add(match[1]);
50
61
  }
51
- return { meta, body, bodyLine };
62
+ return { meta, body, bodyLine, quotedKeys };
52
63
  }
53
64
 
54
65
  /**
@@ -86,13 +97,23 @@ export function warnLikelyFrontmatter(raw, file, comp) {
86
97
  // Block sequences (`- item` on following lines) are intentionally out of scope —
87
98
  // the parser stays single-pass and line-based.
88
99
  /**
100
+ * Parse one frontmatter value, reporting whether the author QUOTED it.
101
+ *
102
+ * The inline-array branch tracks `quoted` per item already (an item is a string
103
+ * either way, so the flag only guards whitespace trimming); the scalar branch
104
+ * did not, and downstream that made `sku: "007"` indistinguishable from
105
+ * `sku: 007`. Both are `"007"` here, but only the first is text the author
106
+ * pinned, so only the second is a candidate for numeric coercion.
89
107
  * @param {string} raw
90
- * @returns {FrontmatterValue}
108
+ * @returns {{ value: FrontmatterValue, quoted: boolean }}
91
109
  */
92
110
  function parseFrontmatterValue(raw) {
93
111
  const value = raw.trim();
94
- if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
95
- return stripQuotes(value);
112
+ if (value.startsWith("[") && value.endsWith("]"))
113
+ return { value: parseInlineArray(value), quoted: false };
114
+ const quote = value[0];
115
+ const quoted = value.length >= 2 && (quote === '"' || quote === "'") && value.endsWith(quote);
116
+ return { value: stripQuotes(value), quoted };
96
117
  }
97
118
 
98
119
  /**
@@ -180,14 +180,23 @@ export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
180
180
  }
181
181
  for (const candidate of candidates) {
182
182
  const resolved = path.resolve(candidate);
183
- if (!isAllowedInclude(resolved, context)) {
183
+ const refuse = () => {
184
184
  throw wdError(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`, {
185
185
  code: "WD601",
186
186
  file: fromFile
187
187
  });
188
- }
188
+ };
189
+ // Lexical containment first: it is the only check available for a path that
190
+ // does not exist yet, and it is what rejects `../` traversal and a
191
+ // sibling-prefix directory (`site/pages-secret/`).
192
+ if (!isAllowedInclude(resolved, context)) refuse();
189
193
  if (!reader.exists(resolved)) continue;
190
194
  if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
195
+ // CANONICAL containment second: the lexical check reads the path as WRITTEN,
196
+ // so a symlink sitting inside `site/pages` and pointing anywhere on the disk
197
+ // passed it and leaked the target's bytes into the built page. Only reachable
198
+ // once the file exists, which is why it cannot replace the lexical check.
199
+ if (!isAllowedInclude(resolved, context, reader)) refuse();
191
200
  return resolved;
192
201
  }
193
202
  throw wdError(
@@ -197,13 +206,42 @@ export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
197
206
  }
198
207
 
199
208
  /**
200
- * @param {string} file
209
+ * Whether an absolute path is inside the `site/pages` / `site/_` sandbox.
210
+ *
211
+ * Two modes, both used by {@link resolveInclude}. Without a `reader` the check is
212
+ * LEXICAL — the path exactly as written — which is what rejects `../` traversal
213
+ * and a sibling directory that merely shares the root's prefix
214
+ * (`site/pages-secret/` is not inside `site/pages/`, hence the `path.sep`
215
+ * boundary). With a `reader` it re-runs against the CANONICAL paths, closing the
216
+ * symlink hole: a link inside `site/pages` pointing at `../../secret.md` is
217
+ * lexically fine and still escapes the sandbox. Both sides are canonicalized,
218
+ * because a project root can itself live under a symlinked parent (macOS
219
+ * `/tmp` → `/private/tmp`), where realpathing only the file would reject every
220
+ * legitimate include.
221
+ * @param {string} file Absolute path to the include target.
201
222
  * @param {Paths} context
223
+ * @param {import("./reader.js").Reader} [reader] When given, also check the
224
+ * canonical (symlink-resolved) path. Requires `file` to exist.
202
225
  * @returns {boolean}
203
226
  */
204
- export function isAllowedInclude(file, context) {
227
+ export function isAllowedInclude(file, context, reader) {
205
228
  const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
206
- return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
229
+ /** @param {string} target @param {string[]} within */
230
+ const contains = (target, within) =>
231
+ within.some((root) => target === root || target.startsWith(`${root}${path.sep}`));
232
+ if (!contains(path.resolve(file), roots)) return false;
233
+ if (!reader) return true;
234
+ // A root that does not exist (a project with no `site/_`) has no canonical
235
+ // form; keep it as written rather than failing the whole check.
236
+ /** @param {string} target */
237
+ const canonical = (target) => {
238
+ try {
239
+ return reader.realpath(target);
240
+ } catch {
241
+ return target;
242
+ }
243
+ };
244
+ return contains(canonical(path.resolve(file)), roots.map(canonical));
207
245
  }
208
246
 
209
247
  // ---------------------------------------------------------------------------
@@ -231,7 +269,9 @@ export function scanMarkdownHints(body, file, comp) {
231
269
  continue;
232
270
  }
233
271
  const hit = line.match(
234
- /^(@include|@loop|@repeat|:state|:button|:if|:for|:try|:note|:sprint|:::)(\s|$)/
272
+ // `:note`/`:sprint` are gone from the `.wd` directive set, so naming them
273
+ // here would tell a `.md` author to rename a file to activate nothing.
274
+ /^(@include|@loop|@repeat|:state|:button|:if|:for|:try|:::)(\s|$)/
235
275
  );
236
276
  if (hit) {
237
277
  comp.warnings.push(
@@ -22,7 +22,14 @@ export function lookupVar(scope, name) {
22
22
  /** @type {Scope | null} */
23
23
  let current = scope;
24
24
  for (; current; current = current.parent) {
25
- if (name in current.vars) return { found: true, value: current.vars[name] };
25
+ // `Object.hasOwn`, never `in`: a scope frame is a plain object, so `in` also
26
+ // answers true for every Object.prototype member. With it, `{ toString }`
27
+ // resolved to the inherited function and rendered
28
+ // "function toString() { [native code] }" into the page, and
29
+ // `@include /p.wd with v={ constructor }` leaked the Object constructor into
30
+ // the value pipeline. Own keys only — the scope holds exactly what the
31
+ // author put there.
32
+ if (Object.hasOwn(current.vars, name)) return { found: true, value: current.vars[name] };
26
33
  }
27
34
  return { found: false };
28
35
  }
@@ -112,6 +119,26 @@ export function interpolateLeaf(value, expr, ctx) {
112
119
  return String(value);
113
120
  }
114
121
 
122
+ /**
123
+ * Render the INITIAL value of a reactive binding (`data-wd-bind` /
124
+ * `data-wd-each`) as text.
125
+ *
126
+ * Identical to {@link interpolateLeaf} except for arrays: the runtime paints a
127
+ * bind by assigning to `textContent`, which coerces `["a", "b"]` with `String()`
128
+ * to `"a,b"`, and the loop row-template fill already agrees (it escapes through
129
+ * `String()` too). `interpolateLeaf`'s `", "` join is the STATIC contract — the
130
+ * documented `{ meta.tags }` behavior for a value nothing will ever repaint — so
131
+ * the two cannot share one rule. Painting the static form here made the first
132
+ * reactive render silently rewrite `"a, b"` to `"a,b"`.
133
+ * @param {unknown} value
134
+ * @param {string} expr
135
+ * @param {Ctx} ctx
136
+ * @returns {string}
137
+ */
138
+ export function interpolateBound(value, expr, ctx) {
139
+ return Array.isArray(value) ? String(value) : interpolateLeaf(value, expr, ctx);
140
+ }
141
+
115
142
  // ---------------------------------------------------------------------------
116
143
  // Literals
117
144
  // ---------------------------------------------------------------------------
@@ -201,6 +228,34 @@ export function humanizeName(name) {
201
228
  return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
202
229
  }
203
230
 
231
+ // ---------------------------------------------------------------------------
232
+ // A BOUND href/src CARRIES A VALUE THE COMPILER NEVER SEES.
233
+ //
234
+ // `validateFetchUrl` and markdown-it's `validateLink` both vet a URL the AUTHOR
235
+ // wrote. A reactive attribute binding (`[go]({ url })`) is different: the
236
+ // compiler only sees the seed, and every later value arrives at runtime from
237
+ // state, a form field, or a fetched payload. So the scheme check has to run
238
+ // where the value lands, and it has to run in BOTH places — here for the
239
+ // build-time paint, and again in `src/runtime.js` for every repaint.
240
+ //
241
+ // Control characters are stripped before the test because browsers strip tab,
242
+ // newline and carriage return out of a URL attribute before resolving it, so
243
+ // `java\tscript:alert(1)` is a live `javascript:` URL to the browser and must be
244
+ // one to this guard too. Whitespace is stripped along with them, which only
245
+ // makes the test stricter. `tests/attr-binding.test.js` runs the same table
246
+ // through the compiler and the runtime so the two cannot drift.
247
+ // ---------------------------------------------------------------------------
248
+
249
+ /**
250
+ * True for a value that must never be applied to a bound `href`/`src`.
251
+ * @param {unknown} value
252
+ * @returns {boolean}
253
+ */
254
+ export function unsafeUrlValue(value) {
255
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — browsers strip these out of a URL, so the guard must too.
256
+ return /^(javascript|data|vbscript):/i.test(String(value).replace(/[\u0000-\u0020\u007F]+/g, ""));
257
+ }
258
+
204
259
  /**
205
260
  * Escape a value for safe inclusion in HTML text and attribute contexts.
206
261
  * @param {unknown} value