@zvndev/darkmown 0.10.0 → 0.11.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/CHANGELOG.md +10 -0
- package/README.md +213 -9
- package/docs/spec-alignment.md +10 -2
- package/package.json +1 -1
- package/site/_/nav.wd +2 -0
- package/site/pages/docs/index.wd +100 -4
- package/site/pages/loops.skin +80 -0
- package/site/pages/loops.wd +86 -0
- package/site/pages/store.skin +70 -0
- package/site/pages/store.wd +53 -0
- package/src/builder.js +14 -9
- package/src/compiler.js +494 -72
- package/src/runtime.js +294 -62
- package/types/compiler.d.ts +49 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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.0 — 2026-06-16
|
|
6
|
+
|
|
7
|
+
Three big `.wd` capabilities for building real apps — global state, a full fetch lifecycle, and complete loop ergonomics — plus a leaner runtime.
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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`).
|
|
11
|
+
- **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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
5
15
|
## 0.10.0 — 2026-06-15
|
|
6
16
|
|
|
7
17
|
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
|
-
|
|
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.
|
|
209
|
-
- `:state x = [] persist` keeps
|
|
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
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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
package/site/_/nav.wd
CHANGED
package/site/pages/docs/index.wd
CHANGED
|
@@ -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
|
-
|
|
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/`)
|
|
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>
|