@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
@@ -1,8 +1,8 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // Structure: the directives that shape a page's tree — `@include` (sub-file
3
3
  // compile + scoped-skin stamp), `::: container` sections, `:if` conditional
4
- // regions, the `:carousel` block — plus the documentation-demo directives
5
- // (`:try`/`:note`/`:sprint`). Nested block bodies recurse through
4
+ // regions, the `:carousel` block — plus the documentation-demo directive
5
+ // `:try`. Nested block bodies recurse through
6
6
  // `ctx.compileBody` (and `@include` through `ctx.compileFile`), threaded in by
7
7
  // the per-file compile so the module graph stays an import-cycle-free DAG.
8
8
  //
@@ -12,7 +12,6 @@
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
14
  import { at, createScope, LOOP_META, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
15
- import { astOf, serializeExpr } from "./expr-ast.js";
16
15
  import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
17
16
  import {
18
17
  escapeHtml,
@@ -22,6 +21,7 @@ import {
22
21
  parseScalar,
23
22
  resolveStateKey
24
23
  } from "./interpolation.js";
24
+ import { astAt, serializeExprAt } from "./loops.js";
25
25
  import { compileWhen, evalPredicate } from "./predicates.js";
26
26
 
27
27
  /**
@@ -31,6 +31,9 @@ import { compileWhen, evalPredicate } from "./predicates.js";
31
31
  // Concrete, compilable structure examples for the hint tails + the catalog.
32
32
  export const INCLUDE_EXAMPLE = "@include /header.wd";
33
33
  export const CONTAINER_EXAMPLE = "::: card .featured";
34
+ // The same header carrying the accessibility attributes the whitelist allows —
35
+ // the WD650/WD651 hint tail, so a rejected attribute shows the accepted shape.
36
+ export const CONTAINER_A11Y_EXAMPLE = '::: card .note role="region" aria-label="Notes"';
34
37
  export const IF_EXAMPLE = ":if count > 0";
35
38
  export const CAROUSEL_EXAMPLE = ":carousel autoplay=3000";
36
39
  const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INCLUDE_EXAMPLE}`;
@@ -43,6 +46,105 @@ const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INC
43
46
  // author-supplied — so no tag-injection surface; classes/ids still escape.
44
47
  const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
45
48
 
49
+ // ---------------------------------------------------------------------------
50
+ // The accessibility attribute whitelist, shared by `::: container` headers and
51
+ // `:button` lines.
52
+ //
53
+ // A disclosure widget, a tab list, a menu, or a semantic table needs `role` and
54
+ // `aria-*` on the elements the compiler emits, and until now neither directive
55
+ // accepted any attribute at all — so those patterns simply could not be built
56
+ // (audit 8.3). This opens exactly three shapes and nothing else:
57
+ //
58
+ // role="…" one ARIA role
59
+ // aria-*="…" any `aria-` attribute
60
+ // title="…" the native advisory title
61
+ //
62
+ // The value is STATIC quoted text; it is HTML-escaped on emit. There is no
63
+ // `{ state }` interpolation in an attribute value in this pass — a reactive
64
+ // `aria-expanded` would need a runtime attribute binding, which does not exist.
65
+ // Everything else (`onclick`, `style`, `href`, `class`, `data-*`, `id=`) is a
66
+ // compile error naming the whitelist, so the directive surface cannot quietly
67
+ // become "arbitrary HTML attributes".
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** The corrective `Use:` tail every attribute error carries. */
71
+ export const A11Y_ATTR_USE = 'role="…", aria-…="…", or title="…" (quoted static text)';
72
+
73
+ // `aria-` plus at least one lowercase letter; `role`/`title` exactly. Names are
74
+ // matched against this whitelist, so an emitted name can never carry markup.
75
+ const A11Y_ATTR_NAME = /^(?:role|title|aria-[a-z][a-z0-9-]*)$/;
76
+
77
+ // One `name="value"` pair at the cursor. The value cannot contain `"`, so the
78
+ // pair always ends where it looks like it ends.
79
+ const ATTR_PAIR = /^([A-Za-z][A-Za-z0-9-]*)="([^"]*)"/;
80
+ // Anything that OPENS like an attribute, so a rejected one gets the whitelist
81
+ // error rather than the generic "unexpected token".
82
+ const ATTR_START = /^([A-Za-z][A-Za-z0-9-]*)\s*=/;
83
+
84
+ /**
85
+ * Peel the leading run of whitelisted accessibility attributes off `rest`.
86
+ *
87
+ * Stops at the first token that does not open like an attribute (a `.class`, a
88
+ * `#id`, a `->`, or the end of the line) and hands that remainder back. A token
89
+ * that DOES open like an attribute but is misspelt, unquoted, or outside the
90
+ * whitelist is a compile error — never silently skipped.
91
+ * @param {string} rest Everything from the cursor to the end of the line.
92
+ * @param {Ctx} ctx
93
+ * @param {number} index 0-based line index for `file:line` errors.
94
+ * @param {string} what Directive label for the message, e.g. `:button`.
95
+ * @returns {{ attrs: [string, string][], rest: string }}
96
+ */
97
+ export function takeA11yAttrs(rest, ctx, index, what) {
98
+ /** @type {[string, string][]} */
99
+ const attrs = [];
100
+ let s = rest.trim();
101
+ for (let start = s.match(ATTR_START); start; start = s.match(ATTR_START)) {
102
+ // The NAME is checked before the quoting, so `onclick=x` reports the
103
+ // whitelist (the attribute is the problem) rather than sending the author
104
+ // off to add quotes to something that will still be rejected.
105
+ if (!A11Y_ATTR_NAME.test(start[1])) {
106
+ throw wdError(
107
+ `Attribute "${start[1]}" is not allowed in ${what} at ${at(ctx, index)}. ` +
108
+ `Only accessibility attributes are: ${A11Y_ATTR_USE}`,
109
+ {
110
+ code: "WD650",
111
+ file: ctx.file,
112
+ line: lineOf(ctx, index),
113
+ hint: A11Y_ATTR_USE,
114
+ example: CONTAINER_A11Y_EXAMPLE
115
+ }
116
+ );
117
+ }
118
+ const pair = s.match(ATTR_PAIR);
119
+ if (!pair) {
120
+ throw wdError(
121
+ `Attribute "${start[1]}" in ${what} at ${at(ctx, index)} needs a double-quoted value. Use: ${A11Y_ATTR_USE}`,
122
+ {
123
+ code: "WD651",
124
+ file: ctx.file,
125
+ line: lineOf(ctx, index),
126
+ hint: A11Y_ATTR_USE,
127
+ example: CONTAINER_A11Y_EXAMPLE
128
+ }
129
+ );
130
+ }
131
+ attrs.push([pair[1], pair[2]]);
132
+ s = s.slice(pair[0].length).trim();
133
+ }
134
+ return { attrs, rest: s };
135
+ }
136
+
137
+ /**
138
+ * Serialize validated attribute pairs into an HTML attribute string. The NAME
139
+ * came through {@link A11Y_ATTR_NAME} so it is inert; the VALUE is author text
140
+ * and is escaped.
141
+ * @param {[string, string][]} attrs
142
+ * @returns {string}
143
+ */
144
+ export function a11yAttrHtml(attrs) {
145
+ return attrs.map(([name, value]) => ` ${name}="${escapeHtml(value)}"`).join("");
146
+ }
147
+
46
148
  /**
47
149
  * @param {string} line
48
150
  * @param {Ctx} ctx
@@ -138,13 +240,20 @@ export function handleContainer(header, bodyLines, ctx, index) {
138
240
  const eachClasses = [];
139
241
  /** @type {[string, any[]][]} Global state-driven class bindings (data-wd-class): [class, exprAST]. */
140
242
  const stateClasses = [];
243
+ /** @type {[string, string][]} Whitelisted accessibility attributes: [name, value]. */
244
+ const a11y = [];
141
245
  let id = "";
142
246
  // Leading tag/name token (anything not starting with . or #). "section" keeps
143
247
  // the <section> tag; a whitelisted semantic landmark (`nav`/`main`) emits that
144
248
  // real element AND keeps the name as a class hook (so `.nav`/`.main` skins
145
249
  // still cascade); any other name becomes a <div> and also a class. The tag is
146
250
  // always one of these fixed constants — never author text — so it can't inject.
147
- const lead = rest.match(/^([^\s.#]\S*)/);
251
+ // …unless the header OPENS with an attribute (`::: role="region" .card`), which
252
+ // `^([^\s.#]\S*)` would otherwise swallow whole and emit as a class:
253
+ // `class="role=&quot;region&quot; card"`. An attribute in first position leaves
254
+ // the tag at its default and falls through to the token loop below, so both
255
+ // orders work — `::: nav role="navigation"` still gets the <nav> element.
256
+ const lead = ATTR_START.test(rest) ? null : rest.match(/^([^\s.#]\S*)/);
148
257
  let nameToken = "section";
149
258
  if (lead) {
150
259
  nameToken = lead[1];
@@ -166,6 +275,16 @@ export function handleContainer(header, bodyLines, ctx, index) {
166
275
  rest = rest.slice((m ? m[0] : rest).length).trim();
167
276
  continue;
168
277
  }
278
+ // `role="…"` / `aria-…="…"` / `title="…"`, in any position among the class
279
+ // and id tokens. A token that opens like an attribute but is not on the
280
+ // whitelist throws here rather than reaching the generic unexpected-token
281
+ // error, so the message names what IS allowed.
282
+ const taken = takeA11yAttrs(rest, ctx, index, `container "::: ${header.trim()}"`);
283
+ if (taken.attrs.length) {
284
+ a11y.push(...taken.attrs);
285
+ rest = taken.rest;
286
+ continue;
287
+ }
169
288
  const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
170
289
  if (!cm)
171
290
  throw wdError(
@@ -176,14 +295,18 @@ export function handleContainer(header, bodyLines, ctx, index) {
176
295
  rest = rest.slice(cm[0].length).trim();
177
296
  // Optional `when <predicate>` makes the class reactive. The predicate runs to
178
297
  // the next ` .`/` #` token or the end of the header.
179
- const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
298
+ // The predicate runs to the next ` .`/` #` token, the next ATTRIBUTE token,
299
+ // or the end of the header — without the attribute stop, `.live when a == b
300
+ // role="status"` swallowed the attribute into the predicate.
301
+ const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|\s+[A-Za-z][A-Za-z0-9-]*\s*=|$)/);
180
302
  if (whenMatch) {
181
303
  rest = rest.slice(whenMatch[0].length).trim();
182
304
  const compiled = compileWhen(whenMatch[1].trim(), ctx);
183
305
  if (compiled.static) {
184
306
  if (compiled.value) extraClass.push(cls);
185
- } else if (compiled.item) eachClasses.push([cls, astOf(compiled.body)]);
186
- else stateClasses.push([cls, astOf(compiled.body)]);
307
+ } else if (compiled.item)
308
+ eachClasses.push([cls, astAt(compiled.body, ctx, index, '"::: … when"')]);
309
+ else stateClasses.push([cls, astAt(compiled.body, ctx, index, '"::: … when"')]);
187
310
  } else {
188
311
  extraClass.push(cls);
189
312
  }
@@ -200,6 +323,7 @@ export function handleContainer(header, bodyLines, ctx, index) {
200
323
  }
201
324
  const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
202
325
  const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
326
+ const a11yAttr = a11yAttrHtml(a11y);
203
327
  let classBind = "";
204
328
  if (eachClasses.length) {
205
329
  ctx.comp.assets.runtime = true;
@@ -209,7 +333,7 @@ export function handleContainer(header, bodyLines, ctx, index) {
209
333
  ctx.comp.assets.runtime = true;
210
334
  classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`;
211
335
  }
212
- return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
336
+ return `<${tag}${idAttr}${classAttr}${a11yAttr}${classBind}>\n${inner}\n</${tag}>`;
213
337
  }
214
338
 
215
339
  /**
@@ -288,7 +412,10 @@ function fetchLiveAttr(key, sourceLines, ctx) {
288
412
  export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart = index + 1) {
289
413
  const truthyCtx = nestedCtx(ctx, index + 1);
290
414
  const falsyCtx = nestedCtx(ctx, falsyStart);
291
- const condition = line.replace(/^:if\s+/, "").trim();
415
+ // `\s*` (not `\s+`) so a BARE `:if` yields an empty condition and lands on the
416
+ // malformed-`:if` error below, instead of falling through with the literal
417
+ // text `:if` as its condition and reporting a baffling unsupported operand.
418
+ const condition = line.replace(/^:if\s*/, "").trim();
292
419
  recordSymbol(ctx, index, { kind: "if", name: condition, detail: `:if ${condition}` });
293
420
  // Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
294
421
  // existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
@@ -313,12 +440,12 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
313
440
  // static: the meta boolean is in scope — fall through to the static branch below.
314
441
  }
315
442
 
316
- const staticValue = lookupVar(ctx.scope, head);
317
- if (staticValue.found) {
318
- const active = Boolean(getPath(staticValue.value, segs.slice(1)));
319
- return ctx.compileBody(active ? truthyLines : falsyLines, active ? truthyCtx : falsyCtx);
320
- }
321
-
443
+ // RESOLUTION ORDER: reactive loop item → static scope → declared state, the
444
+ // framework-wide order `{ }` interpolation and `@loop … where` already use.
445
+ // Static scope used to be tried FIRST here, so in a nested static-then-
446
+ // reactive loop that reuses one item name, `{ it.name }` bound to the
447
+ // reactive row while `:if it.done` folded against the OUTER static value and
448
+ // hard-baked a branch with no `data-wd-each-if` — unfixable at runtime.
322
449
  if (ctx.loopItem && head === ctx.loopItem) {
323
450
  const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
324
451
  const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
@@ -327,6 +454,12 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
327
454
  return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
328
455
  }
329
456
 
457
+ const staticValue = lookupVar(ctx.scope, head);
458
+ if (staticValue.found) {
459
+ const active = Boolean(getPath(staticValue.value, segs.slice(1)));
460
+ return ctx.compileBody(active ? truthyLines : falsyLines, active ? truthyCtx : falsyCtx);
461
+ }
462
+
330
463
  const key = resolveStateKey(head, ctx);
331
464
  if (!key) {
332
465
  throw wdError(
@@ -342,7 +475,11 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
342
475
  const liveAttr = restPath ? "" : fetchLiveAttr(key, [...truthyLines, ...falsyLines], ctx);
343
476
  const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
344
477
  const active = initialTruthy ? truthy : falsy;
345
- return `<div data-wd-if="${key}"${pathAttr}${liveAttr} 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>`;
478
+ // The key carries the `::: name #id` section prefix, which is author text.
479
+ // It is escaped exactly like the `id="…"` attribute beside it — an unescaped
480
+ // `&`/`"` in a section id closed the attribute early and produced broken
481
+ // markup with a dead binding.
482
+ return `<div data-wd-if="${escapeHtml(key)}"${pathAttr}${liveAttr} 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>`;
346
483
  }
347
484
 
348
485
  // Predicate path: a comparison / logical condition. Compiles through the same
@@ -368,17 +505,20 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
368
505
  ctx.comp.assets.runtime = true;
369
506
  const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
370
507
  const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
371
- const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExpr(compiled.body))}"`;
508
+ const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExprAt(compiled.body, ctx, index, '":if"'))}"`;
372
509
  if (compiled.item) {
373
510
  return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
374
511
  }
375
- const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
512
+ const initialTruthy = evalPredicate(compiled.body, undefined, ctx, '":if"');
376
513
  const active = initialTruthy ? truthy : falsy;
377
514
  return `<div data-wd-if=""${exprAttr} 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>`;
378
515
  }
379
516
 
380
517
  /**
381
- * Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
518
+ * Render the documentation-demo directive `:try` (the "Try it" card the homepage
519
+ * uses). `:note` and `:sprint` lived here too and were deleted: nothing in the
520
+ * site, the docs, or the public directive set used them, and a directive the
521
+ * catalog does not list is a trap for an AI author that discovers it in source.
382
522
  * @param {string} line
383
523
  * @param {Ctx} ctx
384
524
  * @returns {string}
@@ -389,16 +529,6 @@ export function renderDemoDirective(line, ctx) {
389
529
  const href = validateDemoHref(tryMatch[2], ctx);
390
530
  return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
391
531
  }
392
- const note = line.match(/^:note\s+"([^"]+)"$/);
393
- if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
394
- const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
395
- if (sprint) {
396
- const roles = sprint[3]
397
- .split(",")
398
- .map((role) => role.trim())
399
- .filter(Boolean);
400
- return `<section class="sprint-board" data-min="${sprint[1]}" data-max="${sprint[2]}">${roles.map((role) => `<article><strong>${escapeHtml(role)}</strong><span>active lane</span></article>`).join("")}</section>`;
401
- }
402
532
  return "";
403
533
  }
404
534
 
package/src/errors.js CHANGED
@@ -62,9 +62,16 @@ import {
62
62
  } from "./compiler/forms.js";
63
63
  import { LOOP_EXAMPLE } from "./compiler/loops.js";
64
64
  import { EMBED_EXAMPLE, VIDEO_EXAMPLE } from "./compiler/media.js";
65
- import { COMPUTED_EXAMPLE, STATE_EXAMPLE, STORE_EXAMPLE, THEME_EXAMPLE } from "./compiler/state.js";
65
+ import {
66
+ COMPUTED_EXAMPLE,
67
+ STATE_EXAMPLE,
68
+ STORE_EXAMPLE,
69
+ THEME_EXAMPLE,
70
+ URL_STATE_EXAMPLE
71
+ } from "./compiler/state.js";
66
72
  import {
67
73
  CAROUSEL_EXAMPLE,
74
+ CONTAINER_A11Y_EXAMPLE,
68
75
  CONTAINER_EXAMPLE,
69
76
  IF_EXAMPLE,
70
77
  INCLUDE_EXAMPLE
@@ -362,6 +369,20 @@ const ENTRIES = [
362
369
  cause: "An entry's value does not match its schema type.",
363
370
  fix: "Fix the entry's value, or widen the type in `_schema.wd`."
364
371
  },
372
+ {
373
+ code: "WD190",
374
+ title: "Expression could not be compiled",
375
+ cause:
376
+ "A `:if` or `::: … when` condition folded a build-time value the expression re-parser cannot read back.",
377
+ fix: 'Use simpler operands — a field path, a declared `:state`, a plain number, or a `"string"`.'
378
+ },
379
+ {
380
+ code: "WD191",
381
+ title: "Reactive `@loop` over markdown table rows",
382
+ cause:
383
+ "A `@loop` whose body is bare `| … |` cells resolves to a reactive source, and a reactive row is cloned into a `<div>`, which cannot live inside a `<table>`.",
384
+ fix: "Loop a static source (a JSON file, a frontmatter list, or a collection) for a markdown table — and if the source is already static, drop the `:state` the `where`/`limit`/`sort by` clause reads — or build reactive rows from containers (`::: trow` / `::: td`) instead of `|` cells."
385
+ },
365
386
 
366
387
  // --- WD2xx: state and expressions ----------------------------------------
367
388
  {
@@ -435,8 +456,9 @@ const ENTRIES = [
435
456
  {
436
457
  code: "WD211",
437
458
  title: "Persistence token on `:computed`",
438
- cause: "The expression ends in a bare `persist` or `ephemeral`, which is swallowed into it.",
439
- fix: "Computed values are derived, not stored. Persist the state they derive from instead.",
459
+ cause:
460
+ "The expression ends in a bare `persist`, `ephemeral`, or `from-url`, which is swallowed into it.",
461
+ fix: "Computed values are derived, not stored. Put the token on the state they derive from instead.",
440
462
  example: COMPUTED_EXAMPLE
441
463
  },
442
464
  {
@@ -534,6 +556,37 @@ const ENTRIES = [
534
556
  fix: "Declare it with `:state`, `:store`, or `:fetch` first.",
535
557
  example: COMPUTED_EXAMPLE
536
558
  },
559
+ {
560
+ code: "WD250",
561
+ title: "Reserved state declaration name",
562
+ cause:
563
+ "A `:state`/`:store`/`:computed`/`:fetch`/`:form`/`:slider`/`:theme` name is `__proto__`, `constructor`, or `prototype`, which the runtime's state object inherits rather than owns.",
564
+ fix: "Rename the key — any other name works.",
565
+ example: STATE_EXAMPLE
566
+ },
567
+ {
568
+ code: "WD251",
569
+ title: "State seeded from another state",
570
+ cause:
571
+ "A `:state`/`:store` value is a bare name that is already declared state, so it would be stored as that literal text and never track the value.",
572
+ fix: "Derive it with `:computed`, or quote the text to keep it literal.",
573
+ example: COMPUTED_EXAMPLE
574
+ },
575
+ {
576
+ code: "WD260",
577
+ title: "`from-url` on a keyword that cannot take it",
578
+ cause:
579
+ "`:store` and `:theme` are shared by every page and every tab, while a query parameter belongs to one page's address, so the two cannot mean the same thing.",
580
+ fix: "Declare the value with `:state … from-url`, or drop the token.",
581
+ example: URL_STATE_EXAMPLE
582
+ },
583
+ {
584
+ code: "WD261",
585
+ title: "Conflicting persistence modifiers",
586
+ cause: "A declaration carries `persist` and `ephemeral` at once, or repeats the same modifier.",
587
+ fix: "Pick one: `persist` keeps the value across reloads, `ephemeral` drops it.",
588
+ example: STATE_EXAMPLE
589
+ },
537
590
 
538
591
  // --- WD3xx: actions ------------------------------------------------------
539
592
  {
@@ -627,6 +680,14 @@ const ENTRIES = [
627
680
  cause: "The action value is not a quoted string, number, boolean, null, or valid JSON.",
628
681
  fix: "Quote strings and use valid JSON for arrays and objects."
629
682
  },
683
+ {
684
+ code: "WD315",
685
+ title: "`:every`/`:effect` inside a reactive `@loop`",
686
+ cause:
687
+ "A reactive loop clones its body per row, so the timer or watcher would be registered once per row and a removed row's would keep firing.",
688
+ fix: "Declare it once outside the loop — at page level or inside the `:::` section — and act on the whole list.",
689
+ example: EVERY_EXAMPLE
690
+ },
630
691
 
631
692
  // --- WD4xx: forms and fields ---------------------------------------------
632
693
  {
@@ -819,6 +880,32 @@ const ENTRIES = [
819
880
  example: RADIO_EXAMPLE
820
881
  },
821
882
 
883
+ {
884
+ code: "WD450",
885
+ title: "Bound field with no state",
886
+ cause:
887
+ "A `:select`/`:radio`/`:checkbox` outside a `:form` names state that is not declared, so there is nothing for it to bind to.",
888
+ fix: "Declare the state first, or move the field inside a `:form`, where the name is a form field instead.",
889
+ example: SELECT_EXAMPLE
890
+ },
891
+ {
892
+ code: "WD451",
893
+ title: "Bound `:checkbox` with several options",
894
+ cause:
895
+ "A `:checkbox` bound to state carries one true/false, so a list of options has no meaning.",
896
+ fix: "Give it one `- Label` line, or use a `:radio` group for a set of choices.",
897
+ example: CHECKBOX_EXAMPLE
898
+ },
899
+
900
+ {
901
+ code: "WD452",
902
+ title: "File field in a GET form",
903
+ cause:
904
+ 'A `:form` contains an `<input type=file>` and declares `method="get"`. A GET request has no body, so only the file\'s name would travel.',
905
+ fix: 'Drop `method="get"` — a `:form` posts by default, and a form with a file field is submitted as multipart.',
906
+ example: FORM_EXAMPLE
907
+ },
908
+
822
909
  // --- WD5xx: data fetching and URL safety ---------------------------------
823
910
  {
824
911
  code: "WD501",
@@ -963,6 +1050,21 @@ const ENTRIES = [
963
1050
  "An `@include` chain reaches a file that is already being compiled, so the compile would never terminate.",
964
1051
  fix: "Break the loop: remove the `@include` that points back, or move the shared content into a third file both sides include."
965
1052
  },
1053
+ {
1054
+ code: "WD650",
1055
+ title: "Attribute is not on the accessibility whitelist",
1056
+ cause:
1057
+ "A `::: ` container header or a `:button` carries an attribute outside the accessibility whitelist (`onclick=`, `style=`, `href=`, `class=`, `data-…=`).",
1058
+ fix: 'Keep only `role="…"`, `aria-…="…"`, and `title="…"`; style with `.class` tokens and act with `->` actions.',
1059
+ example: CONTAINER_A11Y_EXAMPLE
1060
+ },
1061
+ {
1062
+ code: "WD651",
1063
+ title: "Accessibility attribute has no quoted value",
1064
+ cause: "An attribute in a container header or a `:button` is missing its double-quoted value.",
1065
+ fix: 'Write the value in double quotes, e.g. `role="region"`.',
1066
+ example: CONTAINER_A11Y_EXAMPLE
1067
+ },
966
1068
 
967
1069
  // --- WD7xx: media and embeds ---------------------------------------------
968
1070
  {
@@ -1063,6 +1165,12 @@ const ENTRIES = [
1063
1165
  title: "Unknown `ai_crawlers` policy",
1064
1166
  cause: "The home page's `ai_crawlers:` is neither `allow` nor `deny`.",
1065
1167
  fix: "Write `ai_crawlers: allow` or `ai_crawlers: deny` (absent means allow)."
1168
+ },
1169
+ {
1170
+ code: "WD950",
1171
+ title: "Invalid `rss_limit`",
1172
+ cause: "The home page's `rss_limit:` is not a positive whole number of items.",
1173
+ fix: "Write `rss_limit: 20` (absent means 20)."
1066
1174
  }
1067
1175
  ];
1068
1176
 
package/src/feeds.js CHANGED
@@ -190,10 +190,43 @@ export function rssDescription(meta, file, body) {
190
190
  return "";
191
191
  }
192
192
 
193
+ /**
194
+ * The `<lastmod>` form the sitemap protocol accepts: W3C Datetime, of which
195
+ * `yyyy-mm-dd` is the shortest legal spelling. Anchored at the start so a full
196
+ * ISO timestamp (`2026-01-05T10:00:00Z`) matches on its date half.
197
+ */
198
+ const W3C_DATE = /^\d{4}-\d{2}-\d{2}$/;
199
+
200
+ /**
201
+ * Validate a frontmatter `date:` into a sitemap `<lastmod>` value, or "" when it
202
+ * is not one.
203
+ *
204
+ * This is the only feed value that used to be neither validated nor escaped, and
205
+ * both halves mattered: `date: 2026<bad&"q"` made the whole document
206
+ * unparseable, so NOTHING got indexed, and the far likelier `date: Jan 5, 2026`
207
+ * was silently truncated to `Jan 5, 202` by a blind 10-character slice. Returning
208
+ * "" (rather than guessing) lets the caller omit the element and say so — a
209
+ * sitemap entry with no `<lastmod>` is valid; one with a bogus date is not.
210
+ * @param {string} raw Trimmed frontmatter `date:` value.
211
+ * @returns {string} `yyyy-mm-dd`, or "" when `raw` is not a real calendar date.
212
+ */
213
+ export function sitemapDate(raw) {
214
+ const day = String(raw ?? "")
215
+ .trim()
216
+ .slice(0, 10);
217
+ if (!W3C_DATE.test(day)) return "";
218
+ // The shape is right; is it a real day? `new Date("2026-02-30")` does NOT
219
+ // throw — V8 rolls it forward to March 2 — so the only honest check is a round
220
+ // trip: a genuine calendar date formats back to the string it came from.
221
+ const parsed = new Date(`${day}T00:00:00Z`);
222
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === day ? day : "";
223
+ }
224
+
193
225
  /**
194
226
  * Build `sitemap.xml` from the emitted (post-draft-filter) routes. One `<url>`
195
227
  * per route — reactive pages included (they're indexable HTML) — with `<loc>` =
196
- * `site_url` + path and `<lastmod>` from the supplied date. The `/404/` route is
228
+ * `site_url` + path and `<lastmod>` from the supplied date. An empty `lastmod`
229
+ * omits the element rather than emitting a blank one. The `/404/` route is
197
230
  * excluded by the caller before this point. No `<priority>`/`<changefreq>` —
198
231
  * Google ignores them and they invite bit-rot.
199
232
  * @param {DatedRoute[]} entries Absolute loc + ISO lastmod, in route order.
@@ -201,11 +234,15 @@ export function rssDescription(meta, file, body) {
201
234
  */
202
235
  export function buildSitemap(entries) {
203
236
  const urls = entries
204
- .map(
205
- (entry) =>
206
- ` <url>\n <loc>${escapeXml(entry.loc)}</loc>\n` +
207
- ` <lastmod>${entry.lastmod}</lastmod>\n </url>`
208
- )
237
+ .map((entry) => {
238
+ const lines = [" <url>", ` <loc>${escapeXml(entry.loc)}</loc>`];
239
+ // Escaped even though `sitemapDate` already guarantees a safe value:
240
+ // this builder is exported and called directly, so the guarantee has to
241
+ // live where the string is written, not only in one caller.
242
+ if (entry.lastmod) lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
243
+ lines.push(" </url>");
244
+ return lines.join("\n");
245
+ })
209
246
  .join("\n");
210
247
  return (
211
248
  '<?xml version="1.0" encoding="UTF-8"?>\n' +
@@ -214,6 +251,67 @@ export function buildSitemap(entries) {
214
251
  );
215
252
  }
216
253
 
254
+ /**
255
+ * The sitemap protocol's hard cap: 50,000 `<url>` entries per sitemap file. Over
256
+ * it, Google rejects the document outright — so a large site must split into
257
+ * numbered sitemaps behind a `<sitemapindex>`.
258
+ */
259
+ export const SITEMAP_URL_LIMIT = 50000;
260
+
261
+ /**
262
+ * Build a `<sitemapindex>` — the document that points at the numbered sitemap
263
+ * shards. No `<lastmod>` per shard: it would have to be the max over the shard's
264
+ * entries, which is a number nobody consumes and one more thing to keep true.
265
+ * @param {string[]} locs Absolute URLs of the shard files, in order.
266
+ * @returns {string} The full XML document (trailing newline).
267
+ */
268
+ export function buildSitemapIndex(locs) {
269
+ const entries = locs
270
+ .map((loc) => ` <sitemap>\n <loc>${escapeXml(loc)}</loc>\n </sitemap>`)
271
+ .join("\n");
272
+ return (
273
+ '<?xml version="1.0" encoding="UTF-8"?>\n' +
274
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
275
+ `${entries}\n</sitemapindex>\n`
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Plan the sitemap file(s) a set of entries needs.
281
+ *
282
+ * At or under {@link SITEMAP_URL_LIMIT} that is one `sitemap.xml`, byte-identical
283
+ * to what every site got before. Over it, the entries shard into
284
+ * `sitemap-1.xml`, `sitemap-2.xml`, … and `sitemap.xml` becomes the
285
+ * `<sitemapindex>` pointing at them — so `robots.txt` keeps naming
286
+ * `/sitemap.xml` and every existing submission stays valid.
287
+ *
288
+ * Returned rather than written so the split is a pure function the tests can
289
+ * drive with 50,001 synthetic entries without building 50,001 pages.
290
+ * @param {DatedRoute[]} entries
291
+ * @param {string} siteUrl Absolute origin, for the index's shard URLs.
292
+ * @returns {{ file: string, xml: string }[]} Shards first, `sitemap.xml` last.
293
+ */
294
+ export function sitemapDocuments(entries, siteUrl) {
295
+ if (entries.length <= SITEMAP_URL_LIMIT) {
296
+ return [{ file: "sitemap.xml", xml: buildSitemap(entries) }];
297
+ }
298
+ /** @type {{ file: string, xml: string }[]} */
299
+ const shards = [];
300
+ for (let i = 0; i < entries.length; i += SITEMAP_URL_LIMIT) {
301
+ shards.push({
302
+ file: `sitemap-${shards.length + 1}.xml`,
303
+ xml: buildSitemap(entries.slice(i, i + SITEMAP_URL_LIMIT))
304
+ });
305
+ }
306
+ return [
307
+ ...shards,
308
+ {
309
+ file: "sitemap.xml",
310
+ xml: buildSitemapIndex(shards.map((shard) => absoluteUrl(siteUrl, `/${shard.file}`)))
311
+ }
312
+ ];
313
+ }
314
+
217
315
  /**
218
316
  * An RSS item — a dated post in feed order.
219
317
  * @typedef {object} RssItem
@@ -392,5 +490,40 @@ export function aiCrawlerPolicy(meta, file) {
392
490
  );
393
491
  }
394
492
 
395
- /** Most-recent-first RSS post cap — a feed is a recent window, not an archive. */
493
+ /**
494
+ * The DEFAULT most-recent-first RSS post cap — a feed is a recent window, not an
495
+ * archive. Overridable per site with `rss_limit:` on the home page
496
+ * ({@link rssItemLimit}).
497
+ */
396
498
  export const RSS_ITEM_LIMIT = 20;
499
+
500
+ /**
501
+ * Resolve the site's RSS item cap from the HOME page's `rss_limit:` frontmatter.
502
+ *
503
+ * Darkmown has no config file by design: site-wide settings live in the home
504
+ * page's frontmatter next to `site_url` / `ai_crawlers`, and this is one of them.
505
+ * Absent means {@link RSS_ITEM_LIMIT}. A value that is not a positive integer
506
+ * THROWS rather than silently falling back, for the same reason `ai_crawlers`
507
+ * does: `rss_limit: 0` and `rss_limit: all` are both attempts to state a policy,
508
+ * and quietly publishing 20 items instead is the wrong failure direction.
509
+ * @param {import("./compiler.js").Meta} meta Home page frontmatter.
510
+ * @param {string} [file] Home page source path, for the error message.
511
+ * @returns {number} A positive integer.
512
+ */
513
+ export function rssItemLimit(meta, file) {
514
+ const raw = meta.rss_limit;
515
+ if (raw === undefined) return RSS_ITEM_LIMIT;
516
+ const text = String(raw).trim();
517
+ // `Number("")` is 0 and `Number("12px")` is NaN — both fail the guard below,
518
+ // so an empty or non-numeric value lands on the same actionable error.
519
+ // Digits only: `Number()` would also accept "1e3", "0x10" and "7.0", none
520
+ // of which is the "positive whole number" the error promises.
521
+ const value = /^\d+$/.test(text) ? Number(text) : Number.NaN;
522
+ if (Number.isInteger(value) && value > 0) return value;
523
+ const hint = "rss_limit: 20";
524
+ throw wdError(
525
+ `Invalid rss_limit "${text}"${file ? ` in ${file}` : ""}. ` +
526
+ `Use a positive whole number of items: ${hint}.`,
527
+ { code: "WD950", file, hint }
528
+ );
529
+ }