@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 +9 -0
- package/README.md +3 -1
- package/docs/spec-alignment.md +6 -1
- package/package.json +2 -1
- package/site/pages/data.wd +1 -0
- 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 +117 -6
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 **
|
|
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` → ``
|
package/docs/spec-alignment.md
CHANGED
|
@@ -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 })`, `` — 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
|
|
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.
|
|
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
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.
|