@zvndev/darkmown 0.9.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 CHANGED
@@ -2,6 +2,26 @@
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
+
15
+ ## 0.10.0 — 2026-06-15
16
+
17
+ Launch-readiness: every public claim is now backed by runnable evidence instead of stale copy.
18
+
19
+ - **Provable docs.** README, `docs/cli.md`, `docs/spec-alignment.md`, and the gold-standard audit are re-synced to the shipped code — loop semantics, the build-time vs reactive split, and per-row `:button` actions (`cart += product` carries the row into another list; `cart remove line` drops it) are documented exactly as they compile. A docs-snippet test compiles the README/docs examples so the copy can't drift from the code again.
20
+ - **Consumer smoke script.** `npm run smoke` packs the tarball, installs it into a throwaway project, scaffolds, and builds it the way a real consumer would — the same supply-chain check the release workflow runs, now available locally.
21
+ - **CLI + scaffold polish.** `darkmown version` prints the installed package version; first-run scaffold output and CLI guidance now match the docs.
22
+ - **Editor + packaging scripts.** `npm run test:extension` and `npm run pack:extension` build and tokenize-test the VS Code grammar from the repo root; the extension ships its own `LICENSE` and a corrected README.
23
+ - **`homepage` now points at https://darkmown.com.**
24
+
5
25
  ## 0.9.0 — 2026-06-15
6
26
 
7
27
  The "proof + full test pyramid" release: prove the moat on the homepage, test everything from unit to browser e2e, and pass a gold-standard open-source audit.
package/README.md CHANGED
@@ -27,7 +27,8 @@ The package is `@zvndev/darkmown`; the command it installs is plain `darkmown`.
27
27
  ```sh
28
28
  npm install
29
29
  npm test
30
- npm run dev # live demo site — the same site that runs darkmown.com
30
+ npm run smoke # pack, install, scaffold, and build a temporary consumer app
31
+ npm run dev # live demo site — the same site that runs darkmown.com
31
32
  ```
32
33
 
33
34
  ## Commands
@@ -36,6 +37,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
36
37
  - `darkmown dev` starts the live compiler with browser reload and an in-browser error overlay when a build fails.
37
38
  - `darkmown build` writes static output to `dist`.
38
39
  - `darkmown serve` previews the built `dist` locally.
40
+ - `darkmown version` prints the installed package version.
39
41
  - `darkmown help` prints CLI usage.
40
42
 
41
43
  ## Authoring model
@@ -76,14 +78,16 @@ tags: [sales, revenue, "q1, q2"]
76
78
 
77
79
  `@loop <things> into <thing>` is the only loop. The source decides the behavior:
78
80
 
81
+ A JSON-file loop is unrolled at build time; includes inside inherit the loop value. A `:state` list loop is reactive and patched by key.
82
+
79
83
  ```wd
80
- @loop /features.json into card <- JSON file: unrolled at build time
81
- @include /feature-card.wd <- includes inherit the loop value
84
+ @loop /features.json into card
85
+ @include /feature-card.wd
82
86
  @endloop
83
87
 
84
88
  :state todos = [{"id": 1, "title": "Route pages"}]
85
89
 
86
- @loop todos into todo <- :state list: reactive, patched by key
90
+ @loop todos into todo
87
91
  - { todo.title }
88
92
  @endloop
89
93
  ```
@@ -117,9 +121,70 @@ Operators: `==` `!=` `<` `<=` `>` `>=`, plus `contains` for case-insensitive sub
117
121
 
118
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.
119
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
+
120
185
  ### Editable lists — per-row actions
121
186
 
122
- A `:button` inside a reactive `@loop` can act on its own row. Two row actions exist:
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:
123
188
 
124
189
  ```wd
125
190
  :state products = [{"id": 1, "name": "Aurora", "price": 49}]
@@ -128,14 +193,14 @@ A `:button` inside a reactive `@loop` can act on its own row. Two row actions ex
128
193
  @loop products into product
129
194
  ::: card
130
195
  **{ product.name }** — ${ product.price }
131
- :button "Add to cart" -> cart += product <- carry this row into another list
196
+ :button "Add to cart" -> cart += product
132
197
  :::
133
198
  @endloop
134
199
 
135
200
  @loop cart into line
136
201
  ::: card
137
202
  { line.name }
138
- :button "Remove" -> cart remove line <- drop this row from the looped list
203
+ :button "Remove" -> cart remove line
139
204
  :::
140
205
  @endloop
141
206
  ```
@@ -177,18 +242,162 @@ Count is still zero.
177
242
 
178
243
  Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
179
244
 
180
- ## 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:
181
394
 
182
395
  ```wd
183
396
  :fetch team from "/__wd/data/team.json"
184
397
 
185
- :if team
186
398
  @loop team into member
187
399
  - { member.name }
188
400
  @endloop
189
- :else
190
- Loading…
191
- :endif
192
401
 
193
402
  :form into profile
194
403
  :input name placeholder="Your name" required
@@ -198,11 +407,10 @@ Loading…
198
407
  :state cart = [] persist
199
408
  ```
200
409
 
201
- - `: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.
202
- - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
203
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.
204
- - `: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.
205
- - `: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).
206
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.
207
415
 
208
416
  ## The escape hatch
@@ -213,7 +421,7 @@ Set `window.wd.debug = true` (it defaults to `false`) to log any `:computed` or
213
421
 
214
422
  ## Editor support
215
423
 
216
- A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. Install it from source by building a `.vsix`: `cd editors/vscode && npx @vscode/vsce package`, then `code --install-extension darkmown-*.vsix`. A Marketplace listing is coming soon.
424
+ A VS Code extension in [`editors/vscode`](editors/vscode) gives `.wd` and `.skin` files syntax highlighting, snippets, and folding — so a `.wd` file reads as Markdown-plus-directives, never as broken Markdown. For launch, it is source-installable: build a `.vsix` with `npm run pack:extension`, then install `editors/vscode/darkmown-*.vsix` with VS Code. Marketplace/Open VSX publishing is tracked as post-launch distribution work, so the public package does not imply store availability yet.
217
425
 
218
426
  ## Security
219
427
 
package/docs/cli.md CHANGED
@@ -12,6 +12,14 @@ darkmown help
12
12
 
13
13
  ## Commands
14
14
 
15
+ ### `darkmown help` / `darkmown --help`
16
+
17
+ Prints the command summary, authoring directories, and common directive syntax.
18
+
19
+ ### `darkmown version` / `darkmown --version`
20
+
21
+ Prints the installed package version.
22
+
15
23
  ### `darkmown init [dir]`
16
24
 
17
25
  Creates a minimal Darkmown site with:
@@ -23,7 +31,7 @@ Creates a minimal Darkmown site with:
23
31
  - `site/_/nav.wd`
24
32
  - `README.md`
25
33
 
26
- Existing files are not overwritten.
34
+ Existing files are not overwritten. The scaffolded `package.json` is private by default, includes `dev`, `build`, and `preview` scripts, and names the app after the target directory.
27
35
 
28
36
  After scaffolding:
29
37
 
@@ -50,4 +58,18 @@ Compiles `site/pages` into `dist`.
50
58
 
51
59
  Static pages do not receive `/__wd/runtime.js`; reactive pages do.
52
60
 
53
- When a `.md` file contains `.wd` syntax (directives, includes, loops), the build prints a hint suggesting a rename to `.wd` — the syntax stays plain text in `.md` by design.
61
+ When a `.md` file contains `.wd` syntax (directives, includes, loops), the build prints a hint suggesting a rename to `.wd` — the syntax stays plain text in `.md` by design.
62
+
63
+ ### `darkmown serve`
64
+
65
+ Serves the already-built `dist` directory for local preview. Run `darkmown build` first.
66
+
67
+ ## Smoke checks
68
+
69
+ From this repository, run:
70
+
71
+ ```sh
72
+ npm run smoke
73
+ ```
74
+
75
+ The smoke script packs the local tarball, installs the packed CLI in a temporary driver project, scaffolds a consumer app through that installed bin, installs the same tarball into the app, builds it, verifies the reactive home route, and verifies the plain `.md` about route stays zero-JS.
@@ -44,7 +44,7 @@ This audit compares the current Darkmown implementation with the original MARROW
44
44
  - **Adapter-style Tier 2 (decided):** Darkmown does not own a server. `:form action="/url" into reply` posts urlencoded via fetch and lands the JSON reply in state, degrading to a plain native POST without JS. Sessions ride on `:fetch` plus ordinary cookies against any backend. A first-party `site/api/` runtime stays open as a future option if a real project demands it.
45
45
  - **Dev error overlay:** failed rebuilds render the compiler error in the browser; the overlay clears on the next good build.
46
46
  - **`darkmown serve`:** local preview of the built `dist`, no dev client injected.
47
- - **Packaging:** v0.3.0, license UNLICENSED pending a licensing decision, scaffold pins the live version, npm pack contents covered by tests.
47
+ - **Packaging:** v0.3.0 introduced package metadata and pack tests; the current public package is MIT licensed and scaffolded projects pin the live Darkmown version.
48
48
 
49
49
  ## Stage 6 additions (2026-06-13)
50
50
 
@@ -56,23 +56,31 @@ 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
- - Editor tooling, richer diagnostics, and an actual npm publish (needs a license decision).
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
 
74
82
  ## Current claim
75
83
 
76
84
  Darkmown is faithful to the core thesis: Markdown remains the authoring center, static pages ship zero framework JavaScript, and reactive behavior compiles into tiny direct-DOM islands only where declared. The extension is the feature gate, the loop is one concept with progressive disclosure, and state scopes to document structure.
77
85
 
78
- It is not yet the full production framework described in the original document.
86
+ The public npm package is published as `@zvndev/darkmown`, the project is MIT licensed, and the remaining gaps are explicitly scoped as post-launch product/distribution work rather than missing v0.9 claims.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -17,9 +17,12 @@
17
17
  "test": "node --test tests/*.test.js",
18
18
  "test:cov": "node --test --experimental-test-coverage tests/*.test.js",
19
19
  "test:e2e": "playwright test",
20
+ "test:extension": "npm --prefix editors/vscode ci && npm --prefix editors/vscode test",
21
+ "pack:extension": "cd editors/vscode && npx --yes @vscode/vsce package",
20
22
  "typecheck": "tsc --noEmit",
21
23
  "types": "tsc -p tsconfig.types.json",
22
- "prepublishOnly": "npm test"
24
+ "prepublishOnly": "npm test",
25
+ "smoke": "node scripts/smoke-consumer.mjs"
23
26
  },
24
27
  "files": [
25
28
  "src",
@@ -48,7 +51,7 @@
48
51
  "type": "git",
49
52
  "url": "git+https://github.com/ZVN-DEV/darkmown.git"
50
53
  },
51
- "homepage": "https://github.com/ZVN-DEV/darkmown#readme",
54
+ "homepage": "https://darkmown.com",
52
55
  "bugs": {
53
56
  "url": "https://github.com/ZVN-DEV/darkmown/issues"
54
57
  },
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>
@@ -1,7 +1,7 @@
1
1
  [
2
- { "id": "loops", "title": "Nested conditionals in loops", "shipped": true, "blocked": false },
2
+ { "id": "loops", "title": "Nested `:if` inside loop rows", "shipped": true, "blocked": false },
3
3
  { "id": "editor", "title": "VS Code syntax highlighting", "shipped": true, "blocked": false },
4
- { "id": "filter", "title": "State-driven list filtering", "shipped": false, "blocked": false },
4
+ { "id": "filter", "title": "Reactive `@loop where` filtering", "shipped": true, "blocked": false },
5
5
  { "id": "api", "title": "First-party site/api/ runtime", "shipped": false, "blocked": true },
6
6
  { "id": "swap", "title": "HTML-fragment swap responses", "shipped": false, "blocked": false }
7
7
  ]
@@ -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 nested `:if` over loop items. 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>
@@ -166,4 +166,3 @@ Darkmown itself was built by a small agent team. The board below is a live `@loo
166
166
  @include /sprint-loop.wd
167
167
 
168
168
  </main>
169
- </content>