@zvndev/darkmown 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +4 -1
- package/CHANGELOG.md +17 -0
- package/README.md +10 -2
- package/docs/spec-alignment.md +10 -1
- package/package.json +2 -1
- package/site/pages/data.wd +9 -2
- package/site/pages/docs/index.wd +9 -0
- package/site/pages/index.wd +1 -0
- package/site/pages/loops.wd +1 -0
- package/site/pages/markdown.md +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/store.wd +1 -0
- package/src/compiler.js +187 -6
- package/src/runtime.js +8 -1
package/AGENTS.md
CHANGED
|
@@ -156,13 +156,16 @@ Your `.skin` selectors must match the **real** output. The emitted HTML is:
|
|
|
156
156
|
| `::: name .card … :::` (non-`section`) | `<div class="card">…</div>` |
|
|
157
157
|
| `:button "x" -> …` | `<button>x</button>` |
|
|
158
158
|
| `:input email …` | `<input type="…">` |
|
|
159
|
+
| `:textarea note …` | `<textarea>` |
|
|
160
|
+
| `:select name` + `- Label` lines | `<select>` of `<option>`s |
|
|
161
|
+
| `:checkbox name` / `:radio name` + `- Label` lines | labelled `<input type=checkbox\|radio>` group (`:checkbox` captures an **array**, `:radio` one value) |
|
|
159
162
|
| `:bind q placeholder="…"` | `<input>` bound two-way to `:state q` |
|
|
160
163
|
| `:submit "Go"` | `<button type="submit">Go</button>` |
|
|
161
164
|
| `:form …` | `<form>…</form>` |
|
|
162
165
|
| reactive `@loop` of a list | `<ul>`/`<ol>` (or `<div>`) of items |
|
|
163
166
|
| `{ name }` | a text node |
|
|
164
167
|
|
|
165
|
-
So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class
|
|
168
|
+
So style with real selectors: `section`, `.card`, `button`, `input`, `form`, `h1`. **To add a class to a container, use `::: name .class`; to add one to a single inline element (link, image, emphasis, inline code), use a trailing `{.class .class #id}` — e.g. `[Get started](/start/){.btn}`.** A container class can also be reactive: `::: card .on-sale when p.price < 50` toggles the class from a predicate (same grammar as `:if`).
|
|
166
169
|
|
|
167
170
|
## Styling with `.skin` — ship this on every page
|
|
168
171
|
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
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.18.0 — 2026-06-24
|
|
6
|
+
|
|
7
|
+
Navigation goes instant and flash-free, and images are hardened at compile time.
|
|
8
|
+
|
|
9
|
+
- **Prerendered, gap-free navigation.** `transitions: true` now also emits a declarative `<script type="speculationrules">` that **prerenders** same-origin links on hover/pointerdown (eagerness `moderate`). The next page is fully rendered before the click, so activation is instant — eliminating the white render-gap flash that plain navigation (and even prefetch, which only warms the cache) leaves for the view transition to paper over. It is browser-interpreted JSON, not framework runtime JS, so static pages stay zero-JS; unsupported browsers (or those with preloading disabled) ignore it and navigate normally. Mark a link `{.no-prefetch}` to opt it out; `rel=nofollow` links are never speculated. (Chrome disables prerendering while DevTools is open — verify on the built site with DevTools closed.)
|
|
10
|
+
- **Directional view transition.** The root transition is no longer the UA cross-fade, which left both pages superimposed at ~50 % opacity mid-navigation — a visible double-exposure ghost. `transitions: true` now emits a short directional fade+slide (old lifts up and out, new rises up and in, 200 ms eased) so pages move past each other instead of stacking. Honors `prefers-reduced-motion`.
|
|
11
|
+
- **Compile-time image hardening.** Every `<img>` is stamped with its intrinsic `width`/`height` (read from the file on disk via `image-size`, a new build-time dependency — no runtime cost), `decoding="async"`, and a load-priority split: the first image stays eager with `fetchpriority="high"` (the LCP candidate), the rest get `loading="lazy"`. This removes the layout-shift "jump" as images decode and stops below-the-fold images competing for bandwidth. Author-set attributes are never overwritten; remote or unreadable sources degrade gracefully (no dimensions, no error).
|
|
12
|
+
- All three are compile-time only — the shipped reactive runtime is unchanged (still ~5.8 KB gzipped, under the 6 KB budget). The demo site (`site/pages/`) now opts into `transitions: true` to dogfood the navigation.
|
|
13
|
+
|
|
14
|
+
## 0.17.0 — 2026-06-23
|
|
15
|
+
|
|
16
|
+
Multi-select and single-choice form groups: **`:checkbox` and `:radio`**.
|
|
17
|
+
|
|
18
|
+
- **`:checkbox name` and `:radio name`** (each followed by `- Label` option lines) render a labelled group of `<input>`s that share one `name`, wrapped in `<label>`s inside a `role="group"` / `role="radiogroup"` container with a derived aria-label. They join `:input`/`:textarea`/`:select` as `:form into` field directives.
|
|
19
|
+
- **Checkbox groups capture an array.** A `:checkbox` group is marked `data-wd-multi`, and the runtime's submit handler now collects every checked value via `FormData.getAll` instead of collapsing duplicate names to the last one. A `:radio` group captures a single value like `:input`. This is the only runtime change (shipped reactive runtime is 5836 B gzipped, still under the 6 KB budget); everything else is compile-time.
|
|
20
|
+
- Live demo on `/data/` (the inquiry form), and the AGENTS.md inline-`{.class}` note is corrected (it had stale "no `{.class}` syntax" copy from before 0.15.0).
|
|
21
|
+
|
|
5
22
|
## 0.16.0 — 2026-06-23
|
|
6
23
|
|
|
7
24
|
Cross-document **view transitions** return as a per-page opt-in.
|
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
82
82
|
|
|
83
83
|
Three keys also drive the document `<head>`: `title` sets `<title>`, `description` adds the meta description plus Open Graph / Twitter tags, and `image` (an absolute URL) sets the social-share preview (`og:image` / `twitter:image` and a `summary_large_image` card).
|
|
84
84
|
|
|
85
|
-
`transitions: true` opts a page into **
|
|
85
|
+
`transitions: true` opts a page into **instant, flash-free navigation** — zero JavaScript, all declarative. It emits a directional fade+slide **view transition** for the page swap (old lifts up and out, new rises up and in — replacing the default cross-fade, which left both pages ghosted at ~50 % opacity mid-navigation), plus a **`<script type="speculationrules">` prerender** hint that renders the next same-origin page on hover/pointerdown so the click activates an already-painted page (no white render-gap flash). It honors `prefers-reduced-motion`. Only same-origin pages that both opt in transition; browsers without support — or with page-preloading disabled — navigate normally. Off by default; opt out with `transitions: false`. Mark a link `{.no-prefetch}` to exclude it from prerendering. (Chrome disables prerendering while DevTools is open, so test the built site with DevTools closed.)
|
|
86
86
|
|
|
87
87
|
## Static assets
|
|
88
88
|
|
|
@@ -93,6 +93,8 @@ Images, fonts, icons, and other non-page files live on the include shelf at `sit
|
|
|
93
93
|
|
|
94
94
|
Reference them with a normal URL: ``. The preview/`serve` layer returns the correct `Content-Type` per extension.
|
|
95
95
|
|
|
96
|
+
**Images are hardened at compile time.** Every `<img>` the compiler emits is stamped with its intrinsic `width`/`height` (read from the source file, so the browser reserves space and the page doesn't reflow as images decode), `decoding="async"`, and a load-priority split — the first image on the page stays eager with `fetchpriority="high"` (the LCP candidate), the rest get `loading="lazy"`. Author-set attributes always win, and remote or unreadable sources just skip the dimensions. The compiler **measures**, it doesn't resize — keep source images web-sized (modern formats, sensible dimensions).
|
|
97
|
+
|
|
96
98
|
**Or colocate an asset next to the page that uses it.** Any non-page file under `site/pages/` (anything but `.md`/`.wd` routes and the colocated `.skin`/`.js`) is copied to `dist/` at its own path, so a clean relative URL just works:
|
|
97
99
|
|
|
98
100
|
- `site/pages/logo.svg` → `dist/logo.svg` → ``
|
|
@@ -495,6 +497,12 @@ Fetched data and a form live happily on the same page:
|
|
|
495
497
|
:select topic
|
|
496
498
|
- General
|
|
497
499
|
- Billing
|
|
500
|
+
:checkbox channels
|
|
501
|
+
- Email
|
|
502
|
+
- SMS
|
|
503
|
+
:radio plan
|
|
504
|
+
- Basic
|
|
505
|
+
- Pro
|
|
498
506
|
:textarea note placeholder="Anything else?"
|
|
499
507
|
:submit "Save"
|
|
500
508
|
:endform
|
|
@@ -504,7 +512,7 @@ Fetched data and a form live happily on the same page:
|
|
|
504
512
|
|
|
505
513
|
- `: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.
|
|
506
514
|
- `: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.
|
|
507
|
-
- Field directives: `:input`, `:textarea name [rows=N]`, and `:
|
|
515
|
+
- Field directives: `:input`, `:textarea name [rows=N]`, `:select name`, `:checkbox name`, and `:radio name` (the last three take `- Label` option lines) all capture into `:form into` state the same way. A `:checkbox` group captures **every checked value as an array**; a `:radio` group captures a single value. Each derives a non-visual `aria-label` from its placeholder, else a humanized field name, unless you supply `aria-label`/`aria-describedby`.
|
|
508
516
|
- `: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.)
|
|
509
517
|
- `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
|
|
510
518
|
- `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -79,6 +79,15 @@ Three capability gaps from the 2026-06-16 audit, closed together over a shared `
|
|
|
79
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
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
81
|
|
|
82
|
+
## Stage 14 additions (2026-06-24, v0.18.0)
|
|
83
|
+
|
|
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
|
+
- **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
|
+
|
|
87
|
+
## Stage 13 additions (2026-06-23, v0.17.0)
|
|
88
|
+
|
|
89
|
+
- **`: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.
|
|
90
|
+
|
|
82
91
|
## Stage 11 additions (2026-06-19, v0.14.1)
|
|
83
92
|
|
|
84
93
|
- **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.
|
|
@@ -103,7 +112,7 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
|
|
|
103
112
|
## Still missing from the full vision
|
|
104
113
|
|
|
105
114
|
- First-party server runtime (`site/api/`), HTML-fragment `swap` semantics, and cart server sync — parked behind the adapter decision until a real project needs them.
|
|
106
|
-
- **View transitions:** named/typed transitions (`view-transition-name`, per-element morphs) and `@starting-style` emission on top of the
|
|
115
|
+
- **View transitions:** named/typed transitions (`view-transition-name`, per-element morphs) and `@starting-style` emission, on top of the directional fade + speculation-rules prerender shipped in 0.18.0 (see Stage 4 / Stage 14).
|
|
107
116
|
- Richer diagnostics and package-consumer QA in regular CI. Editor tooling exists as a source-installable VS Code extension; public Marketplace/Open VSX distribution remains a launch/distribution follow-up.
|
|
108
117
|
|
|
109
118
|
## Current claim
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zvndev/darkmown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"node": ">=20"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
+
"image-size": "^1.2.1",
|
|
45
46
|
"markdown-it": "^14.2.0"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
package/site/pages/data.wd
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Data, forms, and persistence
|
|
3
|
+
transitions: true
|
|
3
4
|
---
|
|
4
5
|
|
|
5
6
|
@include /nav.wd
|
|
@@ -150,7 +151,7 @@ Scroll me into view and I will load.
|
|
|
150
151
|
|
|
151
152
|
## Richer form controls
|
|
152
153
|
|
|
153
|
-
`:textarea
|
|
154
|
+
`:textarea`, `:select`, `:checkbox`, and `:radio` join `:input`, all captured into the same `:form into` state with no backend. A `:checkbox` group captures every checked value as an **array**; a `:radio` group captures a single value:
|
|
154
155
|
|
|
155
156
|
:form into inquiry
|
|
156
157
|
:input fullname placeholder="Your full name"
|
|
@@ -158,12 +159,18 @@ Scroll me into view and I will load.
|
|
|
158
159
|
- General question
|
|
159
160
|
- New case
|
|
160
161
|
- Billing
|
|
162
|
+
:checkbox services
|
|
163
|
+
- Cleaning
|
|
164
|
+
- Whitening
|
|
165
|
+
:radio urgency
|
|
166
|
+
- Routine
|
|
167
|
+
- Urgent
|
|
161
168
|
:textarea message placeholder="How can we help?"
|
|
162
169
|
:submit "Send inquiry"
|
|
163
170
|
:endform
|
|
164
171
|
|
|
165
172
|
:if inquiry
|
|
166
|
-
Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note.
|
|
173
|
+
Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note. Services: { inquiry.services }. Urgency: { inquiry.urgency }.
|
|
167
174
|
:else
|
|
168
175
|
Pick a topic and send — it is captured client-side, no backend.
|
|
169
176
|
:endif
|
package/site/pages/docs/index.wd
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Darkmown docs
|
|
3
3
|
description: Learn Darkmown routing, directives, state, loops, forms, and the static-to-reactive model.
|
|
4
|
+
transitions: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
@include /nav.wd
|
|
@@ -164,6 +165,14 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
164
165
|
|
|
165
166
|
`{ meta.title }` prints a scalar and `{ meta.tags }` joins an array with `, `. You can loop a frontmatter array at build time — `@loop meta.tags into tag` — and the page stays static. Arrays are inline flow only (`[a, b]`); quoted items keep their commas.
|
|
166
167
|
|
|
168
|
+
## Instant navigation
|
|
169
|
+
|
|
170
|
+
Add `transitions: true` to a page's frontmatter for **instant, flash-free navigation** — zero JavaScript. It emits a directional fade+slide view transition for the page swap and a `<script type="speculationrules">` prerender hint, so the browser renders the next same-origin page on hover/pointerdown and the click activates an already-painted page (no white render-gap). It honors `prefers-reduced-motion`, and only same-origin pages that both opt in transition. Off by default; opt out with `transitions: false`, or exclude a single link from prerendering with `{.no-prefetch}`. (Chrome turns prerendering off while DevTools is open — test the built site with DevTools closed.)
|
|
171
|
+
|
|
172
|
+
## Images
|
|
173
|
+
|
|
174
|
+
You don't size images by hand. Every `<img>` the compiler emits gets its intrinsic `width`/`height` read from the source file (so the page doesn't reflow as images decode), `decoding="async"`, and a priority split — the first image stays eager with `fetchpriority="high"`, the rest `loading="lazy"`. Anything you set yourself wins. The compiler measures, it doesn't resize, so keep source images web-sized.
|
|
175
|
+
|
|
167
176
|
## Sections
|
|
168
177
|
|
|
169
178
|
`::: section #id .class` opens a container and `:::` closes it. Sections scope state: a `:state` declared inside a section belongs to that section, so two sections can both declare `count` without colliding. Bindings and buttons resolve to the nearest scope.
|
package/site/pages/index.wd
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
title: Darkmown — Markdown that runs, not Markdown that needs React
|
|
3
3
|
description: A live store widget — search filter, add-to-cart, running total — written as one Markdown file that runs. The same outcome in Markdoc needs a React component, hydration, and a build step. Compare them side by side.
|
|
4
4
|
image: https://darkmown.com/__wd/media/og.png
|
|
5
|
+
transitions: true
|
|
5
6
|
---
|
|
6
7
|
|
|
7
8
|
@include /nav.wd
|
package/site/pages/loops.wd
CHANGED
package/site/pages/markdown.md
CHANGED
package/site/pages/reactive.wd
CHANGED
package/site/pages/store.wd
CHANGED
package/src/compiler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import MarkdownIt from "markdown-it";
|
|
4
|
+
import { imageSize } from "image-size";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* @typedef {import("./config.js").Paths} Paths
|
|
@@ -135,12 +136,48 @@ export function compilePage(file, context) {
|
|
|
135
136
|
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
136
137
|
const scripts = scriptSrcs.map((src) => `<script type="module" src="${src}"></script>`).join("\n");
|
|
137
138
|
// Cross-document view transitions: opt in per page with `transitions: true` in
|
|
138
|
-
// frontmatter to emit
|
|
139
|
-
//
|
|
140
|
-
//
|
|
139
|
+
// frontmatter to emit a CSS-only stylesheet — a smooth same-origin transition
|
|
140
|
+
// on navigation, with zero JavaScript. Ignored by browsers without support
|
|
141
|
+
// (graceful, transition-free fallback) and only applies to same-origin
|
|
141
142
|
// navigations where both the outgoing and incoming page opt in.
|
|
143
|
+
//
|
|
144
|
+
// The default UA animation cross-fades the root: outgoing opacity 1→0 while
|
|
145
|
+
// incoming 0→1, both at once. Mid-navigation that leaves the two pages
|
|
146
|
+
// superimposed at ~50% opacity — headings ghost over headings. We override it
|
|
147
|
+
// with a directional fade+slide (old lifts up and out, new rises up and in) so
|
|
148
|
+
// the pages move past each other instead of stacking. Short + eased = peppy.
|
|
142
149
|
const wantTransitions = compiled.meta.transitions === true || compiled.meta.transitions === "true";
|
|
143
|
-
const transitions = wantTransitions
|
|
150
|
+
const transitions = wantTransitions
|
|
151
|
+
? `\n <style>
|
|
152
|
+
@view-transition { navigation: auto; }
|
|
153
|
+
::view-transition-old(root) { animation: wd-nav-out 200ms cubic-bezier(0.4, 0, 1, 1) both; }
|
|
154
|
+
::view-transition-new(root) { animation: wd-nav-in 200ms cubic-bezier(0, 0, 0.2, 1) both; }
|
|
155
|
+
@keyframes wd-nav-out { to { opacity: 0; transform: translateY(-1rem); } }
|
|
156
|
+
@keyframes wd-nav-in { from { opacity: 0; transform: translateY(1rem); } }
|
|
157
|
+
@media (prefers-reduced-motion: reduce) {
|
|
158
|
+
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; }
|
|
159
|
+
@keyframes wd-nav-out { to { opacity: 0; } }
|
|
160
|
+
@keyframes wd-nav-in { from { opacity: 0; } }
|
|
161
|
+
}
|
|
162
|
+
</style>`
|
|
163
|
+
: "";
|
|
164
|
+
|
|
165
|
+
// The latency half of smooth navigation — and what kills the white flash. A
|
|
166
|
+
// declarative speculationrules script (the browser interprets it — not
|
|
167
|
+
// framework runtime JS, so the zero-JS invariant holds) *prerenders* the next
|
|
168
|
+
// same-origin page on hover/pointerdown. Prefetch only warms the cache, so the
|
|
169
|
+
// page still has to render on click — that render gap is the white flash. A
|
|
170
|
+
// prerender renders the whole page in a hidden tab ahead of the click, so
|
|
171
|
+
// activation is instant: no render gap, and the view transition fires on the
|
|
172
|
+
// already-painted page. Safe now that pages are light. Eagerness `moderate` =
|
|
173
|
+
// ~200ms hover / pointerdown, capped at two. Mark a link `{.no-prefetch}` to
|
|
174
|
+
// opt it out; `rel=nofollow` links are never speculated. Browsers without
|
|
175
|
+
// support (or with preloading disabled) ignore the tag and navigate normally.
|
|
176
|
+
const speculation = wantTransitions
|
|
177
|
+
? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
|
|
178
|
+
: "";
|
|
179
|
+
|
|
180
|
+
const body = enhanceImages(compiled.html, context);
|
|
144
181
|
|
|
145
182
|
return {
|
|
146
183
|
meta: compiled.meta,
|
|
@@ -151,10 +188,10 @@ export function compilePage(file, context) {
|
|
|
151
188
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
152
189
|
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
153
190
|
<link rel="icon" href="${favicon}">
|
|
154
|
-
${cssLinks}${transitions}
|
|
191
|
+
${cssLinks}${transitions}${speculation}
|
|
155
192
|
</head>
|
|
156
193
|
<body>
|
|
157
|
-
${
|
|
194
|
+
${body}
|
|
158
195
|
${scripts}
|
|
159
196
|
</body>
|
|
160
197
|
</html>`,
|
|
@@ -163,6 +200,80 @@ ${scripts}
|
|
|
163
200
|
};
|
|
164
201
|
}
|
|
165
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Harden every `<img>` in the assembled page body — compile-time only, zero
|
|
205
|
+
* runtime JS. Bare markdown images reflow the page as they decode (cumulative
|
|
206
|
+
* layout shift — the literal "jump" on navigation) and load eagerly. This
|
|
207
|
+
* stamps intrinsic `width`/`height` (read from the file on disk so the browser
|
|
208
|
+
* reserves space), `decoding="async"` on all, and a load-priority split: the
|
|
209
|
+
* first image is the LCP candidate (eager + `fetchpriority="high"`), the rest
|
|
210
|
+
* lazy-load. Author-set attributes are never overwritten.
|
|
211
|
+
* @param {string} html Assembled page body HTML.
|
|
212
|
+
* @param {Paths} paths Resolved project paths.
|
|
213
|
+
* @returns {string}
|
|
214
|
+
*/
|
|
215
|
+
export function enhanceImages(html, paths) {
|
|
216
|
+
let index = 0;
|
|
217
|
+
return html.replace(/<img\b[^>]*?\/?>/g, (tag) => {
|
|
218
|
+
const i = index++;
|
|
219
|
+
const has = (/** @type {string} */ attr) => new RegExp(`\\b${attr}\\s*=`, "i").test(tag);
|
|
220
|
+
/** @type {string[]} */
|
|
221
|
+
const add = [];
|
|
222
|
+
// Dimensions only when the author hasn't sized it — adding one axis to a
|
|
223
|
+
// manually-sized image would distort its aspect ratio.
|
|
224
|
+
if (!has("width") && !has("height")) {
|
|
225
|
+
const dim = measureImage(srcOf(tag), paths);
|
|
226
|
+
if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
|
|
227
|
+
}
|
|
228
|
+
if (!has("decoding")) add.push(`decoding="async"`);
|
|
229
|
+
if (i === 0) {
|
|
230
|
+
// First image is the LCP candidate: stays eager, gets a priority hint.
|
|
231
|
+
if (!has("fetchpriority")) add.push(`fetchpriority="high"`);
|
|
232
|
+
} else if (!has("loading")) {
|
|
233
|
+
add.push(`loading="lazy"`);
|
|
234
|
+
}
|
|
235
|
+
if (!add.length) return tag;
|
|
236
|
+
return `${tag.replace(/\s*\/?>\s*$/, "")} ${add.join(" ")}>`;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Extract the `src` attribute value from an `<img>` tag, or "" if absent.
|
|
242
|
+
* @param {string} tag
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
function srcOf(tag) {
|
|
246
|
+
const m = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
|
|
247
|
+
return m ? m[1] : "";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Read an image's intrinsic dimensions from disk, or null when the src is
|
|
252
|
+
* remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
|
|
253
|
+
* comes from the shelf (`site/_`), other absolute paths from `site/pages`.
|
|
254
|
+
* @param {string} src
|
|
255
|
+
* @param {Paths} paths
|
|
256
|
+
* @returns {{ width: number, height: number } | null}
|
|
257
|
+
*/
|
|
258
|
+
function measureImage(src, paths) {
|
|
259
|
+
if (!src || /^(https?:)?\/\//i.test(src) || src.startsWith("data:")) return null;
|
|
260
|
+
let filePath;
|
|
261
|
+
if (src.startsWith("/__wd/media/")) {
|
|
262
|
+
filePath = path.join(paths.shelfRoot, src.slice("/__wd/media/".length));
|
|
263
|
+
} else if (src.startsWith("/")) {
|
|
264
|
+
filePath = path.join(paths.routesRoot, src.slice(1));
|
|
265
|
+
} else {
|
|
266
|
+
return null; // page-relative: the source directory is lost after assembly
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const { width, height } = imageSize(fs.readFileSync(filePath));
|
|
270
|
+
if (typeof width === "number" && typeof height === "number") return { width, height };
|
|
271
|
+
} catch {
|
|
272
|
+
/* missing or unreadable — degrade to no dimensions */
|
|
273
|
+
}
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
166
277
|
/**
|
|
167
278
|
* Compile a source file into its body HTML, frontmatter, and assets (no page shell).
|
|
168
279
|
* @param {string} file Absolute path to the source file.
|
|
@@ -413,6 +524,16 @@ function compileBody(lines, ctx) {
|
|
|
413
524
|
i = j - 1;
|
|
414
525
|
continue;
|
|
415
526
|
}
|
|
527
|
+
const choiceMatch = line.match(/^:(checkbox|radio)\s/);
|
|
528
|
+
if (choiceMatch) {
|
|
529
|
+
flush();
|
|
530
|
+
const opts = [];
|
|
531
|
+
let j = i + 1;
|
|
532
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
|
|
533
|
+
out.push(handleChoiceGroup(line, opts, ctx, /** @type {"checkbox" | "radio"} */ (choiceMatch[1])));
|
|
534
|
+
i = j - 1;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
416
537
|
if (/^:bind\s/.test(line)) {
|
|
417
538
|
flush();
|
|
418
539
|
out.push(handleBind(line, ctx));
|
|
@@ -1209,6 +1330,66 @@ function handleSelect(line, optionLines, ctx) {
|
|
|
1209
1330
|
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
1210
1331
|
}
|
|
1211
1332
|
|
|
1333
|
+
/**
|
|
1334
|
+
* `:checkbox name [required]` / `:radio name [required]` followed by `- Label`
|
|
1335
|
+
* lines → a labelled group of `<input type=checkbox|radio>`, each sharing `name`
|
|
1336
|
+
* with value === label and wrapped in its own `<label>`. The group is a
|
|
1337
|
+
* `role="group"` / `role="radiogroup"` container with an aria-label (explicit or
|
|
1338
|
+
* humanized from the name). Checkbox groups are marked `data-wd-multi="name"` so
|
|
1339
|
+
* the runtime captures every checked value as an array (`FormData.getAll`); radio
|
|
1340
|
+
* groups capture a single value like `:input`. Flags: `required` (radio → the
|
|
1341
|
+
* group; not emitted on checkboxes, where "at least one" has no native HTML form),
|
|
1342
|
+
* `disabled` (whole group), `autofocus` (first control).
|
|
1343
|
+
* @param {string} line
|
|
1344
|
+
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
1345
|
+
* @param {Ctx} ctx
|
|
1346
|
+
* @param {"checkbox" | "radio"} kind
|
|
1347
|
+
* @returns {string}
|
|
1348
|
+
*/
|
|
1349
|
+
function handleChoiceGroup(line, optionLines, ctx, kind) {
|
|
1350
|
+
const directive = `:${kind}`;
|
|
1351
|
+
const match = line.match(kind === "checkbox" ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/ : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1352
|
+
if (!match) throw new Error(`Malformed ${directive} in ${ctx.file}: ${line}. Use: ${directive} name [required] then "- Label" lines`);
|
|
1353
|
+
const name = match[1];
|
|
1354
|
+
const flags = [];
|
|
1355
|
+
let ariaLabel = null;
|
|
1356
|
+
let ariaDescribedby = null;
|
|
1357
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1358
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
1359
|
+
if (token[3]) {
|
|
1360
|
+
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
1361
|
+
throw new Error(`Unknown ${directive} flag "${token[3]}" in ${ctx.file}`);
|
|
1362
|
+
}
|
|
1363
|
+
flags.push(token[3]);
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
const value = stripQuotes(token[2]);
|
|
1367
|
+
if (token[1] === "aria-label") ariaLabel = value;
|
|
1368
|
+
else if (token[1] === "aria-describedby") ariaDescribedby = value;
|
|
1369
|
+
else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${ctx.file}`);
|
|
1370
|
+
}
|
|
1371
|
+
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
1372
|
+
if (!options.length) {
|
|
1373
|
+
throw new Error(`${directive} "${name}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
|
|
1374
|
+
}
|
|
1375
|
+
const isCheckbox = kind === "checkbox";
|
|
1376
|
+
const groupAttrs = [`class="wd-${isCheckbox ? "checkboxes" : "radios"}"`, `role="${isCheckbox ? "group" : "radiogroup"}"`];
|
|
1377
|
+
if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
|
|
1378
|
+
groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
|
|
1379
|
+
if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
|
|
1380
|
+
const disabled = flags.includes("disabled");
|
|
1381
|
+
const required = flags.includes("required");
|
|
1382
|
+
const autofocus = flags.includes("autofocus");
|
|
1383
|
+
const controls = options.map((opt, idx) => {
|
|
1384
|
+
const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
|
|
1385
|
+
if (disabled) a.push("disabled");
|
|
1386
|
+
if (required && !isCheckbox) a.push("required");
|
|
1387
|
+
if (autofocus && idx === 0) a.push("autofocus");
|
|
1388
|
+
return `<label><input ${a.join(" ")}> ${escapeHtml(opt)}</label>`;
|
|
1389
|
+
}).join("");
|
|
1390
|
+
return `<div ${groupAttrs.join(" ")}>${controls}</div>`;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1212
1393
|
/**
|
|
1213
1394
|
* @param {string} line
|
|
1214
1395
|
* @param {Ctx} ctx
|
package/src/runtime.js
CHANGED
|
@@ -557,7 +557,14 @@ document.addEventListener("submit", (event) => {
|
|
|
557
557
|
const action = form.getAttribute("action");
|
|
558
558
|
|
|
559
559
|
if (!action) {
|
|
560
|
-
|
|
560
|
+
const fd = new FormData(form);
|
|
561
|
+
const data = /** @type {Record<string, any>} */ (Object.fromEntries(fd));
|
|
562
|
+
// :checkbox groups share one name; collect every checked value as an array.
|
|
563
|
+
for (const group of form.querySelectorAll("[data-wd-multi]")) {
|
|
564
|
+
const field = group.getAttribute("data-wd-multi");
|
|
565
|
+
if (field) data[field] = fd.getAll(field);
|
|
566
|
+
}
|
|
567
|
+
state[key] = data;
|
|
561
568
|
savePersisted();
|
|
562
569
|
render();
|
|
563
570
|
return;
|