@zvndev/darkmown 2.0.1 → 2.2.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 (45) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +61 -6
  4. package/SECURITY.md +4 -4
  5. package/docs/cli.md +8 -0
  6. package/docs/spec-alignment.md +26 -6
  7. package/grammar/wd-directives.gbnf +58 -0
  8. package/package.json +5 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/_/nav.wd +1 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/blog/zero-js-by-default.md +1 -1
  13. package/site/pages/docs/index.wd +19 -2
  14. package/site/pages/index.wd +5 -3
  15. package/site/pages/playground/index.js +158 -0
  16. package/site/pages/playground/index.skin +128 -0
  17. package/site/pages/playground/index.wd +51 -0
  18. package/site/pages/showcase/index.wd +1 -1
  19. package/src/builder.js +15 -8
  20. package/src/catalog.js +586 -0
  21. package/src/cli.js +8 -0
  22. package/src/compiler/actions.js +45 -12
  23. package/src/compiler/collections.js +16 -11
  24. package/src/compiler/context.js +52 -3
  25. package/src/compiler/expr-ast.js +296 -0
  26. package/src/compiler/fetch.js +12 -4
  27. package/src/compiler/forms.js +78 -24
  28. package/src/compiler/fs-reader.js +25 -0
  29. package/src/compiler/includes.js +20 -15
  30. package/src/compiler/index.js +1 -1
  31. package/src/compiler/loops.js +26 -8
  32. package/src/compiler/media.js +21 -7
  33. package/src/compiler/page.js +68 -15
  34. package/src/compiler/predicates.js +29 -18
  35. package/src/compiler/reader.js +60 -0
  36. package/src/compiler/state.js +81 -32
  37. package/src/compiler/structure.js +48 -17
  38. package/src/compiler.js +57 -6
  39. package/src/grammar.js +131 -0
  40. package/src/headers.js +15 -13
  41. package/src/index.js +2 -0
  42. package/src/runtime.js +77 -45
  43. package/src/statics.js +6 -7
  44. package/types/compiler.d.ts +13 -0
  45. package/types/index.d.ts +1 -0
package/src/catalog.js ADDED
@@ -0,0 +1,586 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Machine-readable directive catalog + llms.txt cheatsheet.
3
+ //
4
+ // `directiveCatalog()` returns a structured description of the whole `.wd`
5
+ // authoring surface — every public directive, `@loop` clause, loop row variable,
6
+ // button-action op, format pipe, and predicate operator — with a one-line syntax
7
+ // template, a one-line description, one CONCRETE compilable example, and whether
8
+ // it needs the reactive runtime. It is the single artifact an app stuffs into a
9
+ // small model's system prompt (via `llmsText()`), the source the GBNF grammar
10
+ // generator reads, and the thing the CLI's `catalog` command prints.
11
+ //
12
+ // Examples are sourced from the SAME `*_EXAMPLE` constants the compiler's error
13
+ // hints use, and the operator/formatter lists are read from the compiler's own
14
+ // tables (FORMATTER_NAMES, PREDICATE_OPS, LOOP_META) — so the catalog cannot
15
+ // drift from what actually compiles (drift-guarded in tests/catalog.test.js).
16
+ // ---------------------------------------------------------------------------
17
+
18
+ import fs from "node:fs";
19
+ import { BUTTON_EXAMPLE, EFFECT_EXAMPLE, EVERY_EXAMPLE } from "./compiler/actions.js";
20
+ import { LOOP_META } from "./compiler/context.js";
21
+ import { FETCH_EXAMPLE } from "./compiler/fetch.js";
22
+ import { FORMATTER_NAMES } from "./compiler/format.js";
23
+ import {
24
+ BIND_EXAMPLE,
25
+ CHECKBOX_EXAMPLE,
26
+ FORM_EXAMPLE,
27
+ INPUT_EXAMPLE,
28
+ RADIO_EXAMPLE,
29
+ SELECT_EXAMPLE,
30
+ SLIDER_EXAMPLE,
31
+ SUBMIT_EXAMPLE,
32
+ TEXTAREA_EXAMPLE
33
+ } from "./compiler/forms.js";
34
+ import { LOOP_EXAMPLE } from "./compiler/loops.js";
35
+ import { AUDIO_EXAMPLE, EMBED_EXAMPLE, VIDEO_EXAMPLE } from "./compiler/media.js";
36
+ import { PREDICATE_JOINERS, PREDICATE_OPS } from "./compiler/predicates.js";
37
+ import { COMPUTED_EXAMPLE, STATE_EXAMPLE, STORE_EXAMPLE, THEME_EXAMPLE } from "./compiler/state.js";
38
+ import {
39
+ CAROUSEL_EXAMPLE,
40
+ CONTAINER_EXAMPLE,
41
+ IF_EXAMPLE,
42
+ INCLUDE_EXAMPLE
43
+ } from "./compiler/structure.js";
44
+
45
+ /**
46
+ * @typedef {"static" | "reactive" | "either"} Reactivity Whether the directive
47
+ * ships zero JS (static), needs the reactive runtime (reactive), or can be
48
+ * either depending on whether it reads `:state`/`:store` (either).
49
+ */
50
+
51
+ /**
52
+ * @typedef {object} DirectiveEntry
53
+ * @property {string} name The directive token (`@loop`, `:state`, `:::`, …).
54
+ * @property {"line" | "block"} kind A single line, or an opener with a closer.
55
+ * @property {string} syntax One-line schematic (`[…]` = optional, `<…>` = slot).
56
+ * @property {string} description One-line, plain-language summary.
57
+ * @property {string} example One concrete, compilable line.
58
+ * @property {Reactivity} reactive
59
+ */
60
+
61
+ /**
62
+ * @typedef {object} CatalogEntry A clause / action-op / pipe / operator entry.
63
+ * @property {string} name
64
+ * @property {string} [syntax]
65
+ * @property {string} description
66
+ * @property {string} [example]
67
+ */
68
+
69
+ /**
70
+ * @typedef {object} DirectiveCatalog
71
+ * @property {string} version The installed Darkmown version.
72
+ * @property {string} format Always `.wd` — the file extension that gates directives.
73
+ * @property {DirectiveEntry[]} directives
74
+ * @property {CatalogEntry[]} loopClauses Clauses of the `@loop` header (fixed order).
75
+ * @property {CatalogEntry[]} loopVariables Per-row meta variables valid in a loop body.
76
+ * @property {CatalogEntry[]} actionOps The `:button`/`:effect`/`:every` action vocabulary.
77
+ * @property {CatalogEntry[]} formatPipes The `{ value | pipe }` formatter whitelist.
78
+ * @property {CatalogEntry[]} predicateOps Comparison operators for `where`/`:if`/`when`.
79
+ * @property {string[]} predicateJoiners Logical joiners (`and`/`or`/`not`).
80
+ */
81
+
82
+ /** The directive surface. `example` is drawn from the compiler's own constants. */
83
+ /** @type {DirectiveEntry[]} */
84
+ const DIRECTIVES = [
85
+ {
86
+ name: "@include",
87
+ kind: "line",
88
+ syntax: '@include /partial.wd [with key="value" …]',
89
+ description:
90
+ "Inline another .wd/.md file from site/pages or site/_, passing optional scope args.",
91
+ example: INCLUDE_EXAMPLE,
92
+ reactive: "static"
93
+ },
94
+ {
95
+ name: "@loop",
96
+ kind: "block",
97
+ syntax:
98
+ "@loop <src> into <item> [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable] … @endloop",
99
+ description: "Iterate a JSON file, an in-scope value, or a :state/:store list.",
100
+ example: LOOP_EXAMPLE,
101
+ reactive: "either"
102
+ },
103
+ {
104
+ name: ":::",
105
+ kind: "block",
106
+ syntax: "::: [tag] [.class …] [#id] [.class when <pred>] … :::",
107
+ description: "Group content in a section/div/nav/main with classes, ids, and reactive classes.",
108
+ example: CONTAINER_EXAMPLE,
109
+ reactive: "either"
110
+ },
111
+ {
112
+ name: ":if",
113
+ kind: "block",
114
+ syntax: ":if <name> | :if <a> <op> <b> [and|or|not …] … [:else if …] [:else] :endif",
115
+ description: "Render a region only when a state/loop-item condition holds.",
116
+ example: IF_EXAMPLE,
117
+ reactive: "either"
118
+ },
119
+ {
120
+ name: ":state",
121
+ kind: "line",
122
+ syntax: ":state name = value [persist]",
123
+ description: "Declare page-scoped reactive state (persist keeps it across reloads).",
124
+ example: STATE_EXAMPLE,
125
+ reactive: "reactive"
126
+ },
127
+ {
128
+ name: ":store",
129
+ kind: "line",
130
+ syntax: ":store name = value [ephemeral]",
131
+ description: "Declare a page-global store, persisted by default (ephemeral opts out).",
132
+ example: STORE_EXAMPLE,
133
+ reactive: "reactive"
134
+ },
135
+ {
136
+ name: ":computed",
137
+ kind: "line",
138
+ syntax: ":computed name = <expression>",
139
+ description: "Derive state from other state (numbers, comparisons, sum/avg/min/max/count).",
140
+ example: COMPUTED_EXAMPLE,
141
+ reactive: "reactive"
142
+ },
143
+ {
144
+ name: ":fetch",
145
+ kind: "line",
146
+ syntax:
147
+ ':fetch name from "url" [method=…] [when=visible] [timeout=ms] [retry=N] [headers=key] [body=key]',
148
+ description: "Load remote JSON with auto loading/error/empty lifecycle state.",
149
+ example: FETCH_EXAMPLE,
150
+ reactive: "reactive"
151
+ },
152
+ {
153
+ name: ":effect",
154
+ kind: "line",
155
+ syntax: ":effect watched -> action[; action…]",
156
+ description: "Run button-vocabulary actions whenever the watched state changes.",
157
+ example: EFFECT_EXAMPLE,
158
+ reactive: "reactive"
159
+ },
160
+ {
161
+ name: ":every",
162
+ kind: "line",
163
+ syntax: ":every <duration> -> action[; action…]",
164
+ description: "Run actions on a timer (5s/500ms/2m); auto-pauses while the tab is hidden.",
165
+ example: EVERY_EXAMPLE,
166
+ reactive: "reactive"
167
+ },
168
+ {
169
+ name: ":theme",
170
+ kind: "line",
171
+ syntax: ':theme [name] [= "auto"]',
172
+ description: "Add a light/dark theme store reflected onto <html data-theme>.",
173
+ example: THEME_EXAMPLE,
174
+ reactive: "reactive"
175
+ },
176
+ {
177
+ name: ":button",
178
+ kind: "line",
179
+ syntax: ':button "Label" -> action[; action…]',
180
+ description: "A button that runs one or more state actions on click.",
181
+ example: BUTTON_EXAMPLE,
182
+ reactive: "reactive"
183
+ },
184
+ {
185
+ name: ":form",
186
+ kind: "block",
187
+ syntax: ':form [into name] [action="/url"] [method="post"] … :endform',
188
+ description: "A form: client state (into), native post (action), or fetch round-trip (both).",
189
+ example: FORM_EXAMPLE,
190
+ reactive: "either"
191
+ },
192
+ {
193
+ name: ":input",
194
+ kind: "line",
195
+ syntax: ':input name [type=…] [placeholder="…"] [required]',
196
+ description: "A single-line form input (auto aria-label).",
197
+ example: INPUT_EXAMPLE,
198
+ reactive: "static"
199
+ },
200
+ {
201
+ name: ":textarea",
202
+ kind: "line",
203
+ syntax: ':textarea name [placeholder="…"] [rows=N] [required]',
204
+ description: "A multi-line form input.",
205
+ example: TEXTAREA_EXAMPLE,
206
+ reactive: "static"
207
+ },
208
+ {
209
+ name: ":select",
210
+ kind: "block",
211
+ syntax: ':select name [required] then "- Label" lines',
212
+ description: "A dropdown; one <option> per following - Label line.",
213
+ example: SELECT_EXAMPLE,
214
+ reactive: "static"
215
+ },
216
+ {
217
+ name: ":checkbox",
218
+ kind: "block",
219
+ syntax: ':checkbox name [required] then "- Label" lines',
220
+ description: "A checkbox group that captures every checked value.",
221
+ example: CHECKBOX_EXAMPLE,
222
+ reactive: "static"
223
+ },
224
+ {
225
+ name: ":radio",
226
+ kind: "block",
227
+ syntax: ':radio name [required] then "- Label" lines',
228
+ description: "A radio group that captures a single value.",
229
+ example: RADIO_EXAMPLE,
230
+ reactive: "static"
231
+ },
232
+ {
233
+ name: ":submit",
234
+ kind: "line",
235
+ syntax: ':submit "Label"',
236
+ description: "A form submit button.",
237
+ example: SUBMIT_EXAMPLE,
238
+ reactive: "static"
239
+ },
240
+ {
241
+ name: ":bind",
242
+ kind: "line",
243
+ syntax: ':bind state [placeholder="…"] [type=…]',
244
+ description: "A two-way text input bound to a declared :state.",
245
+ example: BIND_EXAMPLE,
246
+ reactive: "reactive"
247
+ },
248
+ {
249
+ name: ":slider",
250
+ kind: "line",
251
+ syntax: ":slider name [= value] [min=N] [max=N] [step=N] [persist]",
252
+ description: "A range input two-way bound to a number :state.",
253
+ example: SLIDER_EXAMPLE,
254
+ reactive: "reactive"
255
+ },
256
+ {
257
+ name: ":video",
258
+ kind: "line",
259
+ syntax: ":video /clip.mp4 [poster=…] [width=…] [height=…] [controls] [autoplay] [loop] [muted]",
260
+ description: "A hardened HTML5 <video> (zero runtime).",
261
+ example: VIDEO_EXAMPLE,
262
+ reactive: "static"
263
+ },
264
+ {
265
+ name: ":audio",
266
+ kind: "line",
267
+ syntax: ":audio /clip.mp3 [preload=…] [controls] [autoplay] [loop] [muted]",
268
+ description: "A hardened HTML5 <audio> player (zero runtime).",
269
+ example: AUDIO_EXAMPLE,
270
+ reactive: "static"
271
+ },
272
+ {
273
+ name: ":embed",
274
+ kind: "line",
275
+ syntax: ':embed <url> [title="…"]',
276
+ description: "A lazy, privacy-friendly YouTube/Vimeo/iframe embed.",
277
+ example: EMBED_EXAMPLE,
278
+ reactive: "static"
279
+ },
280
+ {
281
+ name: ":carousel",
282
+ kind: "block",
283
+ syntax: ":carousel [autoplay=N] … :endcarousel",
284
+ description: "A scroll-snapping carousel; each direct child block is one slide.",
285
+ example: CAROUSEL_EXAMPLE,
286
+ reactive: "either"
287
+ }
288
+ ];
289
+
290
+ /** The `@loop` header clauses, in the fixed order the parser accepts them. */
291
+ /** @type {CatalogEntry[]} */
292
+ const LOOP_CLAUSES = [
293
+ {
294
+ name: "where",
295
+ syntax: "where <item>.field <op> value [and|or …]",
296
+ description: "Keep only rows matching a predicate.",
297
+ example: "where p.price < 50"
298
+ },
299
+ {
300
+ name: "sort by",
301
+ syntax: "sort by <item>.field [asc|desc]",
302
+ description: "Order rows by a field (or a { state } for clickable-header sort).",
303
+ example: "sort by p.price asc"
304
+ },
305
+ { name: "reverse", syntax: "reverse", description: "Reverse the row order.", example: "reverse" },
306
+ {
307
+ name: "offset",
308
+ syntax: "offset N",
309
+ description: "Skip the first N rows.",
310
+ example: "offset 2"
311
+ },
312
+ { name: "limit", syntax: "limit N", description: "Keep at most N rows.", example: "limit 5" },
313
+ {
314
+ name: "paginate",
315
+ syntax: "paginate N",
316
+ description: "Split a collection listing into static pages of N.",
317
+ example: "paginate 10"
318
+ },
319
+ {
320
+ name: "sortable",
321
+ syntax: "sortable",
322
+ description: "Let the user drag-reorder a :state/:store list.",
323
+ example: "sortable"
324
+ }
325
+ ];
326
+
327
+ /** Per-row meta variables valid inside a loop body, read from LOOP_META. */
328
+ /** @type {Record<string, string>} */
329
+ const LOOP_VAR_DESC = {
330
+ $index: "Zero-based row index.",
331
+ $number: "One-based row number.",
332
+ $first: "True on the first row.",
333
+ $last: "True on the last row.",
334
+ $count: "Total row count."
335
+ };
336
+
337
+ /** The button-action vocabulary. `token` is the distinguishing keyword the drift
338
+ * test checks against actions.js's ACTION_USE string. */
339
+ const ACTION_OPS = [
340
+ {
341
+ name: "increment",
342
+ token: "++",
343
+ syntax: "name++",
344
+ description: "Increment a number.",
345
+ example: "count++"
346
+ },
347
+ {
348
+ name: "decrement",
349
+ token: "--",
350
+ syntax: "name--",
351
+ description: "Decrement a number.",
352
+ example: "count--"
353
+ },
354
+ {
355
+ name: "add",
356
+ token: "+=",
357
+ syntax: "n += k",
358
+ description: "Add to a number.",
359
+ example: "count += 5"
360
+ },
361
+ {
362
+ name: "subtract",
363
+ token: "-=",
364
+ syntax: "n -= k",
365
+ description: "Subtract from a number.",
366
+ example: "count -= 5"
367
+ },
368
+ {
369
+ name: "set",
370
+ token: "=",
371
+ syntax: "name = v",
372
+ description: "Assign a value.",
373
+ example: "open = true"
374
+ },
375
+ {
376
+ name: "toggle",
377
+ token: "toggle",
378
+ syntax: "flag toggle",
379
+ description: "Flip a boolean (or toggle a list member with `list toggle v`).",
380
+ example: "open toggle"
381
+ },
382
+ {
383
+ name: "append",
384
+ token: "append",
385
+ syntax: "list append v",
386
+ description: "Append a value to a list.",
387
+ example: 'items append "x"'
388
+ },
389
+ {
390
+ name: "prepend",
391
+ token: "prepend",
392
+ syntax: "list prepend v",
393
+ description: "Prepend a value to a list.",
394
+ example: 'items prepend "x"'
395
+ },
396
+ {
397
+ name: "remove",
398
+ token: "remove",
399
+ syntax: "list remove v",
400
+ description: "Remove a value from a list (or the current loop row).",
401
+ example: 'items remove "x"'
402
+ },
403
+ {
404
+ name: "clear",
405
+ token: "clear",
406
+ syntax: "x clear",
407
+ description: "Empty a list or reset a value to null.",
408
+ example: "items clear"
409
+ },
410
+ {
411
+ name: "merge",
412
+ token: "merge",
413
+ syntax: "obj merge other",
414
+ description: "Shallow-merge another object/state key.",
415
+ example: "settings merge patch"
416
+ },
417
+ {
418
+ name: "delete",
419
+ token: "delete",
420
+ syntax: "obj delete key",
421
+ description: "Delete a key from an object.",
422
+ example: 'settings delete "beta"'
423
+ },
424
+ {
425
+ name: "reset",
426
+ token: "reset",
427
+ syntax: "name reset",
428
+ description: "Reset a state key to its initial value.",
429
+ example: "form reset"
430
+ }
431
+ ];
432
+
433
+ /** Hand metadata for each formatter, keyed by name; keys are drift-checked to
434
+ * equal FORMATTER_NAMES exactly (tests/catalog.test.js). */
435
+ /** @type {Record<string, { description: string, example: string }>} */
436
+ const PIPE_META = {
437
+ money: { description: "Currency format.", example: "{ p.price | money }" },
438
+ number: { description: "Number with optional fixed decimals.", example: "{ p.qty | number:2 }" },
439
+ percent: { description: "Percentage format.", example: "{ p.rate | percent }" },
440
+ round: { description: "Round to N decimals.", example: "{ p.score | round:1 }" },
441
+ date: { description: "Localized date.", example: "{ p.published | date }" },
442
+ time: { description: "Localized time.", example: "{ p.published | time }" },
443
+ datetime: { description: "Localized date + time.", example: "{ p.published | datetime }" },
444
+ upper: { description: "UPPERCASE.", example: "{ p.name | upper }" },
445
+ lower: { description: "lowercase.", example: "{ p.name | lower }" },
446
+ capitalize: { description: "Capitalize the first letter.", example: "{ p.name | capitalize }" },
447
+ truncate: { description: "Cut to length with an ellipsis.", example: "{ p.bio | truncate:80 }" },
448
+ trim: { description: "Trim surrounding whitespace.", example: "{ p.name | trim }" },
449
+ pluralize: {
450
+ description: "Count + singular/plural word.",
451
+ example: '{ p.qty | pluralize:"item" }'
452
+ },
453
+ default: {
454
+ description: "Fallback when empty/null.",
455
+ example: '{ p.nickname | default:"friend" }'
456
+ },
457
+ sum: { description: "Sum a list (optionally a field).", example: '{ cart | sum:"price" }' },
458
+ avg: { description: "Average a list.", example: "{ scores | avg }" },
459
+ min: { description: "Minimum of a list.", example: "{ prices | min }" },
460
+ max: { description: "Maximum of a list.", example: "{ prices | max }" },
461
+ count: { description: "Item count of a list.", example: "{ cart | count }" },
462
+ join: { description: "Join a list into a string.", example: '{ tags | join:", " }' }
463
+ };
464
+
465
+ /** One-line description per comparison operator. */
466
+ /** @type {Record<string, string>} */
467
+ const OP_DESC = {
468
+ contains: "List/string membership.",
469
+ "==": "Equal.",
470
+ "!=": "Not equal.",
471
+ ">=": "Greater than or equal.",
472
+ "<=": "Less than or equal.",
473
+ ">": "Greater than.",
474
+ "<": "Less than."
475
+ };
476
+
477
+ /** Read the installed version from package.json (always shipped beside src/). */
478
+ function readVersion() {
479
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
480
+ return String(pkg.version || "0.0.0");
481
+ }
482
+
483
+ /**
484
+ * The structured, machine-readable catalog of the whole `.wd` authoring surface.
485
+ * @returns {DirectiveCatalog}
486
+ */
487
+ export function directiveCatalog() {
488
+ return {
489
+ version: readVersion(),
490
+ format: ".wd",
491
+ directives: DIRECTIVES.map((d) => ({ ...d })),
492
+ loopClauses: LOOP_CLAUSES.map((c) => ({ ...c })),
493
+ loopVariables: Object.keys(LOOP_META).map((name) => ({
494
+ name,
495
+ description: LOOP_VAR_DESC[name],
496
+ example: `{ ${name} }`
497
+ })),
498
+ actionOps: ACTION_OPS.map((a) => ({
499
+ name: a.name,
500
+ syntax: a.syntax,
501
+ description: a.description,
502
+ example: a.example
503
+ })),
504
+ formatPipes: FORMATTER_NAMES.map((name) => ({
505
+ name,
506
+ syntax: `{ value | ${name} }`,
507
+ description: PIPE_META[name].description,
508
+ example: PIPE_META[name].example
509
+ })),
510
+ predicateOps: PREDICATE_OPS.map((op) => ({
511
+ name: op,
512
+ description: OP_DESC[op],
513
+ example: op === "contains" ? 'p.tags contains "sale"' : `p.price ${op} 50`
514
+ })),
515
+ predicateJoiners: [...PREDICATE_JOINERS]
516
+ };
517
+ }
518
+
519
+ /** The action tokens the catalog exposes — used by the drift guard. */
520
+ export const CATALOG_ACTION_TOKENS = ACTION_OPS.map((a) => a.token);
521
+
522
+ /**
523
+ * Render the catalog as a compact llms.txt-style markdown cheatsheet — the
524
+ * artifact an app pastes into a small model's system prompt. Generated entirely
525
+ * from {@link directiveCatalog}, so it can never disagree with what compiles.
526
+ * @returns {string}
527
+ */
528
+ export function llmsText() {
529
+ const cat = directiveCatalog();
530
+ const out = [];
531
+ out.push(`# Darkmown .wd cheatsheet (v${cat.version})`);
532
+ out.push("");
533
+ out.push("Darkmown compiles Markdown. A plain `.md` file stays plain; rename it to `.wd`");
534
+ out.push("to unlock the directives below. Static pages ship ZERO JavaScript; only pages");
535
+ out.push("that use :state/:store/:fetch/:button (reactive) load the ~8 KB runtime. Copy an");
536
+ out.push("example verbatim — every one compiles. Interpolate values with `{ path }`.");
537
+ out.push("");
538
+
539
+ out.push("## Directives");
540
+ for (const d of cat.directives) {
541
+ out.push(`- \`${d.name}\` (${d.reactive}) — ${d.description} e.g. \`${d.example}\``);
542
+ }
543
+ out.push("");
544
+
545
+ out.push("## @loop clauses (this fixed order, all optional)");
546
+ for (const c of cat.loopClauses) {
547
+ out.push(`- \`${c.syntax}\` — ${c.description} e.g. \`${c.example}\``);
548
+ }
549
+ out.push("");
550
+
551
+ out.push("## Loop row variables (inside a loop body)");
552
+ out.push(
553
+ `- ${cat.loopVariables.map((v) => `\`${v.name}\``).join(", ")} — ${cat.loopVariables
554
+ .map((v) => `${v.name} = ${v.description.toLowerCase().replace(/\.$/, "")}`)
555
+ .join("; ")}.`
556
+ );
557
+ out.push("");
558
+
559
+ out.push("## Button / :effect / :every actions (chain with `;`)");
560
+ for (const a of cat.actionOps) {
561
+ out.push(`- \`${a.syntax}\` — ${a.description} e.g. \`${a.example}\``);
562
+ }
563
+ out.push("");
564
+
565
+ out.push("## Format pipes `{ value | name:arg }`");
566
+ for (const p of cat.formatPipes) {
567
+ out.push(`- \`${p.name}\` — ${p.description} e.g. \`${p.example}\``);
568
+ }
569
+ out.push("");
570
+
571
+ out.push("## Predicate operators (where / :if / `.class when`)");
572
+ out.push(
573
+ `- ${cat.predicateOps.map((o) => `\`${o.name}\``).join(", ")} — join conditions with ${cat.predicateJoiners
574
+ .map((j) => `\`${j}\``)
575
+ .join(" / ")}. e.g. \`${cat.predicateOps[0].example}\``
576
+ );
577
+ out.push("");
578
+
579
+ out.push("## Rules");
580
+ out.push("- `.md` never gets directives — the `.wd` extension is the feature gate.");
581
+ out.push("- One loop syntax (`@loop … into … @endloop`), one interpolation syntax (`{ name }`).");
582
+ out.push("- Actions and predicates are a fixed whitelist — no JavaScript, no `x.filter(...)`.");
583
+ out.push("- Close every block: `@endloop`, `:endif`, `:endform`, `:endcarousel`, `:::`.");
584
+ out.push("");
585
+ return out.join("\n");
586
+ }
package/src/cli.js CHANGED
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { handleApiRequest } from "./api-runner.js";
8
8
  import { buildSite } from "./builder.js";
9
+ import { directiveCatalog, llmsText } from "./catalog.js";
9
10
  import { createPaths } from "./config.js";
10
11
  import { DEPLOY_TARGETS, deploy } from "./deploy.js";
11
12
  import {
@@ -62,6 +63,12 @@ export async function run(argv, env = {}) {
62
63
  log(pkg.version);
63
64
  return { command: "version" };
64
65
  }
66
+ if (command === "catalog") {
67
+ // `catalog` prints the machine-readable directive catalog as JSON; `--llms`
68
+ // prints the compact llms.txt cheatsheet an app pastes into a model prompt.
69
+ log(argv.includes("--llms") ? llmsText() : JSON.stringify(directiveCatalog(), null, 2));
70
+ return { command: "catalog" };
71
+ }
65
72
  if (command === "init") {
66
73
  const target = argv.slice(1).find((arg) => !arg.startsWith("-")) || ".";
67
74
  const template = flagValue(argv, "--template");
@@ -385,6 +392,7 @@ Usage:
385
392
  [--drafts] Include draft: true pages (staging)
386
393
  darkmown deploy <${DEPLOY_TARGETS.join("|")}> [--prod] Build + deploy to a platform
387
394
  darkmown serve Preview the built dist locally
395
+ darkmown catalog [--llms] Print the .wd directive catalog (JSON, or --llms cheatsheet)
388
396
  darkmown version Print the installed Darkmown version
389
397
  darkmown help Show this help
390
398