@zvndev/darkmown 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +14 -7
- package/CHANGELOG.md +11 -0
- package/README.md +75 -7
- package/SECURITY.md +1 -1
- package/docs/spec-alignment.md +13 -2
- package/package.json +2 -2
- package/site/_/empty.json +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/data.wd +41 -0
- package/site/pages/docs/index.wd +57 -7
- package/site/pages/index.wd +2 -2
- package/site/pages/reactive.wd +20 -7
- package/src/builder.js +32 -0
- package/src/compiler.js +210 -21
- package/src/runtime.js +113 -5
- package/src/statics.js +1 -0
package/AGENTS.md
CHANGED
|
@@ -7,7 +7,7 @@ Darkmown is a Markdown-native web framework. You build sites by writing `.md` an
|
|
|
7
7
|
- `.md` files are plain CommonMark. Directives stay inert text.
|
|
8
8
|
- `.wd` files are the same Markdown **plus directives** (state, loops, conditionals, includes, forms).
|
|
9
9
|
- To make a `.md` page interactive, **rename it to `.wd`**. ⚠️ Renaming means the old `.md` is **gone** — never leave both `index.md` and `index.wd`, that is a fatal `Duplicate route` build error. Upgrade = delete the `.md`, create the `.wd`.
|
|
10
|
-
- Static pages ship **zero** JavaScript; a page loads the ~
|
|
10
|
+
- Static pages ship **zero** JavaScript; a page loads the ~5.7 KB gzipped runtime (CI-enforced under 6 KB) only if it declares reactive behavior.
|
|
11
11
|
|
|
12
12
|
## Project layout & routing
|
|
13
13
|
|
|
@@ -57,13 +57,15 @@ Button actions are a **fixed whitelist**: `x++`, `x--`, `x += <number>`, `list +
|
|
|
57
57
|
|
|
58
58
|
### Conditionals
|
|
59
59
|
```wd
|
|
60
|
-
:if
|
|
61
|
-
|
|
60
|
+
:if isPro
|
|
61
|
+
Pro plan.
|
|
62
|
+
:else if isTrial
|
|
63
|
+
Trial.
|
|
62
64
|
:else
|
|
63
|
-
|
|
65
|
+
Free plan.
|
|
64
66
|
:endif
|
|
65
67
|
```
|
|
66
|
-
`:if name.path` tests a dotted path. Conditionals nest, including per-row inside a loop.
|
|
68
|
+
`:if name.path` tests a dotted path for truthiness (a bare path — no operators). Chain with `:else if` (any number; a bare `:else` must be last). Conditionals nest, including per-row inside a loop.
|
|
67
69
|
|
|
68
70
|
### Loops — the only loop
|
|
69
71
|
```wd
|
|
@@ -120,8 +122,13 @@ Thanks, **{ profile.name }**!
|
|
|
120
122
|
:endif
|
|
121
123
|
|
|
122
124
|
:computed total = items.length * 4 ← derived state; arithmetic & comparisons only
|
|
125
|
+
|
|
126
|
+
:store session = { "Authorization": "Bearer …" } ← persisted token
|
|
127
|
+
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh" ← auth header; 401 → renew token, retry once
|
|
128
|
+
|
|
129
|
+
:effect q -> searches++ ← run actions when watched state changes
|
|
123
130
|
```
|
|
124
|
-
Shelf `.json` files (in `site/_/`) publish to `/__wd/data/`. `:form action="/api/x" into reply` posts urlencoded and lands the JSON reply in state (without JS it degrades to a native POST). Darkmown owns no backend — point forms at your own API.
|
|
131
|
+
Shelf `.json` files (in `site/_/`) publish to `/__wd/data/`. `:form action="/api/x" into reply` posts urlencoded and lands the JSON reply in state (without JS it degrades to a native POST). Darkmown owns no backend — point forms at your own API. `:fetch`/`refresh=` URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation. `:effect` actions are the `:button` vocabulary (`;`-chained); effects run on change, not on load, capped at 10 settle passes.
|
|
125
132
|
|
|
126
133
|
**Form-state rules (these cause most form failures):**
|
|
127
134
|
- `:form into x` declares `x` only at `:endform`. A `:if x` (or `{ x.field }`) that reads it must come **after `:endform`**, never inside the form body.
|
|
@@ -144,7 +151,7 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
|
|
|
144
151
|
| reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
|
|
145
152
|
| `{ name }` | a text node |
|
|
146
153
|
|
|
147
|
-
So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`. For any other custom element, write raw HTML `<div class="…">`. There is NO `{.class}` attribute syntax.**
|
|
154
|
+
So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`. For any other custom element, write raw HTML `<div class="…">`. There is NO `{.class}` attribute syntax.** A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same whitelist as `@loop … where`).
|
|
148
155
|
|
|
149
156
|
## Styling with `.skin` — ship this on every page
|
|
150
157
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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.13.0 — 2026-06-18
|
|
6
|
+
|
|
7
|
+
Layered capabilities — four reactive features shaped as progressive disclosure: a simple default, with more power one layer down. The reactive runtime grew to ~5.7 KB gzipped; the CI budget moves from 5 KB to **6 KB** (still shipped un-minified, source fully commented).
|
|
8
|
+
|
|
9
|
+
- **Conditionals — `:else if` chains.** `:if A` / `:else if B` / … / `:else` / `:endif`. Any number of `:else if`; an optional trailing bare `:else` must come last (a later `:else if` is a compile error). Desugars to nested `:if` regions the runtime already drives — identical behavior for static, reactive, and loop-row conditionals, and no runtime change.
|
|
10
|
+
- **Styling — reactive classes (`::: … .class when <predicate>`).** A container class can be toggled by a predicate using the same whitelisted grammar as `@loop … where` / `:if` (`item.field`, `:state`, `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains`, `and`/`or`). A fully-static predicate folds at build time; a state predicate becomes a global `data-wd-class` binding; a loop-item predicate reacts per row. Static `.class` tokens are unchanged.
|
|
11
|
+
- **Fetch — authenticated requests + token refresh (`:fetch … headers= refresh=`).** `headers=<stateKey>` (already shipped) spreads a state object into request headers; pair with `:store` to persist a bearer token. New `refresh="<url>"`: on an HTTP 401 the runtime POSTs the current headers to the refresh URL, writes the renewed token back to state (persisting if it is a `:store`), and retries the original request once. Concurrent 401s sharing a refresh URL are de-duplicated into a single in-flight refresh.
|
|
12
|
+
- **Effects — `:effect <state> -> <actions>`.** Run actions (the full `:button` vocabulary, `;`-chained) whenever a watched state path changes — for side effects beyond `:computed` (derive state) and fetch deps (auto-refetch). Effects run after a render against settled state, with a 10-pass settle cap guarding against effect→effect loops.
|
|
13
|
+
- **Assets — page-colocated static files.** Any non-page file under `site/pages/` (images, SVG, fonts — anything but `.md`/`.wd`/`.skin`/`.js`) is copied to `dist/` at its path: `site/pages/logo.svg` → `dist/logo.svg`, `site/pages/blog/cover.png` → `dist/blog/cover.png`. The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; `.avif` joins the served content-types.
|
|
14
|
+
- **Security — `:fetch` URL scheme hardening.** `:fetch` (and `refresh=`) URLs are validated at compile time: relative paths, `http(s)://`, and a leading `{ state }` interpolation are allowed; a protocol-relative `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is now a compile error with a corrective hint.
|
|
15
|
+
|
|
5
16
|
## 0.12.1 — 2026-06-18
|
|
6
17
|
|
|
7
18
|
Release-hygiene and DX polish (post-0.12.0 smoke audit).
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
**[darkmown.com](https://darkmown.com)** · Markdown that runs.
|
|
9
9
|
|
|
10
|
-
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~
|
|
10
|
+
Darkmown is a Markdown-native web framework. Two formats, one rule: `.md` stays plain CommonMark forever, and renaming a file to `.wd` ("whateverdown") is what unlocks directives — includes, loops, state, conditionals, and sections. Static pages ship **zero** framework JavaScript; reactive pages share one runtime around ~5.7 KB gzipped, CI-enforced under 6 KB.
|
|
11
11
|
|
|
12
12
|
## Quick start
|
|
13
13
|
|
|
@@ -52,7 +52,7 @@ npm run dev # live demo site — the same site that runs darkmown.com
|
|
|
52
52
|
- Files or folders starting with `.`, `-`, or `_` are hidden from routing.
|
|
53
53
|
- `site/_` is the include shelf for `@include /name.wd`.
|
|
54
54
|
- Matching `page.skin` and `page.js` colocate styling and behavior by basename.
|
|
55
|
-
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~
|
|
55
|
+
- Static pages ship zero Darkmown runtime. Reactive pages share `/__wd/runtime.js` (currently ~5.7 KB gzipped, CI-enforced under 6 KB).
|
|
56
56
|
- Shelf `.json` files are published at `/__wd/data/` so `:fetch` works on any static host.
|
|
57
57
|
|
|
58
58
|
## Interpolation
|
|
@@ -88,7 +88,14 @@ Images, fonts, icons, and other non-page files live on the include shelf at `sit
|
|
|
88
88
|
- Any non-`.md`/`.wd` file in `site/_/` → served at `/__wd/media/<path>` (e.g. `site/_/logo.svg` → `/__wd/media/logo.svg`).
|
|
89
89
|
- `.json` files → served at `/__wd/data/<name>` (this is what `:fetch` reads).
|
|
90
90
|
|
|
91
|
-
Reference them with a normal URL: ``. The preview/`serve` layer returns the correct `Content-Type` per extension.
|
|
91
|
+
Reference them with a normal URL: ``. The preview/`serve` layer returns the correct `Content-Type` per extension.
|
|
92
|
+
|
|
93
|
+
**Or colocate an asset next to the page that uses it.** Any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and the colocated `.skin`/`.js`) is copied to `dist/` at its own path, so a clean relative URL just works:
|
|
94
|
+
|
|
95
|
+
- `site/pages/logo.svg` → `dist/logo.svg` → ``
|
|
96
|
+
- `site/pages/blog/cover.png` → `dist/blog/cover.png` → ``
|
|
97
|
+
|
|
98
|
+
Use the shelf for assets shared across many pages; colocate the ones that belong to a single page or section. Both ship untouched with the correct content-type.
|
|
92
99
|
|
|
93
100
|
## Loops
|
|
94
101
|
|
|
@@ -240,6 +247,20 @@ Cart has { count } items.
|
|
|
240
247
|
|
|
241
248
|
State declared inside a section is scoped to it — two sections can both own a `count`. Bindings and actions resolve to the nearest scope.
|
|
242
249
|
|
|
250
|
+
### Reactive classes — `.class when <predicate>`
|
|
251
|
+
|
|
252
|
+
A container class can be toggled by a predicate. Static `.class` tokens are unchanged; add `when <predicate>` to make one reactive:
|
|
253
|
+
|
|
254
|
+
```wd
|
|
255
|
+
@loop products into p
|
|
256
|
+
::: card .product .on-sale when p.price < 50 .featured when p.featured
|
|
257
|
+
**{ p.name }** — ${ p.price }
|
|
258
|
+
:::
|
|
259
|
+
@endloop
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The predicate uses the same whitelist as `@loop … where` / `:if` — item fields, declared `:state`/`:store`, numbers, strings, the `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains` operators, and `and`/`or`. A bare path (`.featured when p.featured`) reads as truthy. A predicate over only static values folds at build time into a plain class; one that reads state or the loop item stays reactive and ships with the runtime.
|
|
263
|
+
|
|
243
264
|
## Reactive directives
|
|
244
265
|
|
|
245
266
|
```wd
|
|
@@ -256,6 +277,20 @@ Count is still zero.
|
|
|
256
277
|
:endif
|
|
257
278
|
```
|
|
258
279
|
|
|
280
|
+
Conditionals chain with `:else if`. Each condition is a bare truthy path (the same form `:if` takes — no operators). Use any number of them; an optional bare `:else` must be the last branch:
|
|
281
|
+
|
|
282
|
+
```wd
|
|
283
|
+
:if isPro
|
|
284
|
+
Pro plan
|
|
285
|
+
:else if isTrial
|
|
286
|
+
Trial — { daysLeft } days left
|
|
287
|
+
:else
|
|
288
|
+
Free plan
|
|
289
|
+
:endif
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
A whole chain compiles to nested conditional regions, so it stays reactive (or folds at build time when the values are static) exactly like a single `:if`.
|
|
293
|
+
|
|
259
294
|
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in colocated `.js` files.
|
|
260
295
|
|
|
261
296
|
### Button actions
|
|
@@ -297,6 +332,19 @@ Values are literals: a `"string"`, number, `true`/`false`/`null`, or inline JSON
|
|
|
297
332
|
|
|
298
333
|
> **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.
|
|
299
334
|
|
|
335
|
+
### Effects — `:effect`
|
|
336
|
+
|
|
337
|
+
`:effect <watched> -> <actions>` runs actions whenever a watched state path changes. The actions are the same `:button` vocabulary (`;`-chained) — this is the escape hatch for side effects beyond `:computed` (which derives state) and `:fetch` deps (which auto-refetch):
|
|
338
|
+
|
|
339
|
+
```wd
|
|
340
|
+
:state q = ""
|
|
341
|
+
:state searches = 0
|
|
342
|
+
|
|
343
|
+
:effect q -> searches++
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Effects run after a render, against settled state, and an effect that mutates state triggers another pass — bounded by a 10-pass settle cap that warns (and stops) if an effect never settles. They do not fire on the initial load, only on a real change.
|
|
347
|
+
|
|
300
348
|
## Fetching data
|
|
301
349
|
|
|
302
350
|
`:fetch name from "url"` declares state and fills it from JSON over the network:
|
|
@@ -323,8 +371,7 @@ Cover loading, errors, empty, and data — `@empty` (from the loops above) absor
|
|
|
323
371
|
|
|
324
372
|
:if roster_loading
|
|
325
373
|
Loading…
|
|
326
|
-
:else
|
|
327
|
-
:if roster_error
|
|
374
|
+
:else if roster_error
|
|
328
375
|
Couldn't load the team: { roster_error }
|
|
329
376
|
:else
|
|
330
377
|
@loop roster into member
|
|
@@ -333,7 +380,6 @@ Couldn't load the team: { roster_error }
|
|
|
333
380
|
No team members yet.
|
|
334
381
|
@endloop
|
|
335
382
|
:endif
|
|
336
|
-
:endif
|
|
337
383
|
```
|
|
338
384
|
|
|
339
385
|
### Options
|
|
@@ -344,6 +390,28 @@ No team members yet.
|
|
|
344
390
|
- `retry=<N>` — retry on network failure or a 5xx response before surfacing the error.
|
|
345
391
|
- `headers=<key>` — a `:state`/`:store` key holding an object, sent as request headers.
|
|
346
392
|
- `body=<key>` — a `:state`/`:store` key, JSON-serialized as the request body (for non-`GET`).
|
|
393
|
+
- `refresh=<url>` — a token-refresh endpoint; on a `401`, renew the `headers=` token and retry once (see below).
|
|
394
|
+
|
|
395
|
+
> **URL safety.** A `:fetch`/`refresh` URL must be a relative path, an `http(s)://` URL, or a leading `{ state }` interpolation. A protocol-relative `//host` or a non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is a compile error.
|
|
396
|
+
|
|
397
|
+
### Authenticated requests and token refresh
|
|
398
|
+
|
|
399
|
+
`headers=<key>` sends a state object as request headers — pair it with `:store` to persist a bearer token across reloads:
|
|
400
|
+
|
|
401
|
+
```wd
|
|
402
|
+
:store session = { "Authorization": "Bearer …" }
|
|
403
|
+
:fetch feed from "/api/feed" headers=session
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Add `refresh="<url>"` and Darkmown manages the token lifecycle: when a request comes back `401`, it POSTs the current `headers` object to the refresh URL, writes the returned headers (the new token) back into the `session` state — persisting it, since `session` is a `:store` — and retries the original request once. Concurrent `401`s sharing a refresh URL are de-duplicated into a single in-flight refresh.
|
|
407
|
+
|
|
408
|
+
```wd
|
|
409
|
+
:store session = { "Authorization": "Bearer …" }
|
|
410
|
+
|
|
411
|
+
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`refresh=` requires `headers=` (it needs a state key to renew). The refresh endpoint should accept the current headers as a JSON body and reply with the new headers object.
|
|
347
415
|
|
|
348
416
|
### Dynamic URLs and refetching
|
|
349
417
|
|
|
@@ -447,7 +515,7 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
|
|
|
447
515
|
|
|
448
516
|
- **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, form input — without sanitizing it first.
|
|
449
517
|
- **Raw HTML passes through by default.** Darkmown runs `markdown-it` with `html: true`, so raw HTML in your content is emitted verbatim — by design, like every Markdown site generator. There is no built-in sanitizer; a raw `<script>` or event-handler attribute in untrusted content would execute in the visitor's browser. A page can opt into strict mode with `html: false` frontmatter, which drops raw HTML entirely.
|
|
450
|
-
- **`:fetch` has no
|
|
518
|
+
- **`:fetch` has no host allowlist, and reactive pages need `unsafe-eval`.** A `:fetch` (or `refresh=`) URL is taken straight from the page source; the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF protection is the author's responsibility. Reactive pages also require `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function` — a strict CSP without `unsafe-eval` will break reactive pages (static pages are unaffected).
|
|
451
519
|
|
|
452
520
|
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
|
|
453
521
|
|
package/SECURITY.md
CHANGED
|
@@ -16,7 +16,7 @@ Darkmown is a **trusted-author** static site generator. The threat model assumes
|
|
|
16
16
|
|
|
17
17
|
1. **Compile only trusted, author-written content.** Do not compile `.md`/`.wd` files you did not write — user-generated content, third-party docs, or form input — without applying your own sanitization first.
|
|
18
18
|
2. **Raw HTML passes through by default.** `markdown-it` runs with `html: true`, so raw HTML in content is emitted verbatim. There is no built-in sanitizer, so a raw `<script>` or event-handler attribute in untrusted content would execute in the visitor's browser. A page can opt into strict mode with `html: false` frontmatter, which strips raw HTML.
|
|
19
|
-
3. **`:fetch` has no
|
|
19
|
+
3. **`:fetch` has no host allowlist, and reactive pages require `unsafe-eval`.** A `:fetch` (or `refresh=`) URL is read directly from the page source; the compiler rejects non-http(s) schemes (`file:`, `data:`, `javascript:`, protocol-relative `//host`) but does not restrict which hosts you call, so SSRF protection is the author's responsibility. Reactive pages also need `script-src 'unsafe-eval'` in your Content-Security-Policy, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions through `new Function`; a strict CSP without `unsafe-eval` breaks reactive pages (static pages are unaffected).
|
|
20
20
|
|
|
21
21
|
This is the same trust posture as every Markdown SSG: the framework is safe for the trusted-author use case it targets, and unsafe content is the author's responsibility to sanitize before it reaches the compiler.
|
|
22
22
|
|
package/docs/spec-alignment.md
CHANGED
|
@@ -6,7 +6,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
6
6
|
|
|
7
7
|
- **Markdown-first authoring:** `.md` is strict CommonMark rendered by a real parser (markdown-it); `.wd` is the same Markdown plus first-party directives. Renaming the file is the upgrade path — extensions genuinely gate functionality.
|
|
8
8
|
- **Content tree over components:** routing, includes, and repeated fragments are file/document based instead of React-style components.
|
|
9
|
-
- **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~
|
|
9
|
+
- **Tiered output:** static routes emit HTML/CSS and no Darkmown runtime; reactive routes opt into `/__wd/runtime.js` (~5.7 KB gzipped, CI-enforced below the 6 KB target).
|
|
10
10
|
- **No virtual DOM:** state changes patch direct DOM bindings, conditional regions, and keyed loop regions.
|
|
11
11
|
- **Keyed list patching:** reactive loops reconcile by item key (`id`/`key`/value) instead of re-rendering the region.
|
|
12
12
|
- **Sections with scoped state:** `::: section #id .class` containers scope `:state`; bindings and actions resolve through the nearest scope chain.
|
|
@@ -69,9 +69,20 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
69
69
|
|
|
70
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).
|
|
71
71
|
|
|
72
|
+
## Stage 9 additions (2026-06-18, v0.13.0)
|
|
73
|
+
|
|
74
|
+
Four progressive-disclosure capabilities plus an assets and a security change. The reactive runtime grew to ~5.7 KB gzipped; the CI budget moved 5 KB → 6 KB (still un-minified).
|
|
75
|
+
|
|
76
|
+
- **`:else if` chains:** `:if … :else if … :else … :endif`, any number of `:else if`, optional trailing bare `:else` (a `:else if` after the bare `:else` is a compile error). Implemented by **desugaring** the chain tail into a nested `:if` inside the falsy branch, which re-enters the existing compile path — so static, reactive, and per-row (loop) conditionals all behave identically with no runtime change. **Honest caveat:** each condition is a bare truthy path, the same grammar `:if` accepts — comparisons (`==`, `<`, …) are not supported in a conditional (use `@loop … where` for those, or a `:computed` boolean).
|
|
77
|
+
- **Reactive classes — `::: … .class when <predicate>`:** a container class toggled by a predicate using the `@loop … where` / `:if` whitelist (item fields, `:state`/`:store`, numbers, strings, `==`/`!=`/`>`/`<`/`>=`/`<=`/`contains`, `and`/`or`, plus a bare truthy path). A fully-static predicate folds to a plain class at build time (`runtime: false` preserved); a predicate over `:state` emits a global `data-wd-class`; a loop-item predicate emits `data-wd-each-class` evaluated per row. The predicate compiles to the same whitelisted grammar over `I()`/`S()`/`C()` and runs via `new Function`.
|
|
78
|
+
- **Authenticated fetch + token refresh — `:fetch … headers= refresh=`:** `headers=<stateKey>` (existing) spreads a state object into request headers. New `refresh="<url>"`: on HTTP 401 the runtime POSTs the current headers to the refresh URL, writes the returned headers object back into the `headers` state (persisting if it is a `:store`), and retries the original request once; concurrent 401s sharing a refresh URL collapse to a single in-flight refresh. **Honest caveat:** this is bearer-token-style renewal only — no OAuth authorization-code flow, no refresh-token rotation beyond what the endpoint returns; `refresh=` requires `headers=`.
|
|
79
|
+
- **Effects — `:effect <watched> -> <actions>`:** run the `:button` action vocabulary (`;`-chained) whenever a watched state path changes. Emits a zero-output `data-wd-effect` marker; the runtime snapshots each watched value and, after a render against settled state, runs the actions on change, re-rendering and re-checking up to a 10-pass settle cap (a non-settling effect warns and stops). **Honest caveat:** effects do not fire on initial load (only on change), and row-scoped actions (`append-row`/`remove`) have no clicked row in an effect, so they no-op.
|
|
80
|
+
- **Page-colocated static assets:** any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and colocated `.skin`/`.js`) is copied to `dist/` at its path (`site/pages/logo.svg` → `dist/logo.svg`). The shelf (`site/_/` → `/__wd/media`, `/__wd/data`) is unchanged; a name clash with an emitted route is skipped with a warning. `.avif` was added to the served content-types.
|
|
81
|
+
- **`:fetch` URL scheme hardening (security):** `:fetch`/`refresh=` URLs are validated at compile time — relative, `http(s)://`, or a leading `{ state }` interpolation are allowed; protocol-relative `//host` and non-http(s) schemes (`file:`, `data:`, `javascript:`, …) are rejected with a corrective hint. **This is scheme validation, not a host allowlist** — SSRF to arbitrary http(s) hosts remains the author's responsibility (see the trust boundary).
|
|
82
|
+
|
|
72
83
|
## Demo-only directives (not part of the public surface)
|
|
73
84
|
|
|
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
|
|
85
|
+
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`, `:effect`, `:button`, `:bind`, `:if`/`:else if`/`:else`/`:endif`, `:fetch` (with `headers`/`refresh`), `:form`/`:input`/`:submit`, `@loop`/`@empty`/`@endloop` (with `where`, `sort by`, `reverse`, `offset`, `limit`), `@include`, and `::: section` (with reactive `.class when`).
|
|
75
86
|
|
|
76
87
|
## Still missing from the full vision
|
|
77
88
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"types": "./types/index.d.ts",
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "node src/cli.js build",
|
|
16
|
-
"runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget:
|
|
16
|
+
"runtime:size": "node -e \"const fs=require('fs'),zlib=require('zlib'); const size=zlib.gzipSync(fs.readFileSync('dist/__wd/runtime.js')).length; console.log('shipped runtime gzipped: '+size+' bytes (budget: 6144)'); if (size >= 6144) process.exit(1)\"",
|
|
17
17
|
"dev": "node src/cli.js dev",
|
|
18
18
|
"test": "node --test tests/*.test.js",
|
|
19
19
|
"test:cov": "node scripts/test-coverage-gate.mjs 90",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
package/site/pages/app.wd
CHANGED
|
@@ -19,7 +19,7 @@ This whole section is plain Markdown. The list below is unrolled **at build time
|
|
|
19
19
|
- **{ feature.title }** — { feature.body }
|
|
20
20
|
@endloop
|
|
21
21
|
|
|
22
|
-
<aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~
|
|
22
|
+
<aside class="boundary">If you deleted everything below this line, this page would ship <strong>zero</strong> bytes of JavaScript. Everything above is static HTML. Below is where the one ~5.7 KB gzipped runtime loads (CI-enforced under 6 KB) — and only because the document asks for it.</aside>
|
|
23
23
|
|
|
24
24
|
## Fetched data, looped, with nested conditions
|
|
25
25
|
|
package/site/pages/data.wd
CHANGED
|
@@ -33,6 +33,47 @@ Loading the team…
|
|
|
33
33
|
|
|
34
34
|
That loop also proves `:if` over loop items: the lead badge is decided per row.
|
|
35
35
|
|
|
36
|
+
## The four states of a fetch
|
|
37
|
+
|
|
38
|
+
Every `:fetch` tracks the whole lifecycle — `loading`, `error`, `empty`, and data — so a page can answer each one. Here the same primitive shows an **error** (a missing URL 404s) and an **empty** result (a file that is just `[]`):
|
|
39
|
+
|
|
40
|
+
:fetch broken from "/__wd/data/intentionally-missing.json"
|
|
41
|
+
|
|
42
|
+
:if broken_error
|
|
43
|
+
The request failed on purpose — `broken_error` holds: { broken_error }
|
|
44
|
+
:else if broken_loading
|
|
45
|
+
Trying the missing endpoint…
|
|
46
|
+
:else
|
|
47
|
+
Waiting…
|
|
48
|
+
:endif
|
|
49
|
+
|
|
50
|
+
:fetch roster from "/__wd/data/empty.json"
|
|
51
|
+
|
|
52
|
+
@loop roster into person
|
|
53
|
+
- { person.name }
|
|
54
|
+
@empty
|
|
55
|
+
The roster came back empty — `@empty` renders instead of the rows.
|
|
56
|
+
@endloop
|
|
57
|
+
|
|
58
|
+
The error branch uses an `:else if` chain; the empty branch uses the loop's `@empty`. No state machine to wire by hand.
|
|
59
|
+
|
|
60
|
+
## Authenticated requests
|
|
61
|
+
|
|
62
|
+
A real API usually wants a token. `:fetch … headers=<key>` spreads a state object into the request headers, so pair it with `:store` to persist that token across reloads:
|
|
63
|
+
|
|
64
|
+
:store session = { "Authorization": "Bearer demo-token" }
|
|
65
|
+
:fetch feed from "/__wd/data/team.json" headers=session
|
|
66
|
+
|
|
67
|
+
:if feed_error
|
|
68
|
+
The API rejected the token: { feed_error }
|
|
69
|
+
:else if feed
|
|
70
|
+
Authenticated fetch returned { feed.length } row(s). Open the network tab — the `Authorization` header rides along on the request.
|
|
71
|
+
:else
|
|
72
|
+
Loading the authenticated feed…
|
|
73
|
+
:endif
|
|
74
|
+
|
|
75
|
+
This demo points `from=` at a static file (which ignores the header), so it stays self-contained — point it at your real API and the bearer token travels with every request. When that token expires, add `refresh="/auth/refresh"` and Darkmown renews it on a `401` and retries once, automatically.
|
|
76
|
+
|
|
36
77
|
## Forms write state, no backend required
|
|
37
78
|
|
|
38
79
|
`:form into profile` captures the submit straight into state. With `action="/url"` instead, Darkmown emits a plain native form and stays out of the way entirely:
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -170,7 +170,7 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
170
170
|
|
|
171
171
|
## Reactive directives
|
|
172
172
|
|
|
173
|
-
Reactive pages opt into `/__wd/runtime.js` (~
|
|
173
|
+
Reactive pages opt into `/__wd/runtime.js` (~5.7 KB gzipped, under a CI-enforced 6 KB budget). Static pages do not.
|
|
174
174
|
|
|
175
175
|
```wd
|
|
176
176
|
:state count = 0
|
|
@@ -196,6 +196,49 @@ Count is still zero.
|
|
|
196
196
|
|
|
197
197
|
Directive actions are intentionally narrow and compile-time checked. Arbitrary JavaScript belongs in a colocated `.js` file.
|
|
198
198
|
|
|
199
|
+
### Multi-branch conditionals
|
|
200
|
+
|
|
201
|
+
Chain conditionals with `:else if`. Each condition is a bare truthy path (the same form `:if` takes). Use any number of branches; a bare `:else` must come last:
|
|
202
|
+
|
|
203
|
+
```wd
|
|
204
|
+
:if isPro
|
|
205
|
+
Pro plan
|
|
206
|
+
:else if isTrial
|
|
207
|
+
Trial — { daysLeft } days left
|
|
208
|
+
:else
|
|
209
|
+
Free plan
|
|
210
|
+
:endif
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A whole chain compiles to nested conditional regions, so it folds at build time when the values are static and stays reactive otherwise — exactly like a single `:if`.
|
|
214
|
+
|
|
215
|
+
### Reactive classes — `.class when …`
|
|
216
|
+
|
|
217
|
+
A container class can react to state or the loop item. Static `.class` tokens stay as-is; add `when <predicate>` for a reactive one:
|
|
218
|
+
|
|
219
|
+
```wd
|
|
220
|
+
@loop products into p
|
|
221
|
+
::: card .product .on-sale when p.price < 50
|
|
222
|
+
**{ p.name }** — ${ p.price }
|
|
223
|
+
:::
|
|
224
|
+
@endloop
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The predicate uses the `@loop … where` / `:if` whitelist (item fields, `:state`, numbers, strings, comparisons, `and`/`or`, `contains`). A static predicate folds to a plain class at build; a state or loop-item predicate stays reactive.
|
|
228
|
+
|
|
229
|
+
### Effects — `:effect`
|
|
230
|
+
|
|
231
|
+
`:effect <watched> -> <actions>` runs actions (the `:button` vocabulary, `;`-chained) whenever the watched state changes — for side effects beyond `:computed` and fetch deps:
|
|
232
|
+
|
|
233
|
+
```wd
|
|
234
|
+
:state q = ""
|
|
235
|
+
:state searches = 0
|
|
236
|
+
|
|
237
|
+
:effect q -> searches++
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Effects run after a render against settled state, never on the initial load, with a 10-pass settle cap guarding against loops.
|
|
241
|
+
|
|
199
242
|
### Button actions
|
|
200
243
|
|
|
201
244
|
A `:button "Label" -> action` mutates one `:state` or `:store` value. The vocabulary is the same for both:
|
|
@@ -223,8 +266,7 @@ Values are literals — a `"string"`, number, `true`/`false`/`null`, or inline J
|
|
|
223
266
|
|
|
224
267
|
:if team_loading
|
|
225
268
|
Loading…
|
|
226
|
-
:else
|
|
227
|
-
:if team_error
|
|
269
|
+
:else if team_error
|
|
228
270
|
Couldn't load the team: { team_error }
|
|
229
271
|
:else
|
|
230
272
|
@loop team into member
|
|
@@ -233,10 +275,18 @@ Couldn't load the team: { team_error }
|
|
|
233
275
|
No team members yet.
|
|
234
276
|
@endloop
|
|
235
277
|
:endif
|
|
236
|
-
:endif
|
|
237
278
|
```
|
|
238
279
|
|
|
239
|
-
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`.
|
|
280
|
+
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`. URLs must be relative, `http(s)://`, or a leading `{ state }` interpolation — other schemes are rejected at compile time.
|
|
281
|
+
|
|
282
|
+
### Authenticated requests
|
|
283
|
+
|
|
284
|
+
`headers=<key>` sends a state object as request headers; pair it with `:store` to persist a bearer token. Add `refresh="<url>"` and a `401` triggers a token refresh: Darkmown POSTs the current headers to the refresh URL, writes the renewed token back into the `headers` state, and retries the request once (concurrent `401`s share one refresh).
|
|
285
|
+
|
|
286
|
+
```wd
|
|
287
|
+
:store session = { "Authorization": "Bearer …" }
|
|
288
|
+
:fetch feed from "/api/feed" headers=session refresh="/auth/refresh"
|
|
289
|
+
```
|
|
240
290
|
|
|
241
291
|
## Global state — `:store`
|
|
242
292
|
|
|
@@ -271,7 +321,7 @@ Darkmown is a trusted-author site generator: you compile content you wrote, the
|
|
|
271
321
|
|
|
272
322
|
- **Compile only content you authored.** Don't run `.md`/`.wd` you didn't write — user-generated content, third-party docs — through the compiler without sanitizing it first.
|
|
273
323
|
- **Raw HTML passes through by default.** Markdown renders with `html: true`, so raw HTML is emitted verbatim and there is no built-in sanitizer. Set `html: false` in a page's frontmatter to drop raw HTML for untrusted content.
|
|
274
|
-
- **`:fetch` has no
|
|
324
|
+
- **`:fetch` has no host allowlist, and reactive pages need `unsafe-eval`.** Fetch URLs come straight from the page source — the compiler rejects non-http(s) schemes but does not restrict which hosts you call, so SSRF is the author's responsibility. The runtime compiles `:computed` / `@loop … where` / `.class when` expressions via `new Function`, so a reactive page needs `script-src 'unsafe-eval'` in your CSP. Static pages need neither.
|
|
275
325
|
|
|
276
326
|
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in the package for the full security model.
|
|
277
327
|
|
|
@@ -283,6 +333,6 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in
|
|
|
283
333
|
|
|
284
334
|
## Spec status
|
|
285
335
|
|
|
286
|
-
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`,
|
|
336
|
+
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`, dotted loop sources, authenticated `headers=` + `refresh=` token renewal), `:form` (including server round-trips), loop ergonomics (`sort by`/`limit`/`offset`/`reverse`/`@empty` and `$index`/`$number`/`$first`/`$last`/`$count`), `:if … :else if … :else`, reactive `.class when …`, `:effect`, `:computed`, and `persist` are all shipped and live — try them on the [Data & Forms page](/data/) and the [Reactive page](/reactive/). 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.
|
|
287
337
|
|
|
288
338
|
</main>
|
package/site/pages/index.wd
CHANGED
|
@@ -97,14 +97,14 @@ Markdoc.renderers.react(content, React, {
|
|
|
97
97
|
|
|
98
98
|
</section>
|
|
99
99
|
|
|
100
|
-
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~
|
|
100
|
+
:note "Static pages ship zero JavaScript. The moment a page asks for reactivity — like this very homepage — one ~5.7 KB gzipped runtime loads (CI-enforced under 6 KB), never before. JS optional, CSS optional, always."
|
|
101
101
|
|
|
102
102
|
<section class="props">
|
|
103
103
|
|
|
104
104
|
## Three rules, no escape hatch
|
|
105
105
|
|
|
106
106
|
- **Zero-JS static.** A page with no state ships no framework JavaScript. `/markdown/` and `/docs/` prove it — open them and check the network tab.
|
|
107
|
-
- **One runtime.** Every reactive page shares the same ~
|
|
107
|
+
- **One runtime.** Every reactive page shares the same ~5.7 KB gzipped runtime, CI-enforced under 6 KB. No per-component bundles, no hydration tax that scales with your UI.
|
|
108
108
|
- **One loop, one binding.** `@loop … into …` is the only loop; `{ name }` is the only interpolation. Nothing else to learn, nothing else to misuse.
|
|
109
109
|
|
|
110
110
|
</section>
|
package/site/pages/reactive.wd
CHANGED
|
@@ -39,30 +39,34 @@ The same `@loop` that unrolls static data also drives reactive lists. Items are
|
|
|
39
39
|
|
|
40
40
|
:button "Add todo" -> todos += {"id": 3, "title": "Live compile", "owner": "Testing"}
|
|
41
41
|
|
|
42
|
-
##
|
|
42
|
+
## Multi-branch conditionals + reactive classes
|
|
43
43
|
|
|
44
|
-
`:if`
|
|
44
|
+
`:if … :else if … :else … :endif` picks one of several branches per row, and a container class can react to the row too: `.class when <predicate>` toggles a class from the same whitelist `@loop … where` uses. Each card below picks one of three badges *and* tags itself `.is-done` / `.is-blocked`, all reactive:
|
|
45
|
+
|
|
46
|
+
<style>
|
|
47
|
+
.task-row { border-left: 4px solid #9991; padding-left: .6rem; }
|
|
48
|
+
.task-row.is-done { border-left-color: #2e9e5b; }
|
|
49
|
+
.task-row.is-blocked { border-left-color: #d2453f; }
|
|
50
|
+
</style>
|
|
45
51
|
|
|
46
52
|
:state tasks = [{"id": 1, "title": "Draft the spec", "done": true, "blocked": false}, {"id": 2, "title": "Wire the runtime", "done": false, "blocked": true}, {"id": 3, "title": "Write the demo", "done": false, "blocked": false}]
|
|
47
53
|
|
|
48
54
|
@loop tasks into task
|
|
49
|
-
::: card .task-row
|
|
55
|
+
::: card .task-row .is-done when task.done .is-blocked when task.blocked
|
|
50
56
|
**{ task.title }** —
|
|
51
57
|
:if task.done
|
|
52
58
|
done ✓
|
|
53
|
-
:else
|
|
54
|
-
:if task.blocked
|
|
59
|
+
:else if task.blocked
|
|
55
60
|
blocked ✗
|
|
56
61
|
:else
|
|
57
62
|
in progress …
|
|
58
63
|
:endif
|
|
59
|
-
:endif
|
|
60
64
|
:::
|
|
61
65
|
@endloop
|
|
62
66
|
|
|
63
67
|
:button "Add a finished task" -> tasks += {"id": 4, "title": "Ship it", "done": true, "blocked": false}
|
|
64
68
|
|
|
65
|
-
Each row picks one of three states from
|
|
69
|
+
Each row picks one of three states from a single `:else if` chain, and its left border color comes from a reactive `.class when …` — plain Markdown, no component, no ternary, no JavaScript.
|
|
66
70
|
|
|
67
71
|
## Live filtering — search in pure Markdown
|
|
68
72
|
|
|
@@ -83,6 +87,15 @@ Each row picks one of three states from two nested conditionals, all in plain Ma
|
|
|
83
87
|
|
|
84
88
|
The predicate whitelist is compile-time validated — item fields, declared state, numbers, and strings only. An item-only predicate (say `where p.price < 50`) would instead filter at build time and ship zero JavaScript.
|
|
85
89
|
|
|
90
|
+
### …and an effect that watches the search
|
|
91
|
+
|
|
92
|
+
`:effect <state> -> <actions>` runs actions whenever the watched state changes — for side effects past deriving state. This one counts how many times the search box above has changed:
|
|
93
|
+
|
|
94
|
+
:state searches = 0
|
|
95
|
+
:effect q -> searches++
|
|
96
|
+
|
|
97
|
+
The search has changed **{ searches }** time(s). Type in the box above and this updates on its own — no handler wired by hand.
|
|
98
|
+
|
|
86
99
|
### …and an editable cart
|
|
87
100
|
|
|
88
101
|
A `:button` inside a loop can act on its own row. **Add to cart** carries the row into another list; **Remove** drops a line — a whole add/remove flow with no JavaScript:
|
package/src/builder.js
CHANGED
|
@@ -71,6 +71,11 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
71
71
|
fs.writeFileSync(path.join(paths.distRoot, "404.html"), notFoundHtml ?? defaultNotFoundHtml());
|
|
72
72
|
|
|
73
73
|
fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
|
|
74
|
+
|
|
75
|
+
// Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
|
|
76
|
+
// sees every emitted route/framework file already on disk.
|
|
77
|
+
emitPageAssets(paths);
|
|
78
|
+
|
|
74
79
|
return { routes: manifest, distRoot: paths.distRoot };
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -168,6 +173,33 @@ function emitShelfAssets(paths) {
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Copy page-colocated static assets (images, SVG, fonts, …) from `site/pages`
|
|
178
|
+
* into `dist`, preserving their relative path: `site/pages/logo.svg` →
|
|
179
|
+
* `dist/logo.svg`; `site/pages/blog/cover.png` → `dist/blog/cover.png`. Routes
|
|
180
|
+
* (`.md`/`.wd`) and the compiler's colocated `.skin`/`.js` files are skipped —
|
|
181
|
+
* those have their own emit paths. A backstop guard skips any asset whose output
|
|
182
|
+
* would clobber an already-emitted route/framework file (in practice their
|
|
183
|
+
* extensions differ, so this only fires on a genuine name clash).
|
|
184
|
+
* @param {Paths} paths
|
|
185
|
+
* @returns {void}
|
|
186
|
+
*/
|
|
187
|
+
function emitPageAssets(paths) {
|
|
188
|
+
if (!fs.existsSync(paths.routesRoot)) return;
|
|
189
|
+
for (const file of walk(paths.routesRoot)) {
|
|
190
|
+
const ext = path.extname(file).toLowerCase();
|
|
191
|
+
if ([".md", ".wd", ".skin", ".js"].includes(ext)) continue;
|
|
192
|
+
const rel = path.relative(paths.routesRoot, file);
|
|
193
|
+
const out = path.join(paths.distRoot, rel);
|
|
194
|
+
if (fs.existsSync(out)) {
|
|
195
|
+
console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
199
|
+
fs.copyFileSync(file, out);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
/**
|
|
172
204
|
* @param {string} dir
|
|
173
205
|
* @returns {string[]}
|
package/src/compiler.js
CHANGED
|
@@ -377,6 +377,11 @@ function compileBody(lines, ctx) {
|
|
|
377
377
|
out.push(handleComputed(line, ctx));
|
|
378
378
|
continue;
|
|
379
379
|
}
|
|
380
|
+
if (/^:effect\s/.test(line)) {
|
|
381
|
+
flush();
|
|
382
|
+
out.push(handleEffect(line, ctx));
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
380
385
|
if (/^:form\s/.test(line)) {
|
|
381
386
|
flush();
|
|
382
387
|
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
@@ -542,7 +547,13 @@ function scanContainer(lines, start, file) {
|
|
|
542
547
|
}
|
|
543
548
|
|
|
544
549
|
/**
|
|
545
|
-
* Split an `:if … :else … :endif`
|
|
550
|
+
* Split an `:if … :else if … :else … :endif` chain into the first condition's
|
|
551
|
+
* truthy body and a falsy body. A bare `:else` works as before. The first
|
|
552
|
+
* `:else if B` is **desugared** into a nested `:if` that lives in the falsy
|
|
553
|
+
* branch: the falsy body becomes `[":if B", …rest…, ":endif"]`, which re-enters
|
|
554
|
+
* `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
|
|
555
|
+
* therefore compiles to nested `data-wd-if` regions the runtime already drives —
|
|
556
|
+
* identical behavior for static, reactive, and loop-row conditionals.
|
|
546
557
|
* @param {string[]} lines
|
|
547
558
|
* @param {number} start Index of the `:if` line.
|
|
548
559
|
* @param {string} file Source path for errors.
|
|
@@ -554,6 +565,11 @@ function scanConditional(lines, start, file) {
|
|
|
554
565
|
/** @type {string[]} */
|
|
555
566
|
const falsy = [];
|
|
556
567
|
let current = truthy;
|
|
568
|
+
// "truthy": collecting the :if body. "else": a bare :else closed the chain —
|
|
569
|
+
// no further branches allowed. "elseif": the first :else if was desugared into
|
|
570
|
+
// a nested :if inside `falsy`; subsequent depth-0 :else if/:else lines belong to
|
|
571
|
+
// that nested chain and are emitted verbatim for the recursive compile to split.
|
|
572
|
+
let mode = "truthy";
|
|
557
573
|
let depth = 0;
|
|
558
574
|
let fence = null;
|
|
559
575
|
for (let i = start + 1; i < lines.length; i++) {
|
|
@@ -571,12 +587,28 @@ function scanConditional(lines, start, file) {
|
|
|
571
587
|
}
|
|
572
588
|
if (/^:if\s/.test(line)) depth++;
|
|
573
589
|
if (line.trim() === ":endif") {
|
|
574
|
-
if (depth === 0)
|
|
590
|
+
if (depth === 0) {
|
|
591
|
+
if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
|
|
592
|
+
return { truthy, falsy, end: i };
|
|
593
|
+
}
|
|
575
594
|
depth--;
|
|
576
595
|
}
|
|
577
|
-
if (
|
|
578
|
-
|
|
579
|
-
|
|
596
|
+
if (depth === 0 && mode !== "elseif") {
|
|
597
|
+
const t = line.trim();
|
|
598
|
+
const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
|
|
599
|
+
if (elseIf) {
|
|
600
|
+
if (mode === "else") throw new Error(`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`);
|
|
601
|
+
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
602
|
+
current = falsy;
|
|
603
|
+
mode = "elseif";
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (t === ":else") {
|
|
607
|
+
if (mode === "else") throw new Error(`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`);
|
|
608
|
+
current = falsy;
|
|
609
|
+
mode = "else";
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
580
612
|
}
|
|
581
613
|
current.push(line);
|
|
582
614
|
}
|
|
@@ -634,20 +666,44 @@ function parseIncludeArgs(raw, ctx) {
|
|
|
634
666
|
* @returns {string}
|
|
635
667
|
*/
|
|
636
668
|
function handleContainer(header, bodyLines, ctx) {
|
|
637
|
-
|
|
669
|
+
let rest = header.trim();
|
|
638
670
|
let tag = "section";
|
|
639
|
-
/** @type {string[]} */
|
|
640
|
-
|
|
671
|
+
/** @type {string[]} Static classes baked into class="". */
|
|
672
|
+
const extraClass = [];
|
|
673
|
+
/** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
|
|
674
|
+
const eachClasses = [];
|
|
675
|
+
/** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
|
|
676
|
+
const stateClasses = [];
|
|
641
677
|
let id = "";
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
678
|
+
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
679
|
+
// the <section> tag; any other name becomes a <div> and also a class.
|
|
680
|
+
const lead = rest.match(/^([^\s.#]\S*)/);
|
|
681
|
+
let nameToken = "section";
|
|
682
|
+
if (lead) { nameToken = lead[1]; rest = rest.slice(lead[0].length).trim(); }
|
|
683
|
+
if (nameToken !== "section") { tag = "div"; extraClass.push(nameToken); }
|
|
684
|
+
while (rest.length) {
|
|
685
|
+
if (rest.startsWith("#")) {
|
|
686
|
+
const m = rest.match(/^#(\S+)/);
|
|
687
|
+
id = (m ? m[1] : "");
|
|
688
|
+
rest = rest.slice((m ? m[0] : rest).length).trim();
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
|
|
692
|
+
if (!cm) throw new Error(`Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${ctx.file}`);
|
|
693
|
+
const cls = cm[1];
|
|
694
|
+
rest = rest.slice(cm[0].length).trim();
|
|
695
|
+
// Optional `when <predicate>` makes the class reactive. The predicate runs to
|
|
696
|
+
// the next ` .`/` #` token or the end of the header.
|
|
697
|
+
const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
|
|
698
|
+
if (whenMatch) {
|
|
699
|
+
rest = rest.slice(whenMatch[0].length).trim();
|
|
700
|
+
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
701
|
+
if (compiled.static) { if (compiled.value) extraClass.push(cls); }
|
|
702
|
+
else if (compiled.item) eachClasses.push([cls, compiled.body]);
|
|
703
|
+
else stateClasses.push([cls, compiled.body]);
|
|
704
|
+
} else {
|
|
705
|
+
extraClass.push(cls);
|
|
706
|
+
}
|
|
651
707
|
}
|
|
652
708
|
const explicitId = Boolean(id);
|
|
653
709
|
if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
|
|
@@ -661,7 +717,10 @@ function handleContainer(header, bodyLines, ctx) {
|
|
|
661
717
|
}
|
|
662
718
|
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
663
719
|
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
664
|
-
|
|
720
|
+
let classBind = "";
|
|
721
|
+
if (eachClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`; }
|
|
722
|
+
if (stateClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`; }
|
|
723
|
+
return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
665
724
|
}
|
|
666
725
|
|
|
667
726
|
/**
|
|
@@ -740,7 +799,7 @@ function declareErrorState(key, ctx) {
|
|
|
740
799
|
}
|
|
741
800
|
|
|
742
801
|
const FETCH_USE =
|
|
743
|
-
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key]';
|
|
802
|
+
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
|
|
744
803
|
|
|
745
804
|
/**
|
|
746
805
|
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
@@ -755,14 +814,14 @@ function handleFetch(line, ctx) {
|
|
|
755
814
|
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
756
815
|
if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
|
|
757
816
|
const name = head[1];
|
|
758
|
-
const url = stripQuotes(head[2]);
|
|
817
|
+
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
759
818
|
/** @type {Record<string, string>} */
|
|
760
819
|
const opts = {};
|
|
761
820
|
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
762
821
|
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
763
822
|
if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
|
|
764
823
|
const optName = kv[1];
|
|
765
|
-
if (!["method", "when", "timeout", "retry", "headers", "body"].includes(optName)) {
|
|
824
|
+
if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
|
|
766
825
|
throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
|
|
767
826
|
}
|
|
768
827
|
opts[optName] = stripQuotes(kv[2]);
|
|
@@ -779,6 +838,14 @@ function handleFetch(line, ctx) {
|
|
|
779
838
|
throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
|
|
780
839
|
}
|
|
781
840
|
}
|
|
841
|
+
if (opts.refresh !== undefined) {
|
|
842
|
+
// Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
|
|
843
|
+
// The new token is written back into the `headers=` state, so it is required.
|
|
844
|
+
if (!opts.headers) {
|
|
845
|
+
throw new Error(`:fetch refresh= needs headers= (the state key holding the token to renew) in ${ctx.file}. ${FETCH_USE}`);
|
|
846
|
+
}
|
|
847
|
+
opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
|
|
848
|
+
}
|
|
782
849
|
|
|
783
850
|
// Extract `{ path }` dependency keys from the URL (validated against poison
|
|
784
851
|
// segments). The runtime fills them from state on each (re)fetch.
|
|
@@ -811,11 +878,40 @@ function handleFetch(line, ctx) {
|
|
|
811
878
|
attr("retry", opts.retry) +
|
|
812
879
|
attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
|
|
813
880
|
attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
|
|
881
|
+
attr("refresh", opts.refresh) +
|
|
814
882
|
attr("deps", deps.join(",")) +
|
|
815
883
|
`></span>`;
|
|
816
884
|
return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
|
|
817
885
|
}
|
|
818
886
|
|
|
887
|
+
/**
|
|
888
|
+
* Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
|
|
889
|
+
* `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
|
|
890
|
+
* URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
|
|
891
|
+
* `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
|
|
892
|
+
* rejected. Interpolated values are percent-encoded by the runtime, so a scheme
|
|
893
|
+
* cannot be injected through state at request time.
|
|
894
|
+
* @param {string} url
|
|
895
|
+
* @param {Ctx} ctx
|
|
896
|
+
* @param {string} [what] Option name for the error message.
|
|
897
|
+
* @returns {string}
|
|
898
|
+
*/
|
|
899
|
+
function validateFetchUrl(url, ctx, what = ":fetch") {
|
|
900
|
+
const value = url.trim();
|
|
901
|
+
if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
902
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`);
|
|
903
|
+
}
|
|
904
|
+
if (value.startsWith("//")) {
|
|
905
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`);
|
|
906
|
+
}
|
|
907
|
+
if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
|
|
908
|
+
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
909
|
+
if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
|
|
910
|
+
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`);
|
|
911
|
+
}
|
|
912
|
+
return value;
|
|
913
|
+
}
|
|
914
|
+
|
|
819
915
|
/**
|
|
820
916
|
* @param {string} line
|
|
821
917
|
* @param {Ctx} ctx
|
|
@@ -1031,6 +1127,30 @@ function handleButton(line, ctx) {
|
|
|
1031
1127
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
1032
1128
|
}
|
|
1033
1129
|
|
|
1130
|
+
const EFFECT_USE = "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
1134
|
+
* watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
|
|
1135
|
+
* action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
|
|
1136
|
+
* (derive state) and fetch deps (auto-refetch).
|
|
1137
|
+
* @param {string} line
|
|
1138
|
+
* @param {Ctx} ctx
|
|
1139
|
+
* @returns {string}
|
|
1140
|
+
*/
|
|
1141
|
+
function handleEffect(line, ctx) {
|
|
1142
|
+
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
1143
|
+
if (!match) throw new Error(`Malformed :effect in ${ctx.file}: ${line}. ${EFFECT_USE}`);
|
|
1144
|
+
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
1145
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1146
|
+
if (!key) throw new Error(`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
|
|
1147
|
+
ctx.comp.assets.runtime = true;
|
|
1148
|
+
const watch = [key, ...segs.slice(1)].join(".");
|
|
1149
|
+
const action = parseAction(match[2], ctx);
|
|
1150
|
+
const actions = Array.isArray(action) ? action : [action];
|
|
1151
|
+
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1034
1154
|
/**
|
|
1035
1155
|
* @param {string} line
|
|
1036
1156
|
* @param {string[]} truthyLines
|
|
@@ -1375,6 +1495,75 @@ function evalPredicate(body, item, ctx) {
|
|
|
1375
1495
|
}
|
|
1376
1496
|
}
|
|
1377
1497
|
|
|
1498
|
+
/**
|
|
1499
|
+
* Compile one operand of a `::: … .class when <predicate>` expression. Like the
|
|
1500
|
+
* `@loop where` operand, but also folds a static-scope value (a loop-unrolled
|
|
1501
|
+
* item field or include arg) to its build-time literal, so a fully-static
|
|
1502
|
+
* predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
|
|
1503
|
+
* resolution order.
|
|
1504
|
+
* @param {string} tok
|
|
1505
|
+
* @param {Ctx} ctx
|
|
1506
|
+
* @returns {{ code: string, state: boolean, item: boolean }}
|
|
1507
|
+
*/
|
|
1508
|
+
function compileWhenOperand(tok, ctx) {
|
|
1509
|
+
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
|
|
1510
|
+
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
|
|
1511
|
+
if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
|
|
1512
|
+
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
1513
|
+
throw new Error(`Unsupported operand "${tok}" in "::: … when" (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
|
|
1514
|
+
}
|
|
1515
|
+
const segs = tok.split(".");
|
|
1516
|
+
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1517
|
+
throw new Error(`Path "${tok}" is not allowed in "::: … when" (${ctx.file})`);
|
|
1518
|
+
}
|
|
1519
|
+
const scoped = lookupVar(ctx.scope, segs[0]);
|
|
1520
|
+
if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
|
|
1521
|
+
if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
|
|
1522
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
1523
|
+
if (!key) throw new Error(`"::: … when" references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
|
|
1524
|
+
const rest = segs.slice(1).join(".");
|
|
1525
|
+
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
/**
|
|
1529
|
+
* Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
|
|
1530
|
+
* paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
|
|
1531
|
+
* Folds to a static verdict when every operand is build-known, else returns a
|
|
1532
|
+
* runtime body over I()/S()/C() plus whether it reads the reactive loop item
|
|
1533
|
+
* (which decides data-wd-each-class vs the global data-wd-class).
|
|
1534
|
+
* @param {string} raw
|
|
1535
|
+
* @param {Ctx} ctx
|
|
1536
|
+
* @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
|
|
1537
|
+
*/
|
|
1538
|
+
function compileWhen(raw, ctx) {
|
|
1539
|
+
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
1540
|
+
/** @type {string[]} */
|
|
1541
|
+
const pieces = [];
|
|
1542
|
+
let usesState = false;
|
|
1543
|
+
let usesItem = false;
|
|
1544
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1545
|
+
if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
|
|
1546
|
+
const seg = parts[i].trim();
|
|
1547
|
+
const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
1548
|
+
if (opMatch) {
|
|
1549
|
+
const left = compileWhenOperand(opMatch[1].trim(), ctx);
|
|
1550
|
+
const right = compileWhenOperand(opMatch[3].trim(), ctx);
|
|
1551
|
+
usesState = usesState || left.state || right.state;
|
|
1552
|
+
usesItem = usesItem || left.item || right.item;
|
|
1553
|
+
const op = opMatch[2].toLowerCase();
|
|
1554
|
+
pieces.push(op === "contains" ? `(C(${left.code}, ${right.code}))` : `(${left.code} ${op} ${right.code})`);
|
|
1555
|
+
} else {
|
|
1556
|
+
const only = compileWhenOperand(seg, ctx);
|
|
1557
|
+
usesState = usesState || only.state;
|
|
1558
|
+
usesItem = usesItem || only.item;
|
|
1559
|
+
pieces.push(`(${only.code})`);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const body = pieces.join(" ");
|
|
1563
|
+
if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
|
|
1564
|
+
return { static: false, body, item: usesItem };
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1378
1567
|
/**
|
|
1379
1568
|
* @param {unknown[]} rows
|
|
1380
1569
|
* @param {string} itemName
|
package/src/runtime.js
CHANGED
|
@@ -131,7 +131,11 @@ function isEmpty(v) {
|
|
|
131
131
|
async function httpJson(url, init) {
|
|
132
132
|
const response = await fetch(url, init);
|
|
133
133
|
const text = await response.text();
|
|
134
|
-
if (!response.ok)
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const err = /** @type {Error & { status?: number }} */ (new Error(`HTTP ${response.status}`));
|
|
136
|
+
err.status = response.status; // lets the :fetch lifecycle single out a 401 for token refresh
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
135
139
|
try { return JSON.parse(text); } catch { return { status: response.status, body: text }; }
|
|
136
140
|
}
|
|
137
141
|
|
|
@@ -186,6 +190,29 @@ function loopPredicate(body) {
|
|
|
186
190
|
return fn;
|
|
187
191
|
}
|
|
188
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Toggle reactive `.class when <predicate>` bindings on an element. `raw` is the
|
|
195
|
+
* JSON `[[name, body], …]` from data-wd-class (global, item undefined) or
|
|
196
|
+
* data-wd-each-class (per loop row); each body is the same I()/S()/C()
|
|
197
|
+
* expression the loop `where` uses.
|
|
198
|
+
* @param {Element} el
|
|
199
|
+
* @param {string | null} raw
|
|
200
|
+
* @param {any} item Loop item for each-class; undefined for a global class.
|
|
201
|
+
* @returns {void}
|
|
202
|
+
*/
|
|
203
|
+
function classToggle(el, raw, item) {
|
|
204
|
+
if (!raw) return;
|
|
205
|
+
/** @type {[string, string][]} */
|
|
206
|
+
let pairs;
|
|
207
|
+
try { pairs = JSON.parse(raw); } catch { return; }
|
|
208
|
+
for (const [name, body] of pairs) {
|
|
209
|
+
let on = false;
|
|
210
|
+
try { on = Boolean(loopPredicate(body)((/** @type {string} */ p) => getPath(item, p), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn)); }
|
|
211
|
+
catch (error) { warn(body, error); }
|
|
212
|
+
el.classList.toggle(name, on);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
189
216
|
/**
|
|
190
217
|
* Stable per-render key for a loop row, disambiguating duplicates with `#n`.
|
|
191
218
|
* @param {any} item
|
|
@@ -231,6 +258,8 @@ function fillItem(node, item, meta = {}) {
|
|
|
231
258
|
for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
|
|
232
259
|
marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
|
|
233
260
|
}
|
|
261
|
+
const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
|
|
262
|
+
for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
|
|
234
263
|
}
|
|
235
264
|
|
|
236
265
|
/**
|
|
@@ -290,6 +319,10 @@ function renderNow() {
|
|
|
290
319
|
output.innerHTML = template?.innerHTML || "";
|
|
291
320
|
}
|
|
292
321
|
|
|
322
|
+
// Reactive styling: toggle state-driven `.class when …` bindings. Loop-row
|
|
323
|
+
// bindings (data-wd-each-class) are handled per item inside fillItem.
|
|
324
|
+
for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
|
|
325
|
+
|
|
293
326
|
for (const region of document.querySelectorAll("[data-wd-loop]")) {
|
|
294
327
|
const key = region.getAttribute("data-wd-loop");
|
|
295
328
|
const data = region.getAttribute("data-wd-loop-data");
|
|
@@ -362,6 +395,36 @@ function renderNow() {
|
|
|
362
395
|
for (const input of document.querySelectorAll("[data-wd-bind-input]")) {
|
|
363
396
|
if (document.activeElement !== input) /** @type {HTMLInputElement} */ (input).value = state[input.getAttribute("data-wd-bind-input") || ""] ?? "";
|
|
364
397
|
}
|
|
398
|
+
|
|
399
|
+
runEffects(); // side effects run last, against fully settled state
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** @typedef {{ watch: string, actions: { op: string, target?: string, value?: any }[], last: string }} Effect */
|
|
403
|
+
/** @type {Effect[]} */
|
|
404
|
+
const effects = [...document.querySelectorAll("script[data-wd-effect]")].map((node) => {
|
|
405
|
+
const def = JSON.parse(node.textContent || "{}");
|
|
406
|
+
return { watch: def.watch, actions: def.actions || [], last: JSON.stringify(getPath(state, def.watch) ?? null) };
|
|
407
|
+
});
|
|
408
|
+
let effectDepth = 0;
|
|
409
|
+
/**
|
|
410
|
+
* After a render, fire any `:effect` whose watched state changed, running its
|
|
411
|
+
* actions through the shared action runner. If that mutates state, re-render and
|
|
412
|
+
* re-check; a settle cap of 10 passes guards against effect→effect loops.
|
|
413
|
+
* @returns {void}
|
|
414
|
+
*/
|
|
415
|
+
function runEffects() {
|
|
416
|
+
if (!effects.length) return;
|
|
417
|
+
let changed = false;
|
|
418
|
+
for (const fx of effects) {
|
|
419
|
+
const now = JSON.stringify(getPath(state, fx.watch) ?? null);
|
|
420
|
+
if (now === fx.last) continue;
|
|
421
|
+
fx.last = now;
|
|
422
|
+
for (const a of fx.actions) applyAction(null, a.op, a.target || "", a.value);
|
|
423
|
+
changed = true;
|
|
424
|
+
}
|
|
425
|
+
if (!changed) { effectDepth = 0; return; }
|
|
426
|
+
if (++effectDepth >= 10) { effectDepth = 0; console.warn("effect did not settle"); return; }
|
|
427
|
+
renderNow(); // reflect the effect's state changes, then re-check
|
|
365
428
|
}
|
|
366
429
|
|
|
367
430
|
/**
|
|
@@ -391,10 +454,11 @@ document.addEventListener("input", (event) => {
|
|
|
391
454
|
/**
|
|
392
455
|
* Resolve the loop source and row item for a clicked element, if inside a loop.
|
|
393
456
|
* The clicked button's row is the nearest reconciled loop node; it carries its item.
|
|
394
|
-
* @param {Element} el
|
|
457
|
+
* @param {Element | null} el Null for effect-driven actions (no clicked row).
|
|
395
458
|
* @returns {{ srcKey: string | null, item: any } | null}
|
|
396
459
|
*/
|
|
397
460
|
function clickedRow(el) {
|
|
461
|
+
if (!el) return null; // effect-driven actions have no clicked row → row ops no-op
|
|
398
462
|
const row = /** @type {WdRow | null} */ (el.closest("[data-wd-loop-key]"));
|
|
399
463
|
const region = el.closest("[data-wd-loop]");
|
|
400
464
|
if (!row || !region) return null;
|
|
@@ -404,7 +468,7 @@ function clickedRow(el) {
|
|
|
404
468
|
/**
|
|
405
469
|
* Apply one parsed action `{op,target,value}` against state. Targets are dotted
|
|
406
470
|
* paths read via getPath / written via setPath; a bare name is a 1-segment path.
|
|
407
|
-
* @param {Element} action The clicked element (for loop-row resolution).
|
|
471
|
+
* @param {Element | null} action The clicked element (for loop-row resolution); null for effects.
|
|
408
472
|
* @param {string} op
|
|
409
473
|
* @param {string} target
|
|
410
474
|
* @param {any} value
|
|
@@ -518,12 +582,17 @@ function startFetch(node) {
|
|
|
518
582
|
|
|
519
583
|
const headers = g("headers");
|
|
520
584
|
const body = g("body");
|
|
585
|
+
const refresh = g("refresh");
|
|
521
586
|
const method = g("method") || "GET";
|
|
522
587
|
const timeout = Number(g("timeout"));
|
|
523
588
|
let tries = Number(g("retry"));
|
|
589
|
+
let refreshed = false; // a token refresh + retry happens at most once per request
|
|
524
590
|
state[key + "_error"] = null;
|
|
525
591
|
set("_loading", true);
|
|
526
592
|
|
|
593
|
+
/** @param {any} error */
|
|
594
|
+
const fail = (error) => { state[key + "_error"] = String(error); set("_loading", false); };
|
|
595
|
+
|
|
527
596
|
const attempt = () => {
|
|
528
597
|
/** @type {RequestInit} */
|
|
529
598
|
const init = { method };
|
|
@@ -542,14 +611,53 @@ function startFetch(node) {
|
|
|
542
611
|
}).catch((error) => {
|
|
543
612
|
clearTimeout(timer);
|
|
544
613
|
if (dead()) return;
|
|
614
|
+
const is401 = error && error.status === 401;
|
|
615
|
+
// Layer 2: a 401 with a refresh URL renews the token once, then retries.
|
|
616
|
+
if (is401 && refresh && headers && !refreshed) {
|
|
617
|
+
refreshed = true;
|
|
618
|
+
return void refreshToken(refresh, headers).then((ok) => {
|
|
619
|
+
if (dead()) return;
|
|
620
|
+
ok ? attempt() : fail(error);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (is401 && refreshed) return void fail(error); // a second 401 after refresh → give up
|
|
545
624
|
if (tries-- > 0) return void setTimeout(attempt, 200);
|
|
546
|
-
|
|
547
|
-
set("_loading", false);
|
|
625
|
+
fail(error);
|
|
548
626
|
});
|
|
549
627
|
};
|
|
550
628
|
attempt();
|
|
551
629
|
}
|
|
552
630
|
|
|
631
|
+
/** @type {Map<string, Promise<boolean>>} In-flight token refreshes, keyed by URL. */
|
|
632
|
+
const refreshFlights = new Map();
|
|
633
|
+
/**
|
|
634
|
+
* Single-flight token refresh: POST the current header state to `refreshUrl`,
|
|
635
|
+
* and on a 2xx JSON reply write the returned headers object back into the
|
|
636
|
+
* `headersKey` state (persisting if it is a `:store`). Concurrent 401s sharing a
|
|
637
|
+
* refresh URL await the same request, then each retries its own fetch.
|
|
638
|
+
* @param {string} refreshUrl
|
|
639
|
+
* @param {string} headersKey
|
|
640
|
+
* @returns {Promise<boolean>} whether the token was renewed
|
|
641
|
+
*/
|
|
642
|
+
function refreshToken(refreshUrl, headersKey) {
|
|
643
|
+
const inflight = refreshFlights.get(refreshUrl);
|
|
644
|
+
if (inflight) return inflight;
|
|
645
|
+
const flight = httpJson(refreshUrl, {
|
|
646
|
+
method: "POST",
|
|
647
|
+
headers: { "content-type": "application/json" },
|
|
648
|
+
body: JSON.stringify(state[headersKey] ?? {})
|
|
649
|
+
}).then((value) => {
|
|
650
|
+
if (value && typeof value === "object") {
|
|
651
|
+
state[headersKey] = value; // the new headers shape, e.g. { Authorization: "Bearer …" }
|
|
652
|
+
savePersisted();
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
}).catch(() => false).finally(() => refreshFlights.delete(refreshUrl));
|
|
657
|
+
refreshFlights.set(refreshUrl, flight);
|
|
658
|
+
return flight;
|
|
659
|
+
}
|
|
660
|
+
|
|
553
661
|
/** A fetch marker carrying its last dependency snapshot for refetch diffing and
|
|
554
662
|
* a generation token so a superseded in-flight response/retry bails on write. */
|
|
555
663
|
/** @typedef {Element & { __wdSnap?: string, __wdGen?: number }} FetchNode */
|