@zvndev/darkmown 0.17.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/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
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
+
5
14
  ## 0.17.0 — 2026-06-23
6
15
 
7
16
  Multi-select and single-choice form groups: **`:checkbox` and `:radio`**.
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 **cross-document view transitions** — a smooth same-origin cross-fade on navigation, emitted as the CSS-only `@view-transition { navigation: auto; }` rule. It ships zero JavaScript, only animates between same-origin pages that both opt in, and is silently ignored by browsers without support (the navigation just doesn't fade). Off by default; opt out explicitly with `transitions: false`.
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: `![logo](/__wd/media/logo.svg)`. 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` → `![logo](/logo.svg)`
@@ -79,6 +79,11 @@ 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 })`, `![{ p.alt }]({ p.src })` — 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
+
82
87
  ## Stage 13 additions (2026-06-23, v0.17.0)
83
88
 
84
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.
@@ -107,7 +112,7 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
107
112
  ## Still missing from the full vision
108
113
 
109
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.
110
- - **View transitions:** named/typed transitions (`view-transition-name`, per-element morphs) and `@starting-style` emission on top of the opt-in cross-document fade shipped in 0.16.0 (see Stage 4).
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).
111
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.
112
117
 
113
118
  ## Current claim
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.17.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": {
@@ -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
@@ -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.
@@ -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
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Loop ergonomics
3
+ transitions: true
3
4
  ---
4
5
 
5
6
  @include /nav.wd
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Plain Markdown
3
+ transitions: true
3
4
  ---
4
5
 
5
6
  # Plain Markdown stays plain
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Reactive Markdown
3
+ transitions: true
3
4
  ---
4
5
 
5
6
  @include /nav.wd
@@ -1,5 +1,6 @@
1
1
  ---
2
2
  title: Global store
3
+ transitions: true
3
4
  ---
4
5
 
5
6
  @include /nav.wd
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 the CSS-only @view-transition rule — a smooth same-origin
139
- // cross-fade on navigation, with zero JavaScript. Ignored by browsers without
140
- // support (graceful fade-free fallback) and only applies to same-origin
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 ? `\n <style>@view-transition { navigation: auto; }</style>` : "";
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
- ${compiled.html}
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.