@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
package/src/catalog.js CHANGED
@@ -20,6 +20,7 @@
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
22
  import fs from "node:fs";
23
+ import { gzipSync } from "node:zlib";
23
24
  import { BUTTON_EXAMPLE, EFFECT_EXAMPLE, EVERY_EXAMPLE } from "./compiler/actions.js";
24
25
  import { LOOP_META } from "./compiler/context.js";
25
26
  import { FETCH_EXAMPLE } from "./compiler/fetch.js";
@@ -115,8 +116,10 @@ const DIRECTIVES = [
115
116
  {
116
117
  name: ":::",
117
118
  kind: "block",
118
- syntax: "::: [tag] [.class …] [#id] [.class when <pred>] … :::",
119
- description: "Group content in a section/div/nav/main with classes, ids, and reactive classes.",
119
+ syntax:
120
+ '::: [tag] [.class …] [#id] [.class when <pred>] [role="…"] [aria-…="…"] [title="…"] … :::',
121
+ description:
122
+ "Group content in a section/div/nav/main with classes, ids, reactive classes, and role/aria-*/title.",
120
123
  example: CONTAINER_EXAMPLE,
121
124
  reactive: "either"
122
125
  },
@@ -131,9 +134,9 @@ const DIRECTIVES = [
131
134
  {
132
135
  name: ":state",
133
136
  kind: "line",
134
- syntax: ":state name = value [persist|ephemeral]",
137
+ syntax: ":state name = value [persist|ephemeral] [from-url]",
135
138
  description:
136
- "Declare page-scoped reactive state. Ephemeral by default; persist keeps it across reloads.",
139
+ "Declare page-scoped reactive state. Ephemeral by default; persist keeps it across reloads, from-url mirrors it into the query string.",
137
140
  example: STATE_EXAMPLE,
138
141
  reactive: "reactive"
139
142
  },
@@ -158,7 +161,8 @@ const DIRECTIVES = [
158
161
  kind: "line",
159
162
  syntax:
160
163
  ':fetch name from "url" [method=…] [when=visible] [timeout=ms] [retry=N] [headers=key] [body=key]',
161
- description: "Load remote JSON with auto loading/error/empty lifecycle state.",
164
+ description:
165
+ "Load remote JSON, auto-declaring name/_loading/_error/_error_body/_empty lifecycle state.",
162
166
  example: FETCH_EXAMPLE,
163
167
  reactive: "reactive"
164
168
  },
@@ -166,7 +170,8 @@ const DIRECTIVES = [
166
170
  name: ":effect",
167
171
  kind: "line",
168
172
  syntax: ":effect watched -> action[; action…]",
169
- description: "Run button-vocabulary actions whenever the watched state changes.",
173
+ description:
174
+ "Run button-vocabulary actions whenever the watched state changes. Page level only, never inside a reactive @loop body.",
170
175
  example: EFFECT_EXAMPLE,
171
176
  reactive: "reactive"
172
177
  },
@@ -174,7 +179,8 @@ const DIRECTIVES = [
174
179
  name: ":every",
175
180
  kind: "line",
176
181
  syntax: ":every <duration> -> action[; action…]",
177
- description: "Run actions on a timer (5s/500ms/2m); auto-pauses while the tab is hidden.",
182
+ description:
183
+ "Run actions on a timer (5s/500ms/2m); auto-pauses while the tab is hidden. Page level only, never inside a reactive @loop body.",
178
184
  example: EVERY_EXAMPLE,
179
185
  reactive: "reactive"
180
186
  },
@@ -189,8 +195,9 @@ const DIRECTIVES = [
189
195
  {
190
196
  name: ":button",
191
197
  kind: "line",
192
- syntax: ':button "Label" -> action[; action…]',
193
- description: "A button that runs one or more state actions on click.",
198
+ syntax: ':button "Label" [role="…"] [aria-…="…"] [title="…"] -> action[; action…]',
199
+ description:
200
+ "A button that runs one or more state actions on click; takes role/aria-*/title before the arrow.",
194
201
  example: BUTTON_EXAMPLE,
195
202
  reactive: "reactive"
196
203
  },
@@ -198,7 +205,8 @@ const DIRECTIVES = [
198
205
  name: ":form",
199
206
  kind: "block",
200
207
  syntax: ':form [into name] [action="/url"] [method="post"] … :endform',
201
- description: "A form: client state (into), native post (action), or fetch round-trip (both).",
208
+ description:
209
+ "A form: client state (into), native post (action), or fetch round-trip (both). Multipart when it holds a file field.",
202
210
  example: FORM_EXAMPLE,
203
211
  reactive: "either"
204
212
  },
@@ -206,7 +214,7 @@ const DIRECTIVES = [
206
214
  name: ":input",
207
215
  kind: "line",
208
216
  syntax: ':input name [type=…] [placeholder="…"] [required]',
209
- description: "A single-line form input (auto aria-label).",
217
+ description: "A single-line form input (auto aria-label). type=file makes its form multipart.",
210
218
  example: INPUT_EXAMPLE,
211
219
  reactive: "static"
212
220
  },
@@ -222,25 +230,28 @@ const DIRECTIVES = [
222
230
  name: ":select",
223
231
  kind: "block",
224
232
  syntax: ':select name [required] then "- Label" lines',
225
- description: "A dropdown; one <option> per following - Label line.",
233
+ description:
234
+ "A dropdown; one <option> per following - Label line. Inside a :form it is a form field; outside one it binds a :state of that name.",
226
235
  example: SELECT_EXAMPLE,
227
- reactive: "static"
236
+ reactive: "either"
228
237
  },
229
238
  {
230
239
  name: ":checkbox",
231
240
  kind: "block",
232
241
  syntax: ':checkbox name [required] then "- Label" lines',
233
- description: "A checkbox group that captures every checked value.",
242
+ description:
243
+ "In a :form, a checkbox group capturing every checked value; outside one, a single box bound to a boolean :state.",
234
244
  example: CHECKBOX_EXAMPLE,
235
- reactive: "static"
245
+ reactive: "either"
236
246
  },
237
247
  {
238
248
  name: ":radio",
239
249
  kind: "block",
240
250
  syntax: ':radio name [required] then "- Label" lines',
241
- description: "A radio group that captures a single value.",
251
+ description:
252
+ "A radio group capturing a single value. Inside a :form it is a form field; outside one it binds a :state of that name.",
242
253
  example: RADIO_EXAMPLE,
243
- reactive: "static"
254
+ reactive: "either"
244
255
  },
245
256
  {
246
257
  name: ":submit",
@@ -347,12 +358,32 @@ const LOOP_VAR_DESC = {
347
358
  $count: "Total row count."
348
359
  };
349
360
 
350
- /** The button-action vocabulary. `token` is the distinguishing keyword the drift
351
- * test checks against actions.js's ACTION_USE string. */
361
+ /**
362
+ * The button-action vocabulary — the ONE list every downstream surface reads.
363
+ *
364
+ * `token` is the distinguishing keyword (`refetch` drifted out of this list for
365
+ * three releases: implemented in the compiler, documented in the README seven
366
+ * times, and missing from `llms.txt`, `llms-full.txt` and the `WD311` hint,
367
+ * because the guard compared this array against a hardcoded literal copy of
368
+ * itself. `tests/catalog-actions.test.js` now derives the compiler's real
369
+ * vocabulary by compiling probes, so that cannot recur.)
370
+ *
371
+ * `place` and `operand` describe the token's SHAPE, which is what
372
+ * `src/grammar.js` needs to emit the GBNF `action-op` rule:
373
+ * place "suffix" → `name++` | "infix" → `name toggle`
374
+ * operand "none" → nothing follows the token
375
+ * "required" → exactly one value follows
376
+ * "optional" → a value may follow (`flag toggle` / `list toggle v`)
377
+ * @type {{ name: string, token: string, place: "suffix" | "infix",
378
+ * operand: "none" | "required" | "optional", syntax: string,
379
+ * description: string, example: string }[]}
380
+ */
352
381
  const ACTION_OPS = [
353
382
  {
354
383
  name: "increment",
355
384
  token: "++",
385
+ place: "suffix",
386
+ operand: "none",
356
387
  syntax: "name++",
357
388
  description: "Increment a number.",
358
389
  example: "count++"
@@ -360,6 +391,8 @@ const ACTION_OPS = [
360
391
  {
361
392
  name: "decrement",
362
393
  token: "--",
394
+ place: "suffix",
395
+ operand: "none",
363
396
  syntax: "name--",
364
397
  description: "Decrement a number.",
365
398
  example: "count--"
@@ -367,6 +400,8 @@ const ACTION_OPS = [
367
400
  {
368
401
  name: "add",
369
402
  token: "+=",
403
+ place: "infix",
404
+ operand: "required",
370
405
  syntax: "n += k",
371
406
  description: "Add to a number.",
372
407
  example: "count += 5"
@@ -374,6 +409,8 @@ const ACTION_OPS = [
374
409
  {
375
410
  name: "subtract",
376
411
  token: "-=",
412
+ place: "infix",
413
+ operand: "required",
377
414
  syntax: "n -= k",
378
415
  description: "Subtract from a number.",
379
416
  example: "count -= 5"
@@ -381,6 +418,8 @@ const ACTION_OPS = [
381
418
  {
382
419
  name: "set",
383
420
  token: "=",
421
+ place: "infix",
422
+ operand: "required",
384
423
  syntax: "name = v",
385
424
  description: "Assign a value.",
386
425
  example: "open = true"
@@ -388,6 +427,8 @@ const ACTION_OPS = [
388
427
  {
389
428
  name: "toggle",
390
429
  token: "toggle",
430
+ place: "infix",
431
+ operand: "optional",
391
432
  syntax: "flag toggle",
392
433
  description: "Flip a boolean (or toggle a list member with `list toggle v`).",
393
434
  example: "open toggle"
@@ -395,6 +436,8 @@ const ACTION_OPS = [
395
436
  {
396
437
  name: "append",
397
438
  token: "append",
439
+ place: "infix",
440
+ operand: "required",
398
441
  syntax: "list append v",
399
442
  description: "Append a value to a list.",
400
443
  example: 'items append "x"'
@@ -402,6 +445,8 @@ const ACTION_OPS = [
402
445
  {
403
446
  name: "prepend",
404
447
  token: "prepend",
448
+ place: "infix",
449
+ operand: "required",
405
450
  syntax: "list prepend v",
406
451
  description: "Prepend a value to a list.",
407
452
  example: 'items prepend "x"'
@@ -409,6 +454,8 @@ const ACTION_OPS = [
409
454
  {
410
455
  name: "remove",
411
456
  token: "remove",
457
+ place: "infix",
458
+ operand: "required",
412
459
  syntax: "list remove v",
413
460
  description: "Remove a value from a list (or the current loop row).",
414
461
  example: 'items remove "x"'
@@ -416,6 +463,8 @@ const ACTION_OPS = [
416
463
  {
417
464
  name: "clear",
418
465
  token: "clear",
466
+ place: "infix",
467
+ operand: "none",
419
468
  syntax: "x clear",
420
469
  description: "Empty a list or reset a value to null.",
421
470
  example: "items clear"
@@ -423,6 +472,8 @@ const ACTION_OPS = [
423
472
  {
424
473
  name: "merge",
425
474
  token: "merge",
475
+ place: "infix",
476
+ operand: "required",
426
477
  syntax: "obj merge other",
427
478
  description: "Shallow-merge another object/state key.",
428
479
  example: "settings merge patch"
@@ -430,6 +481,8 @@ const ACTION_OPS = [
430
481
  {
431
482
  name: "delete",
432
483
  token: "delete",
484
+ place: "infix",
485
+ operand: "required",
433
486
  syntax: "obj delete key",
434
487
  description: "Delete a key from an object.",
435
488
  example: 'settings delete "beta"'
@@ -437,9 +490,20 @@ const ACTION_OPS = [
437
490
  {
438
491
  name: "reset",
439
492
  token: "reset",
493
+ place: "infix",
494
+ operand: "none",
440
495
  syntax: "name reset",
441
496
  description: "Reset a state key to its initial value.",
442
497
  example: "form reset"
498
+ },
499
+ {
500
+ name: "refetch",
501
+ token: "refetch",
502
+ place: "infix",
503
+ operand: "none",
504
+ syntax: "name refetch",
505
+ description: "Re-run the matching :fetch.",
506
+ example: "board refetch"
443
507
  }
444
508
  ];
445
509
 
@@ -509,6 +573,11 @@ const FRONTMATTER_KEYS = [
509
573
  description: "HOME PAGE ONLY: allow (default) or deny the AI crawlers named in robots.txt.",
510
574
  example: "ai_crawlers: deny"
511
575
  },
576
+ {
577
+ name: "rss_limit",
578
+ description: "HOME PAGE ONLY: how many newest posts rss.xml carries (default 20).",
579
+ example: "rss_limit: 50"
580
+ },
512
581
  {
513
582
  name: "date",
514
583
  description: "Marks the page a post: RSS item, sitemap lastmod, og:type=article.",
@@ -573,6 +642,102 @@ const OP_DESC = {
573
642
  "<": "Less than."
574
643
  };
575
644
 
645
+ /**
646
+ * The shipped reactive runtime's human-readable gzip size, for the cheatsheet.
647
+ *
648
+ * MEASURED, not written down. `src/runtime.min.js` is the exact artifact
649
+ * `emitRuntime` copies to `/__wd/runtime.js`, and it ships inside the published
650
+ * tarball, so gzipping it answers the question the same way in this repo and in
651
+ * a consumer's `node_modules`. `.size-snapshot.json` records the same number for
652
+ * the size gate, but that file is a repo artifact and is NOT published, so
653
+ * reading it here would throw for every npm consumer whose `darkmown build`
654
+ * emits `dist/llms.txt`.
655
+ *
656
+ * Before 2.7 this was a hand-maintained literal kept honest by
657
+ * `tests/size-prose.test.js`. Deriving it removes the drift instead of policing
658
+ * it — and that test now cross-checks the measurement against the snapshot.
659
+ * The cheatsheet is the artifact an app pastes into a model's system prompt, so
660
+ * a wrong number here is the one that gets repeated back at users; it once said
661
+ * "~8 KB", which was the BUDGET, not the size.
662
+ */
663
+ const RUNTIME_SIZE = `~${(gzipSync(fs.readFileSync(new URL("runtime.min.js", import.meta.url))).length / 1000).toFixed(1)} KB`;
664
+
665
+ /**
666
+ * Block openers close explicitly; these are the closers and the mid-block
667
+ * markers. They are real vocabulary an author must write and were missing from
668
+ * the cheatsheet entirely, so a model could open a block it could not close.
669
+ * @returns {string[]}
670
+ */
671
+ function blockStructureSection() {
672
+ return [
673
+ "## Block structure (every block closes explicitly)",
674
+ "- `@loop … @endloop`. `@empty` inside the loop body renders when the list is empty.",
675
+ "- `:if … :endif`, with optional `:else if <condition>` and `:else` branches before the close.",
676
+ "- `:form … :endform`, `:carousel … :endcarousel`, and `::: … :::` (a bare `:::` closes a container).",
677
+ ""
678
+ ];
679
+ }
680
+
681
+ /**
682
+ * The `{.class #id}` inline-attribute block. Real, undocumented in the
683
+ * cheatsheet, and with one sharp edge (inline code is not supported) that an
684
+ * author otherwise discovers by shipping a broken page.
685
+ * @returns {string[]}
686
+ */
687
+ function inlineAttributesSection() {
688
+ return [
689
+ "## Inline attributes `{.class #id}`",
690
+ "- Attach classes/an id to an inline element by following it with NO space:" +
691
+ " `[Get started](/start/){.btn}`, `![logo](/l.png){.brand}`, `*text*{.hl}`.",
692
+ "- Several at once: `[Go](/u/){.btn .lg #cta}`. A space before the block breaks it (stays literal).",
693
+ "- Works on links, images, and emphasis/strong. It does NOT work on inline `code`.",
694
+ ""
695
+ ];
696
+ }
697
+
698
+ /**
699
+ * Collections: any `site/pages/<name>/` folder is queryable by its bare name,
700
+ * with optional `_schema.wd` validation. The docs site covers this; the
701
+ * cheatsheet did not, so an AI author had no way to reach it.
702
+ * @returns {string[]}
703
+ */
704
+ function collectionsSection() {
705
+ return [
706
+ "## Collections (a folder of pages, queried like data)",
707
+ "- Any `site/pages/<name>/` folder is a collection. Loop it by BARE NAME:" +
708
+ " `@loop posts into post sort by post.date desc`",
709
+ "- Each row is that entry's frontmatter plus derived `url`, `slug`, and `excerpt`.",
710
+ "- Optional `site/pages/<name>/_schema.wd` type-checks every entry at build time," +
711
+ " one `field: type` per line. Types: `string`, `number`, `boolean`, `date`, `string[]`;" +
712
+ " a trailing `?` makes a field optional. e.g. `title: string`, `featured: boolean?`",
713
+ ""
714
+ ];
715
+ }
716
+
717
+ /**
718
+ * The `.skin` language. AGENTS.md tells a model to always ship styling, and the
719
+ * cheatsheet carried not one line of the styling syntax.
720
+ * @returns {string[]}
721
+ */
722
+ function skinSection() {
723
+ return [
724
+ "## Styling: the `.skin` language (zero runtime)",
725
+ "- A `.skin` beside a page or include (`index.wd` → `index.skin`) compiles to CSS and links itself.",
726
+ "- Indentation IS the structure: no braces, no semicolons, no colon between property and value.",
727
+ "- Selector, then indented `property value` lines: `.card` / ` bg white` / ` radius 8px`.",
728
+ "- `tokens` opens a design-token block (`accent #16645a`) emitting CSS variables;" +
729
+ " `tokens dark` supplies the dark-mode values for the same names. Read one with `$name`" +
730
+ " (`color $accent`).",
731
+ "- Shorthands: `bg`→background, `radius`→border-radius, `shadow`→box-shadow," +
732
+ " `font`→font-family, and `page` targets `<body>`.",
733
+ "- Nest by indenting (a nested selector is a descendant); `&:hover` / `&.active` attach to" +
734
+ " the parent; `@media (min-width: 40rem)` wraps its indented rules.",
735
+ "- A first line of `scoped` scopes every selector in that file to its own subtree at compile" +
736
+ " time. `tokens` stay global; `:global(.x)` opts one selector out.",
737
+ ""
738
+ ];
739
+ }
740
+
576
741
  /** Read the installed version from package.json (always shipped beside src/). */
577
742
  function readVersion() {
578
743
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -622,6 +787,19 @@ export function directiveCatalog() {
622
787
  /** The action tokens the catalog exposes — used by the drift guard. */
623
788
  export const CATALOG_ACTION_TOKENS = ACTION_OPS.map((a) => a.token);
624
789
 
790
+ /**
791
+ * The same vocabulary with each token's SHAPE, for `src/grammar.js`. The GBNF
792
+ * `action-op` rule used to be a hand-written string listing all fourteen
793
+ * alternatives; it is now generated from this, so the grammar and the catalog
794
+ * cannot list different ops.
795
+ * @type {{ token: string, place: "suffix" | "infix", operand: "none" | "required" | "optional" }[]}
796
+ */
797
+ export const CATALOG_ACTION_GRAMMAR = ACTION_OPS.map((a) => ({
798
+ token: a.token,
799
+ place: a.place,
800
+ operand: a.operand
801
+ }));
802
+
625
803
  /**
626
804
  * One page of a built site, as it appears in the generated `llms.txt` index and
627
805
  * `llms-full.txt` corpus.
@@ -662,7 +840,9 @@ export function llmsText(site) {
662
840
  out.push("");
663
841
  out.push("Darkmown compiles Markdown. A plain `.md` file stays plain; rename it to `.wd`");
664
842
  out.push("to unlock the directives below. Static pages ship ZERO JavaScript; only pages");
665
- out.push("that use :state/:store/:fetch/:button (reactive) load the ~8 KB runtime. Copy an");
843
+ out.push(
844
+ `that use :state/:store/:fetch/:button (reactive) load the ${RUNTIME_SIZE} runtime. Copy an`
845
+ );
666
846
  out.push("example verbatim — every one compiles. Interpolate values with `{ path }`.");
667
847
  out.push("");
668
848
 
@@ -672,6 +852,8 @@ export function llmsText(site) {
672
852
  }
673
853
  out.push("");
674
854
 
855
+ out.push(...blockStructureSection());
856
+
675
857
  out.push("## @loop clauses (this fixed order, all optional)");
676
858
  for (const c of cat.loopClauses) {
677
859
  out.push(`- \`${c.syntax}\` — ${c.description} e.g. \`${c.example}\``);
@@ -706,17 +888,24 @@ export function llmsText(site) {
706
888
  );
707
889
  out.push("");
708
890
 
891
+ out.push(...inlineAttributesSection());
892
+
709
893
  out.push("## Page frontmatter");
710
894
  for (const key of cat.frontmatterKeys) {
711
895
  out.push(`- \`${key.name}\`: ${key.description} e.g. \`${key.example}\``);
712
896
  }
713
897
  out.push("");
714
898
 
899
+ out.push(...collectionsSection());
900
+ out.push(...skinSection());
901
+
715
902
  out.push("## Rules");
716
903
  out.push("- `.md` never gets directives — the `.wd` extension is the feature gate.");
717
904
  out.push("- One loop syntax (`@loop … into … @endloop`), one interpolation syntax (`{ name }`).");
718
905
  out.push("- Actions and predicates are a fixed whitelist — no JavaScript, no `x.filter(...)`.");
719
- out.push("- Close every block: `@endloop`, `:endif`, `:endform`, `:endcarousel`, `:::`.");
906
+ out.push(
907
+ "- Close every block (see Block structure above) — an unclosed block is a compile error."
908
+ );
720
909
  out.push(
721
910
  "- Compile errors start with a stable code (`[WD201] …`) and always name the file," +
722
911
  " the line, and a fix. Read the message; it tells you exactly what to write."
@@ -776,6 +965,8 @@ export function llmsFullText(site) {
776
965
  }
777
966
  out.push("");
778
967
 
968
+ out.push(...blockStructureSection());
969
+
779
970
  out.push("## @loop clauses (this fixed order, all optional)");
780
971
  for (const c of cat.loopClauses) {
781
972
  out.push(`- \`${c.syntax}\`: ${c.description} e.g. \`${c.example}\``);
@@ -807,12 +998,17 @@ export function llmsFullText(site) {
807
998
  out.push(`Join conditions with ${cat.predicateJoiners.map((j) => `\`${j}\``).join(" / ")}.`);
808
999
  out.push("");
809
1000
 
1001
+ out.push(...inlineAttributesSection());
1002
+
810
1003
  out.push("## Page frontmatter");
811
1004
  for (const key of cat.frontmatterKeys) {
812
1005
  out.push(`- \`${key.name}\`: ${key.description} e.g. \`${key.example}\``);
813
1006
  }
814
1007
  out.push("");
815
1008
 
1009
+ out.push(...collectionsSection());
1010
+ out.push(...skinSection());
1011
+
816
1012
  out.push("## Compile error codes");
817
1013
  for (const area of cat.errorAreas) {
818
1014
  out.push(`### ${area.range} ${area.title}`);
package/src/cli.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  } from "./dev.js";
20
20
  import { discoverRoutes, isDraft, outputPathForRoute } from "./router.js";
21
21
  import { availableTemplates, initProject } from "./scaffold.js";
22
- import { contentType, resolvePublicFile, serve } from "./statics.js";
22
+ import { contentType, isServableFile, pipeFile, resolvePublicFile, serve } from "./statics.js";
23
23
 
24
24
  const cliPath = fileURLToPath(import.meta.url);
25
25
 
@@ -329,7 +329,10 @@ function startPreviewServer({ cwd, log, error }) {
329
329
  */
330
330
  function serveDev(distRoot, url, res, draftFiles, buildError) {
331
331
  const file = resolvePublicFile(distRoot, url);
332
- if (!file || !fs.existsSync(file)) {
332
+ // `isServableFile`, not `existsSync`: a route whose last segment contains a
333
+ // dot (`/v1.2/`) resolves to a DIRECTORY, and streaming one is an unhandled
334
+ // EISDIR that kills the dev server. It is a miss here, like any other.
335
+ if (!file || !isServableFile(file)) {
333
336
  // An HTML route with no dist output while the last build FAILED is almost
334
337
  // certainly unbuilt because of that failure — serve the compile error (the
335
338
  // SSE overlay replays it too), not the misleading "hidden or not created"
@@ -353,7 +356,7 @@ function serveDev(distRoot, url, res, draftFiles, buildError) {
353
356
  return;
354
357
  }
355
358
  res.writeHead(200, { "content-type": contentType(file) });
356
- fs.createReadStream(file).pipe(res);
359
+ pipeFile(file, res);
357
360
  }
358
361
 
359
362
  /**
@@ -17,6 +17,7 @@ import {
17
17
  stripQuotes,
18
18
  validatePath
19
19
  } from "./interpolation.js";
20
+ import { a11yAttrHtml, takeA11yAttrs } from "./structure.js";
20
21
 
21
22
  /**
22
23
  * @typedef {import("./context.js").Ctx} Ctx
@@ -25,7 +26,7 @@ import {
25
26
 
26
27
  // A concrete, compilable :button line for the malformed hint + directive catalog.
27
28
  export const BUTTON_EXAMPLE = ':button "Add one" -> count++';
28
- const BUTTON_USE = `Use: :button "Label" -> action — e.g. ${BUTTON_EXAMPLE}`;
29
+ const BUTTON_USE = `Use: :button "Label" [role="…"] [aria-…="…"] [title="…"] -> action — e.g. ${BUTTON_EXAMPLE}`;
29
30
 
30
31
  /**
31
32
  * @param {string} line
@@ -34,8 +35,8 @@ const BUTTON_USE = `Use: :button "Label" -> action — e.g. ${BUTTON_EXAMPLE}`;
34
35
  * @returns {string}
35
36
  */
36
37
  export function handleButton(line, ctx, index) {
37
- const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
38
- if (!match)
38
+ /** @returns {never} */
39
+ const malformed = () => {
39
40
  throw wdError(`Malformed :button in ${at(ctx, index)}: ${line}. ${BUTTON_USE}`, {
40
41
  code: "WD301",
41
42
  file: ctx.file,
@@ -43,24 +44,42 @@ export function handleButton(line, ctx, index) {
43
44
  hint: BUTTON_USE.slice("Use: ".length),
44
45
  example: BUTTON_EXAMPLE
45
46
  });
47
+ };
48
+ // Label first, then any whitelisted accessibility attributes, then `->` and
49
+ // the action chain. The attributes are peeled BEFORE the arrow is looked for,
50
+ // so a value containing `->` (`aria-label="a -> b"`) cannot split the line in
51
+ // the wrong place, and a rejected attribute reports the whitelist rather than
52
+ // "malformed :button".
53
+ const head = line.match(/^:button\s+"([^"]+)"\s*([\s\S]*)$/);
54
+ if (!head) return malformed();
55
+ const taken = takeA11yAttrs(head[2], ctx, index, ":button");
56
+ const arrow = taken.rest.match(/^->\s*(.+)$/);
57
+ if (!arrow) return malformed();
58
+ /** @type {[string, string]} */
59
+ const match = [head[1], arrow[1]];
46
60
  ctx.comp.assets.runtime = true;
47
- const action = parseAction(match[2], ctx);
61
+ const action = parseAction(match[1], ctx);
48
62
  for (const a of Array.isArray(action) ? action : [action])
49
63
  recordSymbol(ctx, index, {
50
64
  kind: "action",
51
65
  name: a.target,
52
66
  target: a.target,
53
67
  op: a.op,
54
- detail: `:button "${match[1]}" -> ${match[2].trim()}`
68
+ detail: `:button "${match[0]}" -> ${match[1].trim()}`
55
69
  });
70
+ const a11y = a11yAttrHtml(taken.attrs);
56
71
  if (Array.isArray(action)) {
57
- return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
72
+ return `<button type="button"${a11y} data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[0])}</button>`;
58
73
  }
59
74
  const valueAttr =
60
75
  action.value === undefined
61
76
  ? ""
62
77
  : ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
63
- return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
78
+ // The target is a STATE KEY, and a section-scoped key carries the `::: name #id`
79
+ // id verbatim (`a"onmouseover=…:count`), so it needs the same escaping the
80
+ // neighbouring `id=` has always had — otherwise the id closes the attribute and
81
+ // whatever follows becomes a live one.
82
+ return `<button type="button"${a11y} data-wd-action="${action.op}" data-wd-target="${escapeHtml(action.target)}"${valueAttr}>${escapeHtml(match[0])}</button>`;
64
83
  }
65
84
 
66
85
  // Concrete, compilable examples for the effect / every hint tails + catalog.
@@ -69,6 +88,47 @@ export function handleButton(line, ctx, index) {
69
88
  export const EFFECT_EXAMPLE = ":effect query -> searches++";
70
89
  const EFFECT_USE = `Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary) — e.g. ${EFFECT_EXAMPLE}`;
71
90
 
91
+ // ---------------------------------------------------------------------------
92
+ // `:every` and `:effect` are PAGE-LEVEL registrations, not per-row ones.
93
+ //
94
+ // A reactive `@loop` compiles its body once into a `<template>` and the runtime
95
+ // clones that template per list item, so a timer or a watcher written inside the
96
+ // body is registered once per row: three rows meant three intervals, and a row
97
+ // removed from the list left its interval running, so a single tick advanced a
98
+ // counter by the number of rows the page had EVER shown. There is no correct
99
+ // per-row meaning to give either directive — an effect watches a top-level state
100
+ // key and an action targets one, neither can name the row — so the placement is
101
+ // refused at compile time rather than repaired at runtime.
102
+ //
103
+ // Mirrors `declareState`'s WD202 check: `ctx.loopItem` is set only inside a
104
+ // REACTIVE loop body (a static unroll passes the row through `ctx.scope`), which
105
+ // is exactly the case that clones. It is inherited by everything nested in that
106
+ // body, so a nested loop and an `:if`/each-if branch are covered by the same test.
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Refuse a `:every`/`:effect` written inside a reactive `@loop` body.
111
+ * @param {string} what The directive keyword, for the message.
112
+ * @param {string} onList The same directive rewritten to act on the whole list,
113
+ * which is what the author almost always meant.
114
+ * @param {string} example The directive's compilable example, for the hint tail.
115
+ * @param {Ctx} ctx
116
+ * @param {number} index 0-based line index for `file:line` errors.
117
+ * @returns {void}
118
+ */
119
+ function rejectInReactiveLoop(what, onList, example, ctx, index) {
120
+ if (!ctx.loopItem) return;
121
+ const hint =
122
+ `declare ${what} once outside the loop — at page level, or inside the ::: section — and act on the ` +
123
+ `whole list (${onList}) — e.g. ${example}`;
124
+ throw wdError(
125
+ `${what} cannot be used inside a reactive @loop body in ${at(ctx, index)}. ` +
126
+ `Each row is cloned from the loop template, so every row would register its own ` +
127
+ `timer/watcher and a removed row's would keep firing. Use: ${hint}`,
128
+ { code: "WD315", file: ctx.file, line: lineOf(ctx, index), hint, example }
129
+ );
130
+ }
131
+
72
132
  /**
73
133
  * Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
74
134
  * watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
@@ -80,6 +140,7 @@ const EFFECT_USE = `Use: :effect watchedState -> action[; action…] (actions us
80
140
  * @returns {string}
81
141
  */
82
142
  export function handleEffect(line, ctx, index) {
143
+ rejectInReactiveLoop(":effect", ":effect query -> rows refetch", EFFECT_EXAMPLE, ctx, index);
83
144
  const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
84
145
  if (!match)
85
146
  throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
@@ -138,6 +199,7 @@ function parseDuration(raw) {
138
199
  * @returns {string}
139
200
  */
140
201
  export function handleEvery(line, ctx, index) {
202
+ rejectInReactiveLoop(":every", ":every 5s -> rows refetch", EVERY_EXAMPLE, ctx, index);
141
203
  const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
142
204
  if (!match)
143
205
  throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
@@ -173,7 +235,7 @@ export function handleEvery(line, ctx, index) {
173
235
  // than the schematic `name++`. The action vocabulary the directive catalog draws
174
236
  // from lives here (the parser branches below), mirrored compactly in ACTION_USE.
175
237
  export const ACTION_EXAMPLE = "count++";
176
- export const ACTION_USE = `Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";" — e.g. ${ACTION_EXAMPLE}`;
238
+ export const ACTION_USE = `Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset, name refetch — chain with ";" — e.g. ${ACTION_EXAMPLE}`;
177
239
 
178
240
  /**
179
241
  * Parse a `:button` action expression into a validated `{ op, target, value }`,
@@ -252,10 +314,16 @@ function parseSingleAction(expression, raw, ctx) {
252
314
  if (toggle[2] === undefined) return { op: "toggle", target };
253
315
  return { op: "member-toggle", target, value: parseActionLiteral(toggle[2], ctx) };
254
316
  }
255
- const prepend = expression.match(new RegExp(`^(${PATH})\\s+(?:append|prepend)\\s+(.+)$`));
256
- if (prepend) {
257
- const op = expression.includes(" prepend ") ? "prepend" : "append";
258
- return { op, target: resolveTarget(prepend[1]), value: parseActionLiteral(prepend[2], ctx) };
317
+ // Capture the OPERATOR, never search the whole expression for it: scanning for
318
+ // " prepend " made `list append "we prepend things"` compile to
319
+ // data-wd-action="prepend", so the operand silently chose the operator.
320
+ const listAdd = expression.match(new RegExp(`^(${PATH})\\s+(append|prepend)\\s+(.+)$`));
321
+ if (listAdd) {
322
+ return {
323
+ op: listAdd[2],
324
+ target: resolveTarget(listAdd[1]),
325
+ value: parseActionLiteral(listAdd[3], ctx)
326
+ };
259
327
  }
260
328
  // Per-row: remove the current row from the list being looped — `todos remove todo`.
261
329
  // Disambiguation: operand IS the loop item name → `remove` (current row);