@zvndev/darkmown 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +58 -1
- package/SECURITY.md +39 -4
- package/docs/spec-alignment.md +9 -0
- package/package.json +3 -3
- package/site/_/nav.wd +1 -0
- package/site/pages/loops.wd +22 -0
- package/site/pages/theme.skin +53 -0
- package/site/pages/theme.wd +58 -0
- package/src/builder.js +50 -0
- package/src/compiler.js +200 -21
- package/src/headers.js +94 -0
- package/src/runtime.js +83 -59
- package/src/skin.js +60 -2
- package/src/statics.js +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Darkmown are documented here. Versions follow [semver](https://semver.org); pre-1.0 minor versions may contain breaking changes.
|
|
4
4
|
|
|
5
|
+
## 0.19.0 — 2026-06-25
|
|
6
|
+
|
|
7
|
+
Post-review sprint: deeper reactive loops, hardened deploys, and OS-aware theming.
|
|
8
|
+
|
|
9
|
+
- **Nested reactive loops (one level).** A reactive `@loop` can now contain an inner reactive `@loop <outerItem>.<field> into x` — the inner source is a dotted path on the outer row, so each reconciled row renders its own keyed inner list and both stay live. Interpolation resolves the inner item first, then the outer (`{ line.qty }` and `{ order.ref }` both in scope). Build-time loop nesting over JSON/frontmatter was already supported; what's new is reactive-inside-reactive. Nesting is **one level only** — an inner reactive loop may not contain a third.
|
|
10
|
+
- **Security headers / CSP on deploy.** Builds now emit security response headers so a deployed site is hardened by default: a `dist/_headers` file (Cloudflare Pages format), with the Vercel and local `serve` paths applying the equivalent. Every page carries `X-Content-Type-Options: nosniff`, a `Referrer-Policy`, and a `frame-ancestors` directive. The **Content-Security-Policy is tiered**: static pages get a tight policy, and reactive pages (those shipping `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'` for the runtime's `new Function` expression compiler. The default CSP allows same-origin connections only — a `:fetch`/`:form action=` to another host must widen `connect-src` by hand. It is defense-in-depth, not a host allowlist; SSRF protection stays the author's responsibility.
|
|
11
|
+
- **Dark-mode theming — `tokens dark`.** A `.skin` file can add a `tokens dark` block beside its base `tokens`; the dark tokens compile to `@media (prefers-color-scheme: dark) :root { … }`, overriding the matching base tokens under the visitor's OS dark preference. Rules keep referencing the same `$name` tokens, so dark mode is a zero-JavaScript palette swap. A skin with no `tokens dark` block is unchanged.
|
|
12
|
+
- **Runtime test backfill.** Added runtime/compiler tests covering the new reactive-loop nesting and the security-header and theming output.
|
|
13
|
+
- **Compile errors carry a line number.** A compile error now includes the offending line number alongside the file path and corrective suggestion, so the source of a directive mistake is easier to find.
|
|
14
|
+
|
|
5
15
|
## 0.18.0 — 2026-06-24
|
|
6
16
|
|
|
7
17
|
Navigation goes instant and flash-free, and images are hardened at compile time.
|
package/README.md
CHANGED
|
@@ -240,6 +240,34 @@ A `:button` inside a reactive `@loop` can act on its own row. `cart += product`
|
|
|
240
240
|
|
|
241
241
|
That is a full add-to-cart / remove-line flow — and a to-do list with delete — in plain Markdown, no JavaScript.
|
|
242
242
|
|
|
243
|
+
### Nested reactive loops
|
|
244
|
+
|
|
245
|
+
*New in 0.19.0.* A reactive `@loop` may contain an inner reactive `@loop` over one of the outer row's fields — one level of nesting. The inner source is a dotted path rooted at the outer item (`order.lines`), so each row renders its own list and both stay live as state changes:
|
|
246
|
+
|
|
247
|
+
```wd
|
|
248
|
+
:state orders = [
|
|
249
|
+
{"id": 1, "ref": "A-100", "lines": [{"sku": "x1", "qty": 2}, {"sku": "x2", "qty": 1}]},
|
|
250
|
+
{"id": 2, "ref": "A-101", "lines": [{"sku": "y9", "qty": 5}]}
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
@loop orders into order
|
|
254
|
+
::: card
|
|
255
|
+
**{ order.ref }**
|
|
256
|
+
@loop order.lines into line
|
|
257
|
+
- { line.sku } × { line.qty }
|
|
258
|
+
@endloop
|
|
259
|
+
:::
|
|
260
|
+
@endloop
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
- The inner loop is keyed and reconciled per outer row, like any reactive loop.
|
|
264
|
+
- Interpolation inside the inner body resolves the inner item first, then the outer item — `{ line.qty }` and `{ order.ref }` are both in scope.
|
|
265
|
+
- Build-time loops over JSON/frontmatter already nest freely (the "loops nest" behavior above); what's new is **reactive-inside-reactive** over a `:state`/`:store`/`:fetch` row field.
|
|
266
|
+
|
|
267
|
+
> **Honest caveat:** nesting is **one level only** — an inner reactive loop may not itself contain a third reactive loop. Build-time loop nesting is unaffected by this limit.
|
|
268
|
+
>
|
|
269
|
+
> **Per-row actions inside an inner loop:** `cart += member` (append-row) works from an inner loop — it carries the inner item into a top-level `:state`/`:store` list. But a per-row **`remove`** needs a top-level list as its source, and an inner loop's source is a path off the outer row (`team.members`), so deleting an inner row in place is **not** supported — the compiler rejects it with a corrective message. Carry the row into a top-level list and remove it there.
|
|
270
|
+
|
|
243
271
|
## Sections
|
|
244
272
|
|
|
245
273
|
```wd
|
|
@@ -528,6 +556,26 @@ A trailing `{.class .class #id}` attaches classes / an id to the inline element
|
|
|
528
556
|
|
|
529
557
|
The block must follow the element with no space, and works on links, images, emphasis, and inline code. It never collides with `{ name }` interpolation — an interpolation always starts with a name, never a `.` or `#`.
|
|
530
558
|
|
|
559
|
+
## Dark mode — `tokens dark`
|
|
560
|
+
|
|
561
|
+
*New in 0.19.0.* A colocated `.skin` file already declares its palette in a `tokens` block (`name value` pairs referenced elsewhere as `$name`). Add a second `tokens dark` block to override any of those tokens under the visitor's OS dark preference — it compiles to a `@media (prefers-color-scheme: dark) :root { … }` rule, so the page follows the system theme with **zero JavaScript**.
|
|
562
|
+
|
|
563
|
+
```skin
|
|
564
|
+
tokens
|
|
565
|
+
paper #ffffff
|
|
566
|
+
ink #171717
|
|
567
|
+
|
|
568
|
+
tokens dark
|
|
569
|
+
paper #0b0b0f
|
|
570
|
+
ink #f4f4f5
|
|
571
|
+
|
|
572
|
+
page
|
|
573
|
+
bg $paper
|
|
574
|
+
color $ink
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
The `tokens dark` values override the matching base tokens only when the OS reports a dark preference; the rules below keep referencing the same `$paper` / `$ink`. The base `tokens` block stays the light default, so a skin with no `tokens dark` block is unchanged.
|
|
578
|
+
|
|
531
579
|
## The escape hatch
|
|
532
580
|
|
|
533
581
|
Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
|
|
@@ -548,10 +596,19 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
|
|
|
548
596
|
- **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.
|
|
549
597
|
- **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** A fetch/form 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/exfiltration 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).
|
|
550
598
|
|
|
551
|
-
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
|
|
599
|
+
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer. A page can flip to strict mode with `html: false` frontmatter, which drops raw HTML entirely (see [SECURITY.md](SECURITY.md)).
|
|
552
600
|
|
|
553
601
|
Directive actions and `:computed`/`@loop … where` expressions are never `eval`'d as raw user content — they compile to a whitelisted grammar (item paths, declared `:state`, numbers, strings) that runs via `new Function`. See [SECURITY.md](SECURITY.md) for the full security model.
|
|
554
602
|
|
|
603
|
+
### Shipped security headers
|
|
604
|
+
|
|
605
|
+
Builds emit security response headers so a deployed site gets sane defaults without hand-writing a config. The build writes a `dist/_headers` file (Cloudflare Pages format), and the Vercel/local `serve` paths apply the equivalent. On every page:
|
|
606
|
+
|
|
607
|
+
- **`Content-Security-Policy`** — static pages get a tight policy; **reactive pages** (those that ship `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'`, which the runtime needs to compile validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. Static pages stay strict.
|
|
608
|
+
- **`X-Content-Type-Options: nosniff`**, **`Referrer-Policy`**, and a **`frame-ancestors`** directive (clickjacking protection) on every page.
|
|
609
|
+
|
|
610
|
+
**If you use `:fetch` or `:form action=` against another host, widen `connect-src`.** The shipped CSP allows same-origin connections; calling a third-party API is otherwise blocked by the policy. Add the host to `connect-src` in your deploy config (it is not auto-derived from your page sources). The CSP is a defense-in-depth layer — it does not replace the trust-boundary rules above; see [SECURITY.md](SECURITY.md) for tightening guidance.
|
|
611
|
+
|
|
555
612
|
## Spec status
|
|
556
613
|
|
|
557
614
|
See [docs/spec-alignment.md](docs/spec-alignment.md) for the deep alignment audit against the original vision.
|
package/SECURITY.md
CHANGED
|
@@ -31,10 +31,45 @@ Things Darkmown deliberately guards at compile time and runtime:
|
|
|
31
31
|
- **Output escaping.** Interpolated values are HTML-escaped; state scripts escape `<` to prevent script-tag breakout.
|
|
32
32
|
- **Static server path containment.** The dev/preview servers resolve requests strictly inside `dist`.
|
|
33
33
|
|
|
34
|
-
## The
|
|
34
|
+
## The #1 footgun: raw HTML passthrough (`html: true`)
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
This is the single most important thing to understand about Darkmown's security model. **Read this before pointing Darkmown at anything you did not write yourself.**
|
|
37
37
|
|
|
38
|
-
-
|
|
39
|
-
|
|
38
|
+
Darkmown configures markdown-it with `html: true`, so **raw HTML in `.md`/`.wd` files is passed through verbatim** — by design, like every Markdown site generator. A raw `<script>` or an `onerror=` attribute in your content reaches the visitor's browser and runs.
|
|
39
|
+
|
|
40
|
+
- **Treat every content file as trusted input**, the same way you trust your own source code.
|
|
41
|
+
- **Never compile untrusted or user-submitted Markdown** (comments, form input, third-party docs, scraped content) without sanitizing it first. Darkmown ships **no built-in sanitizer**.
|
|
40
42
|
- If you must render untrusted content, sanitize it (for example with a library like DOMPurify) before it reaches the compiler.
|
|
43
|
+
|
|
44
|
+
### The `html: false` per-page opt-out
|
|
45
|
+
|
|
46
|
+
The built-in mitigation is per-page strict mode. Add `html: false` to a page's frontmatter and that page's Markdown renders through a strict renderer that **escapes raw HTML entirely** — a stray `<script>` becomes inert text instead of executing.
|
|
47
|
+
|
|
48
|
+
```wd
|
|
49
|
+
---
|
|
50
|
+
title: User comments
|
|
51
|
+
html: false
|
|
52
|
+
---
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use it for any page whose content is even partly out of your hands. The default stays `html: true` because the framework targets trusted authors; `html: false` is the switch for the pages where that assumption doesn't hold.
|
|
56
|
+
|
|
57
|
+
> **Note:** `html: false` is a *per-page* frontmatter key — there is no global/site-wide `html: false` default today (no config loader exists yet). Flipping the global default to strict-by-default is a deliberate future option, not a shipped setting; until then, set `html: false` on each page that needs it.
|
|
58
|
+
|
|
59
|
+
## Deploying with a Content-Security-Policy
|
|
60
|
+
|
|
61
|
+
Builds emit security response headers so a deployed site is hardened by default rather than relying on hand-written config. The build writes a `dist/_headers` file (Cloudflare Pages format) and the Vercel and local `serve` paths apply the equivalent. Every page gets:
|
|
62
|
+
|
|
63
|
+
- **`Content-Security-Policy`** — **static pages** (no runtime) get a tight policy. **Reactive pages** (those that ship `/__wd/runtime.js`) additionally need `script-src 'unsafe-eval' 'unsafe-inline'`, because the runtime compiles validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. A strict CSP without `unsafe-eval` breaks reactive pages; static pages are unaffected and stay strict.
|
|
64
|
+
- **`X-Content-Type-Options: nosniff`** — stops MIME-type sniffing.
|
|
65
|
+
- **`Referrer-Policy`** — limits referrer leakage.
|
|
66
|
+
- **`frame-ancestors`** — clickjacking protection (controls who may frame the page).
|
|
67
|
+
|
|
68
|
+
### Tightening the policy
|
|
69
|
+
|
|
70
|
+
The shipped CSP is a sensible default, not a finished policy for every site. Tighten or widen it for your deployment:
|
|
71
|
+
|
|
72
|
+
- **`:fetch` / `:form action=` to another host needs a wider `connect-src`.** The default CSP permits same-origin connections; a call to a third-party API is otherwise blocked. Add each external host to `connect-src` explicitly — it is **not** auto-derived from your page sources.
|
|
73
|
+
- **Remote images, fonts, or embeds** need their hosts added to `img-src` / `font-src` / `frame-src`.
|
|
74
|
+
- **Reactive pages** require `unsafe-eval` in `script-src`; do not remove it on a page that ships the runtime. If you can keep a page static, it can run under a stricter policy.
|
|
75
|
+
- The CSP is **defense-in-depth** — it limits the blast radius of a mistake but does **not** replace the trust-boundary rules above. It is not a substitute for sanitizing untrusted content, and it does not add a host allowlist to `:fetch`/`:form` (SSRF/exfiltration protection remains the author's responsibility).
|
package/docs/spec-alignment.md
CHANGED
|
@@ -84,6 +84,15 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
84
84
|
- **Instant, flash-free navigation under `transitions: true`:** alongside the cross-document view transition, the page emits a declarative `<script type="speculationrules">` that **prerenders** same-origin links at `moderate` eagerness (hover/pointerdown). A prerendered page activates instantly, so there is no render-gap white flash for the transition to cover; the transition itself is now a directional fade+slide (old lifts out, new rises in, 200 ms eased) instead of the UA cross-fade that superimposed both pages at ~50 % opacity. Browser-interpreted JSON, not framework JS — static pages stay zero-JS; `prefers-reduced-motion` is honored; `{.no-prefetch}` / `rel=nofollow` opt links out. (Chrome disables prerendering while DevTools is open.)
|
|
85
85
|
- **Compile-time image hardening:** every emitted `<img>` is stamped with intrinsic `width`/`height` (read from disk via the `image-size` build-time dependency — no runtime cost), `decoding="async"`, and `fetchpriority="high"` on the first image / `loading="lazy"` on the rest. Eliminates cumulative layout shift and de-prioritizes below-the-fold images. Author attributes are never overwritten; remote/unreadable sources degrade to no dimensions.
|
|
86
86
|
|
|
87
|
+
## Stage 15 additions (2026-06-25, v0.19.0)
|
|
88
|
+
|
|
89
|
+
The post-8/10-review sprint: one reactive-loop capability, a deploy-hardening layer, and a theming primitive — plus a test backfill and clearer compile errors. Tracks landed in parallel; behavior below reflects the intended surface, and any item gated on the runtime-size budget is flagged.
|
|
90
|
+
|
|
91
|
+
- **Nested reactive loops (one level):** a reactive `@loop` may contain an inner reactive `@loop <outerItem>.<field> into x` — the inner source is a dotted path rooted at the outer row, so each reconciled outer row renders its own keyed inner list and both stay live. Interpolation inside the inner body resolves the inner item first, then the outer (`{ line.qty }` and `{ order.ref }` both in scope). Build-time loop nesting over JSON/frontmatter was already supported; what's new is reactive-inside-reactive over a `:state`/`:store`/`:fetch` row field. **Honest caveat:** nesting is **one level only** — an inner reactive loop may not contain a third reactive loop. It shipped within the runtime-size budget (5910 B gzipped, no minification). A second caveat: per-row `cart += member` (append-row) works from an inner loop, but a per-row **`remove`** needs a top-level `:state`/`:store` list as its source — an inner loop's source is a path off the outer row, so deleting an inner row in place is unsupported and the compiler rejects it with a corrective hint (carry the row into a top-level list, remove it there).
|
|
92
|
+
- **Security headers / CSP on deploy:** the build emits security response headers so a deployed site is hardened by default — `dist/_headers` (Cloudflare Pages format), with the Vercel and local `serve` paths applying the equivalent. Every page carries `X-Content-Type-Options: nosniff`, a `Referrer-Policy`, and a `frame-ancestors` directive. **Content-Security-Policy is tiered like the runtime:** static pages get a tight policy; reactive pages (those shipping `/__wd/runtime.js`) add `script-src 'unsafe-eval' 'unsafe-inline'`, which the runtime needs to run validated `:computed` / `@loop … where` / `.class when` expressions via `new Function`. **Honest caveat:** the shipped CSP allows only same-origin connections — a `:fetch`/`:form action=` to another host must widen `connect-src` by hand (it is not derived from page sources), and the CSP remains defense-in-depth, **not** a host allowlist for `:fetch` (SSRF stays the author's responsibility per the trust boundary). The `html: false` per-page strict-render opt-out is unchanged; a global strict-by-default `html` setting remains a deliberate future option (no config loader exists yet).
|
|
93
|
+
- **Dark-mode theming — `tokens dark`:** a `.skin` file may add a `tokens dark` block alongside its base `tokens`; the dark tokens compile to `@media (prefers-color-scheme: dark) :root { … }`, overriding the matching base tokens under the visitor's OS dark preference. Rules keep referencing the same `$name` tokens, so dark mode is a palette swap with **zero JavaScript** — consistent with the existing zero-JS `.skin` output. A skin with no `tokens dark` block is unchanged.
|
|
94
|
+
- **Test backfill + compile-error line numbers:** the sprint backfilled runtime/compiler tests for the new surface, and compile errors now carry the offending **line number** alongside the file path and corrective suggestion. **Honest caveat:** this entry documents the intended sprint scope; the exact assertion count and the precise error-message format are owned by the test/compiler tracks and were not verified from this docs track.
|
|
95
|
+
|
|
87
96
|
## Stage 13 additions (2026-06-23, v0.17.0)
|
|
88
97
|
|
|
89
98
|
- **`:checkbox` and `:radio` field groups:** `:checkbox name` / `:radio name` (with `- Label` option lines) render a labelled `role="group"` / `role="radiogroup"` of `<input>`s that share one `name`, each wrapped in its own `<label>` and deriving an aria-label like the other fields. A `:checkbox` group is marked `data-wd-multi` so the runtime's submit handler collects **every** checked value into an array (`FormData.getAll`) instead of collapsing to the last — the one runtime change (compile-time only otherwise; shipped runtime stays ~5.8 KB). A `:radio` group captures a single value like `:input`. Closes the multi-select / single-choice gap that previously forced raw HTML and lost `:form into` capture.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"@playwright/test": "^1.49.0",
|
|
50
50
|
"@types/markdown-it": "^14.1.2",
|
|
51
|
-
"@types/node": "^
|
|
52
|
-
"typescript": "^
|
|
51
|
+
"@types/node": "^26.0.0",
|
|
52
|
+
"typescript": "^6.0.3"
|
|
53
53
|
},
|
|
54
54
|
"repository": {
|
|
55
55
|
"type": "git",
|
package/site/_/nav.wd
CHANGED
package/site/pages/loops.wd
CHANGED
|
@@ -84,4 +84,26 @@ No sort key needed — `reverse` walks the list back to front:
|
|
|
84
84
|
- { r.label }
|
|
85
85
|
@endloop
|
|
86
86
|
|
|
87
|
+
## Nested reactive loops
|
|
88
|
+
|
|
89
|
+
*New in 0.19.0.* A reactive `@loop` can hold an inner reactive `@loop` over a
|
|
90
|
+
field of the outer row — one level of nesting. Type to filter the teams: the
|
|
91
|
+
outer list re-filters live and every team keeps its own member list.
|
|
92
|
+
|
|
93
|
+
:state teams = [{"id": 1, "name": "Platform", "members": [{"id": 11, "name": "Ada"}, {"id": 12, "name": "Bao"}]}, {"id": 2, "name": "Growth", "members": [{"id": 21, "name": "Cyrus"}, {"id": 22, "name": "Dax"}]}, {"id": 3, "name": "Research", "members": [{"id": 31, "name": "Esme"}]}]
|
|
94
|
+
:state teamQuery = ""
|
|
95
|
+
|
|
96
|
+
:bind teamQuery placeholder="Filter teams…"
|
|
97
|
+
|
|
98
|
+
@loop teams into team where team.name contains teamQuery
|
|
99
|
+
::: card .person-row
|
|
100
|
+
**{ team.name }**
|
|
101
|
+
@loop team.members into member
|
|
102
|
+
- { member.name }
|
|
103
|
+
@endloop
|
|
104
|
+
:::
|
|
105
|
+
@empty
|
|
106
|
+
No teams match.
|
|
107
|
+
@endloop
|
|
108
|
+
|
|
87
109
|
</main>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
tokens
|
|
2
|
+
ink #1a1a1a
|
|
3
|
+
paper #ffffff
|
|
4
|
+
panel #ffffff
|
|
5
|
+
line #dedbd3
|
|
6
|
+
accent #16645a
|
|
7
|
+
radius 8px
|
|
8
|
+
|
|
9
|
+
tokens dark
|
|
10
|
+
ink #f0f0f0
|
|
11
|
+
paper #14140f
|
|
12
|
+
panel #1e1d18
|
|
13
|
+
line #34322b
|
|
14
|
+
accent #5fd0c0
|
|
15
|
+
|
|
16
|
+
page
|
|
17
|
+
margin 0
|
|
18
|
+
font ui-serif, Georgia, Cambria, serif
|
|
19
|
+
color $ink
|
|
20
|
+
bg $paper
|
|
21
|
+
line-height 1.7
|
|
22
|
+
|
|
23
|
+
main
|
|
24
|
+
max-width 820px
|
|
25
|
+
margin 0 auto
|
|
26
|
+
padding 3rem 2rem 5rem
|
|
27
|
+
|
|
28
|
+
h1
|
|
29
|
+
font-size 3rem
|
|
30
|
+
line-height 1
|
|
31
|
+
|
|
32
|
+
h2
|
|
33
|
+
margin-top 2.4rem
|
|
34
|
+
border-top 1px solid $line
|
|
35
|
+
padding-top 1.2rem
|
|
36
|
+
|
|
37
|
+
a
|
|
38
|
+
color $accent
|
|
39
|
+
|
|
40
|
+
pre
|
|
41
|
+
bg $panel
|
|
42
|
+
border 1px solid $line
|
|
43
|
+
padding 1rem
|
|
44
|
+
radius $radius
|
|
45
|
+
overflow-x auto
|
|
46
|
+
|
|
47
|
+
.swatch
|
|
48
|
+
border 1px solid $line
|
|
49
|
+
bg $panel
|
|
50
|
+
color $ink
|
|
51
|
+
padding 1.4rem
|
|
52
|
+
margin 1.5rem 0
|
|
53
|
+
radius $radius
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Dark-mode tokens
|
|
3
|
+
transitions: true
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
@include /nav.wd
|
|
7
|
+
|
|
8
|
+
<main>
|
|
9
|
+
|
|
10
|
+
# Dark-mode tokens
|
|
11
|
+
|
|
12
|
+
Darkmown skins get automatic, OS-driven theming with **zero JavaScript**. Declare
|
|
13
|
+
your design tokens once under `tokens`, then declare the dark overrides under
|
|
14
|
+
`tokens dark`. The light values land on `:root`; the dark values compile to a
|
|
15
|
+
`prefers-color-scheme: dark` media query over the same custom properties.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
tokens
|
|
19
|
+
ink #1a1a1a
|
|
20
|
+
paper #ffffff
|
|
21
|
+
tokens dark
|
|
22
|
+
ink #f0f0f0
|
|
23
|
+
paper #14140f
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Every `$ink` / `$paper` reference keeps working unchanged — the variables simply
|
|
27
|
+
resolve to their dark values when the operating system asks for dark mode.
|
|
28
|
+
|
|
29
|
+
## See it live
|
|
30
|
+
|
|
31
|
+
This page's `theme.skin` uses exactly that pattern. Switch your OS (or browser)
|
|
32
|
+
appearance between light and dark and watch the panel below recolor itself — no
|
|
33
|
+
toggle, no runtime, no flash.
|
|
34
|
+
|
|
35
|
+
::: card .swatch
|
|
36
|
+
The card surface, border, ink, and accent below are all driven by `tokens` plus
|
|
37
|
+
a `tokens dark` override. Nothing on this page ships framework JavaScript.
|
|
38
|
+
:::
|
|
39
|
+
|
|
40
|
+
[A link tinted with the accent token](/docs/) sits in the same scope, so it
|
|
41
|
+
recolors with the theme too.
|
|
42
|
+
|
|
43
|
+
## Manual toggle (optional variant)
|
|
44
|
+
|
|
45
|
+
For an author who wants an explicit switch instead of (or alongside) the OS
|
|
46
|
+
preference, a bracketed modifier scopes the same tokens to a `:root` attribute:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
tokens [data-theme=dark]
|
|
50
|
+
ink #f0f0f0
|
|
51
|
+
paper #14140f
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
That compiles to `:root[data-theme="dark"] { … }`, so setting `data-theme="dark"`
|
|
55
|
+
on the document root flips the palette — pair it with your own toggle when you
|
|
56
|
+
need manual control.
|
|
57
|
+
|
|
58
|
+
</main>
|
package/src/builder.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { compilePage } from "./compiler.js";
|
|
5
5
|
import { createPaths } from "./config.js";
|
|
6
|
+
import { BASE_SECURITY_HEADERS, REACTIVE_CSP, STATIC_CSP } from "./headers.js";
|
|
6
7
|
import { discoverRoutes, outputPathForRoute } from "./router.js";
|
|
7
8
|
import { compileSkin } from "./skin.js";
|
|
8
9
|
|
|
@@ -72,6 +73,12 @@ export function buildSite(cwd = process.cwd()) {
|
|
|
72
73
|
|
|
73
74
|
fs.writeFileSync(path.join(paths.distRoot, "routes.json"), JSON.stringify(manifest, null, 2));
|
|
74
75
|
|
|
76
|
+
// Cloudflare Pages reads dist/_headers for security headers (Vercel reads
|
|
77
|
+
// vercel.json; the local server adds them in src/statics.js). Static routes
|
|
78
|
+
// get the stricter CSP (no 'unsafe-eval'); reactive routes get the relaxed
|
|
79
|
+
// one. A catch-all keeps assets and any future path covered.
|
|
80
|
+
fs.writeFileSync(path.join(paths.distRoot, "_headers"), renderCloudflareHeaders(manifest));
|
|
81
|
+
|
|
75
82
|
// Page-colocated static assets (images, SVG, fonts, …) copy last, so the guard
|
|
76
83
|
// sees every emitted route/framework file already on disk.
|
|
77
84
|
emitPageAssets(paths);
|
|
@@ -103,6 +110,49 @@ export function stripRuntimeComments(source) {
|
|
|
103
110
|
.replace(/^[ \t]*\n/gm, "");
|
|
104
111
|
}
|
|
105
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Render the Cloudflare Pages `_headers` file from the route manifest.
|
|
115
|
+
*
|
|
116
|
+
* Cloudflare applies every matching block in order; when blocks set the same
|
|
117
|
+
* header, the later block wins. So we emit the catch-all `/*` first (baseline
|
|
118
|
+
* security headers + the relaxed reactive CSP that satisfies every page), then
|
|
119
|
+
* one block per static route that overrides `Content-Security-Policy` with the
|
|
120
|
+
* stricter, eval-free CSP. Reactive routes need no override — the catch-all
|
|
121
|
+
* already carries the relaxed CSP.
|
|
122
|
+
*
|
|
123
|
+
* Clean URLs mean a route like `/docs/` is served at both `/docs` and `/docs/`,
|
|
124
|
+
* so each static route emits a path glob (`/docs`, `/docs/*`) that covers both.
|
|
125
|
+
* @param {RouteManifestEntry[]} manifest
|
|
126
|
+
* @returns {string}
|
|
127
|
+
*/
|
|
128
|
+
export function renderCloudflareHeaders(manifest) {
|
|
129
|
+
const baseLines = Object.entries(BASE_SECURITY_HEADERS).map(([name, value]) => ` ${name}: ${value}`);
|
|
130
|
+
const blocks = [
|
|
131
|
+
["/*", [...baseLines, ` Content-Security-Policy: ${REACTIVE_CSP}`]]
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const entry of manifest) {
|
|
135
|
+
if (entry.assets.runtime) continue; // reactive routes keep the catch-all (relaxed) CSP
|
|
136
|
+
for (const pattern of cloudflarePathPatterns(entry.route)) {
|
|
137
|
+
blocks.push([pattern, [` Content-Security-Policy: ${STATIC_CSP}`]]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return `${blocks.map(([pattern, lines]) => [pattern, ...lines].join("\n")).join("\n\n")}\n`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Cloudflare `_headers` path patterns covering a clean-URL route. `/` →
|
|
146
|
+
* `["/", "/index.html"]`; `/docs/` → `["/docs", "/docs/*"]`.
|
|
147
|
+
* @param {string} route Route path from the manifest (always trailing-slashed).
|
|
148
|
+
* @returns {string[]}
|
|
149
|
+
*/
|
|
150
|
+
function cloudflarePathPatterns(route) {
|
|
151
|
+
if (route === "/") return ["/", "/index.html"];
|
|
152
|
+
const trimmed = route.replace(/\/$/, ""); // "/docs/" -> "/docs"
|
|
153
|
+
return [trimmed, `${trimmed}/*`];
|
|
154
|
+
}
|
|
155
|
+
|
|
106
156
|
/**
|
|
107
157
|
* A minimal, zero-JS fallback 404 page used only when no `404.wd` route exists.
|
|
108
158
|
* @returns {string}
|
package/src/compiler.js
CHANGED
|
@@ -327,6 +327,7 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
|
327
327
|
|
|
328
328
|
const raw = fs.readFileSync(file, "utf8");
|
|
329
329
|
const { meta, body } = parseFrontmatter(raw, file);
|
|
330
|
+
warnLikelyFrontmatter(raw, file, comp);
|
|
330
331
|
collectColocatedAssets(file, context, comp.assets);
|
|
331
332
|
|
|
332
333
|
if (path.extname(file) === ".md") {
|
|
@@ -365,6 +366,37 @@ export function parseFrontmatter(raw, file) {
|
|
|
365
366
|
return { meta, body };
|
|
366
367
|
}
|
|
367
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Warn (non-fatal) when a file looks like it MEANT to open frontmatter but forgot
|
|
371
|
+
* the leading `---`: the first content line is a `key: value` pair and a bare
|
|
372
|
+
* `---` fence appears within the opening lines. Conservative — a normal markdown
|
|
373
|
+
* body whose first line happens to contain a colon (with no early `---`) is left
|
|
374
|
+
* alone, since `---` is also a valid horizontal rule.
|
|
375
|
+
* @param {string} raw
|
|
376
|
+
* @param {string} file
|
|
377
|
+
* @param {Compilation} comp
|
|
378
|
+
* @returns {void}
|
|
379
|
+
*/
|
|
380
|
+
function warnLikelyFrontmatter(raw, file, comp) {
|
|
381
|
+
if (raw.startsWith("---\n")) return; // a real opener: parseFrontmatter handled it
|
|
382
|
+
const lines = raw.replace(/\r\n?/g, "\n").split("\n");
|
|
383
|
+
let i = 0;
|
|
384
|
+
while (i < lines.length && !lines[i].trim()) i++; // skip leading blanks
|
|
385
|
+
if (i >= lines.length || !/^[A-Za-z][\w-]*:\s*\S/.test(lines[i])) return;
|
|
386
|
+
// Only warn when the run of lines up to a bare `---` is ALL `key: value` pairs:
|
|
387
|
+
// a forgotten opener, not prose that merely contains a colon and a later rule.
|
|
388
|
+
for (let j = i; j < lines.length && j < i + 12; j++) {
|
|
389
|
+
const t = lines[j].trim();
|
|
390
|
+
if (t === "---") {
|
|
391
|
+
comp.warnings.push(
|
|
392
|
+
`${file}: this looks like frontmatter missing its opening "---". Use: a "---" line before the fields (and another "---" to close) — otherwise the block renders as page text.`
|
|
393
|
+
);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (t && !/^[A-Za-z][\w-]*:\s*\S/.test(t)) return; // hit prose → not frontmatter
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
368
400
|
// Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
|
|
369
401
|
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
370
402
|
// the parser stays single-pass and line-based.
|
|
@@ -571,6 +603,7 @@ function compileBody(lines, ctx) {
|
|
|
571
603
|
if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
|
|
572
604
|
throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
|
|
573
605
|
}
|
|
606
|
+
warnUnknownDirective(line, ctx);
|
|
574
607
|
prose.push(line);
|
|
575
608
|
}
|
|
576
609
|
|
|
@@ -578,6 +611,44 @@ function compileBody(lines, ctx) {
|
|
|
578
611
|
return out.join("\n");
|
|
579
612
|
}
|
|
580
613
|
|
|
614
|
+
// Lines that reached prose but LOOK like a directive (a `@word`/`:word` token at
|
|
615
|
+
// the very start, followed by a space or end of line) and match no handler are
|
|
616
|
+
// almost always a typo — every real directive was intercepted above. Warn, never
|
|
617
|
+
// throw: prose legitimately contains `@` and `:`, so the match is deliberately
|
|
618
|
+
// narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
|
|
619
|
+
// followed by punctuation do not match).
|
|
620
|
+
const KNOWN_DIRECTIVE =
|
|
621
|
+
/^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
|
|
622
|
+
/**
|
|
623
|
+
* @param {string} line
|
|
624
|
+
* @param {Ctx} ctx
|
|
625
|
+
* @returns {void}
|
|
626
|
+
*/
|
|
627
|
+
function warnUnknownDirective(line, ctx) {
|
|
628
|
+
// Catch capitalized (`:State`) and hyphenated (`:end-loop`) typos too — the
|
|
629
|
+
// known-directive whitelist stays case-sensitive, so a mis-cased real
|
|
630
|
+
// directive correctly trips the warning. Still unindented-only, to keep prose
|
|
631
|
+
// that legitimately starts lines with `@`/`:` from false-positiving.
|
|
632
|
+
const m = line.match(/^([@:][A-Za-z][A-Za-z-]*)(?:\s|$)/);
|
|
633
|
+
if (m && !KNOWN_DIRECTIVE.test(line)) {
|
|
634
|
+
ctx.comp.warnings.push(
|
|
635
|
+
`${ctx.file}: "${m[1]}" looks like a directive but matches none — it will render as literal text. Check the spelling (e.g. @loop, @include, :state, :if).`
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Format a source location as `file:line` (1-based) for compile errors. Keeps the
|
|
642
|
+
* file path intact so existing message matchers still pass, while pointing at the
|
|
643
|
+
* unclosed opener's line.
|
|
644
|
+
* @param {string} file
|
|
645
|
+
* @param {number} index 0-based line index of the opener.
|
|
646
|
+
* @returns {string}
|
|
647
|
+
*/
|
|
648
|
+
function at(file, index) {
|
|
649
|
+
return `${file}:${index + 1}`;
|
|
650
|
+
}
|
|
651
|
+
|
|
581
652
|
/**
|
|
582
653
|
* Capture lines from an opener until a matching close token, honoring nesting and fences.
|
|
583
654
|
* @param {string[]} lines
|
|
@@ -612,7 +683,7 @@ function scanBlock(lines, start, openRe, endToken, file) {
|
|
|
612
683
|
}
|
|
613
684
|
body.push(line);
|
|
614
685
|
}
|
|
615
|
-
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
|
|
686
|
+
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(file, start)}`);
|
|
616
687
|
}
|
|
617
688
|
|
|
618
689
|
/**
|
|
@@ -683,7 +754,7 @@ function scanContainer(lines, start, file) {
|
|
|
683
754
|
}
|
|
684
755
|
body.push(line);
|
|
685
756
|
}
|
|
686
|
-
throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
|
|
757
|
+
throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(file, start)}`);
|
|
687
758
|
}
|
|
688
759
|
|
|
689
760
|
/**
|
|
@@ -752,7 +823,7 @@ function scanConditional(lines, start, file) {
|
|
|
752
823
|
}
|
|
753
824
|
current.push(line);
|
|
754
825
|
}
|
|
755
|
-
throw new Error(`Missing :endif for "${lines[start]}" in ${file}`);
|
|
826
|
+
throw new Error(`Missing :endif for "${lines[start]}" in ${at(file, start)}`);
|
|
756
827
|
}
|
|
757
828
|
|
|
758
829
|
// ---------------------------------------------------------------------------
|
|
@@ -1637,6 +1708,11 @@ function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
|
1637
1708
|
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
1638
1709
|
return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
|
|
1639
1710
|
}
|
|
1711
|
+
// Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
|
|
1712
|
+
// the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
|
|
1713
|
+
if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
|
|
1714
|
+
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
|
|
1715
|
+
}
|
|
1640
1716
|
}
|
|
1641
1717
|
|
|
1642
1718
|
throw new Error(
|
|
@@ -1911,15 +1987,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
1911
1987
|
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, loopMeta: true, scope: createScope(ctx.scope) };
|
|
1912
1988
|
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
1913
1989
|
|
|
1914
|
-
|
|
1915
|
-
let itemTemplate = templateHtml;
|
|
1916
|
-
const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
|
|
1917
|
-
if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
|
|
1918
|
-
wrapperTag = listMatch[1];
|
|
1919
|
-
itemTemplate = listMatch[2].trim();
|
|
1920
|
-
} else {
|
|
1921
|
-
itemTemplate = `<div data-wd-loop-piece>${templateHtml}</div>`;
|
|
1922
|
-
}
|
|
1990
|
+
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
1923
1991
|
|
|
1924
1992
|
const where = opts?.where || null;
|
|
1925
1993
|
const sort = opts?.sort || null;
|
|
@@ -1941,27 +2009,132 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
1941
2009
|
/** @type {Map<string, number>} */
|
|
1942
2010
|
const counts = new Map();
|
|
1943
2011
|
const count = rows.length;
|
|
2012
|
+
// Inner ITEM-RELATIVE loop regions are filled per row by the runtime, not at
|
|
2013
|
+
// build time; mask them so the outer initial-paint string fill leaves their
|
|
2014
|
+
// pristine <template>/empty-out markup untouched, then restore.
|
|
2015
|
+
const { masked, regions } = maskItemLoops(itemTemplate);
|
|
1944
2016
|
const initial = rows
|
|
1945
2017
|
.map((/** @type {unknown} */ item, i) => {
|
|
1946
2018
|
const itemKey = loopKeyOf(item, counts);
|
|
1947
2019
|
const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
|
|
1948
|
-
return fillTemplateString(withLoopKey(
|
|
2020
|
+
return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
|
|
1949
2021
|
})
|
|
1950
2022
|
.join("");
|
|
1951
2023
|
|
|
1952
|
-
const emptyTemplate =
|
|
2024
|
+
const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
|
|
1953
2025
|
const initialOut = count === 0 && emptyLines ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim() : initial;
|
|
1954
2026
|
|
|
1955
|
-
const attrs =
|
|
1956
|
-
(where ? ` data-wd-loop-where="${escapeHtml(where.body)}"` : "") +
|
|
1957
|
-
(sort ? ` data-wd-loop-sort="${escapeHtml(sort.key)}" data-wd-loop-sort-dir="${sort.dir}"` : "") +
|
|
1958
|
-
(reverse ? ` data-wd-loop-reverse` : "") +
|
|
1959
|
-
(offset ? ` data-wd-loop-offset="${numArgAttr(offset)}"` : "") +
|
|
1960
|
-
(limit ? ` data-wd-loop-limit="${numArgAttr(limit)}"` : "") +
|
|
2027
|
+
const attrs = loopClauseAttrs({ where, sort, reverse, offset, limit }) +
|
|
1961
2028
|
(baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
|
|
1962
2029
|
return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
|
|
1963
2030
|
}
|
|
1964
2031
|
|
|
2032
|
+
/**
|
|
2033
|
+
* Unwrap a compiled row body into a wrapper tag + row template. A body that is a
|
|
2034
|
+
* single-`<li>` `<ul>`/`<ol>` becomes an `<li>` row under that list wrapper;
|
|
2035
|
+
* anything else is wrapped in a `<div data-wd-loop-piece>` under a `<div>`. Shared
|
|
2036
|
+
* by top-level reactive loops and item-relative (nested) loops.
|
|
2037
|
+
* @param {string} templateHtml
|
|
2038
|
+
* @returns {{ wrapperTag: string, itemTemplate: string }}
|
|
2039
|
+
*/
|
|
2040
|
+
function wrapRowTemplate(templateHtml) {
|
|
2041
|
+
const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
|
|
2042
|
+
if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
|
|
2043
|
+
return { wrapperTag: listMatch[1], itemTemplate: listMatch[2].trim() };
|
|
2044
|
+
}
|
|
2045
|
+
return { wrapperTag: "div", itemTemplate: `<div data-wd-loop-piece>${templateHtml}</div>` };
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* Compile an `@empty` branch body into a `<template data-wd-loop-empty>` (or "" when
|
|
2050
|
+
* there is no empty branch). Shared by top-level and item-relative loops.
|
|
2051
|
+
* @param {string[] | null} emptyLines
|
|
2052
|
+
* @param {Ctx} ctx
|
|
2053
|
+
* @returns {string}
|
|
2054
|
+
*/
|
|
2055
|
+
function loopEmptyTemplate(emptyLines, ctx) {
|
|
2056
|
+
return emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/**
|
|
2060
|
+
* Serialize the shared `where`/`sort`/`reverse`/`offset`/`limit` clause config to
|
|
2061
|
+
* the `data-wd-loop-*` attribute string consumed by the runtime pipeline. Shared
|
|
2062
|
+
* by top-level reactive loops and item-relative (nested) loops.
|
|
2063
|
+
* @param {Pick<LoopOpts, "where" | "sort" | "reverse" | "offset" | "limit">} c
|
|
2064
|
+
* @returns {string}
|
|
2065
|
+
*/
|
|
2066
|
+
function loopClauseAttrs(c) {
|
|
2067
|
+
return (
|
|
2068
|
+
(c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
|
|
2069
|
+
(c.sort ? ` data-wd-loop-sort="${escapeHtml(c.sort.key)}" data-wd-loop-sort-dir="${c.sort.dir}"` : "") +
|
|
2070
|
+
(c.reverse ? ` data-wd-loop-reverse` : "") +
|
|
2071
|
+
(c.offset ? ` data-wd-loop-offset="${numArgAttr(c.offset)}"` : "") +
|
|
2072
|
+
(c.limit ? ` data-wd-loop-limit="${numArgAttr(c.limit)}"` : "")
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
/**
|
|
2077
|
+
* Emit ONE level of nested reactive loop: a `@loop <outerItem>.<path> into x`
|
|
2078
|
+
* whose rows are read off the enclosing loop row at runtime. The region carries
|
|
2079
|
+
* its own row <template> + clause config but, unlike a top-level loop, its source
|
|
2080
|
+
* is the relative `path` (not a global state key) — `fillItem` resolves the rows
|
|
2081
|
+
* off the current outer item and reconciles per row. The output starts empty; the
|
|
2082
|
+
* runtime fills it on first render (the page already ships the runtime).
|
|
2083
|
+
* @param {string} path Dotted sub-path off the outer item (e.g. `items`).
|
|
2084
|
+
* @param {string} itemName Inner loop item name.
|
|
2085
|
+
* @param {string[]} bodyLines Inner loop body.
|
|
2086
|
+
* @param {Ctx} ctx
|
|
2087
|
+
* @param {Partial<LoopOpts>} opts
|
|
2088
|
+
* @returns {string}
|
|
2089
|
+
*/
|
|
2090
|
+
function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
2091
|
+
/** @type {Ctx} */
|
|
2092
|
+
// loopKey: undefined — an item-relative loop's source is a path off the outer
|
|
2093
|
+
// row, not a top-level state key, so a per-row `remove` inside it has no valid
|
|
2094
|
+
// target and must be rejected (rather than inheriting the outer loop's key).
|
|
2095
|
+
const templateCtx = { ...ctx, loopItem: itemName, loopKey: undefined, loopMeta: true, scope: createScope(ctx.scope) };
|
|
2096
|
+
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
2097
|
+
|
|
2098
|
+
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
2099
|
+
|
|
2100
|
+
const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
|
|
2101
|
+
const attrs = loopClauseAttrs({ where: opts.where || null, sort: opts.sort || null, reverse: opts.reverse || false, offset: opts.offset || null, limit: opts.limit || null });
|
|
2102
|
+
return `<div data-wd-loop-item="${escapeHtml(path)}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out></${wrapperTag}></div>`;
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
/**
|
|
2106
|
+
* Replace each top-level `data-wd-loop-item` region in a row template with an
|
|
2107
|
+
* opaque placeholder so the outer initial-paint string fill cannot reach inside
|
|
2108
|
+
* its pristine markup. Returns the masked string and the excised regions in order.
|
|
2109
|
+
* @param {string} template
|
|
2110
|
+
* @returns {{ masked: string, regions: string[] }}
|
|
2111
|
+
*/
|
|
2112
|
+
function maskItemLoops(template) {
|
|
2113
|
+
const marker = "<div data-wd-loop-item=";
|
|
2114
|
+
/** @type {string[]} */
|
|
2115
|
+
const regions = [];
|
|
2116
|
+
let masked = "";
|
|
2117
|
+
let i = 0;
|
|
2118
|
+
for (;;) {
|
|
2119
|
+
const start = template.indexOf(marker, i);
|
|
2120
|
+
if (start === -1) return { masked: masked + template.slice(i), regions };
|
|
2121
|
+
const end = matchElement(template, start, "div");
|
|
2122
|
+
masked += template.slice(i, start) + `\x00WDLI${regions.length}\x00`;
|
|
2123
|
+
regions.push(template.slice(start, end));
|
|
2124
|
+
i = end;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
/**
|
|
2129
|
+
* Restore the masked `data-wd-loop-item` regions into a filled row string.
|
|
2130
|
+
* @param {string} str
|
|
2131
|
+
* @param {string[]} regions
|
|
2132
|
+
* @returns {string}
|
|
2133
|
+
*/
|
|
2134
|
+
function unmaskItemLoops(str, regions) {
|
|
2135
|
+
return regions.length ? str.replace(/\x00WDLI(\d+)\x00/g, (_, n) => regions[Number(n)]) : str;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
1965
2138
|
/**
|
|
1966
2139
|
* @param {string} template
|
|
1967
2140
|
* @param {string} itemKey
|
|
@@ -2507,7 +2680,13 @@ function parseSingleAction(expression, raw, ctx) {
|
|
|
2507
2680
|
if (remove) {
|
|
2508
2681
|
const operand = remove[2].trim();
|
|
2509
2682
|
if (ctx.loopItem && operand === ctx.loopItem) {
|
|
2510
|
-
|
|
2683
|
+
// A nested (item-relative) loop has a loop item but no top-level state key —
|
|
2684
|
+
// its source is a path off the outer row, which the runtime's row-remove
|
|
2685
|
+
// can't target. Fail loud with the honest workaround.
|
|
2686
|
+
if (!ctx.loopKey) {
|
|
2687
|
+
throw new Error(`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`);
|
|
2688
|
+
}
|
|
2689
|
+
if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
2511
2690
|
throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
|
|
2512
2691
|
}
|
|
2513
2692
|
return { op: "remove", target: ctx.loopKey };
|
package/src/headers.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security headers Darkmown emits on every delivery surface — the local
|
|
3
|
+
* `darkmown serve`/preview server (`src/statics.js`), Cloudflare Pages
|
|
4
|
+
* (`dist/_headers`, written by `src/builder.js`), and Vercel (`vercel.json`).
|
|
5
|
+
*
|
|
6
|
+
* `statics.js` and the generated `_headers` derive from the values here, so they
|
|
7
|
+
* never drift. `vercel.json` is the exception: Vercel reads it BEFORE the build
|
|
8
|
+
* runs, so its CSP strings and static-route list are hand-maintained copies, not
|
|
9
|
+
* build output. The `vercel-csp-routes` test cross-checks vercel.json against the
|
|
10
|
+
* built route manifest so a new static route can't silently miss the strict CSP.
|
|
11
|
+
*
|
|
12
|
+
* The CSP is derived from what the framework's own output actually emits
|
|
13
|
+
* (verify against built `dist/` HTML):
|
|
14
|
+
* - External script `/__wd/runtime.js` (same-origin) on reactive pages.
|
|
15
|
+
* - Inline `<script type="application/json" data-wd-state>` (state seed) and
|
|
16
|
+
* inline `<script type="speculationrules">` (prerender) — JSON/speculation
|
|
17
|
+
* blocks can't be nonce'd cleanly, so `script-src 'unsafe-inline'`.
|
|
18
|
+
* - The reactive runtime compiles validated expressions via `new Function`,
|
|
19
|
+
* so reactive pages additionally need `script-src 'unsafe-eval'`.
|
|
20
|
+
* - Inline `<style>` for view transitions — `style-src 'unsafe-inline'`.
|
|
21
|
+
* - `data:` favicon and remote images — `img-src 'self' data: https:`.
|
|
22
|
+
*
|
|
23
|
+
* Static pages (zero framework JS) still emit inline `<style>` and an inline
|
|
24
|
+
* `<script type="speculationrules">`, so they keep `'unsafe-inline'`, but they
|
|
25
|
+
* never call `new Function`, so their CSP drops `'unsafe-eval'`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `connect-src` for the demo is `'self'`: the framework's own pages only
|
|
30
|
+
* `:fetch` same-origin endpoints. Apps whose `:fetch` targets a remote host
|
|
31
|
+
* must widen this (e.g. to `connect-src 'self' https://api.example.com` or
|
|
32
|
+
* `connect-src *`) by editing their `vercel.json` / `_headers` / serve config.
|
|
33
|
+
* @type {string}
|
|
34
|
+
*/
|
|
35
|
+
const CONNECT_SRC = "connect-src 'self'";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Directives shared by both the static and reactive CSP. Order is cosmetic.
|
|
39
|
+
* @type {string[]}
|
|
40
|
+
*/
|
|
41
|
+
const COMMON_CSP_DIRECTIVES = [
|
|
42
|
+
"default-src 'self'",
|
|
43
|
+
"style-src 'self' 'unsafe-inline'",
|
|
44
|
+
"img-src 'self' data: https:",
|
|
45
|
+
"font-src 'self'",
|
|
46
|
+
CONNECT_SRC,
|
|
47
|
+
"object-src 'none'",
|
|
48
|
+
"base-uri 'self'",
|
|
49
|
+
"frame-ancestors 'self'"
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Relaxed CSP for reactive pages: `new Function` needs `'unsafe-eval'`, the
|
|
54
|
+
* inline state/speculationrules JSON needs `'unsafe-inline'`.
|
|
55
|
+
* @type {string}
|
|
56
|
+
*/
|
|
57
|
+
export const REACTIVE_CSP = [
|
|
58
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
59
|
+
...COMMON_CSP_DIRECTIVES
|
|
60
|
+
].join("; ");
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stricter CSP for static pages: no runtime means no `new Function`, so
|
|
64
|
+
* `'unsafe-eval'` is dropped. Inline `<style>`/`<script type=speculationrules>`
|
|
65
|
+
* keep `'unsafe-inline'` on script/style.
|
|
66
|
+
* @type {string}
|
|
67
|
+
*/
|
|
68
|
+
export const STATIC_CSP = [
|
|
69
|
+
"script-src 'self' 'unsafe-inline'",
|
|
70
|
+
...COMMON_CSP_DIRECTIVES
|
|
71
|
+
].join("; ");
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Baseline security headers applied to every HTML response, independent of the
|
|
75
|
+
* CSP. CSP is added per-route (static vs reactive) by the helpers below.
|
|
76
|
+
* @type {Record<string, string>}
|
|
77
|
+
*/
|
|
78
|
+
export const BASE_SECURITY_HEADERS = {
|
|
79
|
+
"X-Content-Type-Options": "nosniff",
|
|
80
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
81
|
+
"X-Frame-Options": "SAMEORIGIN"
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The full security-header set for one response, including the CSP variant.
|
|
86
|
+
* @param {boolean} reactive Whether the route ships the reactive runtime.
|
|
87
|
+
* @returns {Record<string, string>}
|
|
88
|
+
*/
|
|
89
|
+
export function securityHeaders(reactive) {
|
|
90
|
+
return {
|
|
91
|
+
...BASE_SECURITY_HEADERS,
|
|
92
|
+
"Content-Security-Policy": reactive ? REACTIVE_CSP : STATIC_CSP
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/runtime.js
CHANGED
|
@@ -246,9 +246,15 @@ function loopKeyOf(item, counts) {
|
|
|
246
246
|
* @returns {void}
|
|
247
247
|
*/
|
|
248
248
|
function fillItem(node, item, meta = {}) {
|
|
249
|
+
// Nested item-relative loops (data-wd-loop-item) own their descendants' binds;
|
|
250
|
+
// when present, `skip` excludes a node living inside one so the outer row's
|
|
251
|
+
// text/meta/each-if/class passes never overwrite an inner row's values.
|
|
252
|
+
const inners = node.querySelectorAll("[data-wd-loop-item]");
|
|
253
|
+
const skip = inners.length ? (/** @type {Element} */ el) => el.closest("[data-wd-loop-item]") : () => false;
|
|
249
254
|
// querySelectorAll skips <template> content, so this sees only the outermost
|
|
250
255
|
// regions; recursing into each injected branch fills the rest.
|
|
251
256
|
for (const region of node.querySelectorAll("[data-wd-each-if]")) {
|
|
257
|
+
if (skip(region)) continue;
|
|
252
258
|
const expr = region.getAttribute("data-wd-if-expr");
|
|
253
259
|
const m = region.getAttribute("data-wd-meta");
|
|
254
260
|
const value = expr ? evalPredicate(expr, item) : (m ? meta[m] : getPath(item, region.getAttribute("data-wd-path")));
|
|
@@ -262,13 +268,17 @@ function fillItem(node, item, meta = {}) {
|
|
|
262
268
|
? [node, ...node.querySelectorAll("[data-wd-each]")]
|
|
263
269
|
: [...node.querySelectorAll("[data-wd-each]")];
|
|
264
270
|
for (const target of targets) {
|
|
271
|
+
if (skip(target)) continue;
|
|
265
272
|
target.textContent = getPath(item, target.getAttribute("data-wd-path")) ?? "";
|
|
266
273
|
}
|
|
267
274
|
for (const marker of node.querySelectorAll("[data-wd-each-meta]")) {
|
|
275
|
+
if (skip(marker)) continue;
|
|
268
276
|
marker.textContent = meta[marker.getAttribute("data-wd-each-meta") || ""] ?? "";
|
|
269
277
|
}
|
|
270
278
|
const classNodes = node.matches("[data-wd-each-class]") ? [node, ...node.querySelectorAll("[data-wd-each-class]")] : [...node.querySelectorAll("[data-wd-each-class]")];
|
|
271
|
-
for (const el of classNodes) classToggle(el, el.getAttribute("data-wd-each-class"), item);
|
|
279
|
+
for (const el of classNodes) { if (!skip(el)) classToggle(el, el.getAttribute("data-wd-each-class"), item); }
|
|
280
|
+
// Reconcile nested loops last, against the now-settled outer row.
|
|
281
|
+
for (const region of inners) reconcile(region, getPath(item, region.getAttribute("data-wd-loop-item")));
|
|
272
282
|
}
|
|
273
283
|
|
|
274
284
|
/**
|
|
@@ -335,6 +345,75 @@ function renderIf(node) {
|
|
|
335
345
|
for (const nested of output.querySelectorAll("[data-wd-if]")) renderIf(nested);
|
|
336
346
|
}
|
|
337
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Reconcile one loop region (top-level or nested) from a resolved row source:
|
|
350
|
+
* filter (`where`) → pipeline (sort/reverse/offset/limit) → @empty → keyed
|
|
351
|
+
* reconcile. Each surviving row is `fillItem`-ed, which itself recurses into any
|
|
352
|
+
* nested item-relative loops. Shared by the global pass and `fillItem`.
|
|
353
|
+
* @param {Element} region
|
|
354
|
+
* @param {any} rows Source list (state-resolved globally; item-relative inside fillItem).
|
|
355
|
+
* @returns {void}
|
|
356
|
+
*/
|
|
357
|
+
function reconcile(region, rows) {
|
|
358
|
+
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
|
|
359
|
+
const out = region.querySelector("[data-wd-loop-out]");
|
|
360
|
+
if (!template || !out) return;
|
|
361
|
+
let list = Array.isArray(rows) ? rows.slice() : [];
|
|
362
|
+
const where = region.getAttribute("data-wd-loop-where");
|
|
363
|
+
if (where) {
|
|
364
|
+
const predicate = loopPredicate(where);
|
|
365
|
+
list = list.filter((/** @type {any} */ item) => {
|
|
366
|
+
try {
|
|
367
|
+
return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
warn(where, error);
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
list = pipeline(list, region);
|
|
375
|
+
|
|
376
|
+
// Empty branch: clone the [data-wd-loop-empty] template into the output.
|
|
377
|
+
const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
|
|
378
|
+
if (!list.length && emptyTpl) {
|
|
379
|
+
if (out.getAttribute("data-wd-empty") !== "1") {
|
|
380
|
+
out.textContent = "";
|
|
381
|
+
for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
|
|
382
|
+
out.setAttribute("data-wd-empty", "1");
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
|
|
387
|
+
|
|
388
|
+
/** @type {Map<string, WdRow>} */
|
|
389
|
+
const existing = new Map();
|
|
390
|
+
for (const child of [...out.children]) {
|
|
391
|
+
existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
|
|
392
|
+
}
|
|
393
|
+
/** @type {Map<string, number>} */
|
|
394
|
+
const counts = new Map();
|
|
395
|
+
/** @type {Set<string>} */
|
|
396
|
+
const used = new Set();
|
|
397
|
+
const count = list.length;
|
|
398
|
+
for (let i = 0; i < count; i++) {
|
|
399
|
+
const item = list[i];
|
|
400
|
+
const key = loopKeyOf(item, counts);
|
|
401
|
+
let node = existing.get(key);
|
|
402
|
+
if (!node || used.has(key)) {
|
|
403
|
+
node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
|
|
404
|
+
if (!node) continue;
|
|
405
|
+
node.setAttribute("data-wd-loop-key", key);
|
|
406
|
+
}
|
|
407
|
+
used.add(key);
|
|
408
|
+
fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
|
|
409
|
+
node.__wdItem = item; /** let per-row actions resolve which row was clicked */
|
|
410
|
+
out.appendChild(node);
|
|
411
|
+
}
|
|
412
|
+
for (const [key, node] of existing) {
|
|
413
|
+
if (!used.has(key)) node.remove();
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
338
417
|
/**
|
|
339
418
|
* Synchronously render the whole document from current `state`:
|
|
340
419
|
* computed → if-regions → keyed loop reconcile → text/input binds.
|
|
@@ -348,69 +427,14 @@ function renderNow() {
|
|
|
348
427
|
// bindings (data-wd-each-class) are handled per item inside fillItem.
|
|
349
428
|
for (const el of document.querySelectorAll("[data-wd-class]")) classToggle(el, el.getAttribute("data-wd-class"), undefined);
|
|
350
429
|
|
|
430
|
+
// Nested item-relative loops use data-wd-loop-item, so this top-level query
|
|
431
|
+
// skips them; fillItem reconciles those against their enclosing row.
|
|
351
432
|
for (const region of document.querySelectorAll("[data-wd-loop]")) {
|
|
352
433
|
const key = region.getAttribute("data-wd-loop");
|
|
353
434
|
const data = region.getAttribute("data-wd-loop-data");
|
|
354
435
|
/** Source may be a dotted path (e.g. team.members) read off state via getPath. */
|
|
355
436
|
const dot = key ? key.indexOf(".") : -1;
|
|
356
|
-
|
|
357
|
-
const template = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-template]"));
|
|
358
|
-
const out = region.querySelector("[data-wd-loop-out]");
|
|
359
|
-
if (!template || !out) continue;
|
|
360
|
-
let list = Array.isArray(rows) ? rows.slice() : [];
|
|
361
|
-
const where = region.getAttribute("data-wd-loop-where");
|
|
362
|
-
if (where) {
|
|
363
|
-
const predicate = loopPredicate(where);
|
|
364
|
-
list = list.filter((/** @type {any} */ item) => {
|
|
365
|
-
try {
|
|
366
|
-
return predicate((/** @type {string | null} */ path) => getPath(item, path), (/** @type {string} */ k, /** @type {string} */ r) => getPath(state[k], r || ""), containsFn);
|
|
367
|
-
} catch (error) {
|
|
368
|
-
warn(where, error);
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
list = pipeline(list, region);
|
|
374
|
-
|
|
375
|
-
// Empty branch: clone the [data-wd-loop-empty] template into the output.
|
|
376
|
-
const emptyTpl = /** @type {HTMLTemplateElement | null} */ (region.querySelector("template[data-wd-loop-empty]"));
|
|
377
|
-
if (!list.length && emptyTpl) {
|
|
378
|
-
if (out.getAttribute("data-wd-empty") !== "1") {
|
|
379
|
-
out.textContent = "";
|
|
380
|
-
for (const child of [...emptyTpl.content.children]) out.appendChild(child.cloneNode(true));
|
|
381
|
-
out.setAttribute("data-wd-empty", "1");
|
|
382
|
-
}
|
|
383
|
-
continue;
|
|
384
|
-
}
|
|
385
|
-
if (out.getAttribute("data-wd-empty") === "1") { out.textContent = ""; out.removeAttribute("data-wd-empty"); }
|
|
386
|
-
|
|
387
|
-
/** @type {Map<string, WdRow>} */
|
|
388
|
-
const existing = new Map();
|
|
389
|
-
for (const child of [...out.children]) {
|
|
390
|
-
existing.set(child.getAttribute("data-wd-loop-key") || "", /** @type {WdRow} */ (child));
|
|
391
|
-
}
|
|
392
|
-
/** @type {Map<string, number>} */
|
|
393
|
-
const counts = new Map();
|
|
394
|
-
/** @type {Set<string>} */
|
|
395
|
-
const used = new Set();
|
|
396
|
-
const count = list.length;
|
|
397
|
-
for (let i = 0; i < count; i++) {
|
|
398
|
-
const item = list[i];
|
|
399
|
-
const key = loopKeyOf(item, counts);
|
|
400
|
-
let node = existing.get(key);
|
|
401
|
-
if (!node || used.has(key)) {
|
|
402
|
-
node = /** @type {WdRow} */ (template.content.firstElementChild?.cloneNode(true));
|
|
403
|
-
if (!node) continue;
|
|
404
|
-
node.setAttribute("data-wd-loop-key", key);
|
|
405
|
-
}
|
|
406
|
-
used.add(key);
|
|
407
|
-
fillItem(node, item, { index: i, number: i + 1, first: i === 0, last: i === count - 1, count });
|
|
408
|
-
node.__wdItem = item; /** let per-row actions resolve which row was clicked */
|
|
409
|
-
out.appendChild(node);
|
|
410
|
-
}
|
|
411
|
-
for (const [key, node] of existing) {
|
|
412
|
-
if (!used.has(key)) node.remove();
|
|
413
|
-
}
|
|
437
|
+
reconcile(region, key ? (dot < 0 ? state[key] : getPath(state[key.slice(0, dot)], key.slice(dot + 1))) : (data ? JSON.parse(data) : []));
|
|
414
438
|
}
|
|
415
439
|
|
|
416
440
|
for (const node of document.querySelectorAll("[data-wd-bind]")) {
|
package/src/skin.js
CHANGED
|
@@ -53,7 +53,16 @@ export function compileSkin(source) {
|
|
|
53
53
|
/** @type {number | null} */
|
|
54
54
|
let tokenIndent = null;
|
|
55
55
|
/** @type {string[]} */
|
|
56
|
+
let tokenVars = [];
|
|
57
|
+
/** @type {string[]} */
|
|
56
58
|
const rootVars = [];
|
|
59
|
+
/** @type {string[]} */
|
|
60
|
+
const darkVars = [];
|
|
61
|
+
// Manual-toggle variants: `tokens [data-theme=dark]` collects custom
|
|
62
|
+
// properties under an attribute-scoped `:root` so a `:store theme` toggle can
|
|
63
|
+
// flip them with no JS beyond writing the attribute.
|
|
64
|
+
/** @type {Map<string, string[]>} */
|
|
65
|
+
const attrVars = new Map();
|
|
57
66
|
|
|
58
67
|
for (let i = 0; i < lines.length; i++) {
|
|
59
68
|
const { indent, text } = lines[i];
|
|
@@ -61,14 +70,41 @@ export function compileSkin(source) {
|
|
|
61
70
|
// the next line is indented deeper. Everything else is a declaration.
|
|
62
71
|
const opensBlock = i + 1 < lines.length && lines[i + 1].indent > indent;
|
|
63
72
|
|
|
64
|
-
|
|
73
|
+
// `tokens` opens a design-token block emitting `:root { --name: value }`.
|
|
74
|
+
// An optional modifier picks a variant: `tokens dark` collects into a
|
|
75
|
+
// `prefers-color-scheme: dark` override (OS-driven), while a bracketed
|
|
76
|
+
// `tokens [data-theme=dark]` scopes the override to a `:root` attribute for
|
|
77
|
+
// manual toggling. Both reuse the same `--name` properties.
|
|
78
|
+
// Capture the whole modifier (`.+`, not `\S+`) so a malformed bracketed
|
|
79
|
+
// modifier with internal spaces (`[data-theme=dark extra]`) is still routed
|
|
80
|
+
// to normalizeAttr's null fail-safe rather than slipping through as a bogus
|
|
81
|
+
// `tokens …` selector.
|
|
82
|
+
const tokenMatch = /^tokens(?:\s+(.+))?$/.exec(text);
|
|
83
|
+
if (tokenMatch && opensBlock) {
|
|
65
84
|
tokenIndent = indent;
|
|
85
|
+
const modifier = tokenMatch[1];
|
|
86
|
+
if (!modifier) {
|
|
87
|
+
tokenVars = rootVars;
|
|
88
|
+
} else if (modifier === "dark") {
|
|
89
|
+
tokenVars = darkVars;
|
|
90
|
+
} else if (modifier.startsWith("[") && modifier.endsWith("]")) {
|
|
91
|
+
const attr = normalizeAttr(modifier);
|
|
92
|
+
if (attr) {
|
|
93
|
+
const selector = `:root${attr}`;
|
|
94
|
+
tokenVars = attrVars.get(selector) ?? [];
|
|
95
|
+
attrVars.set(selector, tokenVars);
|
|
96
|
+
} else {
|
|
97
|
+
tokenVars = rootVars; // malformed `[…]` modifier → fall back to default :root tokens
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
tokenVars = rootVars;
|
|
101
|
+
}
|
|
66
102
|
continue;
|
|
67
103
|
}
|
|
68
104
|
|
|
69
105
|
if (tokenIndent !== null && indent > tokenIndent) {
|
|
70
106
|
const [name, ...rest] = text.split(/\s+/);
|
|
71
|
-
|
|
107
|
+
tokenVars.push(` --${name}: ${rest.join(" ")};`);
|
|
72
108
|
continue;
|
|
73
109
|
}
|
|
74
110
|
|
|
@@ -96,10 +132,32 @@ export function compileSkin(source) {
|
|
|
96
132
|
css.push(media ? `${media} { ${rule} }` : rule);
|
|
97
133
|
}
|
|
98
134
|
|
|
135
|
+
// Prepend tokens ahead of selector rules. `unshift` reverses insertion order,
|
|
136
|
+
// so push the overrides first to land them after the `:root` defaults.
|
|
137
|
+
for (const [selector, vars] of attrVars) {
|
|
138
|
+
if (vars.length) css.unshift(`${selector} {\n${vars.join("\n")}\n}`);
|
|
139
|
+
}
|
|
140
|
+
if (darkVars.length) {
|
|
141
|
+
css.unshift(`@media (prefers-color-scheme: dark) {\n:root {\n${darkVars.join("\n")}\n}\n}`);
|
|
142
|
+
}
|
|
99
143
|
if (rootVars.length) css.unshift(`:root {\n${rootVars.join("\n")}\n}`);
|
|
100
144
|
return css.join("\n");
|
|
101
145
|
}
|
|
102
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Normalize a bracketed token modifier into a valid attribute selector,
|
|
149
|
+
* quoting the value so `[data-theme=dark]` becomes `[data-theme="dark"]`.
|
|
150
|
+
* Returns null for a malformed modifier (`[]`, `[a=b c]`, injection-shaped
|
|
151
|
+
* input) so the caller can fall back to default `:root` tokens instead of
|
|
152
|
+
* emitting a junk selector.
|
|
153
|
+
* @param {string} bracketed
|
|
154
|
+
* @returns {string | null}
|
|
155
|
+
*/
|
|
156
|
+
function normalizeAttr(bracketed) {
|
|
157
|
+
const m = bracketed.match(/^\[\s*([^\]=\s]+)\s*=\s*"?([^\]"]*)"?\s*\]$/);
|
|
158
|
+
return m ? `[${m[1]}="${m[2]}"]` : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
103
161
|
/**
|
|
104
162
|
* Combine a selector with its parent (handles `&` nesting and comma lists).
|
|
105
163
|
* @param {string} text
|
package/src/statics.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { BASE_SECURITY_HEADERS, REACTIVE_CSP } from "./headers.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Security headers for an HTML response from the local server. The relaxed
|
|
8
|
+
* (reactive) CSP is a superset that satisfies both static and reactive pages,
|
|
9
|
+
* so the runtime server uses it for every HTML response; the build-time
|
|
10
|
+
* outputs (`vercel.json`, `dist/_headers`) tighten static routes per-path.
|
|
11
|
+
* @type {Record<string, string>}
|
|
12
|
+
*/
|
|
13
|
+
const HTML_SECURITY_HEADERS = {
|
|
14
|
+
...BASE_SECURITY_HEADERS,
|
|
15
|
+
"Content-Security-Policy": REACTIVE_CSP
|
|
16
|
+
};
|
|
4
17
|
|
|
5
18
|
/**
|
|
6
19
|
* Serve a built file for a request URL out of `distRoot`, or a 404 page.
|
|
@@ -12,7 +25,7 @@ import path from "node:path";
|
|
|
12
25
|
export function serve(distRoot, url, res) {
|
|
13
26
|
const file = resolvePublicFile(distRoot, url || "/");
|
|
14
27
|
if (!file || !fs.existsSync(file)) {
|
|
15
|
-
res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
|
|
28
|
+
res.writeHead(404, { "content-type": "text/html; charset=utf-8", ...HTML_SECURITY_HEADERS });
|
|
16
29
|
const notFound = path.join(distRoot, "404.html");
|
|
17
30
|
if (fs.existsSync(notFound)) {
|
|
18
31
|
res.end(fs.readFileSync(notFound));
|
|
@@ -21,7 +34,11 @@ export function serve(distRoot, url, res) {
|
|
|
21
34
|
res.end("<h1>Not found</h1><p>This route is hidden or has not been created.</p>");
|
|
22
35
|
return;
|
|
23
36
|
}
|
|
24
|
-
|
|
37
|
+
const type = contentType(file);
|
|
38
|
+
const headers = type.startsWith("text/html")
|
|
39
|
+
? { "content-type": type, ...HTML_SECURITY_HEADERS }
|
|
40
|
+
: { "content-type": type };
|
|
41
|
+
res.writeHead(200, headers);
|
|
25
42
|
fs.createReadStream(file).pipe(res);
|
|
26
43
|
}
|
|
27
44
|
|