@zvndev/darkmown 2.6.1-next.12.ge562f5d → 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.
- package/AGENTS.md +392 -77
- package/CHANGELOG.md +47 -0
- package/README.md +316 -49
- package/docs/cli.md +51 -17
- package/docs/spec-alignment.md +25 -4
- package/grammar/wd-directives.gbnf +13 -5
- package/package.json +7 -3
- package/site/_/base.skin +1 -1
- package/site/_/footer.wd +1 -1
- package/site/pages/api-errors/index.wd +81 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/attr-binding/index.wd +84 -0
- package/site/pages/blog/darkmown-2-7-0.md +176 -0
- package/site/pages/blog/darkmown-2-7-0.skin +127 -0
- package/site/pages/blog/no-unsafe-eval.md +66 -0
- package/site/pages/blog/no-unsafe-eval.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/branch-state/index.wd +68 -0
- package/site/pages/docs/index.wd +320 -19
- package/site/pages/index.wd +3 -1
- package/site/pages/playground/index.js +63 -56
- package/site/pages/pulse/index.wd +1 -1
- package/site/pages/settings/index.wd +97 -0
- package/site/pages/showcase/index.wd +37 -1
- package/site/pages/upload/index.wd +63 -0
- package/site/pages/url-state/index.wd +70 -0
- package/src/builder.js +222 -34
- package/src/catalog.js +217 -21
- package/src/cli.js +6 -3
- package/src/compiler/actions.js +80 -12
- package/src/compiler/body.js +104 -28
- package/src/compiler/collections.js +29 -13
- package/src/compiler/context.js +9 -2
- package/src/compiler/expr-ast.js +14 -0
- package/src/compiler/fetch.js +8 -4
- package/src/compiler/forms.js +133 -14
- package/src/compiler/frontmatter.js +28 -7
- package/src/compiler/includes.js +46 -6
- package/src/compiler/interpolation.js +56 -1
- package/src/compiler/loops.js +489 -35
- package/src/compiler/markdown.js +457 -44
- package/src/compiler/page.js +8 -3
- package/src/compiler/predicates.js +84 -14
- package/src/compiler/state.js +167 -18
- package/src/compiler/structure.js +159 -29
- package/src/errors.js +111 -3
- package/src/feeds.js +140 -7
- package/src/grammar.js +53 -10
- package/src/runtime.js +509 -130
- package/src/runtime.min.js +2 -0
- package/src/runtime.min.js.map +1 -0
- package/src/statics.js +46 -2
- package/src/templates/blog/README.md +2 -0
- package/src/templates/dashboard/README.md +2 -0
- package/src/templates/landing/README.md +2 -0
- package/src/templates/starter/README.md +1 -1
- package/src/templates/store/README.md +2 -0
- package/src/tools/validate.js +14 -4
- package/types/builder.d.ts +6 -2
- package/types/catalog.d.ts +12 -0
- package/types/compiler/actions.d.ts +1 -1
- package/types/compiler/body.d.ts +0 -3
- package/types/compiler/forms.d.ts +12 -1
- package/types/compiler/frontmatter.d.ts +8 -1
- package/types/compiler/includes.d.ts +16 -2
- package/types/compiler/interpolation.d.ts +23 -0
- package/types/compiler/loops.d.ts +47 -0
- package/types/compiler/markdown.d.ts +63 -1
- package/types/compiler/predicates.d.ts +7 -1
- package/types/compiler/state.d.ts +8 -2
- package/types/compiler/structure.d.ts +32 -1
- package/types/feeds.d.ts +68 -2
- 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:
|
|
119
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: "
|
|
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:
|
|
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: "
|
|
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:
|
|
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: "
|
|
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
|
-
/**
|
|
351
|
-
*
|
|
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}`, `{.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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
pipeFile(file, res);
|
|
357
360
|
}
|
|
358
361
|
|
|
359
362
|
/**
|
package/src/compiler/actions.js
CHANGED
|
@@ -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
|
-
|
|
38
|
-
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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);
|