@zvndev/darkmown 0.14.0 → 0.15.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 +8 -0
- package/README.md +21 -2
- package/SECURITY.md +2 -1
- package/docs/spec-alignment.md +13 -1
- package/package.json +1 -1
- package/site/_/links.json +5 -0
- package/site/pages/data.wd +32 -0
- package/site/pages/docs/index.wd +3 -3
- package/src/builder.js +12 -0
- package/src/compiler.js +197 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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.14.1 — 2026-06-19
|
|
6
|
+
|
|
7
|
+
Patch-release hardening for page assets, form actions, and release metadata.
|
|
8
|
+
|
|
9
|
+
- **Security — private page assets stay private.** Page-colocated assets now follow the same hidden-path convention as routes: files or folders under `site/pages/` whose path segment starts with `.`, `-`, or `_` are not copied to `dist`. Symlinked page assets are also skipped, closing the accidental-publication path for `.env`, `_private/*`, `-draft/*`, and symlink-to-outside files. Normal colocated assets such as `site/pages/logo.svg` and `site/pages/blog/cover.png` still emit unchanged.
|
|
10
|
+
- **Security — `:form action=` scheme validation.** Native and round-trip forms now validate `action=` with the same compile-time URL scheme guard as `:fetch`: relative URLs, explicit `http(s)://`, and leading `{ state }` interpolation are allowed; protocol-relative URLs and non-http(s) schemes (`javascript:`, `data:`, `file:`, …) are compile errors.
|
|
11
|
+
- **Release hygiene.** `package-lock.json` package metadata is synced to the release version, so package review no longer reports stale root version drift.
|
|
12
|
+
|
|
5
13
|
## 0.14.0 — 2026-06-18
|
|
6
14
|
|
|
7
15
|
Richer conditionals — `:if` / `:else if` now read the full predicate grammar instead of a bare truthy path only. The reactive runtime is ~5.7 KB gzipped (5762 B), still under the 6 KB CI budget and shipped un-minified.
|
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ One syntax everywhere: `{ name }` or `{ name.path }`.
|
|
|
63
63
|
- Declared `:state` becomes a live binding.
|
|
64
64
|
- The page's own frontmatter is in scope as `meta` — `{ meta.title }` prints a field.
|
|
65
65
|
- Anything else stays literal text — braces in prose never break a page or pull in the runtime.
|
|
66
|
+
- Build-time values also resolve **inside a link or image destination** — `[{ item.label }]({ item.url })` or `` — so an `@loop` can drive an `href`/`src` and the page stays static. (Reactive `:state` cannot live in a destination.)
|
|
66
67
|
|
|
67
68
|
## Frontmatter
|
|
68
69
|
|
|
@@ -95,6 +96,8 @@ Reference them with a normal URL: ``. The preview/`
|
|
|
95
96
|
- `site/pages/logo.svg` → `dist/logo.svg` → ``
|
|
96
97
|
- `site/pages/blog/cover.png` → `dist/blog/cover.png` → ``
|
|
97
98
|
|
|
99
|
+
Hidden/private page paths stay private: any `site/pages` asset below a path segment starting with `.`, `-`, or `_` is skipped, and symlinked page assets are never copied. Keep drafts, private notes, and local secrets out of public output by naming them like routes: `.env`, `_private/secret.txt`, or `-draft/notes.txt`.
|
|
100
|
+
|
|
98
101
|
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.
|
|
99
102
|
|
|
100
103
|
## Loops
|
|
@@ -487,18 +490,34 @@ Fetched data and a form live happily on the same page:
|
|
|
487
490
|
|
|
488
491
|
:form into profile
|
|
489
492
|
:input name placeholder="Your name" required
|
|
493
|
+
:select topic
|
|
494
|
+
- General
|
|
495
|
+
- Billing
|
|
496
|
+
:textarea note placeholder="Anything else?"
|
|
490
497
|
:submit "Save"
|
|
491
498
|
:endform
|
|
492
499
|
|
|
493
500
|
:state cart = [] persist
|
|
494
501
|
```
|
|
495
502
|
|
|
496
|
-
- `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement.
|
|
503
|
+
- `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement. Form actions use the same URL scheme guard as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
|
|
497
504
|
- `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.
|
|
505
|
+
- Field directives: `:input`, `:textarea name [rows=N]`, and `:select name` (with `- Label` option lines) all capture into `:form into` state the same way. Each derives a non-visual `aria-label` from its placeholder, else a humanized field name, unless you supply `aria-label`/`aria-describedby`.
|
|
498
506
|
- `:state x = [] persist` keeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for [`:store`](#global-state--store) instead.)
|
|
499
507
|
- `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
|
|
500
508
|
- `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
|
|
501
509
|
|
|
510
|
+
## Inline attributes
|
|
511
|
+
|
|
512
|
+
A trailing `{.class .class #id}` attaches classes / an id to the inline element directly before it — most often to style a link as a button without a wrapper:
|
|
513
|
+
|
|
514
|
+
```wd
|
|
515
|
+
[Get started](/start/){.btn .lg}
|
|
516
|
+
{.brand}
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
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 `#`.
|
|
520
|
+
|
|
502
521
|
## The escape hatch
|
|
503
522
|
|
|
504
523
|
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`.
|
|
@@ -517,7 +536,7 @@ Darkmown is a **trusted-author** site generator: you compile content you wrote,
|
|
|
517
536
|
|
|
518
537
|
- **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.
|
|
519
538
|
- **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.
|
|
520
|
-
- **`:fetch`
|
|
539
|
+
- **`: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).
|
|
521
540
|
|
|
522
541
|
> **The single biggest footgun:** do not compile untrusted or user-submitted Markdown without sanitizing it first. There is no built-in sanitizer.
|
|
523
542
|
|
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`
|
|
19
|
+
3. **`:fetch` and `:form action=` have no host allowlist, and reactive pages require `unsafe-eval`.** A `:fetch`, `refresh=`, or `:form action=` 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/exfiltration 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
|
|
|
@@ -27,6 +27,7 @@ Things Darkmown deliberately guards at compile time and runtime:
|
|
|
27
27
|
- **No arbitrary JavaScript in content files.** Directive actions (`:button -> …`), `:computed` expressions, and `@loop … where` predicates compile through strict whitelisted grammars — only item paths, declared `:state`, numbers, and strings are allowed. Assignment, function calls, and unknown syntax are compile errors. Raw user content is never `eval`'d; what runs at runtime is the *validated* expression, compiled to the whitelisted grammar and executed via `new Function` (see `src/runtime.js`).
|
|
28
28
|
- **Prototype-walk protection.** Path lookups (`{ a.b.c }`, computed expressions) reject `constructor`, `prototype`, and `__proto__` segments in both the compiler and the runtime.
|
|
29
29
|
- **Include sandboxing.** `@include` and `@loop` data sources must resolve inside `site/pages` or `site/_`; traversal outside the site tree is a compile error, and include cycles are detected.
|
|
30
|
+
- **Private page assets stay private.** Page-colocated assets under `site/pages` are copied only when their relative path has no hidden segment (`.`, `-`, or `_`) and is not a symlink. This keeps `.env`, `_private/*`, `-draft/*`, and symlink-to-outside files out of `dist`.
|
|
30
31
|
- **Output escaping.** Interpolated values are HTML-escaped; state scripts escape `<` to prevent script-tag breakout.
|
|
31
32
|
- **Static server path containment.** The dev/preview servers resolve requests strictly inside `dist`.
|
|
32
33
|
|
package/docs/spec-alignment.md
CHANGED
|
@@ -73,6 +73,18 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
73
73
|
|
|
74
74
|
- **Richer `:if` / `:else if` conditions:** a conditional now accepts the same compile-time-validated predicate grammar as `.class when` (its sibling through the shared `compileWhen`) — instead of a bare truthy path only. Operands are item paths, declared `:state`/`:store`, numbers, `"strings"`, `true`/`false`/`null`; comparisons `==` `!=` `<` `<=` `>` `>=` and `contains`; joined with `and` / `or` and negated with `not`. A bare path still reads as truthy (the fast path is unchanged). (`@loop … where` stays a comparison-only subset — each condition needs an operator; it has no `not` or bare-truthy form. Unifying it is deferred, not done here.) Reactivity is decided exactly as before: a predicate over only static values folds the branch at build time (`runtime: false` preserved); one over `:state` emits a global `data-wd-if` region carrying `data-wd-if-expr`; one over a loop item emits a per-row `data-wd-each-if` with `data-wd-if-expr`. The expression compiles to the **same** whitelisted grammar over `I()`/`S()`/`C()` and runs via `new Function` through the shared `evalPredicate` runtime helper — no new eval surface, and `constructor`/`prototype`/`__proto__` segments stay rejected in compiler and runtime. `:else if` chains inherit this automatically (each desugared condition re-enters the same path). **Honest caveat:** there is no parenthesis grouping — `and`/`or` associate left-to-right with no precedence between them, so model intent explicitly (split into `:else if` branches or a `:computed` boolean when grouping matters).
|
|
75
75
|
|
|
76
|
+
## Stage 12 additions (2026-06-23, v0.15.0)
|
|
77
|
+
|
|
78
|
+
- **`:textarea` and `:select` form controls:** `:textarea name [placeholder=…] [rows=N] [required]` and `:select name [required]` (with `- Label` option lines) join `:input` as field directives. Both capture into `:form into` state through the runtime's `FormData` with no runtime change, and both derive an accessible name (placeholder, else humanized field name) like `:input`. Closes the gap where multi-line and dropdown fields forced raw HTML.
|
|
79
|
+
- **Interpolation in link/image destinations:** `{ name.path }` now resolves inside a markdown destination — `[{ it.label }]({ it.url })`, `` — so a build-time `@loop` can drive `href`/`src` and the page stays static. Only build-time (static-scope) values substitute; reactive `:state` is left untouched (it cannot live in a destination).
|
|
80
|
+
- **Inline attributes (`{.class #id}`):** a trailing attribute block attaches classes / an id to the inline element before it (link, image, emphasis, code), so a link can be styled as a button without a wrapper container. The block must follow with no space; it never collides with `{ name }` interpolation (which always starts with a name, not `.`/`#`).
|
|
81
|
+
|
|
82
|
+
## Stage 11 additions (2026-06-19, v0.14.1)
|
|
83
|
+
|
|
84
|
+
- **Release hardening — page asset privacy:** page-colocated assets now honor the same hidden-path convention as routes. A file under `site/pages` is emitted only when no relative path segment starts with `.`, `-`, or `_`; symlinked page assets are skipped too. This preserves normal colocated media (`logo.svg`, `blog/cover.png`) while preventing accidental publication of `.env`, `_private/*`, `-draft/*`, or symlink-to-outside files.
|
|
85
|
+
- **Release hardening — form action URLs:** `:form action=` now uses the same scheme validation as `:fetch` and `refresh=`. Relative URLs, explicit `http(s)://`, and leading `{ state }` interpolation are allowed; protocol-relative URLs and non-http(s) schemes are compile errors. There is still no host allowlist — backend trust remains the author's deployment concern.
|
|
86
|
+
- **Release hygiene:** root `package-lock.json` package metadata is synced to the release version so tarball review and reproducible install checks do not see stale package identity.
|
|
87
|
+
|
|
76
88
|
## Stage 9 additions (2026-06-18, v0.13.0)
|
|
77
89
|
|
|
78
90
|
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).
|
|
@@ -81,7 +93,7 @@ Four progressive-disclosure capabilities plus an assets and a security change. T
|
|
|
81
93
|
- **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`.
|
|
82
94
|
- **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=`.
|
|
83
95
|
- **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.
|
|
84
|
-
- **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.
|
|
96
|
+
- **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. As of v0.14.1, hidden path segments (`.`, `-`, `_`) and symlinked page assets are skipped. `.avif` was added to the served content-types.
|
|
85
97
|
- **`: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).
|
|
86
98
|
|
|
87
99
|
## Demo-only directives (not part of the public surface)
|
package/package.json
CHANGED
package/site/pages/data.wd
CHANGED
|
@@ -148,4 +148,36 @@ The list below uses `when=visible` — the request only fires once this section
|
|
|
148
148
|
Scroll me into view and I will load.
|
|
149
149
|
:endif
|
|
150
150
|
|
|
151
|
+
## Richer form controls
|
|
152
|
+
|
|
153
|
+
`:textarea` and `:select` join `:input`, all captured into the same `:form into` state with no backend:
|
|
154
|
+
|
|
155
|
+
:form into inquiry
|
|
156
|
+
:input fullname placeholder="Your full name"
|
|
157
|
+
:select topic
|
|
158
|
+
- General question
|
|
159
|
+
- New case
|
|
160
|
+
- Billing
|
|
161
|
+
:textarea message placeholder="How can we help?"
|
|
162
|
+
:submit "Send inquiry"
|
|
163
|
+
:endform
|
|
164
|
+
|
|
165
|
+
:if inquiry
|
|
166
|
+
Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note.
|
|
167
|
+
:else
|
|
168
|
+
Pick a topic and send — it is captured client-side, no backend.
|
|
169
|
+
:endif
|
|
170
|
+
|
|
171
|
+
## Dynamic links from data, and link buttons
|
|
172
|
+
|
|
173
|
+
A build-time `@loop` can drive the `href` itself — `[{ link.label }]({ link.url })` resolves the destination at compile time, so these stay plain static links:
|
|
174
|
+
|
|
175
|
+
@loop /links.json into link
|
|
176
|
+
- [{ link.label }]({ link.url })
|
|
177
|
+
@endloop
|
|
178
|
+
|
|
179
|
+
A trailing `{.class}` styles a single link as a button — no wrapper container:
|
|
180
|
+
|
|
181
|
+
[Read the docs](/docs/){.pulse-button}
|
|
182
|
+
|
|
151
183
|
</main>
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -307,7 +307,7 @@ You have { cart } items.
|
|
|
307
307
|
|
|
308
308
|
## Forms and persistence
|
|
309
309
|
|
|
310
|
-
- `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead.
|
|
310
|
+
- `:form into name` captures submits straight into state — no backend. `:form action="/url"` emits a plain native form with zero JS instead. Form actions follow the same scheme rules as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
|
|
311
311
|
- `:input field placeholder="…" required` and `:submit "Label"` build the form body.
|
|
312
312
|
- `:state cart = [] persist` survives reloads via localStorage on a single page; use `:store` for state shared across pages and tabs.
|
|
313
313
|
- `:if item.path` works inside reactive loops for per-row branches, and conditionals nest — an inner `:if` resolves after the outer branch, staying reactive.
|
|
@@ -321,7 +321,7 @@ Darkmown is a trusted-author site generator: you compile content you wrote, the
|
|
|
321
321
|
|
|
322
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.
|
|
323
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.
|
|
324
|
-
- **`:fetch`
|
|
324
|
+
- **`:fetch` and `:form action=` have no host allowlist, and reactive pages need `unsafe-eval`.** Fetch/form URLs come 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. 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.
|
|
325
325
|
|
|
326
326
|
See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in the package for the full security model.
|
|
327
327
|
|
|
@@ -330,7 +330,7 @@ See [SECURITY.md](https://github.com/ZVN-DEV/darkmown/blob/main/SECURITY.md) in
|
|
|
330
330
|
- A matching `.skin` file attaches CSS to the page (indentation-based, compiles to real CSS).
|
|
331
331
|
- A matching `.js` file attaches page behavior.
|
|
332
332
|
- Both work for included fragments too, by basename.
|
|
333
|
-
- Any other file under `site/pages/` (an image, font, PDF — anything that isn't a `.md`/`.wd` route or a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type. So `site/pages/logo.svg` is served at `/logo.svg`. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
|
|
333
|
+
- Any other file under `site/pages/` (an image, font, PDF — anything that isn't a `.md`/`.wd` route or a basename-matched `.skin`/`.js`) copies to `dist/` at its own path with the right content-type. So `site/pages/logo.svg` is served at `/logo.svg`. Assets below hidden path segments (`.`, `-`, or `_`) and symlinked page assets are skipped, matching the routing privacy rule. Use the `site/_/` shelf for assets shared across pages; colocate the ones a single page owns.
|
|
334
334
|
|
|
335
335
|
## Spec status
|
|
336
336
|
|
package/src/builder.js
CHANGED
|
@@ -190,6 +190,7 @@ function emitPageAssets(paths) {
|
|
|
190
190
|
const ext = path.extname(file).toLowerCase();
|
|
191
191
|
if ([".md", ".wd", ".skin", ".js"].includes(ext)) continue;
|
|
192
192
|
const rel = path.relative(paths.routesRoot, file);
|
|
193
|
+
if (hasHiddenPathSegment(rel) || fs.lstatSync(file).isSymbolicLink()) continue;
|
|
193
194
|
const out = path.join(paths.distRoot, rel);
|
|
194
195
|
if (fs.existsSync(out)) {
|
|
195
196
|
console.warn(`hint: page asset "${rel}" skipped — a built route already emits /${rel.replaceAll(path.sep, "/")}`);
|
|
@@ -200,6 +201,17 @@ function emitPageAssets(paths) {
|
|
|
200
201
|
}
|
|
201
202
|
}
|
|
202
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Match the router's hidden-path convention for page-colocated assets too:
|
|
206
|
+
* anything below a `.`, `-`, or `_` segment is private/draft framework input,
|
|
207
|
+
* not public output.
|
|
208
|
+
* @param {string} rel
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
function hasHiddenPathSegment(rel) {
|
|
212
|
+
return rel.split(path.sep).some((segment) => /^[._-]/.test(segment));
|
|
213
|
+
}
|
|
214
|
+
|
|
203
215
|
/**
|
|
204
216
|
* @param {string} dir
|
|
205
217
|
* @returns {string[]}
|
package/src/compiler.js
CHANGED
|
@@ -77,6 +77,7 @@ import MarkdownIt from "markdown-it";
|
|
|
77
77
|
|
|
78
78
|
const md = new MarkdownIt({ html: true });
|
|
79
79
|
md.use(bindingPlugin);
|
|
80
|
+
md.use(attrsPlugin);
|
|
80
81
|
|
|
81
82
|
// Raw HTML in markdown passes through by default (a documented design choice).
|
|
82
83
|
// Pages can opt out with frontmatter `html: false` for untrusted content; the
|
|
@@ -94,6 +95,7 @@ function selectMd(meta) {
|
|
|
94
95
|
if (!mdNoHtml) {
|
|
95
96
|
mdNoHtml = new MarkdownIt({ html: false });
|
|
96
97
|
mdNoHtml.use(bindingPlugin);
|
|
98
|
+
mdNoHtml.use(attrsPlugin);
|
|
97
99
|
}
|
|
98
100
|
return mdNoHtml;
|
|
99
101
|
}
|
|
@@ -394,6 +396,20 @@ function compileBody(lines, ctx) {
|
|
|
394
396
|
out.push(handleInput(line, ctx));
|
|
395
397
|
continue;
|
|
396
398
|
}
|
|
399
|
+
if (/^:textarea\s/.test(line)) {
|
|
400
|
+
flush();
|
|
401
|
+
out.push(handleTextarea(line, ctx));
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (/^:select\s/.test(line)) {
|
|
405
|
+
flush();
|
|
406
|
+
const opts = [];
|
|
407
|
+
let j = i + 1;
|
|
408
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
|
|
409
|
+
out.push(handleSelect(line, opts, ctx));
|
|
410
|
+
i = j - 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
397
413
|
if (/^:bind\s/.test(line)) {
|
|
398
414
|
flush();
|
|
399
415
|
out.push(handleBind(line, ctx));
|
|
@@ -987,7 +1003,7 @@ function compileComputedExpr(raw, ctx) {
|
|
|
987
1003
|
*/
|
|
988
1004
|
function handleForm(line, bodyLines, ctx) {
|
|
989
1005
|
let rest = line.slice(":form".length).trim();
|
|
990
|
-
const
|
|
1006
|
+
const rawAction = rest.match(/action="([^"]+)"/)?.[1];
|
|
991
1007
|
const method = rest.match(/method="([^"]+)"/)?.[1] || "post";
|
|
992
1008
|
const into = rest.match(/(?:^|\s)into\s+([A-Za-z_$][\w$]*)/)?.[1];
|
|
993
1009
|
const leftover = rest
|
|
@@ -995,11 +1011,12 @@ function handleForm(line, bodyLines, ctx) {
|
|
|
995
1011
|
.replace(/method="[^"]+"/, "")
|
|
996
1012
|
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
997
1013
|
.trim();
|
|
998
|
-
if ((!
|
|
1014
|
+
if ((!rawAction && !into) || leftover) {
|
|
999
1015
|
throw new Error(
|
|
1000
1016
|
`Malformed :form in ${ctx.file}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
|
|
1001
1017
|
);
|
|
1002
1018
|
}
|
|
1019
|
+
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
1003
1020
|
const inner = compileBody(bodyLines, ctx).trim();
|
|
1004
1021
|
if (!into) {
|
|
1005
1022
|
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
|
|
@@ -1110,6 +1127,85 @@ function handleSubmit(line, ctx) {
|
|
|
1110
1127
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
1111
1128
|
}
|
|
1112
1129
|
|
|
1130
|
+
/**
|
|
1131
|
+
* `:textarea name [placeholder="…"] [rows=N] [required]` → a `<textarea>`. Like
|
|
1132
|
+
* `:input`, it derives a non-visual aria-label from the placeholder (else the
|
|
1133
|
+
* humanized name) when the author supplies none. Captured by the runtime's
|
|
1134
|
+
* FormData exactly like `:input` — no runtime change needed.
|
|
1135
|
+
* @param {string} line
|
|
1136
|
+
* @param {Ctx} ctx
|
|
1137
|
+
* @returns {string}
|
|
1138
|
+
*/
|
|
1139
|
+
function handleTextarea(line, ctx) {
|
|
1140
|
+
const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1141
|
+
if (!match) throw new Error(`Malformed :textarea in ${ctx.file}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`);
|
|
1142
|
+
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1143
|
+
let placeholder;
|
|
1144
|
+
let hasAria = false;
|
|
1145
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1146
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
1147
|
+
if (token[3]) {
|
|
1148
|
+
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
1149
|
+
throw new Error(`Unknown :textarea flag "${token[3]}" in ${ctx.file}`);
|
|
1150
|
+
}
|
|
1151
|
+
attrs.push(token[3]);
|
|
1152
|
+
continue;
|
|
1153
|
+
}
|
|
1154
|
+
const value = stripQuotes(token[2]);
|
|
1155
|
+
if (!["placeholder", "rows", "cols", "minlength", "maxlength", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1156
|
+
throw new Error(`Unknown :textarea attribute "${token[1]}" in ${ctx.file}`);
|
|
1157
|
+
}
|
|
1158
|
+
if (token[1] === "placeholder") placeholder = value;
|
|
1159
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1160
|
+
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1161
|
+
}
|
|
1162
|
+
if (!hasAria) {
|
|
1163
|
+
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
1164
|
+
}
|
|
1165
|
+
return `<textarea ${attrs.join(" ")}></textarea>`;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* `:select name [required]` followed by `- Label` list lines → a `<select>` with
|
|
1170
|
+
* one `<option>` per label (value === label). Derives an aria-label from the
|
|
1171
|
+
* humanized name when none is given. Captured by FormData like the other fields.
|
|
1172
|
+
* @param {string} line
|
|
1173
|
+
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
1174
|
+
* @param {Ctx} ctx
|
|
1175
|
+
* @returns {string}
|
|
1176
|
+
*/
|
|
1177
|
+
function handleSelect(line, optionLines, ctx) {
|
|
1178
|
+
const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1179
|
+
if (!match) throw new Error(`Malformed :select in ${ctx.file}: ${line}. Use: :select name [required] then "- Label" lines`);
|
|
1180
|
+
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1181
|
+
let hasAria = false;
|
|
1182
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1183
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
1184
|
+
if (token[3]) {
|
|
1185
|
+
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
1186
|
+
throw new Error(`Unknown :select flag "${token[3]}" in ${ctx.file}`);
|
|
1187
|
+
}
|
|
1188
|
+
attrs.push(token[3]);
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
const value = stripQuotes(token[2]);
|
|
1192
|
+
if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1193
|
+
throw new Error(`Unknown :select attribute "${token[1]}" in ${ctx.file}`);
|
|
1194
|
+
}
|
|
1195
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1196
|
+
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1197
|
+
}
|
|
1198
|
+
if (!hasAria) {
|
|
1199
|
+
attrs.push(`aria-label="${escapeHtml(humanizeName(match[1]))}"`);
|
|
1200
|
+
}
|
|
1201
|
+
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
1202
|
+
if (!options.length) {
|
|
1203
|
+
throw new Error(`:select "${match[1]}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
|
|
1204
|
+
}
|
|
1205
|
+
const opts = options.map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
|
|
1206
|
+
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1113
1209
|
/**
|
|
1114
1210
|
* @param {string} line
|
|
1115
1211
|
* @param {Ctx} ctx
|
|
@@ -1877,9 +1973,51 @@ function validateDemoHref(href, ctx) {
|
|
|
1877
1973
|
* @returns {string}
|
|
1878
1974
|
*/
|
|
1879
1975
|
function renderProse(text, ctx) {
|
|
1976
|
+
text = resolveDestinationBindings(text, ctx);
|
|
1880
1977
|
return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
|
|
1881
1978
|
}
|
|
1882
1979
|
|
|
1980
|
+
/**
|
|
1981
|
+
* Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
|
|
1982
|
+
* destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
|
|
1983
|
+
* can drive an href/src. A markdown destination cannot contain the spaces inside
|
|
1984
|
+
* `{ … }`, so markdown-it would otherwise never form the link; substituting the
|
|
1985
|
+
* static value here lets the normal link/image parser run. Only build-time
|
|
1986
|
+
* (static-scope) values are substituted — reactive `:state`/reactive-loop
|
|
1987
|
+
* bindings resolve to `null` and are left untouched (they cannot live in a
|
|
1988
|
+
* destination). Issue #19.
|
|
1989
|
+
* @param {string} text
|
|
1990
|
+
* @param {Ctx} ctx
|
|
1991
|
+
* @returns {string}
|
|
1992
|
+
*/
|
|
1993
|
+
function resolveDestinationBindings(text, ctx) {
|
|
1994
|
+
return text.replace(
|
|
1995
|
+
/(\]\(\s*)\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
|
|
1996
|
+
(whole, prefix, expr) => {
|
|
1997
|
+
const raw = resolveBindingRaw(expr, ctx);
|
|
1998
|
+
return raw == null ? whole : prefix + raw;
|
|
1999
|
+
}
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Resolve `{ expr }` to its raw static value (a plain string) for use in a
|
|
2005
|
+
* destination, or `null` when the binding is reactive or not in build-time
|
|
2006
|
+
* scope. Mirrors the static branch of {@link resolveBindingHtml} but returns the
|
|
2007
|
+
* unescaped value (markdown-it normalizes the URL).
|
|
2008
|
+
* @param {string} expr
|
|
2009
|
+
* @param {Ctx} ctx
|
|
2010
|
+
* @returns {string | null}
|
|
2011
|
+
*/
|
|
2012
|
+
function resolveBindingRaw(expr, ctx) {
|
|
2013
|
+
const segs = expr.split(".");
|
|
2014
|
+
const found = lookupVar(ctx.scope, segs[0]);
|
|
2015
|
+
if (!found.found) return null;
|
|
2016
|
+
const resolved = getPath(found.value, segs.slice(1));
|
|
2017
|
+
const leaf = interpolateLeaf(resolved, expr, ctx);
|
|
2018
|
+
return leaf == null ? null : String(leaf);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1883
2021
|
/**
|
|
1884
2022
|
* markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
|
|
1885
2023
|
* whose content comes from the env's `resolveBinding` callback.
|
|
@@ -1903,6 +2041,63 @@ function bindingPlugin(mdInstance) {
|
|
|
1903
2041
|
});
|
|
1904
2042
|
}
|
|
1905
2043
|
|
|
2044
|
+
/**
|
|
2045
|
+
* markdown-it plugin (core rule): a `{.class .class #id}` block immediately
|
|
2046
|
+
* following an inline element (link, image, em/strong, code) attaches those
|
|
2047
|
+
* classes / id to that element. Lets a link be styled as a button without a
|
|
2048
|
+
* wrapper container. The block must directly follow the element (no space), and
|
|
2049
|
+
* `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
|
|
2050
|
+
* Issue #18.
|
|
2051
|
+
* @param {MarkdownIt} mdInstance
|
|
2052
|
+
* @returns {void}
|
|
2053
|
+
*/
|
|
2054
|
+
function attrsPlugin(mdInstance) {
|
|
2055
|
+
mdInstance.core.ruler.push("wd_attrs", (state) => {
|
|
2056
|
+
for (const block of state.tokens) {
|
|
2057
|
+
if (block.type !== "inline" || !block.children) continue;
|
|
2058
|
+
const children = block.children;
|
|
2059
|
+
for (let i = 0; i < children.length; i++) {
|
|
2060
|
+
const tok = children[i];
|
|
2061
|
+
if (tok.type !== "text") continue;
|
|
2062
|
+
const m = /^\{([.#][^}]*)\}/.exec(tok.content);
|
|
2063
|
+
if (!m) continue;
|
|
2064
|
+
const target = attrTarget(children, i);
|
|
2065
|
+
if (!target) continue;
|
|
2066
|
+
for (const part of m[1].split(/\s+/).filter(Boolean)) {
|
|
2067
|
+
if (part[0] === ".") target.attrJoin("class", part.slice(1));
|
|
2068
|
+
else if (part[0] === "#") target.attrSet("id", part.slice(1));
|
|
2069
|
+
}
|
|
2070
|
+
tok.content = tok.content.slice(m[0].length);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
return false;
|
|
2074
|
+
});
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* The inline token an attr block attaches to: the immediately-preceding image
|
|
2079
|
+
* (self-closing) or the open token matching the immediately-preceding close
|
|
2080
|
+
* (link/em/strong/…). Returns null when nothing valid precedes.
|
|
2081
|
+
* @param {any[]} children markdown-it inline child tokens
|
|
2082
|
+
* @param {number} i Index of the attr-block text token.
|
|
2083
|
+
* @returns {any} the matching markdown-it Token, or null
|
|
2084
|
+
*/
|
|
2085
|
+
function attrTarget(children, i) {
|
|
2086
|
+
const prev = children[i - 1];
|
|
2087
|
+
if (!prev) return null;
|
|
2088
|
+
if (prev.type === "image") return prev;
|
|
2089
|
+
if (prev.nesting === -1) {
|
|
2090
|
+
const openType = prev.type.replace(/_close$/, "_open");
|
|
2091
|
+
let depth = 0;
|
|
2092
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
2093
|
+
const t = children[j];
|
|
2094
|
+
if (t.type === prev.type) depth++;
|
|
2095
|
+
else if (t.type === openType) { depth--; if (depth === 0) return t; }
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
return null;
|
|
2099
|
+
}
|
|
2100
|
+
|
|
1906
2101
|
/**
|
|
1907
2102
|
* Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
|
|
1908
2103
|
* a reactive bind span. Returns null when nothing in scope matches.
|