@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
@@ -7,16 +7,23 @@
7
7
 
8
8
  import MarkdownIt from "markdown-it";
9
9
  import { highlightCode } from "../highlight.js";
10
- import { LOOP_META, recordRead, wdError } from "./context.js";
10
+ import { LOOP_META, lineOf, recordRead, wdError } from "./context.js";
11
11
  import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
12
12
  import {
13
13
  escapeHtml,
14
14
  getPath,
15
+ interpolateBound,
15
16
  interpolateLeaf,
16
17
  lookupVar,
17
- resolveStateKey
18
+ resolveStateKey,
19
+ unsafeUrlValue
18
20
  } from "./interpolation.js";
19
21
 
22
+ // The two halves of the destination contract, re-exported together: a caller
23
+ // painting an href itself (a loop row's build-time initial) has to encode the
24
+ // value the same way this module does and answer the same scheme question.
25
+ export { unsafeUrlValue };
26
+
20
27
  /**
21
28
  * @typedef {import("./context.js").Meta} Meta
22
29
  * @typedef {import("./context.js").Ctx} Ctx
@@ -29,6 +36,8 @@ import {
29
36
  const md = new MarkdownIt({ html: false, highlight: highlightCode });
30
37
  md.use(bindingPlugin);
31
38
  md.use(attrsPlugin);
39
+ md.use(attrBindPlugin);
40
+ md.use(rawHtmlPlugin);
32
41
  md.use(anchorPlugin);
33
42
 
34
43
  // Raw HTML in markdown is escaped by default: a stray `<script>` or `onerror=`
@@ -50,63 +59,413 @@ export function selectMd(meta) {
50
59
  mdRawHtml = new MarkdownIt({ html: true, highlight: highlightCode });
51
60
  mdRawHtml.use(bindingPlugin);
52
61
  mdRawHtml.use(attrsPlugin);
62
+ mdRawHtml.use(attrBindPlugin);
63
+ mdRawHtml.use(rawHtmlPlugin);
53
64
  mdRawHtml.use(anchorPlugin);
54
65
  }
55
66
  return mdRawHtml;
56
67
  }
57
68
 
58
69
  /**
70
+ * Render one prose chunk.
59
71
  * @param {string} text
60
72
  * @param {Ctx} ctx
73
+ * @param {number} [index] 0-based line index the chunk starts at in the current
74
+ * `compileBody` slice, so a warning about a binding inside it reports the true
75
+ * `file:line`. Defaults to the top of the slice.
61
76
  * @returns {string}
62
77
  */
63
- export function renderProse(text, ctx) {
64
- text = resolveDestinationBindings(text, ctx);
65
- return (ctx.md ?? md).render(text, {
66
- resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx),
78
+ export function renderProse(text, ctx, index = 0) {
79
+ /** @type {PendingAttr[]} */
80
+ const attrs = [];
81
+ text = resolveDestinationBindings(text, ctx, attrs, index);
82
+ const html = (ctx.md ?? md).render(text, {
83
+ resolveBinding: (/** @type {string} */ expr) => resolveBinding(expr, ctx),
84
+ // Raw-HTML interpolation needs the compile context AND the chunk's offset,
85
+ // and markdown-it's env is the only channel into a core rule. `attrs` is the
86
+ // destination-binding registry `attrBindPlugin` reads back (see below).
87
+ wd: { ctx, index, attrs },
67
88
  headingSlugs: ctx.comp.headingSlugs
68
89
  });
90
+ if (!attrs.length) return html;
91
+ // A `](…)` that markdown did NOT turn into a link — the commonest being an
92
+ // inline code span documenting the syntax — keeps its placeholder. Put the
93
+ // author's own braces back rather than shipping the marker.
94
+ return html.replace(ATTR_MARK_RE, (mark, n) =>
95
+ attrs[Number(n)] ? escapeHtml(attrs[Number(n)].raw) : mark
96
+ );
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // THE SILENT-FAILURE CLASS: interpolation that markdown never sees.
101
+ //
102
+ // `{ name }` is an INLINE rule, so it only fires where markdown-it is parsing
103
+ // inline content. Three positions are not inline content, and in all three the
104
+ // framework used to emit the author's braces verbatim with no error and no
105
+ // warning:
106
+ //
107
+ // 1. A link/image destination. `[a]({ x })` worked (one special case), but
108
+ // `[a](/p/{ x }/)` did not: `{ … }` contains spaces, a markdown destination
109
+ // may not, so the link never forms and the WHOLE construct degrades to the
110
+ // literal text `[a](/p/value/)`.
111
+ // 2. An attribute inside raw HTML (`<a href="{ x }">`, `html: true`). The tag
112
+ // is one opaque `html_inline` token; nothing inside it is ever parsed.
113
+ // 3. The body of a raw `html_block` (`<div>{ x }</div>`).
114
+ //
115
+ // Every build-time value resolves in all three. A REACTIVE value in a
116
+ // destination now BINDS: the destination pass plants a placeholder, and
117
+ // `attrBindPlugin` turns the assembled href/src into a `data-wd-attr` template
118
+ // the runtime repaints (see there). Raw HTML still cannot bind — a tag is one
119
+ // opaque token with no element to mark — so that position keeps the warning.
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Warn (non-fatal) that a reactive value landed in raw HTML, which has no
124
+ * element for the runtime to mark.
125
+ * @param {Ctx} ctx
126
+ * @param {number} index 0-based line index of the offending line in the slice.
127
+ * @param {string} expr The binding source text, without braces.
128
+ * @param {boolean} painted Whether the initial value was written into the output.
129
+ * @returns {void}
130
+ */
131
+ function warnUnbindable(ctx, index, expr, painted) {
132
+ const outcome = painted
133
+ ? "its build-time value is painted once and then never updates"
134
+ : "it stays literal text";
135
+ ctx.comp.warnings.push(
136
+ `${ctx.file}:${lineOf(ctx, index)}: "{ ${expr} }" is reactive, but raw HTML (an attribute or an html block) cannot bind, so ${outcome}. ` +
137
+ `Move the value into ordinary text or a markdown link/image destination (where { ${expr} } does bind), or update it from a colocated .js with wd.subscribe.`
138
+ );
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // REACTIVE ATTRIBUTE BINDING (a link/image destination).
143
+ //
144
+ // A destination is not inline content, so there is no token for the inline rule
145
+ // to emit and nothing to hang `data-wd-bind` on. The pass below therefore runs
146
+ // in two halves:
147
+ //
148
+ // 1. `resolveDestinationBindings` (pre-parse) substitutes a build-time value
149
+ // directly, exactly as before, and replaces a REACTIVE one with the
150
+ // placeholder `~wd-attr-<n>~`, recording what it stands for. The
151
+ // placeholder is deliberately shaped out of characters markdown-it's URL
152
+ // normalizer leaves untouched, so it survives into the token's href/src.
153
+ // 2. `attrBindPlugin` (core rule) finds the placeholder in the assembled
154
+ // href/src, splits the value into literal chunks + bindings, writes the
155
+ // initial paint, and stamps the template onto the element.
156
+ //
157
+ // The template rides on `data-wd-attr` (state/`:computed`, repainted in the
158
+ // runtime's text-bind pass) or `data-wd-each-attr` (anything per-row, filled by
159
+ // `fillItem`) — the same split `data-wd-bind` / `data-wd-each` already use for
160
+ // text. One template serves every row, so a per-row destination paints EMPTY at
161
+ // build time and gets its first real value on hydrate.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * One reactive binding found in a destination: the serialized template part the
166
+ * runtime evaluates, plus the build-time text to paint for it.
167
+ * @typedef {object} PendingAttr
168
+ * @property {["s", string, string] | ["i", string] | ["m", string]} part
169
+ * `s` = state key + sub-path, `i` = loop-row sub-path, `m` = row meta variable.
170
+ * @property {string} text Build-time paint ("" for anything per-row).
171
+ * @property {string} raw The author's own `{ … }` source, restored when the
172
+ * `](…)` turned out not to be a link after all.
173
+ */
174
+
175
+ /** @param {number} n @returns {string} */
176
+ const attrMark = (n) => `~wd-attr-${n}~`;
177
+ const ATTR_MARK_RE = /~wd-attr-(\d+)~/g;
178
+
179
+ // A value going into a markdown link destination is URL text, not markdown, and
180
+ // the destination has no quoting: an unencoded `)` CLOSES it and hands whatever
181
+ // follows back to the parser (raw HTML on an `html: true` page), and a space
182
+ // silently kills the link. Encode the ASCII characters that carry meaning there
183
+ // by hand, because encodeURIComponent deliberately leaves `(`, `)` and `'`
184
+ // alone. Non-ASCII is left to markdown-it's own normalizeLink.
185
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — a control character in a destination has to be encoded, not passed through.
186
+ const DEST_UNSAFE = /[\u0000-\u0020()<>"'`\\\u007F]/g;
187
+
188
+ /**
189
+ * `unknown` rather than `string`: a caller painting a row's own initial href
190
+ * holds whatever `getPath` returned, and the coercion is right here.
191
+ * @param {unknown} value
192
+ * @returns {string}
193
+ */
194
+ export const encodeDest = (value) =>
195
+ String(value).replace(
196
+ DEST_UNSAFE,
197
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`
198
+ );
199
+
200
+ /**
201
+ * Byte ranges of the inline code spans on one line, so a destination being SHOWN
202
+ * rather than used stays literal. `` `[x](/p/{ p.slug }/)` `` in a docs
203
+ * paragraph documents the syntax, exactly like the fenced-block skip.
204
+ * @param {string} line
205
+ * @returns {[number, number][]}
206
+ */
207
+ function codeSpansOf(line) {
208
+ /** @type {[number, number][]} */
209
+ const spans = [];
210
+ const runs = [...line.matchAll(/`+/g)];
211
+ for (let i = 0; i < runs.length; i++) {
212
+ for (let j = i + 1; j < runs.length; j++) {
213
+ // CommonMark closes a run of N backticks with a run of exactly N.
214
+ if (runs[j][0].length !== runs[i][0].length) continue;
215
+ spans.push([runs[i].index, runs[j].index + runs[j][0].length]);
216
+ i = j;
217
+ break;
218
+ }
219
+ }
220
+ return spans;
69
221
  }
70
222
 
71
223
  /**
72
224
  * Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
73
225
  * destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
74
- * can drive an href/src. A markdown destination cannot contain the spaces inside
75
- * `{ … }`, so markdown-it would otherwise never form the link; substituting the
76
- * static value here lets the normal link/image parser run. Only build-time
77
- * (static-scope) values are substituted — reactive `:state`/reactive-loop
78
- * bindings resolve to `null` and are left untouched (they cannot live in a
79
- * destination). Issue #19.
226
+ * can drive an href/src, and a reactive value can be bound by the core rule.
227
+ *
228
+ * A markdown destination cannot contain the spaces inside `{ … }`, so markdown-it
229
+ * would never form the link; substituting here first lets the normal link/image
230
+ * parser run. Every `{ … }` in the destination is substituted, not just one at
231
+ * the very start — `/products/{ p.slug }/` is the shape a collection listing
232
+ * actually writes.
233
+ *
234
+ * Fenced code is skipped: a docs page showing `[{ p.name }](/p/{ p.slug }/)`
235
+ * inside a ```` ```wd ```` block is DOCUMENTING the syntax, not using it.
80
236
  * @param {string} text
81
237
  * @param {Ctx} ctx
238
+ * @param {PendingAttr[]} attrs Registry the core rule reads placeholders back from.
239
+ * @param {number} index 0-based line index of the slice, for `file:line`.
82
240
  * @returns {string}
83
241
  */
84
- function resolveDestinationBindings(text, ctx) {
85
- return text.replace(
86
- /(\]\(\s*)\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
87
- (whole, prefix, expr) => {
88
- const raw = resolveBindingRaw(expr, ctx);
89
- return raw == null ? whole : prefix + raw;
242
+ function resolveDestinationBindings(text, ctx, attrs, index) {
243
+ const lines = text.split("\n");
244
+ /** @type {string | null} */
245
+ let fence = null;
246
+ for (let i = 0; i < lines.length; i++) {
247
+ const fenceMatch = lines[i].match(/^(```+|~~~+)/);
248
+ if (fence) {
249
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
250
+ fence = null;
251
+ continue;
90
252
  }
91
- );
253
+ if (fenceMatch) {
254
+ fence = fenceMatch[1];
255
+ continue;
256
+ }
257
+ const spans = codeSpansOf(lines[i]);
258
+ lines[i] = lines[i].replace(/\]\(([^)\n]*)\)/g, (whole, dest, offset) => {
259
+ if (spans.some(([a, b]) => offset >= a && offset < b)) return whole;
260
+ const filled = dest.replace(
261
+ /\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
262
+ (/** @type {string} */ brace, /** @type {string} */ expr) => {
263
+ const found = resolveBinding(expr, ctx);
264
+ if (!found) return brace; // not in scope — leave the author's text alone
265
+ if (found.kind === "static") {
266
+ // The scheme test runs on the RAW value, before encoding, because
267
+ // that is the string the runtime's guard sees. Encoding first would
268
+ // turn " javascript:x" into "%20javascript:x" and quietly ship it.
269
+ if (unsafeUrlValue(found.text)) {
270
+ warnUnsafeUrl(ctx, index + i, expr);
271
+ return "";
272
+ }
273
+ return encodeDest(found.text);
274
+ }
275
+ const part = /** @type {PendingAttr["part"]} */ (
276
+ found.kind === "state"
277
+ ? ["s", found.key ?? "", found.path ?? ""]
278
+ : found.meta
279
+ ? ["m", found.meta]
280
+ : ["i", found.path ?? ""]
281
+ );
282
+ attrs.push({ part, text: found.text, raw: brace });
283
+ return attrMark(attrs.length - 1);
284
+ }
285
+ );
286
+ return filled === dest ? whole : `](${filled})`;
287
+ });
288
+ }
289
+ return lines.join("\n");
92
290
  }
93
291
 
94
292
  /**
95
- * Resolve `{ expr }` to its raw static value (a plain string) for use in a
96
- * destination, or `null` when the binding is reactive or not in build-time
97
- * scope. Mirrors the static branch of {@link resolveBindingHtml} but returns the
98
- * unescaped value (markdown-it normalizes the URL).
293
+ * markdown-it plugin (core rule): turn a destination placeholder back into a
294
+ * bound attribute. Reads the `~wd-attr-<n>~` markers out of a `link_open`'s
295
+ * `href` / an `image`'s `src`, writes the build-time paint, and stamps the
296
+ * template the runtime re-evaluates on every render.
297
+ *
298
+ * The build-time paint runs through the same scheme guard the runtime uses, on
299
+ * the same unencoded string: markdown-it's own `validateLink` vetted the
300
+ * placeholder, not the value that replaced it, so an unsafe seed would
301
+ * otherwise ship in the HTML.
302
+ * @param {MarkdownIt} mdInstance
303
+ * @returns {void}
304
+ */
305
+ function attrBindPlugin(mdInstance) {
306
+ mdInstance.core.ruler.push("wd_attr_bind", (state) => {
307
+ /** @type {PendingAttr[] | undefined} */
308
+ const registry = state.env?.wd?.attrs;
309
+ if (!registry || !registry.length) return false;
310
+ for (const block of state.tokens) {
311
+ for (const token of block.children ?? []) {
312
+ const name = token.type === "link_open" ? "href" : token.type === "image" ? "src" : "";
313
+ if (!name) continue;
314
+ const raw = token.attrGet(name);
315
+ if (!raw || !raw.includes("~wd-attr-")) continue;
316
+ /** @type {(string | PendingAttr)[]} */
317
+ const template = [];
318
+ let initial = "";
319
+ let plain = ""; // the same value UNencoded: what the runtime will hold
320
+ let perRow = false;
321
+ let end = 0;
322
+ ATTR_MARK_RE.lastIndex = 0;
323
+ /** @type {RegExpExecArray | null} */
324
+ let hit;
325
+ while ((hit = ATTR_MARK_RE.exec(raw))) {
326
+ const pending = registry[Number(hit[1])];
327
+ if (!pending) continue; // not one of ours — leave the author's text
328
+ if (hit.index > end) {
329
+ template.push(raw.slice(end, hit.index));
330
+ initial += raw.slice(end, hit.index);
331
+ plain += raw.slice(end, hit.index);
332
+ }
333
+ template.push(pending);
334
+ initial += encodeDest(pending.text);
335
+ plain += pending.text;
336
+ if (pending.part[0] !== "s") perRow = true;
337
+ end = hit.index + hit[0].length;
338
+ }
339
+ if (!template.length) continue;
340
+ if (end < raw.length) {
341
+ template.push(raw.slice(end));
342
+ initial += raw.slice(end);
343
+ plain += raw.slice(end);
344
+ }
345
+ // Tested on the UNencoded assembly, exactly as `applyAttr` will test it
346
+ // in the browser: encoding first hides a leading space or control
347
+ // character from a guard that is meant to strip it.
348
+ token.attrSet(name, unsafeUrlValue(plain) ? "" : initial);
349
+ token.attrSet(
350
+ perRow ? "data-wd-each-attr" : "data-wd-attr",
351
+ JSON.stringify([name, ...template.map((t) => (typeof t === "string" ? t : t.part))])
352
+ );
353
+ }
354
+ }
355
+ return false;
356
+ });
357
+ }
358
+
359
+ /**
360
+ * markdown-it plugin (core rule): resolve `{ expr }` inside RAW HTML — the
361
+ * opaque `html_inline` tokens a tag becomes and the `html_block` tokens a
362
+ * block-level element becomes, both of which only exist on an `html: true` page.
363
+ * Values are HTML-escaped, which is correct in both positions a brace can sit
364
+ * in (an attribute value and element text), so no tag-position analysis is
365
+ * needed.
366
+ *
367
+ * `.md` files never reach this: `compileFile` renders them with no
368
+ * `resolveBinding` in the env, and this rule is a no-op without it — the
369
+ * extension stays the feature gate.
370
+ * @param {MarkdownIt} mdInstance
371
+ * @returns {void}
372
+ */
373
+ function rawHtmlPlugin(mdInstance) {
374
+ mdInstance.core.ruler.push("wd_raw_html", (state) => {
375
+ const wd = state.env?.wd;
376
+ if (!wd) return false;
377
+ for (const block of state.tokens) {
378
+ const line = wd.index + (block.map ? block.map[0] : 0);
379
+ if (block.type === "html_block") {
380
+ block.content = fillRawHtml(block.content, wd.ctx, line);
381
+ continue;
382
+ }
383
+ // Inline children carry no line map, so count the line breaks walked past:
384
+ // one softbreak/hardbreak per source newline inside the block. Keeps the
385
+ // warning on the author's actual line, not the top of the paragraph.
386
+ let offset = 0;
387
+ for (const child of block.children ?? []) {
388
+ if (child.type === "softbreak" || child.type === "hardbreak") offset++;
389
+ // `child.meta.wdText` marks a token THIS layer already produced (a
390
+ // resolved binding). Its content is DATA, not template: without the skip,
391
+ // a state value that happens to read `{ meta.title }` would be resolved a
392
+ // second time and leak whatever `title` holds into the page.
393
+ else if (child.type === "html_inline" && child.meta?.wdText === undefined)
394
+ child.content = fillRawHtml(child.content, wd.ctx, line + offset);
395
+ }
396
+ }
397
+ return false;
398
+ });
399
+ }
400
+
401
+ // The value of a URL-bearing attribute, as far as a fragment has been written.
402
+ // The capture is the value SO FAR: `<a href="java` yields "java", so the guard
403
+ // can test what the attribute will actually hold rather than what one brace
404
+ // happened to resolve to. Escaping is no defense here: `href` and friends
405
+ // resolve `javascript:` without any quote breakout, and unlike a markdown
406
+ // destination nothing upstream vets an author's own raw `<a href="…">`.
407
+ const URL_ATTR_TAIL =
408
+ /\b(?:href|src|action|formaction|xlink:href)\s*=\s*(?:"([^"]*)|'([^']*)|([^\s>"']*))$/i;
409
+
410
+ /**
411
+ * Warn that a value in URL position carries a scheme the browser would execute.
412
+ * @param {Ctx} ctx
413
+ * @param {number} index 0-based line index, for `file:line`.
99
414
  * @param {string} expr
415
+ * @returns {void}
416
+ */
417
+ function warnUnsafeUrl(ctx, index, expr) {
418
+ ctx.comp.warnings.push(
419
+ `${ctx.file}:${lineOf(ctx, index)}: "{ ${expr} }" resolves to a javascript:, data: or vbscript: URL, so the attribute is emitted empty. ` +
420
+ `Use an https:, mailto: or site-relative value, or render it as text rather than a link target.`
421
+ );
422
+ }
423
+
424
+ /**
425
+ * Substitute every `{ expr }` in a raw-HTML fragment with its escaped value.
426
+ *
427
+ * The output is assembled by hand rather than through `String.replace` because
428
+ * the URL guard has to see the attribute value BEING BUILT: `<a href="java{ x }">`
429
+ * with x = "script:alert(1)" is a live javascript: URL that no single brace
430
+ * value would ever look unsafe on its own. When the assembled value trips the
431
+ * guard, the literal head of that value is dropped from the output too, so the
432
+ * attribute lands empty instead of half-written.
433
+ * @param {string} html
100
434
  * @param {Ctx} ctx
101
- * @returns {string | null}
435
+ * @param {number} index 0-based line index of the fragment, for warnings.
436
+ * @returns {string}
102
437
  */
103
- function resolveBindingRaw(expr, ctx) {
104
- const segs = expr.split(".");
105
- const found = lookupVar(ctx.scope, segs[0]);
106
- if (!found.found) return null;
107
- const resolved = getPath(found.value, segs.slice(1));
108
- const leaf = interpolateLeaf(resolved, expr, ctx);
109
- return leaf == null ? null : String(leaf);
438
+ function fillRawHtml(html, ctx, index) {
439
+ const brace = /\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g;
440
+ let out = "";
441
+ let last = 0;
442
+ /** @type {RegExpExecArray | null} */
443
+ let hit;
444
+ while ((hit = brace.exec(html))) {
445
+ out += html.slice(last, hit.index);
446
+ last = hit.index + hit[0].length;
447
+ const expr = hit[1];
448
+ const found = resolveBinding(expr, ctx);
449
+ if (!found) {
450
+ out += hit[0];
451
+ continue;
452
+ }
453
+ if (found.kind === "row") {
454
+ warnUnbindable(ctx, index, expr, false);
455
+ out += hit[0];
456
+ continue;
457
+ }
458
+ if (found.kind === "state") warnUnbindable(ctx, index, expr, true);
459
+ const open = URL_ATTR_TAIL.exec(out);
460
+ const head = open ? (open[1] ?? open[2] ?? open[3]) : "";
461
+ if (open && unsafeUrlValue(head + found.text)) {
462
+ warnUnsafeUrl(ctx, index, expr);
463
+ out = out.slice(0, out.length - head.length); // the head belongs to the refused value
464
+ continue;
465
+ }
466
+ out += escapeHtml(found.text);
467
+ }
468
+ return out + html.slice(last);
110
469
  }
111
470
 
112
471
  /**
@@ -126,11 +485,16 @@ function bindingPlugin(mdInstance) {
126
485
  );
127
486
  if (!match) return false;
128
487
  const resolve = state.env?.resolveBinding;
129
- const html = resolve ? resolve(match[1].trim()) : null;
130
- if (typeof html !== "string") return false;
488
+ const found = resolve ? resolve(match[1].trim()) : null;
489
+ if (!found) return false;
131
490
  if (!silent) {
132
491
  const token = state.push("html_inline", "", 0);
133
- token.content = html;
492
+ token.content = found.html;
493
+ // The heading-anchor rule reads this back: an id must be a slug of what the
494
+ // reader SEES, and what the reader sees here is `found.text`, not the
495
+ // markup carrying it. Marking the token also keeps genuine author HTML
496
+ // (`## Hi <b>x</b>`) contributing nothing, exactly as before.
497
+ token.meta = { wdText: found.text };
134
498
  }
135
499
  state.pos += match[0].length;
136
500
  return true;
@@ -223,8 +587,9 @@ export function slugify(text) {
223
587
  * so long pages are linkable without any JS. Duplicate slugs dedupe with
224
588
  * `-1`/`-2` suffixes; the counters live in `env.headingSlugs` so a page whose
225
589
  * prose renders in chunks (directives between headings) still dedupes across
226
- * the whole document. Bindings and inline HTML inside a heading contribute no
227
- * slug text — only literal text and inline code do.
590
+ * the whole document. A `{ name }` binding contributes its resolved text (its
591
+ * initial value when reactive); author-written inline HTML still contributes
592
+ * none, so only text the reader actually reads shapes the id.
228
593
  * @param {MarkdownIt} mdInstance
229
594
  * @returns {void}
230
595
  */
@@ -237,6 +602,12 @@ function anchorPlugin(mdInstance) {
237
602
  let text = "";
238
603
  for (const child of tokens[i + 1].children ?? []) {
239
604
  if (child.type === "text" || child.type === "code_inline") text += child.content;
605
+ // A `{ name }` binding is real heading text — without it every
606
+ // interpolated heading on a site slugged to the same `section`,
607
+ // `section-1`, `section-2`, which is what darkmown.com/blog/ shipped.
608
+ // Reactive bindings contribute their INITIAL value: the id has to be
609
+ // stable and crawlable, so it is minted once at build time.
610
+ else if (child.meta?.wdText) text += child.meta.wdText;
240
611
  }
241
612
  const base = slugify(text);
242
613
  const seen = counts.get(base) ?? 0;
@@ -248,13 +619,32 @@ function anchorPlugin(mdInstance) {
248
619
  }
249
620
 
250
621
  /**
251
- * Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
252
- * a reactive bind span. Returns null when nothing in scope matches.
622
+ * A resolved `{ name.path }` binding.
623
+ * @typedef {object} Binding
624
+ * @property {string} html What the inline rule emits: a `data-wd-each` /
625
+ * `data-wd-bind` span for a reactive value, the escaped value for a static one.
626
+ * @property {string} text The PLAIN resolved text (unescaped, no markup) — a
627
+ * reactive value's initial paint. This is what a link destination, a raw-HTML
628
+ * attribute, and a heading slug need; `html` is unusable in all three.
629
+ * @property {"static" | "state" | "row"} kind Where the value came from, and
630
+ * therefore what the non-inline positions may do with it: `static` is a
631
+ * build-time value and always safe to substitute; `state` is `:state`/`:store`
632
+ * and paints its seed then binds; `row` is a reactive `@loop` row, whose ONE
633
+ * template serves every row, so it paints nothing and binds per row.
634
+ * @property {string} [key] For `state`: the fully-qualified state key.
635
+ * @property {string} [path] For `state`/`row`: the dotted sub-path under it ("" for the whole value).
636
+ * @property {string} [meta] For `row`: the per-row meta variable (`index`, `number`, …).
637
+ */
638
+
639
+ /**
640
+ * Resolve a `{ name.path }` binding: loop item span, static value, or a reactive
641
+ * bind span. Returns null when nothing in scope matches, which every caller
642
+ * treats as "leave the author's braces exactly as written".
253
643
  * @param {string} expr
254
644
  * @param {Ctx} ctx
255
- * @returns {string | null}
645
+ * @returns {Binding | null}
256
646
  */
257
- function resolveBindingHtml(expr, ctx) {
647
+ function resolveBinding(expr, ctx) {
258
648
  const { path, stages } = validatePipes(expr, ctx);
259
649
  const segs = path.split(".");
260
650
  const head = segs[0];
@@ -270,19 +660,32 @@ function resolveBindingHtml(expr, ctx) {
270
660
  `"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`,
271
661
  { code: "WD005", file: ctx.file }
272
662
  );
273
- if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`; // reactive: filled per row
663
+ if (ctx.loopItem)
664
+ // reactive: filled per row, so there is no single build-time text.
665
+ return {
666
+ html: `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`,
667
+ text: "",
668
+ kind: "row",
669
+ meta: LOOP_META[head]
670
+ };
274
671
  // static: the value is in scope (injected by staticUnroll); fall through.
275
672
  }
276
673
 
277
674
  if (ctx.loopItem && head === ctx.loopItem) {
278
675
  const rest = segs.slice(1).join(".");
279
- return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}${fmt}></span>`;
676
+ return {
677
+ html: `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}${fmt}></span>`,
678
+ text: "",
679
+ kind: "row",
680
+ path: rest
681
+ };
280
682
  }
281
683
 
282
684
  const staticValue = lookupVar(ctx.scope, head);
283
685
  if (staticValue.found) {
284
686
  const resolved = getPath(staticValue.value, segs.slice(1));
285
- return escapeHtml(interpolateLeaf(fold(resolved), expr, ctx));
687
+ const text = interpolateLeaf(fold(resolved), expr, ctx);
688
+ return { html: escapeHtml(text), text, kind: "static" };
286
689
  }
287
690
 
288
691
  const key = resolveStateKey(head, ctx);
@@ -292,7 +695,17 @@ function resolveBindingHtml(expr, ctx) {
292
695
  const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
293
696
  const rest = segs.slice(1).join(".");
294
697
  const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
295
- return `<span data-wd-bind="${key}"${pathAttr}${fmt}>${escapeHtml(interpolateLeaf(fold(initial), expr, ctx))}</span>`;
698
+ // interpolateBound, not interpolateLeaf: the runtime repaints this node with
699
+ // textContent, so the initial paint has to use the runtime's own coercion or
700
+ // an array bind silently changes from "a, b" to "a,b" on first render.
701
+ const text = interpolateBound(fold(initial), expr, ctx);
702
+ return {
703
+ html: `<span data-wd-bind="${escapeHtml(key)}"${pathAttr}${fmt}>${escapeHtml(text)}</span>`,
704
+ text,
705
+ kind: "state",
706
+ key,
707
+ path: rest
708
+ };
296
709
  }
297
710
 
298
711
  return null;
@@ -293,8 +293,11 @@ export function enhanceImages(html, paths, reader) {
293
293
  /** @type {string[]} */
294
294
  const add = [];
295
295
  // Dimensions only when the author hasn't sized it — adding one axis to a
296
- // manually-sized image would distort its aspect ratio.
297
- if (!has("width") && !has("height")) {
296
+ // manually-sized image would distort its aspect ratio. A REACTIVE src
297
+ // (`![alt]({ photo })`, marked `data-wd-attr`/`data-wd-each-attr`) is sized
298
+ // from whatever the seed happened to be, so baking width/height would
299
+ // stretch every later image to the first one's aspect ratio.
300
+ if (!has("width") && !has("height") && !/\bdata-wd-(?:each-)?attr\s*=/.test(tag)) {
298
301
  const dim = measureImage(srcOf(tag), paths, reader);
299
302
  if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
300
303
  }
@@ -427,7 +430,9 @@ export function compileFile(
427
430
  const real = comp.reader.realpath(file);
428
431
  if (stack.includes(real)) {
429
432
  throw wdError(
430
- `[WD612] Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`,
433
+ // No literal `[WD612]` here: wdError prefixes the code itself, and the
434
+ // hardcoded copy printed it twice (`[WD612] [WD612] Include cycle …`).
435
+ `Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`,
431
436
  { code: "WD612", file }
432
437
  );
433
438
  }