@zvndev/darkmown 0.10.0 → 0.11.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
4
4
 
5
+ ## 0.11.1 — 2026-06-17
6
+
7
+ Maintenance release. No functional changes since 0.11.0.
8
+
9
+ - **Fully comment-free shipped runtime.** The last two inline comments in `src/runtime.js` are converted to a strippable form, so the emitted `runtime.js` now contains zero comments (the source stays fully documented). The reactive runtime is ~4.7 KB gzipped.
10
+
11
+ ## 0.11.0 — 2026-06-16
12
+
13
+ Three big `.wd` capabilities for building real apps — global state, a full fetch lifecycle, and complete loop ergonomics — plus a leaner runtime.
14
+
15
+ - **Global state — `:store`.** `:store cart = []` is durable by default (localStorage), synced across browser tabs in real time, and global by name; opt out of persistence with `ephemeral`. Button actions gained a full vocabulary — `toggle`, `prepend`, `merge`, `delete`, `clear`, `member-toggle`, `remove`/`remove-value`, and `reset` (back to the declared seed) — all working on `:state` and `:store`, with dotted-path targets (`cart.count++`) and `;`-separated multi-action buttons.
16
+ - **Fetch lifecycle.** `:fetch` now exposes `name_loading` and `name_empty` alongside `name_error`, so loading → error → empty → data renders with plain `:if`. URLs interpolate state (`/api/users/{ id }`) and auto-refetch when their inputs change; new `timeout`, `retry`, `method`, `headers`, and `body` options; a `refetch` action; and `@loop` now reads a dotted sub-path of fetched data (`@loop data.items into x`).
17
+ - **Loop ergonomics.** `@loop … sort by key [asc|desc] reverse offset N limit N` (offset/limit accept a `:state` key for reactive paging), per-row `$index` / `$number` / `$first` / `$last` / `$count`, and an `@empty` branch for zero rows. Static loops still resolve at build time and ship zero JS.
18
+ - **Leaner runtime.** The shipped runtime now strips whole-line `//` comments in addition to JSDoc (still no minification — identifiers, structure, and whitespace are preserved), so the source stays fully commented while the download shrinks. Even with all three features above, the reactive runtime is ~4.7 KB gzipped, comfortably under the 5 KB budget.
19
+ - **Security.** The prototype-pollution guard is now a single source of truth shared by the path reader and writer; dotted action targets and dynamic fetch URLs are guarded and URL-encoded.
20
+
5
21
  ## 0.10.0 — 2026-06-15
6
22
 
7
23
  Launch-readiness: every public claim is now backed by runnable evidence instead of stale copy.
package/README.md CHANGED
@@ -121,6 +121,67 @@ Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive sub
121
121
 
122
122
  `:bind <state>` renders an `<input>` wired two-way to a `:state` value — typing updates the state, and the state reflects back into the field. It accepts `type=` (default `text`), `placeholder=`, `autocomplete=`, and the `required` / `autofocus` flags.
123
123
 
124
+ ### Sorting, paging, and meta — `sort by`, `limit`, `offset`, `reverse`
125
+
126
+ Shape a loop without writing JavaScript. Clauses come in a fixed order after `into`:
127
+
128
+ ```
129
+ @loop <src> into <item> [where …] [sort by <key> [asc|desc]] [reverse] [offset <N>] [limit <N>]
130
+ ```
131
+
132
+ ```wd
133
+ @loop /posts.json into post sort by post.date desc limit 5
134
+ { $number }. { post.title }
135
+ @endloop
136
+ ```
137
+
138
+ - `sort by <key> [asc|desc]` — `<key>` must start with the loop item (`post.date`, not `date`). Numbers sort numerically; everything else sorts as text. `asc` is the default.
139
+ - `reverse` — reverse the (already sorted) order.
140
+ - `offset <N>` / `limit <N>` — `<N>` is a non-negative integer **or** a `:state`/`:store` key, which makes pagination reactive:
141
+
142
+ ```wd
143
+ :state pageSize = 10
144
+
145
+ @loop products into product limit pageSize
146
+ - { product.name }
147
+ @endloop
148
+ ```
149
+
150
+ Each row exposes five meta variables, relative to the rendered slice:
151
+
152
+ | Variable | Value |
153
+ |---|---|
154
+ | `{ $index }` | 0-based position |
155
+ | `{ $number }` | 1-based position (`$index + 1`) |
156
+ | `{ $first }` | `true` on the first row |
157
+ | `{ $last }` | `true` on the last row |
158
+ | `{ $count }` | number of rendered rows |
159
+
160
+ They work in interpolation and in `:if`:
161
+
162
+ ```wd
163
+ @loop products into product
164
+ :if $first
165
+ **Top pick:**
166
+ :endif
167
+ { $number } of { $count } — { product.name }
168
+ @endloop
169
+ ```
170
+
171
+ ### Empty lists — `@empty`
172
+
173
+ Add an `@empty` branch to show a fallback when the loop renders no rows (after `where`, `limit`, and the rest):
174
+
175
+ ```wd
176
+ @loop todos into todo
177
+ - { todo.title }
178
+ @empty
179
+ Nothing left to do.
180
+ @endloop
181
+ ```
182
+
183
+ > **Note:** All of these clauses stay build-time when the source and every clause argument are static — a sorted, limited loop over a JSON file ships **zero JavaScript**. The loop becomes reactive only when the source is `:state`/`:store`/`:fetch` data, or a clause reads reactive state (like `limit pageSize`).
184
+
124
185
  ### Editable lists — per-row actions
125
186
 
126
187
  A `:button` inside a reactive `@loop` can act on its own row. `cart += product` carries the current row into another list; `cart remove line` drops the current row from the looped list:
@@ -181,18 +242,162 @@ Count is still zero.
181
242
 
182
243
  Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
183
244
 
184
- ## Data, forms, and persistence
245
+ ### Button actions
246
+
247
+ A `:button "Label" -> action` mutates one `:state` or `:store` value. The same vocabulary works on both:
248
+
249
+ | Action | Syntax | Effect |
250
+ |---|---|---|
251
+ | Increment | `n++` | add 1 |
252
+ | Decrement | `n--` | subtract 1 |
253
+ | Add | `n += 5` | add a number |
254
+ | Subtract | `n -= 2` | subtract a number |
255
+ | Set | `name = value` | assign a literal |
256
+ | Toggle | `flag toggle` | flip a boolean |
257
+ | Append | `list append v` or `list += v` | add to the end of an array |
258
+ | Prepend | `list prepend v` | add to the front |
259
+ | Member toggle | `list toggle v` | add `v` if absent, else remove it |
260
+ | Remove value | `list remove v` | remove a value from an array |
261
+ | Clear | `name clear` | empty an array or object |
262
+ | Merge | `obj merge other` | shallow-merge an object (key or inline `{…}`) |
263
+ | Delete | `obj delete "key"` | remove a key |
264
+ | Reset | `name reset` | restore the declared starting value |
265
+
266
+ Values are literals: a `"string"`, number, `true`/`false`/`null`, or inline JSON (`{…}` / `[…]`).
267
+
268
+ **Targets can be dotted paths,** so a button can reach into nested state:
269
+
270
+ ```wd
271
+ :state cart = {"count": 0, "total": 0}
272
+
273
+ :button "Add item" -> cart.count++
274
+ ```
275
+
276
+ **One button can run several actions** with `;`. They apply in order, then the page renders once:
277
+
278
+ ```wd
279
+ :button "Add to cart" -> cart.count++ ; cart.total += 9
280
+ ```
281
+
282
+ > **Pitfall:** `list toggle v` and `list remove v` match members by value (`===`). That is exact for strings, numbers, and booleans, but not reliable for object members — two equal-looking objects are different values. To remove a row object, loop the list and use the per-row `remove` action below.
283
+
284
+ ## Fetching data
285
+
286
+ `:fetch name from "url"` declares state and fills it from JSON over the network:
287
+
288
+ ```
289
+ :fetch <name> from "<url>" [method=GET] [when=load|visible] [timeout=<ms>] [retry=<N>] [headers=<key>] [body=<key>]
290
+ ```
291
+
292
+ Each fetch automatically declares four state keys you can branch on:
293
+
294
+ | State | Type | Meaning |
295
+ |---|---|---|
296
+ | `name` | the data | `null` until the response arrives |
297
+ | `name_loading` | boolean | `true` while the request is in flight |
298
+ | `name_error` | string | the error message, or `null` |
299
+ | `name_empty` | boolean | `true` when the data is `null`, `[]`, or `{}` |
300
+
301
+ ### The four-state pattern
302
+
303
+ Cover loading, errors, empty, and data — `@empty` (from the loops above) absorbs the empty case:
304
+
305
+ ```wd
306
+ :fetch roster from "/__wd/data/team.json" timeout=8000 retry=2
307
+
308
+ :if roster_loading
309
+ Loading…
310
+ :else
311
+ :if roster_error
312
+ Couldn't load the team: { roster_error }
313
+ :else
314
+ @loop roster into member
315
+ - { member.name }
316
+ @empty
317
+ No team members yet.
318
+ @endloop
319
+ :endif
320
+ :endif
321
+ ```
322
+
323
+ ### Options
324
+
325
+ - `method=` — `GET` (default), `POST`, `PUT`, `PATCH`, or `DELETE`.
326
+ - `when=` — `load` (default) fires on page load; `visible` waits until the spot scrolls into view.
327
+ - `timeout=<ms>` — abort and set `name_error` if the response is too slow.
328
+ - `retry=<N>` — retry on network failure or a 5xx response before surfacing the error.
329
+ - `headers=<key>` — a `:state`/`:store` key holding an object, sent as request headers.
330
+ - `body=<key>` — a `:state`/`:store` key, JSON-serialized as the request body (for non-`GET`).
331
+
332
+ ### Dynamic URLs and refetching
333
+
334
+ A URL can interpolate state with `{ }`. The fetch re-runs automatically when that state changes (and skips while the value is still empty):
335
+
336
+ ```wd
337
+ :state userId = ""
338
+
339
+ :fetch profile from "/api/users/{ userId }"
340
+ ```
341
+
342
+ Trigger a reload by hand with the `refetch` action:
343
+
344
+ ```wd
345
+ :button "Reload" -> roster refetch
346
+ ```
347
+
348
+ ### Looping into fetched data
349
+
350
+ Loop a sub-path of fetched (or any) state with a dotted source:
351
+
352
+ ```wd
353
+ :fetch org from "/__wd/data/org.json"
354
+
355
+ @loop org.members into member
356
+ - { member.name }
357
+ @endloop
358
+ ```
359
+
360
+ > **Note:** Shelf `.json` files are published at `/__wd/data/`, so `:fetch` works on any static host. The `darkmown dev` server also ships a `/__wd/echo` endpoint for demos.
361
+
362
+ ## Global state — `:store`
363
+
364
+ `:state` is local to its page (and section). `:store` is **global, durable, and shared across tabs** — the right home for a cart, a theme, or a signed-in user.
365
+
366
+ ```wd
367
+ :store cart = []
368
+
369
+ :button "Add" -> cart += {"id": 1, "name": "Aurora"}
370
+
371
+ You have { cart } items.
372
+ ```
373
+
374
+ - **Durable by default.** A store is saved to `localStorage` under `wd:store:<name>` and reloaded on the next visit.
375
+ - **Shared across tabs.** Change a store in one tab and every other tab on the same site updates live.
376
+ - **Global by name.** A bare `{ cart }` reads the same store everywhere — stores are never section-scoped.
377
+ - **Same value grammar as `:state`** — string, number, boolean, `null`, array, or object — and the **same** button actions (`cart += …`, `count++`, `theme = "dark"`, and so on).
378
+
379
+ The declared value is a **seed**: it is used only the first time, when the store is absent from storage. After that the persisted value wins, so visitors keep their data.
380
+
381
+ ### Opt out of persistence
382
+
383
+ Add `ephemeral` for an in-memory store that resets on reload and does not sync across tabs:
384
+
385
+ ```wd
386
+ :store sidebarOpen = false ephemeral
387
+ ```
388
+
389
+ > **Pitfall:** A store name must be unique. Declaring the same name as both a `:store` and a `:state` on one page is a compile error.
390
+
391
+ ## Forms and persistence
392
+
393
+ Fetched data and a form live happily on the same page:
185
394
 
186
395
  ```wd
187
396
  :fetch team from "/__wd/data/team.json"
188
397
 
189
- :if team
190
398
  @loop team into member
191
399
  - { member.name }
192
400
  @endloop
193
- :else
194
- Loading…
195
- :endif
196
401
 
197
402
  :form into profile
198
403
  :input name placeholder="Your name" required
@@ -202,11 +407,10 @@ Loading…
202
407
  :state cart = [] persist
203
408
  ```
204
409
 
205
- - `:fetch name from "url"` declares state and fills it from JSON over the network; `name_error` carries failures. Add `when=visible` to defer the request until the spot scrolls into view.
206
- - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
207
410
  - `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement.
208
- - `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one. `darkmown dev` ships a `/__wd/echo` endpoint for demos.
209
- - `:state x = [] persist` keeps that state in localStorage across reloads.
411
+ - `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.
412
+ - `:state x = [] persist` keeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for [`:store`](#global-state--store) instead.)
413
+ - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
210
414
  - `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
211
415
 
212
416
  ## The escape hatch
@@ -56,18 +56,26 @@ This audit compares the current Darkmown implementation with the original MARROW
56
56
  - **Editor tooling:** VS Code extension (`editors/vscode`) ships `.wd` + `.skin` TextMate grammars, snippets, and folding, grammar-tested in CI.
57
57
  - **`AGENTS.md`:** a model-facing build guide (shipped in the npm files array), tuned over a 5-round agent-eval benchmark that lifted build quality from 2.78→4.51/5.
58
58
 
59
+ ## Stage 8 additions (2026-06-16)
60
+
61
+ Three capability gaps from the 2026-06-16 audit, closed together over a shared `setPath` and an expanded action handler.
62
+
63
+ - **Global state — `:store`:** `:store name = value [ephemeral]` declares page-global, durable-by-default state. Non-ephemeral stores persist to `localStorage["wd:store:<name>"]`, hydrate the declared value as a seed only when absent (first write wins thereafter), and sync across tabs via a single `storage` listener that re-renders on change. `ephemeral` opts out of persistence and cross-tab sync (in-memory for the session). Stores are never section-scoped; they share the runtime `state` object with `:state`, and a compile-time collision check keeps the two namespaces disjoint (declaring a `:store` and `:state` with one name, or a store twice, is a path-and-suggestion compile error). Reads (`{ cart }`, `:if`, `@loop`, `:computed`) and all actions resolve stores identically to state.
64
+ - **Full action vocabulary:** `:button … ->` now covers `inc`/`dec` (`n++`, `n--`), `add`/`sub` (`n += k`, `n -= k`), `set` (`name = v`), `toggle` (`flag toggle`), `append` (`list append v` / `list += v`), `prepend`, `member-toggle` (`list toggle v`), `remove-value` (`list remove v`), `clear`, `merge` (`obj merge <key|{…}>`), `delete` (`obj delete "key"`), and `reset` (restore the declared seed) — alongside the existing `append-row` (`cart += item`) and per-row `remove`. Targets are dotted-path aware (`cart.count++`) via a safe `setPath` mirroring `getPath` (proto-pollution guards in compiler and runtime). Buttons may carry a `;`-separated action sequence, applied in order with a single render. Operands are the compile-validated literal grammar (string / number / boolean / null / inline JSON; merge also accepts a bare state/store key). **Honest caveat:** `member-toggle` and `remove-value` compare by value with `===`/`includes`, so they are reliable for primitive members but not for object members (two equal-looking objects are distinct values) — remove row objects with the per-row `remove` action instead.
65
+ - **Fetch lifecycle:** `:fetch name from "url" [method=] [when=load|visible] [timeout=ms] [retry=N] [headers=key] [body=key]`. Each fetch auto-declares four states — `name` (null), `name_loading` (false), `name_error` (null), `name_empty` (false, true for `null`/`[]`/`{}`) — enabling the recommended loading → error → empty → data pattern (with `@empty` absorbing the empty case for list fetches). URLs may interpolate `{ state }` and auto-refetch (≈150 ms debounce) when a dependency key changes, skipping while a required value is empty. A `refetch` action (`name refetch`) re-triggers a load on demand. Full HTTP via `method`/`headers`/`body`; `timeout` arms an AbortController; `retry` backs off on network error or 5xx before surfacing the error.
66
+ - **Loop ergonomics:** `@loop src into item [where …] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`, clauses in that fixed order. `sort by` keys must start with the loop item (`item.path`); the comparator is numeric when both values are numbers, else `String(...).localeCompare`. `offset`/`limit` accept an integer **or** a `:state`/`:store` key for reactive pagination. Per-row meta vars — `$index` (0-based), `$number` (1-based), `$first`, `$last`, `$count` — are usable in interpolation and `:if`. An `@empty` branch renders when the post-pipeline row count is 0. **Honest caveat:** `sort by` over null/missing keys is best-effort — those values stringify (`"null"`/`"undefined"`) and fall into the lexicographic `localeCompare` path rather than getting special handling. Every clause stays build-time when the source and all arguments are static, preserving `runtime: false`.
67
+
59
68
  ## Stage 7 additions (2026-06-14)
60
69
 
61
70
  - **Array frontmatter + readable `meta`:** `parseFrontmatter` now parses inline flow arrays (`tags: [a, b, "x, y"]` → real array; quoted items keep internal commas; a value without a leading `[` stays a scalar — block sequences are intentionally out of scope to keep the parser single-pass). A page's frontmatter is exposed to its body under `meta`, so `{ meta.title }` prints a field, `{ meta.tags }` renders an array joined with `, `, and `@loop meta.tags into tag` iterates a frontmatter array at build time (stays `runtime: false`). Genuine correctness fix on its own, and the groundwork for reading/emitting OKF-style bundles (Google Cloud's Open Knowledge Format — markdown + `type`-bearing frontmatter + cross-links; tracked but deliberately not coupled to its v0.1 Draft).
62
71
 
63
72
  ## Demo-only directives (not part of the public surface)
64
73
 
65
- The compiler recognizes three directives — `:note`, `:try`, and `:sprint` — that exist **only to power the demo site** (darkmown.com). They are **not** part of the supported public directive set, are not documented for authors, and may change or be removed without notice. Do not build pages that depend on them. The supported public directives are the ones catalogued above: `:state`, `:computed`, `:button`, `:bind`, `:if`/`:else`/`:endif`, `:fetch`, `:form`/`:input`/`:submit`, `@loop`/`@endloop` (with `where`), `@include`, and `::: section`.
74
+ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` — that exist **only to power the demo site** (darkmown.com). They are **not** part of the supported public directive set, are not documented for authors, and may change or be removed without notice. Do not build pages that depend on them. The supported public directives are the ones catalogued above: `:state`, `:store`, `:computed`, `:button`, `:bind`, `:if`/`:else`/`:endif`, `:fetch`, `:form`/`:input`/`:submit`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by`, `reverse`, `offset`, `limit`), `@include`, and `::: section`.
66
75
 
67
76
  ## Still missing from the full vision
68
77
 
69
78
  - First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
70
- - `@loop` over fetch sub-paths (looping a path *inside* fetched state rather than top-level state).
71
79
  - **View transitions:** re-enabling `transitions: true` with proper activation fallbacks (disabled today — see Stage 4), plus `@starting-style` emission and browser fallbacks for older engines.
72
80
  - Richer diagnostics and package-consumer QA in regular CI. Editor tooling exists as a source-installable VS Code extension; public Marketplace/Open VSX distribution remains a launch/distribution follow-up.
73
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/site/_/nav.wd CHANGED
@@ -4,6 +4,8 @@
4
4
  <a href="/docs/">Docs</a>
5
5
  <a href="/markdown/">Plain Markdown</a>
6
6
  <a href="/reactive/">Reactive</a>
7
+ <a href="/loops/">Loops</a>
8
+ <a href="/store/">Store</a>
7
9
  <a href="/data/">Data &amp; Forms</a>
8
10
  <a href="/app/">Live app</a>
9
11
  </nav>
@@ -103,6 +103,44 @@ A `:button` inside a reactive loop can act on its own row — the basis for cart
103
103
 
104
104
  `cart += product` appends a copy of the current row to another `:state` list; `cart remove line` removes the current row from the list being looped. Both are checked against the enclosing loop at compile time — no JavaScript.
105
105
 
106
+ ### Sorting, paging, and meta variables
107
+
108
+ Shape a loop with clauses in a fixed order after `into`: `where`, then `sort by`, `reverse`, `offset`, `limit`.
109
+
110
+ ```wd
111
+ @loop /posts.json into post sort by post.date desc limit 5
112
+ { $number }. { post.title }
113
+ @endloop
114
+ ```
115
+
116
+ - `sort by <key> [asc|desc]` — the key must start with the loop item (`post.date`). Numbers sort numerically; anything else sorts as text. `asc` is the default.
117
+ - `reverse` flips the order; `offset N` / `limit N` slice it. `N` can be an integer or a `:state`/`:store` key, which makes paging reactive (`limit pageSize`).
118
+
119
+ Every row exposes meta variables — `{ $index }` (0-based), `{ $number }` (1-based), `{ $first }`, `{ $last }`, `{ $count }` — usable in interpolation and `:if`:
120
+
121
+ ```wd
122
+ @loop products into product
123
+ :if $first
124
+ **Top pick:**
125
+ :endif
126
+ { $number } of { $count } — { product.name }
127
+ @endloop
128
+ ```
129
+
130
+ ### Empty lists with `@empty`
131
+
132
+ Add an `@empty` branch to render a fallback when the loop produces no rows (after filtering and slicing):
133
+
134
+ ```wd
135
+ @loop todos into todo
136
+ - { todo.title }
137
+ @empty
138
+ Nothing left to do.
139
+ @endloop
140
+ ```
141
+
142
+ These clauses stay build-time when the source and every argument are static — a sorted, limited loop over a JSON file ships zero JS. The loop turns reactive only when the source or a clause reads `:state`/`:store`/`:fetch` data.
143
+
106
144
  ## Interpolation
107
145
 
108
146
  One syntax everywhere: `{ name }` or `{ name.path }`.
@@ -157,12 +195,70 @@ Count is still zero.
157
195
 
158
196
  Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in a colocated `.js` file.
159
197
 
160
- ## Data, forms, and persistence
198
+ ### Button actions
199
+
200
+ A `:button "Label" -> action` mutates one `:state` or `:store` value. The vocabulary is the same for both:
201
+
202
+ - Numbers: `n++`, `n--`, `n += 5`, `n -= 2`
203
+ - Set / toggle: `name = value`, `flag toggle`
204
+ - Arrays: `list append v` (or `list += v`), `list prepend v`, `list toggle v`, `list remove v`, `list clear`
205
+ - Objects: `obj merge other`, `obj delete "key"`
206
+ - Universal: `name reset` restores the declared starting value
207
+
208
+ Values are literals — a `"string"`, number, `true`/`false`/`null`, or inline JSON. Targets may be dotted paths (`cart.count++`), and one button can run several actions separated by `;` (applied in order, rendered once):
209
+
210
+ ```wd
211
+ :button "Add to cart" -> cart.count++ ; cart.total += 9
212
+ ```
213
+
214
+ `list toggle v` and `list remove v` match by value, which is exact for strings and numbers but not for object members — remove row objects with the per-row `remove` action instead.
215
+
216
+ ## Fetching data
217
+
218
+ `:fetch name from "url"` declares state and fills it from JSON over the network. Shelf `.json` files are served at `/__wd/data/`. Each fetch auto-declares four states you can branch on: `name`, `name_loading`, `name_error`, and `name_empty`.
219
+
220
+ ```wd
221
+ :fetch team from "/__wd/data/team.json" timeout=8000 retry=2
222
+
223
+ :if team_loading
224
+ Loading…
225
+ :else
226
+ :if team_error
227
+ Couldn't load the team: { team_error }
228
+ :else
229
+ @loop team into member
230
+ - { member.name }
231
+ @empty
232
+ No team members yet.
233
+ @endloop
234
+ :endif
235
+ :endif
236
+ ```
237
+
238
+ Options after the URL: `method=` (default `GET`), `when=load|visible`, `timeout=<ms>`, `retry=<N>`, `headers=<key>`, and `body=<key>` (the last two name a `:state`/`:store` key). A URL can interpolate state (`"/api/users/{ userId }"`) and re-fetches automatically when that state changes; trigger a reload by hand with `:button "Reload" -> team refetch`. Loop a sub-path of fetched data with a dotted source — `@loop team.members into member`.
239
+
240
+ ## Global state — `:store`
241
+
242
+ `:store` is global, durable, and shared across browser tabs — the right home for a cart, a theme, or a signed-in user. Unlike `:state`, it is never section-scoped and persists by default.
243
+
244
+ ```wd
245
+ :store cart = []
246
+
247
+ :button "Add" -> cart += {"id": 1, "name": "Aurora"}
248
+
249
+ You have { cart } items.
250
+ ```
251
+
252
+ - Saved to localStorage under `wd:store:<name>` and reloaded on the next visit; changes sync live to other open tabs.
253
+ - The declared value is a **seed**, used only the first time — afterward the persisted value wins.
254
+ - Same value grammar and the same button actions as `:state`.
255
+ - Add `ephemeral` for an in-memory store that resets on reload: `:store sidebarOpen = false ephemeral`.
256
+
257
+ ## Forms and persistence
161
258
 
162
- - `:fetch name from "url"` declares state and fills it from JSON over the network. Shelf `.json` files are served at `/__wd/data/`.
163
259
  - `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead.
164
260
  - `:input field placeholder="…" required` and `:submit "Label"` build the form body.
165
- - `:state cart = [] persist` survives reloads via localStorage.
261
+ - `:state cart = [] persist` survives reloads via localStorage on a single page; use `:store` for state shared across pages and tabs.
166
262
  - `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
167
263
  - Reactive pages expose `window.wd` (`get`, `set`, `state`, `render`) so colocated `.js` can do anything directives can't. Section state is addressed as `sectionId:name`.
168
264
 
@@ -176,6 +272,6 @@ See it all live on the [Data & Forms page](/data/).
176
272
 
177
273
  ## Spec status
178
274
 
179
- The implementation is faithful to the original core thesis: Markdown-first authoring, no component ceremony, zero runtime on static pages, and tiny direct-DOM reactivity only when declared. `:fetch`, `:form` (including server round-trips), `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/). Still on the roadmap: a first-party server runtime (`site/api/`), HTML-fragment swaps, and `@loop` over fetched sub-paths. See `docs/spec-alignment.md` in the package for the full audit.
275
+ The implementation is faithful to the original core thesis: Markdown-first authoring, no component ceremony, zero runtime on static pages, and tiny direct-DOM reactivity only when declared. `:store`, the full `:fetch` lifecycle (loading/error/empty states, dynamic URLs, `refetch`, and dotted loop sources), `:form` (including server round-trips), loop ergonomics (`sort by`/`limit`/`offset`/`reverse`/`@empty` and `$index`/`$number`/`$first`/`$last`/`$count`), `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/). Still on the roadmap: a first-party server runtime (`site/api/`) and HTML-fragment swaps. See `docs/spec-alignment.md` in the package for the full audit.
180
276
 
181
277
  </main>
@@ -0,0 +1,80 @@
1
+ tokens
2
+ ink #171717
3
+ paper #faf8f3
4
+ panel #ffffff
5
+ line #dedbd3
6
+ accent #16645a
7
+ warm #a83f2a
8
+ radius 8px
9
+
10
+ page
11
+ margin 0
12
+ font ui-serif, Georgia, Cambria, serif
13
+ color $ink
14
+ bg $paper
15
+ line-height 1.7
16
+
17
+ main
18
+ max-width 820px
19
+ margin 0 auto
20
+ padding 3rem 2rem 5rem
21
+
22
+ h1
23
+ font-size 3rem
24
+ line-height 1
25
+
26
+ h2
27
+ margin-top 2.4rem
28
+ border-top 1px solid $line
29
+ padding-top 1.2rem
30
+
31
+ a
32
+ color $accent
33
+
34
+ button
35
+ border 1px solid $ink
36
+ bg $ink
37
+ color white
38
+ padding .72rem 1rem
39
+ margin .3rem .5rem .3rem 0
40
+ radius $radius
41
+ font inherit
42
+ cursor pointer
43
+
44
+ button:hover
45
+ bg $accent
46
+ border-color $accent
47
+
48
+ input
49
+ padding .7rem .9rem
50
+ border 1px solid $line
51
+ radius $radius
52
+ font inherit
53
+ bg $panel
54
+ width 100%
55
+ max-width 360px
56
+ margin 1rem 0
57
+
58
+ ul
59
+ border 1px solid $line
60
+ bg $panel
61
+ padding 1rem 1rem 1rem 2rem
62
+ radius $radius
63
+
64
+ .person-row
65
+ border 1px solid $line
66
+ bg $panel
67
+ padding .65rem 1.1rem
68
+ margin .5rem 0
69
+ radius $radius
70
+ display flex
71
+ gap .6rem
72
+ align-items baseline
73
+ flex-wrap wrap
74
+
75
+ #paged
76
+ border 1px dashed $line
77
+ bg $panel
78
+ padding .4rem 1.2rem 1.2rem
79
+ margin 1rem 0
80
+ radius $radius
@@ -0,0 +1,86 @@
1
+ ---
2
+ title: Loop ergonomics
3
+ ---
4
+
5
+ @include /nav.wd
6
+
7
+ <main>
8
+
9
+ # Loop ergonomics
10
+
11
+ One loop, fully equipped. `@loop` gains `sort by`, `offset`, `limit`, `reverse`,
12
+ an `@empty` branch, and per-row meta variables (`{ $number }`, `{ $first }`,
13
+ `{ $last }`, `{ $count }`) — all in plain Markdown, all reactive when the source
14
+ or a clause argument is `:state`.
15
+
16
+ :state people = [{"id": 1, "name": "Ada", "age": 36}, {"id": 2, "name": "Cyrus", "age": 71}, {"id": 3, "name": "Bao", "age": 29}, {"id": 4, "name": "Dax", "age": 52}, {"id": 5, "name": "Esme", "age": 44}]
17
+ :state sortOffset = 0
18
+ :state pageSize = 2
19
+ :state query = ""
20
+ :state recent = [{"id": 1, "label": "Item 1"}, {"id": 2, "label": "Item 2"}, {"id": 3, "label": "Item 3"}, {"id": 4, "label": "Item 4"}, {"id": 5, "label": "Item 5"}]
21
+
22
+ ## Sorted, numbered, with first / last markers
23
+
24
+ `sort by p.age desc` orders the eldest first. Each row reads its position from
25
+ the loop meta vars: `{ $number }` is the 1-based index, and `:if $first` /
26
+ `:if $last` light up only on the boundary rows.
27
+
28
+ @loop people into p sort by p.age desc offset sortOffset
29
+ ::: card .person-row
30
+ **{ $number }. { p.name }** — age { p.age }
31
+ :if $first
32
+ (first)
33
+ :endif
34
+ :if $last
35
+ (last)
36
+ :endif
37
+ :::
38
+ @endloop
39
+
40
+ The list above is sorted oldest-first. `offset` is wired to a `:state`, so a
41
+ button can slide the window without a reload:
42
+
43
+ :button "Sort: skip the eldest" -> sortOffset = 1
44
+ :button "Reset order" -> sortOffset = 0
45
+
46
+ ## Reactive paging with `limit`
47
+
48
+ `limit pageSize` reads a `:state` value, so paging is just a button that bumps
49
+ the number. The runtime re-slices the same loop in place — no extra request, no
50
+ reload.
51
+
52
+ ::: section #paged
53
+ @loop people into p limit pageSize
54
+ ::: card .person-row
55
+ { p.name } — age { p.age }
56
+ :::
57
+ @endloop
58
+
59
+ :button "Show more" -> pageSize += 2
60
+ :button "Collapse" -> pageSize = 2
61
+ :::
62
+
63
+ ## `where` + `@empty`
64
+
65
+ `where` filters live as you type, and `@empty` renders the moment the filtered
66
+ set hits zero rows — the one place a loop gets to say "nothing here":
67
+
68
+ :bind query placeholder="Search people…"
69
+
70
+ @loop people into p where p.name contains query
71
+ ::: card .person-row
72
+ { p.name }
73
+ :::
74
+ @empty
75
+ No matches.
76
+ @endloop
77
+
78
+ ## `reverse` flips the source order
79
+
80
+ No sort key needed — `reverse` walks the list back to front:
81
+
82
+ @loop recent into r reverse
83
+ - { r.label }
84
+ @endloop
85
+
86
+ </main>