@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 +20 -0
- package/README.md +225 -17
- package/docs/cli.md +24 -2
- package/docs/spec-alignment.md +13 -5
- package/package.json +6 -3
- package/site/_/nav.wd +2 -0
- package/site/_/roadmap.json +2 -2
- package/site/pages/docs/index.wd +100 -4
- package/site/pages/index.wd +0 -1
- 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/cli.js +14 -2
- package/src/compiler.js +494 -72
- package/src/runtime.js +294 -62
- package/src/scaffold.js +20 -1
- package/types/compiler.d.ts +49 -10
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
|
|
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
|
|
81
|
-
@include /feature-card.wd
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
205
|
-
- `: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).
|
|
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.
|
|
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.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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://
|
|
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
package/site/_/roadmap.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[
|
|
2
|
-
{ "id": "loops", "title": "Nested
|
|
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": "
|
|
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
|
]
|
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>
|
package/site/pages/index.wd
CHANGED