@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/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 ~
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 "
|
|
58
|
-
:button "
|
|
166
|
+
:button "Reset" -> count reset
|
|
167
|
+
:button "Two things" -> count++ ; flag toggle
|
|
59
168
|
```
|
|
60
|
-
|
|
169
|
+
`:button "Label" -> action` mutates one declared `:state`/`:store`. Chain actions with `;`. The **fixed whitelist** (nothing else compiles):
|
|
61
170
|
|
|
62
|
-
|
|
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
|
|
66
|
-
|
|
193
|
+
:button "Add to cart" -> cart += product
|
|
194
|
+
@endloop
|
|
195
|
+
|
|
67
196
|
@loop cart into line
|
|
68
|
-
:button "Remove" -> cart remove line
|
|
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
|
|
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
|
|
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
|
|
97
|
-
- **{ product.name }**
|
|
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
|
|
238
|
+
@loop todos into todo
|
|
102
239
|
- { todo.title }
|
|
103
240
|
@endloop
|
|
104
241
|
```
|
|
105
|
-
|
|
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
|
-
|
|
110
|
-
|
|
267
|
+
| Item | Price |
|
|
268
|
+
| --- | --- |
|
|
269
|
+
@loop /products.json into row
|
|
270
|
+
| **{ row.name }** | { row.price } |
|
|
111
271
|
@endloop
|
|
272
|
+
```
|
|
112
273
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
:
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|
125
|
-
@include /card.wd with title=
|
|
305
|
+
@include /nav.wd
|
|
306
|
+
@include /card.wd with title="Aurora"
|
|
126
307
|
|
|
127
|
-
::: section #cart .panel
|
|
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
|
-
|
|
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
|
-
:
|
|
136
|
-
|
|
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
|
-
:
|
|
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"
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
151
|
-
:
|
|
384
|
+
```wd
|
|
385
|
+
:state density = "Comfortable"
|
|
386
|
+
:state previews = true
|
|
152
387
|
|
|
153
|
-
:
|
|
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
|
-
|
|
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 })`, `` | `<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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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 %}`,
|
|
318
|
-
2. **
|
|
319
|
-
3.
|
|
320
|
-
4.
|
|
321
|
-
5. **
|
|
322
|
-
6. **
|
|
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
|
|
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
|
-
|
|
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.
|