@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/AGENTS.md CHANGED
@@ -7,7 +7,7 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
7
7
  - `.md` files are plain CommonMark. Directives stay inert text.
8
8
  - `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
9
9
  - To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
10
- - Static pages ship **zero** JavaScript; a page loads the ~7.7 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior.
10
+ - Static pages ship **zero** JavaScript; a page loads the ~6.6 KB gzipped runtime (CI-enforced under 8 KB) only if it declares reactive behavior. What ships at `/__wd/runtime.js` is a minified build of the readable source, with a sourcemap beside it at `/__wd/runtime.js.map`.
11
11
 
12
12
  ### ⚠️ Raw HTML is escaped by default (since 2.0.0)
13
13
 
@@ -16,10 +16,16 @@ Raw HTML you write in a `.md`/`.wd` body is **escaped by default** — a `<div>`
16
16
  ```wd
17
17
  ---
18
18
  title: My page
19
- html: true ← required for the raw <main>/<div>/<a> below to render as HTML
19
+ html: true
20
20
  ---
21
21
  ```
22
22
 
23
+ `html: true` on that page is what lets a raw `<main>` / `<div>` / `<a>` in its body render as markup instead of escaped text.
24
+
25
+ **Annotations never go inside a `wd` fence.** Every line inside a fenced `wd` block is real source: a trailing `←` note or a `#` comment on a directive line is parsed as part of the directive and either breaks the build or (in frontmatter) is silently swallowed into the value. `.wd` has no comment syntax. Put explanations in prose next to the block, the way this file does.
26
+
27
+ **A directive is a whole line, flush left, with its arguments.** An indented directive is prose and a directive inside a fenced block is code. A line that is only a keyword (`:state`, `:button`, `@loop`, and every other directive that takes arguments) is a **compile error** carrying that directive's own code and `Use:` hint, so a lone `:state` is `[WD201]` and a lone `:button` is `[WD301]`. Three keywords are valid bare because they mean something bare: `:::`, `:theme`, `:carousel`. To print a directive name as text, escape it: `\:fetch` renders `:fetch`.
28
+
23
29
  `{ name }` interpolation, directives, and Markdown never needed `html: true` — only literal HTML tags do. One thing `html: true` does **not** unlock: an inline `<script>` is still blocked by the shipped Content-Security-Policy — put behavior in a colocated `.js` file instead.
24
30
 
25
31
  ## Project layout & routing
@@ -28,14 +34,48 @@ html: true ← required for the raw <main>/<div>/<a> below to render as
28
34
  site/
29
35
  pages/ ← the route tree. index.wd → /, about.wd → /about/, docs/index.wd → /docs/
30
36
  _/ ← include shelf + shared partials (not routed). nav.wd and shelf JSON live here.
37
+ api/ ← optional serverless functions: export default (request) => Response
31
38
  package.json
32
39
  ```
33
40
  - **Folders are routes.** `site/pages/blog/post.wd` serves at `/blog/post/`.
34
41
  - Files/folders starting with `.`, `-`, or `_` are **hidden** from routing (drafts, partials).
35
42
  - A `page.skin` next to `page.wd` attaches styling; a `page.js` attaches behavior. Matched by basename.
36
43
  - **Colocated assets:** any other file under `site/pages/` (not a `.md`/`.wd` route, not a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type — so `site/pages/logo.svg` is served at `/logo.svg`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
44
+ - Shelf `.json` files publish to `/__wd/data/<name>`; every other shelf file publishes to `/__wd/media/<path>`.
45
+
46
+ Commands: `darkmown dev`, `darkmown build [--drafts]`, `darkmown serve`, `darkmown deploy <vercel|cloudflare>`, `darkmown catalog [--llms|--llms-full]`.
47
+
48
+ ## Frontmatter: the complete key set
49
+
50
+ A `---` block at the very top of the file. Values are strings, plus inline arrays (`tags: [a, b, "x, y"]`).
51
+
52
+ ```wd
53
+ ---
54
+ title: Hello, Darkmown
55
+ description: Why I rewrote my blog as plain Markdown files.
56
+ image: https://example.com/og.png
57
+ lang: en
58
+ html: true
59
+ transitions: true
60
+ draft: true
61
+ date: 2026-09-04
62
+ updated: 2026-09-05
63
+ excerpt: A short summary for the feed.
64
+ author: Ada Lovelace
65
+ schema: BlogPosting
66
+ site_url: https://example.com
67
+ ai_crawlers: allow
68
+ rss_limit: 20
69
+ ---
70
+ ```
37
71
 
38
- Commands: `darkmown dev`, `darkmown build`, `darkmown serve`.
72
+ - `title`, `description`, `image` drive `<title>`, the meta description, and the Open Graph / Twitter card. `lang` sets `<html lang>` (default `en`).
73
+ - `html: true` allows raw HTML in that file's body. `transitions: true` opts into view transitions plus link prerendering.
74
+ - `draft: true` keeps the page out of `dist`, `routes.json`, the sitemap, and the feed. `darkmown dev` still serves it; `darkmown build --drafts` includes it.
75
+ - `date` marks the page a post (RSS item, sitemap `lastmod`, `og:type=article`). `updated` and `excerpt` refine that. `author` feeds an article `schema:`.
76
+ - `schema:` emits JSON-LD. One value, or a list. The closed set: `Article`, `BlogPosting`, `TechArticle`, `WebSite`, `Organization`. `organization:` and `logo:` support the `Organization` type. Anything else is a compile error.
77
+ - **Home page only:** `site_url: https://example.com` (no trailing slash) turns on `sitemap.xml`, `rss.xml`, and canonical URLs. `ai_crawlers: allow` (default) or `deny` flips every AI-crawler group in `robots.txt`. `rss_limit: 50` sets how many newest posts `rss.xml` carries (default 20).
78
+ - The page's own frontmatter is in scope as `meta`. Anything else you write is a free-form field, queryable when the page is a collection entry.
39
79
 
40
80
  ## Interpolation — one syntax
41
81
 
@@ -43,34 +83,129 @@ Commands: `darkmown dev`, `darkmown build`, `darkmown serve`.
43
83
 
44
84
  The page's frontmatter is in scope as `meta`: `{ meta.title }` prints a field, `{ meta.tags }` joins an array with `, `, and `@loop meta.tags into tag` iterates a frontmatter array (build-time, stays static). Frontmatter arrays are inline only — `tags: [a, b, "x, y"]`; a value without a leading `[` is a plain string.
45
85
 
86
+ Values resolve **inside a link or image destination** too, and every brace in the destination resolves, not just one filling the whole value: `[Docs](/docs/{ region }/)`. A build-time value folds and the page stays static; a `:state`/`:store`/`:computed` value, a reactive loop row, or a `{ $index }` becomes a **live binding** the runtime rewrites on every render.
87
+
88
+ ```wd
89
+ :state region = "eu"
90
+
91
+ :button "US" -> region = "us"
92
+
93
+ [Open the docs](/docs/{ region }/)
94
+ ```
95
+
96
+ Values in URL position are percent-encoded, and the runtime refuses a `javascript:`, `data:`, or `vbscript:` result by applying an empty attribute. Values resolve in **raw HTML** too (attributes and html blocks, HTML-escaped, `html: true` only), but raw HTML is a build-time paint, **not** a binding: `<a href="{ url }">` never updates, and the compiler warns. Fenced code and inline code spans are never rewritten.
97
+
98
+ ### Format pipes: `{ value | name:arg }`
99
+
100
+ Shape a value for display. Pipes chain left to right and take literal arguments. They fold at build time for static values and re-apply on every render for `:state`/`:store` bindings, same syntax either way.
101
+
102
+ ```wd
103
+ :state cart = [{"name": "Aurora", "price": 89, "joined": "2026-06-22"}]
104
+
105
+ @loop cart into p
106
+ { p.name | upper } · { p.price | money } · { p.joined | date:"medium" }
107
+ @endloop
108
+
109
+ { cart | count } lines, { cart | sum:"price" | money } total.
110
+ ```
111
+
112
+ The whitelist, and nothing else: `money[:currency[:locale]]`, `number[:decimals]`, `percent[:decimals]`, `round[:decimals]`, `date` / `time` / `datetime` `[:style]` (`short`/`medium`/`long`), `upper`, `lower`, `capitalize`, `truncate:n`, `trim`, `pluralize:"item"[:"plural"]`, `default:"text"`, `join:", "`, and the five list aggregates `sum` / `avg` / `min` / `max` / `count` (each takes an optional `"field"` argument). There is no custom-pipe hook and no relative "time ago". An unknown pipe name is a compile error listing the valid ones.
113
+
114
+ A pipe only shapes a value the page can **resolve**. `{ 89 | money }` is not a name in scope, so it stays literal text, braces and all.
115
+
46
116
  ## Directives — the complete set (nothing else exists)
47
117
 
48
- ### State and buttons
118
+ Twenty-five directives. `@include`, `@loop`, `:::`, `:if`, `:state`, `:store`, `:computed`, `:fetch`, `:effect`, `:every`, `:theme`, `:button`, `:form`, `:input`, `:textarea`, `:select`, `:checkbox`, `:radio`, `:submit`, `:bind`, `:slider`, `:video`, `:audio`, `:embed`, `:carousel`. Block forms close with `@endloop`, `:endif`, `:endform`, `:endcarousel`, or a bare `:::`. Run `darkmown catalog` for the same list as JSON.
119
+
120
+ ### State, stores, and computed
49
121
  ```wd
50
122
  :state count = 0
51
- :state cart = [] persist ← persist = localStorage
123
+ :state draftNote = "" persist
124
+ :store cart = []
125
+ :store sidebarOpen = false ephemeral
126
+ :computed total = count * 4
127
+
128
+ Count: { count } · Total: { total }
129
+ ```
130
+ - `:state name = value` is page-scoped (and section-scoped inside a `::: section`). **Ephemeral by default**; add `persist` for localStorage, and `from-url` to mirror the value into the query string.
131
+ - `:store name = value` is global by name, shared across pages and tabs. **Persisted by default**; add `ephemeral` to opt out.
132
+ - `:computed name = <expression>` derives from other state: names, numbers, `+ - * /`, comparisons, and the five aggregates `sum(list, field)` / `avg` / `min` / `max` / `count(list)`. It takes no `persist`/`ephemeral` (that is a compile error).
133
+ - Values are JSON: string, number, `true`/`false`/`null`, array, object. An array or object literal may span lines; the `persist`/`ephemeral` token then goes after the closing bracket. A blank line inside the literal ends it.
134
+ - A **seed is stored verbatim**. `:state b = a` where `a` is declared state is `[WD251]`, not a reference: derive it with `:computed b = a`, or quote it to keep the literal word. A plain undeclared word is still fine (`:state status = draft`).
135
+ - `persist` and `ephemeral` on one line, or the same modifier twice, is `[WD261]`. A declaration named `__proto__`, `constructor`, or `prototype` is `[WD250]` on every declaring directive.
136
+ - A declaration inside an `:if` branch that starts **closed** is hydrated when the branch opens, `persist` included. The seed is claimed once per key, so re-opening the branch does not reset the value.
137
+
138
+ ```wd
139
+ :store rows = [
140
+ {"id": 1, "label": "One"},
141
+ {"id": 2, "label": "Two"}
142
+ ] persist
143
+ ```
144
+
145
+ **`from-url` puts a `:state` in the query string.** The parameter is named after the key (`cart:items` becomes `cart.items`), writes go through `history.replaceState` (no history entry per keystroke), a value equal to its seed drops the parameter, and boot precedence is URL, then storage, then seed. It composes with `persist`.
146
+
147
+ ```wd
148
+ :state q = "" from-url persist
149
+
150
+ :bind q placeholder="Search"
52
151
 
53
- Count: { count }
152
+ Searching for { q }.
153
+ ```
154
+
155
+ `from-url` is `:state` only: on a `:store` or `:theme` it is `[WD260]` (both are shared by every page and tab, a query parameter belongs to one address), and on a `:computed` it is `[WD211]`.
156
+
157
+ ### Buttons and the action vocabulary
158
+ ```wd
159
+ :state count = 0
160
+ :state flag = false
161
+ :state items = []
162
+ :state settings = {"beta": true}
54
163
 
55
164
  :button "Add" -> count++
56
165
  :button "Add 5" -> count += 5
57
- :button "Add item" -> cart += {"id": 1, "name": "Sticker"}
58
- :button "Reset" -> count = 0
166
+ :button "Reset" -> count reset
167
+ :button "Two things" -> count++ ; flag toggle
59
168
  ```
60
- Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list += <json>`, `x = <json>`. Target must be declared `:state`. Literal values are JSON. **`:button` is for state actions only** — for a link styled as a button, write raw HTML `<a class="btn" href="…">` (raw HTML needs `html: true` in the page frontmatter — see the raw-HTML note above; or use a trailing `[label](/url){.btn}` inline-attribute, which needs no `html:` opt-in).
169
+ `:button "Label" -> action` mutates one declared `:state`/`:store`. Chain actions with `;`. The **fixed whitelist** (nothing else compiles):
61
170
 
62
- **Per-row actions (inside a reactive `@loop … into item`):** two actions reference the current row instead of a JSON literal —
171
+ | Action | Syntax |
172
+ |---|---|
173
+ | Increment / decrement | `n++` · `n--` |
174
+ | Add / subtract | `n += 5` · `n -= 2` |
175
+ | Set | `name = value` |
176
+ | Toggle a boolean | `flag toggle` |
177
+ | Append / prepend | `list append v` (or `list += v`) · `list prepend v` |
178
+ | Member toggle | `list toggle v` |
179
+ | Remove a value | `list remove v` |
180
+ | Clear | `name clear` |
181
+ | Merge / delete a key | `obj merge other` · `obj delete "key"` |
182
+ | Reset to the declared seed | `name reset` |
183
+ | Re-run a `:fetch` | `name refetch` |
184
+
185
+ Targets can be dotted paths (`cart.count++`). Values are JSON literals. A `:button` and a `:::` container header accept exactly three static attributes, written before the `->`: `role="…"`, `aria-…="…"`, and `title="…"` (e.g. `:button "Menu" aria-expanded="false" aria-controls="m" -> open toggle`, `::: card .note role="region" aria-label="Notes"`). Anything else (`class=`, `id=`, `style=`, `onclick=`, `data-…=`) is a compile error; style with `.class` tokens and act with `->` actions. For a link styled as a button, write `[Get started](/start/){.btn}` or raw HTML `<a class="btn" href="…">` (raw HTML needs `html: true`).
186
+
187
+ **Per-row actions (inside a reactive `@loop … into item`):** two actions reference the current row instead of a JSON literal.
63
188
  ```wd
189
+ :state products = [{"id": 1, "name": "Aurora"}]
190
+ :state cart = []
191
+
64
192
  @loop products into product
65
- :button "Add to cart" -> cart += product ← append a COPY of this row to another :state list
66
- :::
193
+ :button "Add to cart" -> cart += product
194
+ @endloop
195
+
67
196
  @loop cart into line
68
- :button "Remove" -> cart remove line ← drop this row from the list being looped
197
+ :button "Remove" -> cart remove line
198
+ @endloop
69
199
  ```
70
- `cart += product` is the only way to carry a loop item into another list (literals still can't reference rows). `<list> remove <item>`: `<list>` must be the loop's own `:state` source and `<item>` the loop variable (both compile-checked). This is the canonical add-to-cart / remove-line / delete-todo pattern — no `.js` needed. Removal targets the exact row, so it is correct even under a `where` filter.
200
+ `cart += product` appends a **copy** of this row to another `:state` list; it is the only way to carry a loop item into another list. `<list> remove <item>` drops this row from the list being looped: `<list>` must be that loop's own `:state` source and `<item>` the loop variable, both compile-checked. This is the canonical add-to-cart / remove-line / delete-todo pattern, no `.js` needed. Removal targets the exact row, so it is correct even under a `where` filter.
71
201
 
72
202
  ### Conditionals
73
203
  ```wd
204
+ :state plan = "pro"
205
+ :state seats = 5
206
+ :state trialDays = 0
207
+ :state expired = false
208
+
74
209
  :if plan == "pro" or seats >= 5
75
210
  Pro plan.
76
211
  :else if trialDays > 0 and not expired
@@ -83,8 +218,10 @@ Free plan.
83
218
 
84
219
  ### Reactive classes — `.class when <predicate>`
85
220
  ```wd
221
+ :state tasks = [{"id": 1, "title": "Ship", "done": false, "priority": 4}]
222
+
86
223
  @loop tasks into task
87
- ::: card .task .done when task.done .urgent when task.priority > 3 ← toggles each class by predicate
224
+ ::: card .task .done when task.done .urgent when task.priority > 3
88
225
  **{ task.title }**
89
226
  :::
90
227
  @endloop
@@ -93,71 +230,239 @@ Free plan.
93
230
 
94
231
  ### Loops — the only loop
95
232
  ```wd
96
- @loop /products.json into product ← JSON file: unrolled at build time
97
- - **{ product.name }** — { product.price }
233
+ @loop /products.json into product
234
+ - **{ product.name }** · { product.price | money }
98
235
  @endloop
99
236
 
100
237
  :state todos = [{"id": 1, "title": "Ship"}]
101
- @loop todos into todo ← :state list: reactive, patched by key
238
+ @loop todos into todo
102
239
  - { todo.title }
103
240
  @endloop
104
241
  ```
105
- Source: a JSON file path, an in-scope value, or a `:state` list. Loops nest; `:if todo.done` branches per row.
242
+ A JSON file path is unrolled at build time and stays zero-JS. A `:state`/`:store`/`:fetch` list is reactive and patched by key. An in-scope value (an include argument, a frontmatter array, a dotted path off a row) also works. Loops nest, and `:if todo.done` branches per row. One level of reactive-inside-reactive nesting is allowed; a third is a compile error.
243
+
244
+ **Clauses, in this fixed order after `into <item>`:**
245
+
246
+ ```
247
+ @loop <src> into <item> [where …] [sort by <key> [asc|desc]] [reverse] [offset N] [limit N] [paginate N] [sortable]
248
+ ```
249
+
250
+ ```wd
251
+ @loop /products.json into p where p.price < 80 sort by p.price desc reverse offset 1 limit 5
252
+ { $number }. { p.name }
253
+ @empty
254
+ Nothing matched.
255
+ @endloop
256
+ ```
257
+
258
+ - `where` filters. `sort by <item>.field [asc|desc]` orders (numbers numerically, everything else as text; `asc` is the default). Both the field and the direction may be a `{ state }` reference, which is how a clickable-header sort works.
259
+ - `reverse` flips the order. `offset N` / `limit N` slice, and `N` may be a `:state` key (which makes the loop reactive).
260
+ - `paginate N` is **collections only** and splits the listing into real routes: page 1 keeps the route, pages 2+ land at `/<route>/page/2/`. It cannot combine with `offset`/`limit`.
261
+ - `sortable` (a bare clause, **not** `:sortable`) makes a plain reactive `:state`/`:store` loop drag-reorderable, with keyboard support. Not valid alongside `where`/`sort`/`reverse`/`offset`/`limit`.
262
+ - `@empty` opens a fallback branch rendered when the loop produces no rows. A missing in-scope source is an empty list, not an error.
263
+
264
+ **Markdown table rows.** A **static** loop whose body is bare `| … |` cells fills a table: write the header in prose above the loop, or put the whole table inside it. Either way the result is one `<table>` with one `<tr>` per row.
106
265
 
107
- ### Filtering — `@loop … where` + live search with `:bind`
108
266
  ```wd
109
- @loop /products.json into p where p.featured == true and p.price < 80
110
- - { p.name } ← item-only predicate → filtered at BUILD time, stays zero-JS
267
+ | Item | Price |
268
+ | --- | --- |
269
+ @loop /products.json into row
270
+ | **{ row.name }** | { row.price } |
111
271
  @endloop
272
+ ```
112
273
 
113
- :state products = [{"id":1,"name":"Aurora"},{"id":2,"name":"Briza"}]
114
- :state q = ""
115
- :bind q placeholder="Search" ← <input> bound two-way to :state q
116
- @loop products into p where p.name contains q ← predicate reads :state → reactive, re-filters live
117
- - { p.name }
274
+ A format pipe inside a cell needs its `|` escaped (`{ row.price \| money }`). A pipe row written **after** `@endloop` stays a paragraph. A **reactive** loop over pipe rows is `[WD191]`: a reactive row is cloned into a `<div>`, which cannot live in a `<table>`, so use a static source, or build rows from containers (`::: trow` / `::: td`).
275
+
276
+ **Row variables**, valid only inside a loop body: `{ $index }` (0-based), `{ $number }` (1-based), `{ $first }`, `{ $last }`, `{ $count }`. They work in interpolation and in `:if`.
277
+
278
+ ### Content collections and `_schema.wd`
279
+
280
+ **Any folder under `site/pages/` is a queryable collection**, referenced in `@loop` by its bare name. No `content/` root, no marker file: `site/pages/blog/` *is* the `blog` collection, and it resolves at build time (zero JS).
281
+
282
+ ```wd
283
+ @loop blog into post sort by post.date desc limit 10
284
+ - [{ post.title }]({ post.url }) · { post.date | date:"medium" }
118
285
  @endloop
119
286
  ```
120
- `where` joins conditions with `and` / `or`. Operators: `==` `!=` `<` `<=` `>` `>=` and `contains` (case-insensitive substring). Operands are item paths (`p.field`), declared `:state`, numbers, or `"strings"` — whitelist-validated, no expressions. **Rule that decides JS: if the predicate only reads the row, it filters at build time and the page stays static; if it reads any `:state`, the loop becomes reactive.** `:bind name` is the input primitive for live filtering — it accepts `type=` (default `text`), `placeholder=`, `autocomplete=`, and `required` / `autofocus`.
287
+
288
+ Each entry's frontmatter becomes a row, plus three derived fields: `{ post.url }` (the route), `{ post.slug }` (the filename stem), `{ post.excerpt }` (the frontmatter `excerpt:`, else the first paragraph). Drafts never appear in a default build.
289
+
290
+ Drop a `_schema.wd` at the collection root to type-check every entry's frontmatter at build time. It is frontmatter-shaped, one `field: type` rule per line:
291
+
292
+ ```wd
293
+ ---
294
+ title: string
295
+ date: date
296
+ excerpt: string?
297
+ tags: string[]?
298
+ ---
299
+ ```
300
+
301
+ The closed vocabulary: `string`, `number`, `boolean`, `date`, `string[]`, each with a trailing `?` for optional. A missing required field, a wrong type, an unknown extra field, or an unknown type token fails the build with a `file:line`. No `_schema.wd`, no validation.
121
302
 
122
303
  ### Includes & sections
123
304
  ```wd
124
- @include /nav.wd ← partial from site/_/
125
- @include /card.wd with title={ row.name }
305
+ @include /nav.wd
306
+ @include /card.wd with title="Aurora"
126
307
 
127
- ::: section #cart .panel ← container with id/classes; state inside is scoped to it
308
+ ::: section #cart .panel
128
309
  :state items = []
129
310
  { items.length } items
130
311
  :::
131
312
  ```
313
+ - `@include /partial.wd [with key="value" …]` inlines a file from `site/_/` or `site/pages/`. Paths are **static literals**; there is no dynamic include path. Includes are macros, not components: no children, no slots, no default arguments. A missing argument renders `{ title }` literally.
314
+ - `::: [tag] [.class …] [#id] [.class when …] [role="…"] [aria-…="…"] [title="…"] … :::` groups content. A container named `section`, `nav`, or `main` emits that real element; any other name is a `<div>` with that class. State declared inside a section is scoped to it (`sectionId:name`).
315
+ - Container attributes are **exactly three names** (`role=`, any `aria-…=`, `title=`), always double-quoted static text, interleaved freely with `.class`/`#id` tokens: `::: nav .menu role="navigation" aria-label="Main"`. Anything else (`class=`, `id=`, `style=`, `onclick=`, `data-…=`) is `[WD650]`; a missing quoted value is `[WD651]`. No `{ state }` inside an aria value: it emits the literal braces. Zero runtime cost, a static page stays static.
316
+ - There is **no layout or shell inheritance**. Every page includes its own nav and footer.
317
+
318
+ ### Fetching data
319
+ ```wd
320
+ :fetch roster from "/__wd/data/team.json" timeout=8000 retry=2
321
+ :fetch quotes from "/q.json" when=visible
322
+
323
+ :if roster_loading
324
+ Loading…
325
+ :else if roster_error
326
+ Could not load the team: { roster_error }
327
+ :else
328
+ @loop roster into member
329
+ - { member.name }
330
+ @empty
331
+ No team members yet.
332
+ @endloop
333
+ :endif
334
+ ```
335
+ `:fetch name from "url"` declares five keys: `name` (the data, `null` until it lands), `name_loading`, `name_error`, `name_error_body`, `name_empty`. On a failing response with a JSON body, `name_error` is the body's own `error` field, then its `message` field, falling back to `HTTP <status>`; `name_error_body` is the whole parsed body (`null` if the response was not JSON), so `{ signup_error_body.fields.email }` renders a field-level message. Both are cleared at the start of the next request, and a round-trip `:form` (one with both `into` and `action=`) declares them too. Options: `method=` (GET/POST/PUT/PATCH/DELETE), `when=load|visible`, `timeout=<ms>`, `retry=<N>`, `headers=<state key>`, `body=<state key>`, `refresh=<url>`. A URL may interpolate `{ state }` and re-runs when it changes; `name refetch` re-runs it by hand.
132
336
 
133
- ### Data, forms, computed
337
+ URLs must be a relative path, an explicit `http(s)://`, or a leading `{ state }`. Protocol-relative `//host` and non-http(s) schemes are compile errors. A bare `:if name_loading` region gets `role="status" aria-live="polite"` and `:if name_error` gets `role="alert"` for free.
338
+
339
+ **Authenticated requests.** Seed the store **empty** and let your `api/` endpoint fill it at runtime. A literal token written into a `.wd` file is inlined into public HTML for everyone to read.
134
340
  ```wd
135
- :fetch team from "/__wd/data/team.json" ← loads JSON into state; sets team_error on failure
136
- :fetch quotes from "/q.json" when=visible ← lazy, on scroll into view
341
+ :store session = {}
342
+
343
+ :form action="/api/login" into login
344
+ :input email type=email required
345
+ :input password type=password required
346
+ :submit "Sign in"
347
+ :endform
348
+
349
+ :effect login -> session merge login
137
350
 
138
- :form into profile ← captures submit into state (declares `profile`), no backend
351
+ :fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
352
+ ```
353
+ `headers=<key>` sends a state object as request headers. `refresh=<url>` (which requires `headers=`) renews the token on a `401` and retries once.
354
+
355
+ ### Forms and fields
356
+ ```wd
357
+ :form into profile
139
358
  :input name placeholder="Your name" required
140
- :input email type=email placeholder="Email" ← set type= for email/number/etc (default is text)
359
+ :input email type=email placeholder="Email"
360
+ :textarea note placeholder="Anything else?" rows=4
361
+ :select topic
362
+ - General
363
+ - Billing
364
+ :checkbox channels
365
+ - Email
366
+ - SMS
367
+ :radio plan
368
+ - Basic
369
+ - Pro
141
370
  :submit "Save"
142
371
  :endform
143
372
 
144
- :if profile ← read form state AFTER :endform (see rule below)
373
+ :if profile
145
374
  Thanks, **{ profile.name }**!
146
375
  :endif
376
+ ```
377
+ - `:form into x` captures the submit into state, no backend. `:form action="/url"` emits a plain native POST (zero JS). `:form action="/url" into reply` does both: fetch when JS is on, native POST when it is not, JSON reply into `reply` (`reply_error` on failure). Darkmown owns no backend; point forms at your own `api/` or an absolute URL.
378
+ - `:select` / `:checkbox` / `:radio` take `- Label` option lines. `:checkbox` captures an **array** of checked values; `:radio` captures one.
379
+ - Every generated control derives an `aria-label` from its placeholder or field name unless you supply one.
380
+ - **File upload:** a `:form` containing a file field (`:input photo type=file`, or a raw `<input type="file">` on an `html: true` page) compiles with `enctype="multipart/form-data"` and posts real `FormData`, so the file travels rather than just its name. Bound controls inside the form are appended by state key. A file field with `method="get"` is `[WD452]` (a GET has no body).
147
381
 
148
- :computed total = items.length * 4 ← derived state; arithmetic & comparisons only
382
+ **Outside a `:form`, `:select`/`:radio`/`:checkbox` bind to state.** Same three lines, different meaning: where they sit decides which. Outside a form they bind to a declared `:state`/`:store` of that name, exactly like `:bind`.
149
383
 
150
- :store session = { "Authorization": "Bearer …" } ← persisted token
151
- :fetch feed from "/api/feed" headers=session refresh="/auth/refresh" ← auth header; 401 → renew token, retry once
384
+ ```wd
385
+ :state density = "Comfortable"
386
+ :state previews = true
152
387
 
153
- :effect q -> searches++ ← run actions when watched state changes
388
+ :select density
389
+ - Compact
390
+ - Comfortable
391
+ - Spacious
392
+
393
+ :checkbox previews
394
+ - Show image previews
395
+
396
+ :if density == "Compact"
397
+ Rows sit tight.
398
+ :endif
154
399
  ```
155
- Shelf `.json` files (in `site/_/`) publish to `/__wd/data/`. `:form action="/api/x" into reply` posts urlencoded and lands the JSON reply in state (without JS it degrades to a native POST). Darkmown owns no backend — point forms at your own API. `:fetch`/`refresh=` URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation. `:effect` actions are the `:button` vocabulary (`;`-chained); effects run on change, not on load, capped at 10 settle passes.
400
+
401
+ The state must be declared first: a bound field with no matching state is `[WD450]`. A **bound `:checkbox` is a single boolean**, so it takes exactly one `- Label` line; several options is `[WD451]` (use a `:radio` group). In-form behavior is unchanged, array-capturing checkbox groups included.
156
402
 
157
403
  **Form-state rules (these cause most form failures):**
158
404
  - `:form into x` declares `x` only at `:endform`. A `:if x` (or `{ x.field }`) that reads it must come **after `:endform`**, never inside the form body.
159
405
  - State declared inside a `::: section` is **scoped to that section** (as `sectionId:x`). If you wrap a `:form into x` in a section, a `:if x` *outside* that section can't see it. Keep the form and the `:if` that reads it in the **same scope** — or don't wrap the form in a section.
160
406
 
407
+ ### Bound controls: `:bind`, `:slider`
408
+ ```wd
409
+ :state products = [{"id": 1, "name": "Aurora"}, {"id": 2, "name": "Briza"}]
410
+ :state q = ""
411
+
412
+ :bind q placeholder="Search"
413
+ :slider volume = 50 min=0 max=100 step=5
414
+
415
+ Volume: { volume }
416
+
417
+ @loop products into p where p.name contains q
418
+ - { p.name }
419
+ @endloop
420
+ ```
421
+ `:bind <state>` is an `<input>` wired two-way to a declared `:state`. It accepts `type=` (default `text`), `placeholder=`, `autocomplete=`, and the `required` / `autofocus` flags. `:slider name = v [min=] [max=] [step=] [persist]` is a range input bound the same way; its value coerces to Number so `:computed` sees a number. Both are how live filtering works: **if a `where` predicate reads any `:state`, the loop becomes reactive and re-filters live; if it only reads the row, it filters at build time and the page stays zero-JS.**
422
+
423
+ ### Timers and effects: `:every`, `:effect`
424
+ ```wd
425
+ :state secs = 0
426
+ :state q = ""
427
+ :state searches = 0
428
+
429
+ :every 1s -> secs++
430
+ :effect q -> searches++
431
+ ```
432
+ - `:every <duration> -> <actions>` runs the `:button` vocabulary on an interval. Durations are `<n>ms` / `<n>s` / `<n>m`. Intervals **pause while the tab is hidden** and resume on return, so a background dashboard stops firing requests.
433
+ - `:effect <watched> -> <actions>` runs actions when a watched state path changes. Effects run on change, never on load, and are capped at 10 settle passes.
434
+ - **Both are page-level registrations.** Inside a reactive `@loop` body they are `[WD315]`: the runtime clones the body per row, so the timer or watcher would be registered once per row and a removed row's would keep firing. Declare it once outside the loop (page level, or inside the `::: section`) and act on the whole list: `:every 5s -> rows refetch`. Still fine: page level, inside a `:::`, inside a **static** loop, and in a reactive loop's `@empty` branch. `:button` inside a reactive loop is unaffected.
435
+
436
+ ### Theme toggle: `:theme`
437
+ ```wd
438
+ :theme
439
+ :button "Auto" -> theme = "auto"
440
+ :button "Light" -> theme = "light"
441
+ :button "Dark" -> theme = "dark"
442
+ ```
443
+ `:theme` registers a durable `theme` store and reflects it onto `<html data-theme="…">`. A `tokens dark` block in your `.skin` already powers both this toggle and the OS `prefers-color-scheme` query, so no extra CSS is needed. `:theme name = "light"` renames the store and seeds a different default.
444
+
445
+ ### Media: `:video`, `:audio`, `:embed`
446
+ ```wd
447
+ :video /clip.mp4 poster=/clip.jpg controls
448
+ :audio /track.mp3 controls
449
+ :embed https://youtu.be/aqz-KE-bpKQ title="Big Buck Bunny"
450
+ ```
451
+ All three are **compile-time only** and emit no `data-wd-*`, so a media page stays zero-JS. `:video`/`:audio` take whitelisted flags (`controls`, `autoplay`, `loop`, `muted`, `playsinline`) and attributes (`poster`, `width`, `height`, `preload`); `autoplay` implies `muted`. `:embed` rewrites a YouTube/Vimeo URL to its no-cookie/player form inside a lazy, responsive 16/9 box; any other `http(s)` URL becomes a generic lazy iframe.
452
+
453
+ ### Carousel: `:carousel`
454
+ ```wd
455
+ :carousel autoplay=4000
456
+ ::: slide
457
+ First slide
458
+ :::
459
+ ::: slide
460
+ Second slide
461
+ :::
462
+ :endcarousel
463
+ ```
464
+ Each direct child block is one slide, so wrap each in its own `:::` block and size it in your skin. Native CSS scroll-snap plus prev/next buttons, dots, and mouse drag. `autoplay` is suppressed under `prefers-reduced-motion`. `:carousel` and `sortable` each load a tiny `/__wd/behaviors/*.js` module, only on the pages that use them.
465
+
161
466
  ## What HTML each directive emits — target these in `.skin`
162
467
 
163
468
  Your `.skin` selectors must match the **real** output. The emitted HTML is:
@@ -173,19 +478,25 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
173
478
  | `:select name` + `- Label` lines | `<select>` of `<option>`s |
174
479
  | `:checkbox name` / `:radio name` + `- Label` lines | labelled `<input type=checkbox\|radio>` group (`:checkbox` captures an **array**, `:radio` one value) |
175
480
  | `:bind q placeholder="…"` | `<input>` bound two-way to `:state q` |
481
+ | `:select`/`:radio`/`:checkbox` **outside** a `:form` | the same control, bound to `:state` of that name (no `name=` on a `:select`) |
482
+ | `[x]({ state })`, `![alt]({ state })` | `<a href>` / `<img src>` the runtime rewrites on every render |
483
+ | `:slider v = 50 …` | `<input type="range">` |
176
484
  | `:submit "Go"` | `<button type="submit">Go</button>` |
177
485
  | `:form …` | `<form>…</form>` |
486
+ | `:video` / `:audio` / `:embed` | `<video>` / `<audio>` / a wrapped lazy `<iframe>` |
178
487
  | reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
179
488
  | `{ name }` | a text node |
180
489
 
181
- So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`; to add one to a single inline element (link, image, emphasis, inline code), use a trailing `{.class .class #id}` — e.g. `[Get started](/start/){.btn}`.** A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
490
+ So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`; to add one to a single inline element, use a trailing `{.class .class #id}`, as in `[Get started](/start/){.btn}`.** Inline attributes attach to **links, images, and emphasis** only; they do **not** work on inline code (`` `x` ``{.hl} renders the braces as text). A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
491
+
492
+ Every heading also gets a stable GitHub-style `id` at build time, so `[jump](#my-heading)` deep-links with zero JS.
182
493
 
183
494
  ## Styling with `.skin` — ship this on every page
184
495
 
185
496
  A colocated `.skin` compiles to CSS. It is indentation-structural: a line is a **selector** if the next line is indented under it; otherwise it is a `property value` declaration. Start from a real design system — tokens, a type scale, spacing, a responsive grid:
186
497
 
187
498
  ```skin
188
- tokens ← becomes :root CSS variables ($name → var(--name))
499
+ tokens
189
500
  ink #14181f
190
501
  muted #5b6470
191
502
  paper #ffffff
@@ -194,6 +505,11 @@ tokens ← becomes :root CSS variables ($name → var(--na
194
505
  line #e6e8ec
195
506
  radius 14px
196
507
 
508
+ tokens dark
509
+ ink #f4f4f5
510
+ paper #14181f
511
+ bg #0b0b0f
512
+
197
513
  body
198
514
  margin 0
199
515
  font 16px/1.6 ui-sans-serif, system-ui, -apple-system, sans-serif
@@ -264,10 +580,12 @@ input:focus-visible
264
580
  h1
265
581
  font-size 2.1rem
266
582
  ```
267
- Always include: a tokens block, a real type scale, generous spacing, a `max-width` centered `main`, styled `button`/`input` with `:hover`/`:focus-visible` states, and a responsive `@media` rule (`.skin` supports `@media` — its indented rules are wrapped automatically). The `font` property is the CSS shorthand when it leads with a size (`font 16px/1.6 system-ui`) and `font-family` otherwise. Prefer `.skin`; a plain `<style>` block in a `.wd` also works.
583
+ Always include: a tokens block, a real type scale, generous spacing, a `max-width` centered `main`, styled `button`/`input` with `:hover`/`:focus-visible` states, and a responsive `@media` rule (`.skin` supports `@media`, and its indented rules are wrapped automatically). A second `tokens dark` block overrides any token under the visitor's OS dark preference **and** under a `:theme` toggle, from one place. The `font` property is the CSS shorthand when it leads with a size (`font 16px/1.6 system-ui`) and `font-family` otherwise. Prefer `.skin`; a plain `<style>` block in a `.wd` also works.
268
584
 
269
585
  **Scoped styles — opt in with `scoped`.** A `.skin` is **global** by default (style site-wide; the right default for your design system). To stop a reusable component's classes from clashing with the rest of the page, make the **first line** of its `.skin` the word `scoped` — its selectors then only match the component it's colocated with (a page skin scopes that page; an include skin scopes just that include's subtree). You still write `class="card"`; the build stamps a `data-wd-scope` attribute and rewrites the selectors — zero runtime, a static page stays static. **Tokens stay global** (a `tokens`/`tokens dark` block still emits `:root` vars, so `$accent` and dark mode keep working). Escape a single rule with `:global(.x)` (whole-selector only). `page`/`*`/`html`/`body`/`::selection` are a compile error in a scoped skin — those belong in a global skin.
270
586
 
587
+ **Syntax highlighting is free.** A fenced code block tagged with a language is highlighted at build time (no client JS), and its token classes read `$code-*` tokens from your skin, so highlighted code follows dark mode with no extra wiring.
588
+
271
589
  **Layout gotcha — CTAs and link rows.** Markdown wraps adjacent links/buttons on consecutive lines into a single `<p>`, so putting `display:flex` on their container won't space them. To lay out a row of buttons or nav links, wrap them in explicit raw HTML and style that (the page needs `html: true` in its frontmatter for the raw `<div>`/`<a>` to render — see the raw-HTML note above):
272
590
  ```wd
273
591
  <div class="actions">
@@ -285,52 +603,49 @@ Always include: a tokens block, a real type scale, generous spacing, a `max-widt
285
603
 
286
604
  ## The escape hatch — for logic directives can't express
287
605
 
288
- Directives are narrow on purpose. For real logic (filtering, custom interactions), use a colocated `.js` with `window.wd` (`wd.get`, `wd.set`, `wd.state`, `wd.render`). Section state is keyed `sectionId:name`.
289
-
290
- **Canonical "filter a list" pattern** (filtering is not a directive). Keep the fetched/source list intact and filter the rendered DOM — do not overwrite the source state, do not poll with setInterval (the raw `<input>` needs `html: true` in the page frontmatter, or use `:bind q` which needs no opt-in):
291
- ```wd
292
- <input id="q" placeholder="Search" autocomplete="off">
606
+ Directives are narrow on purpose. For real logic (custom interactions, gestures, canvas, charts), use a colocated `.js` with `window.wd`: `wd.get(key)`, `wd.set(key, value)`, `wd.subscribe(key, cb)` (fires now and on every settled change, returns an unsubscribe), `wd.state`, `wd.render()`. Section state is keyed `sectionId:name`. The `.js` loads after the runtime, so `wd` is ready.
293
607
 
294
- :fetch products from "/__wd/data/products.json"
295
- :if products
296
- @loop products into p
297
- ::: section .card
298
- **{ p.name }** — { p.price }
299
- :::
300
- @endloop
301
- :endif
302
- ```
303
608
  ```js
304
- // products.js
305
- const q = document.getElementById("q");
306
- q.addEventListener("input", () => {
307
- const term = q.value.toLowerCase();
308
- for (const card of document.querySelectorAll(".card")) {
309
- card.style.display = card.textContent.toLowerCase().includes(term) ? "" : "none";
310
- }
609
+ // index.js, colocated beside the page
610
+ wd.subscribe("slide", (i) => {
611
+ document.querySelector("[data-track]").style.transform = `translateX(${-i * 100}%)`;
311
612
  });
312
613
  ```
313
- This visual DOM filter is fine for a static fetched list. (Note: a `wd.render()` triggered by other state changes can reset the toggled `display`; for a list that also mutates reactively, re-apply the filter after such changes.)
614
+
615
+ Set `window.wd.debug = true` to log any `:computed` or `@loop … where` expression that fails to evaluate.
616
+
617
+ ## Compile errors: read the code, not just the prose
618
+
619
+ Every author-facing compile error opens with a stable `WDxxx` code and names the file and line:
620
+
621
+ ```
622
+ [WD201] Malformed :state in site/pages/index.wd:1: :state x.
623
+ Use: :state name = value [persist|ephemeral] — e.g. :state count = 0
624
+ ```
625
+
626
+ Codes are grouped by subsystem: `WD0xx` source and frontmatter, `WD1xx` loops and collections, `WD2xx` state and expressions, `WD3xx` button/effect/timer actions, `WD4xx` forms, `WD5xx` fetching and URL safety, `WD6xx` includes and page structure, `WD7xx` media, `WD8xx` skins, `WD9xx` project and CLI.
627
+
628
+ The ones you are most likely to hit while writing new pages: `WD191` (a reactive `@loop` over `|` table rows), `WD250` (a reserved declaration name), `WD251` (`:state b = a` where `a` is state), `WD260` (`from-url` on `:store`/`:theme`), `WD261` (`persist` and `ephemeral` together), `WD315` (`:every`/`:effect` inside a reactive loop), `WD450` (a bound field with no state), `WD451` (a bound `:checkbox` with several options), `WD452` (a file field in a GET form), `WD650`/`WD651` (a non-whitelisted or unquoted attribute), `WD950` (a bad `rss_limit`). Every `Use:` hint that contains `[bracket]` placeholders ends with a concrete `— e.g. <valid line>`: **copy the example, never the placeholder.** `darkmown catalog --llms-full` prints every code with its cause and fix.
314
629
 
315
630
  ## Hard rules — do not break these
316
631
 
317
- 1. **Never invent directives or syntax.** These do NOT exist: `@section`/`@endsection`, `{% if %}`, `{.class}` attribute lists, `:for`, `v-if`, JSX. The only container is `::: … :::`; the only loop is `@loop`; the only interpolation is `{ }`.
318
- 2. **Always ship styling.** A `.skin` (or `<style>`) with a modern type scale, spacing, and a responsive rule — every page. Unstyled = failure.
319
- 3. **`.skin` must target emitted HTML** (see the table). Verify selectors match real elements.
320
- 4. **Rename = delete.** Upgrading `.md`→`.wd` means removing the `.md`. Never leave both.
321
- 5. **Declare before you bind.** `:state` (or `:form into x`) must appear before any `{ x }` / `:if x` that reads it.
322
- 6. **Whitelisted grammars only.** Button/`:computed` actions are fixed forms; arbitrary logic goes in a `.js` escape hatch.
632
+ 1. **Never invent directives or syntax.** These do NOT exist: `@section`/`@endsection`, `{% if %}`, `:for`, `@repeat`, `v-if`, JSX, or any directive not in the list above. The only container is `::: … :::`; the only loop is `@loop`; the only interpolation is `{ }`.
633
+ 2. **Never annotate inside a fence, and never write a bare keyword.** `.wd` has no comment syntax: a trailing `←` note or `#` comment on a directive line breaks the build, or silently corrupts a frontmatter value. A directive line must carry its arguments; a lone `:state`/`:button`/`@loop` is a compile error (only `:::`, `:theme`, `:carousel` are valid bare). To show a directive name as text, escape it: `\:fetch`.
634
+ 3. **Always ship styling.** A `.skin` (or `<style>`) with a modern type scale, spacing, and a responsive rule — every page. Unstyled = failure.
635
+ 4. **`.skin` must target emitted HTML** (see the table). Verify selectors match real elements.
636
+ 5. **Rename = delete.** Upgrading `.md`→`.wd` means removing the `.md`. Never leave both.
637
+ 6. **Declare before you bind.** `:state` (or `:form into x`) must appear before any `{ x }` / `:if x` that reads it.
638
+ 7. **Whitelisted grammars only.** Button/`:computed`/predicate vocabularies are fixed forms; arbitrary logic goes in a `.js` escape hatch.
639
+ 8. **Never write a secret into a page.** `:state`/`:store` seeds are inlined into public HTML. Tokens and keys arrive at runtime from an `api/` endpoint.
323
640
 
324
641
  ## Minimal complete page
325
642
 
326
643
  ```wd
327
644
  ---
328
645
  title: Hello
329
- html: true ← the raw <main class="hero"> below needs this
646
+ html: true
330
647
  ---
331
648
 
332
- @include /nav.wd
333
-
334
649
  <main class="hero">
335
650
 
336
651
  # Hello
@@ -343,4 +658,4 @@ You clicked { count } times.
343
658
 
344
659
  </main>
345
660
  ```
346
- …with a colocated `index.skin` styling `.hero`, `button`, and the type scale.
661
+ `html: true` is what lets the raw `<main class="hero">` render as markup. Add a colocated `index.skin` styling `.hero`, `button`, and the type scale, and an `@include /nav.wd` if the project has a nav partial on the shelf.