@zvndev/darkmown 0.14.1 → 0.16.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 +14 -0
- package/README.md +19 -0
- package/docs/spec-alignment.md +8 -2
- package/package.json +1 -1
- package/site/_/links.json +5 -0
- package/site/pages/data.wd +32 -0
- package/src/compiler.js +201 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.16.0 — 2026-06-23
|
|
6
|
+
|
|
7
|
+
Cross-document **view transitions** return as a per-page opt-in.
|
|
8
|
+
|
|
9
|
+
- **`transitions: true` frontmatter** emits the CSS-only `@view-transition { navigation: auto; }` rule, giving a smooth same-origin cross-fade on navigation with zero JavaScript. It only animates between same-origin pages that both opt in, and browsers without support silently skip the fade (no hang, no layout change). Default-off; opt out explicitly with `transitions: false`. Re-enabled after the original cross-document render-blocking regression (stalled rAF / hanging navigation on deployed pages) was verified resolved on modern Chrome. Compile-time only — no change to the shipped runtime size.
|
|
10
|
+
|
|
11
|
+
## 0.15.0 — 2026-06-23
|
|
12
|
+
|
|
13
|
+
Form controls and inline styling reach the authoring layer.
|
|
14
|
+
|
|
15
|
+
- **`:textarea` and `:select` field directives.** Forms gain a multi-line `:textarea name [rows=N]` and a `:select name` (with `- Label` option lines). Both capture into `:form into` state exactly like `:input`, and derive a non-visual accessible name from their placeholder or humanized field name.
|
|
16
|
+
- **Inline attributes.** A trailing `{.class .class #id}` (no leading space) attaches classes / an id to the inline element directly before it — link, image, emphasis, or inline code — most often to style a link as a button without a wrapping container. It never collides with `{ name }` interpolation, which always begins with a name.
|
|
17
|
+
- **Interpolated link destinations.** `{ name }` now resolves inside markdown link and image destinations, so `@loop` / state values can data-drive an `href` or `src`.
|
|
18
|
+
|
|
5
19
|
## 0.14.1 — 2026-06-19
|
|
6
20
|
|
|
7
21
|
Patch-release hardening for page assets, form actions, and release metadata.
|
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
|
|
|
@@ -81,6 +82,8 @@ tags: [sales, revenue, "q1, q2"]
|
|
|
81
82
|
|
|
82
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).
|
|
83
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`.
|
|
86
|
+
|
|
84
87
|
## Static assets
|
|
85
88
|
|
|
86
89
|
Images, fonts, icons, and other non-page files live on the include shelf at `site/_/` and are copied into the build untouched:
|
|
@@ -489,6 +492,10 @@ Fetched data and a form live happily on the same page:
|
|
|
489
492
|
|
|
490
493
|
:form into profile
|
|
491
494
|
:input name placeholder="Your name" required
|
|
495
|
+
:select topic
|
|
496
|
+
- General
|
|
497
|
+
- Billing
|
|
498
|
+
:textarea note placeholder="Anything else?"
|
|
492
499
|
:submit "Save"
|
|
493
500
|
:endform
|
|
494
501
|
|
|
@@ -497,10 +504,22 @@ Fetched data and a form live happily on the same page:
|
|
|
497
504
|
|
|
498
505
|
- `: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.
|
|
499
506
|
- `: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 `: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`.
|
|
500
508
|
- `: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.)
|
|
501
509
|
- `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
|
|
502
510
|
- `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
|
|
503
511
|
|
|
512
|
+
## Inline attributes
|
|
513
|
+
|
|
514
|
+
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:
|
|
515
|
+
|
|
516
|
+
```wd
|
|
517
|
+
[Get started](/start/){.btn .lg}
|
|
518
|
+
{.brand}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
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 `#`.
|
|
522
|
+
|
|
504
523
|
## The escape hatch
|
|
505
524
|
|
|
506
525
|
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`.
|
package/docs/spec-alignment.md
CHANGED
|
@@ -33,7 +33,7 @@ This audit compares the current Darkmown implementation with the original MARROW
|
|
|
33
33
|
|
|
34
34
|
## Stage 4 additions (2026-06-11, same day)
|
|
35
35
|
|
|
36
|
-
- **View transitions (
|
|
36
|
+
- **View transitions (re-enabled in 0.16.0, opt-in):** `transitions: true` in frontmatter emits `@view-transition { navigation: auto; }` — the original doc's zero-JS MPA navigation polish (a same-origin cross-fade on navigation). Previously pulled back because cross-document `@view-transition` render-blocked deployed pages (stalled rAF, hanging navigation); verified resolved on modern Chrome (CSS-only, same-origin, graceful no-support fallback, no nav hang), so it returns as a per-page opt-in. Default-off; opt out with `transitions: false`.
|
|
37
37
|
- **Lazy loading:** `:fetch … when=visible` defers the request until the marker scrolls into view (IntersectionObserver) — the adapted form of `:lazy`.
|
|
38
38
|
- **Computed state:** `:computed name = expr` with a compile-time-validated grammar (state refs, numbers, strings, arithmetic, comparisons; assignment, calls, and prototype walks rejected). Initial values are evaluated at build time.
|
|
39
39
|
- **Dev self-reload:** `darkmown dev` rebuilds in a child process, so changes to the framework's own `src/` always compile with fresh modules.
|
|
@@ -73,6 +73,12 @@ 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
|
+
|
|
76
82
|
## Stage 11 additions (2026-06-19, v0.14.1)
|
|
77
83
|
|
|
78
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.
|
|
@@ -97,7 +103,7 @@ The compiler recognizes three directives — `:note`, `:try`, and `:sprint` —
|
|
|
97
103
|
## Still missing from the full vision
|
|
98
104
|
|
|
99
105
|
- 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.
|
|
100
|
-
- **View transitions:**
|
|
106
|
+
- **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).
|
|
101
107
|
- 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.
|
|
102
108
|
|
|
103
109
|
## Current claim
|
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/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
|
}
|
|
@@ -132,10 +134,13 @@ export function compilePage(file, context) {
|
|
|
132
134
|
const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
|
|
133
135
|
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
134
136
|
const scripts = scriptSrcs.map((src) => `<script type="module" src="${src}"></script>`).join("\n");
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
|
|
137
|
+
// 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
|
|
141
|
+
// navigations where both the outgoing and incoming page opt in.
|
|
142
|
+
const wantTransitions = compiled.meta.transitions === true || compiled.meta.transitions === "true";
|
|
143
|
+
const transitions = wantTransitions ? `\n <style>@view-transition { navigation: auto; }</style>` : "";
|
|
139
144
|
|
|
140
145
|
return {
|
|
141
146
|
meta: compiled.meta,
|
|
@@ -394,6 +399,20 @@ function compileBody(lines, ctx) {
|
|
|
394
399
|
out.push(handleInput(line, ctx));
|
|
395
400
|
continue;
|
|
396
401
|
}
|
|
402
|
+
if (/^:textarea\s/.test(line)) {
|
|
403
|
+
flush();
|
|
404
|
+
out.push(handleTextarea(line, ctx));
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (/^:select\s/.test(line)) {
|
|
408
|
+
flush();
|
|
409
|
+
const opts = [];
|
|
410
|
+
let j = i + 1;
|
|
411
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
|
|
412
|
+
out.push(handleSelect(line, opts, ctx));
|
|
413
|
+
i = j - 1;
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
397
416
|
if (/^:bind\s/.test(line)) {
|
|
398
417
|
flush();
|
|
399
418
|
out.push(handleBind(line, ctx));
|
|
@@ -1111,6 +1130,85 @@ function handleSubmit(line, ctx) {
|
|
|
1111
1130
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
1112
1131
|
}
|
|
1113
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* `:textarea name [placeholder="…"] [rows=N] [required]` → a `<textarea>`. Like
|
|
1135
|
+
* `:input`, it derives a non-visual aria-label from the placeholder (else the
|
|
1136
|
+
* humanized name) when the author supplies none. Captured by the runtime's
|
|
1137
|
+
* FormData exactly like `:input` — no runtime change needed.
|
|
1138
|
+
* @param {string} line
|
|
1139
|
+
* @param {Ctx} ctx
|
|
1140
|
+
* @returns {string}
|
|
1141
|
+
*/
|
|
1142
|
+
function handleTextarea(line, ctx) {
|
|
1143
|
+
const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1144
|
+
if (!match) throw new Error(`Malformed :textarea in ${ctx.file}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`);
|
|
1145
|
+
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1146
|
+
let placeholder;
|
|
1147
|
+
let hasAria = false;
|
|
1148
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1149
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
1150
|
+
if (token[3]) {
|
|
1151
|
+
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
1152
|
+
throw new Error(`Unknown :textarea flag "${token[3]}" in ${ctx.file}`);
|
|
1153
|
+
}
|
|
1154
|
+
attrs.push(token[3]);
|
|
1155
|
+
continue;
|
|
1156
|
+
}
|
|
1157
|
+
const value = stripQuotes(token[2]);
|
|
1158
|
+
if (!["placeholder", "rows", "cols", "minlength", "maxlength", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1159
|
+
throw new Error(`Unknown :textarea attribute "${token[1]}" in ${ctx.file}`);
|
|
1160
|
+
}
|
|
1161
|
+
if (token[1] === "placeholder") placeholder = value;
|
|
1162
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1163
|
+
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1164
|
+
}
|
|
1165
|
+
if (!hasAria) {
|
|
1166
|
+
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
1167
|
+
}
|
|
1168
|
+
return `<textarea ${attrs.join(" ")}></textarea>`;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* `:select name [required]` followed by `- Label` list lines → a `<select>` with
|
|
1173
|
+
* one `<option>` per label (value === label). Derives an aria-label from the
|
|
1174
|
+
* humanized name when none is given. Captured by FormData like the other fields.
|
|
1175
|
+
* @param {string} line
|
|
1176
|
+
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
1177
|
+
* @param {Ctx} ctx
|
|
1178
|
+
* @returns {string}
|
|
1179
|
+
*/
|
|
1180
|
+
function handleSelect(line, optionLines, ctx) {
|
|
1181
|
+
const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1182
|
+
if (!match) throw new Error(`Malformed :select in ${ctx.file}: ${line}. Use: :select name [required] then "- Label" lines`);
|
|
1183
|
+
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1184
|
+
let hasAria = false;
|
|
1185
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1186
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
1187
|
+
if (token[3]) {
|
|
1188
|
+
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
1189
|
+
throw new Error(`Unknown :select flag "${token[3]}" in ${ctx.file}`);
|
|
1190
|
+
}
|
|
1191
|
+
attrs.push(token[3]);
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const value = stripQuotes(token[2]);
|
|
1195
|
+
if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1196
|
+
throw new Error(`Unknown :select attribute "${token[1]}" in ${ctx.file}`);
|
|
1197
|
+
}
|
|
1198
|
+
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1199
|
+
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1200
|
+
}
|
|
1201
|
+
if (!hasAria) {
|
|
1202
|
+
attrs.push(`aria-label="${escapeHtml(humanizeName(match[1]))}"`);
|
|
1203
|
+
}
|
|
1204
|
+
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
1205
|
+
if (!options.length) {
|
|
1206
|
+
throw new Error(`:select "${match[1]}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
|
|
1207
|
+
}
|
|
1208
|
+
const opts = options.map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
|
|
1209
|
+
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1114
1212
|
/**
|
|
1115
1213
|
* @param {string} line
|
|
1116
1214
|
* @param {Ctx} ctx
|
|
@@ -1878,9 +1976,51 @@ function validateDemoHref(href, ctx) {
|
|
|
1878
1976
|
* @returns {string}
|
|
1879
1977
|
*/
|
|
1880
1978
|
function renderProse(text, ctx) {
|
|
1979
|
+
text = resolveDestinationBindings(text, ctx);
|
|
1881
1980
|
return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
|
|
1882
1981
|
}
|
|
1883
1982
|
|
|
1983
|
+
/**
|
|
1984
|
+
* Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
|
|
1985
|
+
* destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
|
|
1986
|
+
* can drive an href/src. A markdown destination cannot contain the spaces inside
|
|
1987
|
+
* `{ … }`, so markdown-it would otherwise never form the link; substituting the
|
|
1988
|
+
* static value here lets the normal link/image parser run. Only build-time
|
|
1989
|
+
* (static-scope) values are substituted — reactive `:state`/reactive-loop
|
|
1990
|
+
* bindings resolve to `null` and are left untouched (they cannot live in a
|
|
1991
|
+
* destination). Issue #19.
|
|
1992
|
+
* @param {string} text
|
|
1993
|
+
* @param {Ctx} ctx
|
|
1994
|
+
* @returns {string}
|
|
1995
|
+
*/
|
|
1996
|
+
function resolveDestinationBindings(text, ctx) {
|
|
1997
|
+
return text.replace(
|
|
1998
|
+
/(\]\(\s*)\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
|
|
1999
|
+
(whole, prefix, expr) => {
|
|
2000
|
+
const raw = resolveBindingRaw(expr, ctx);
|
|
2001
|
+
return raw == null ? whole : prefix + raw;
|
|
2002
|
+
}
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* Resolve `{ expr }` to its raw static value (a plain string) for use in a
|
|
2008
|
+
* destination, or `null` when the binding is reactive or not in build-time
|
|
2009
|
+
* scope. Mirrors the static branch of {@link resolveBindingHtml} but returns the
|
|
2010
|
+
* unescaped value (markdown-it normalizes the URL).
|
|
2011
|
+
* @param {string} expr
|
|
2012
|
+
* @param {Ctx} ctx
|
|
2013
|
+
* @returns {string | null}
|
|
2014
|
+
*/
|
|
2015
|
+
function resolveBindingRaw(expr, ctx) {
|
|
2016
|
+
const segs = expr.split(".");
|
|
2017
|
+
const found = lookupVar(ctx.scope, segs[0]);
|
|
2018
|
+
if (!found.found) return null;
|
|
2019
|
+
const resolved = getPath(found.value, segs.slice(1));
|
|
2020
|
+
const leaf = interpolateLeaf(resolved, expr, ctx);
|
|
2021
|
+
return leaf == null ? null : String(leaf);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
1884
2024
|
/**
|
|
1885
2025
|
* markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
|
|
1886
2026
|
* whose content comes from the env's `resolveBinding` callback.
|
|
@@ -1904,6 +2044,63 @@ function bindingPlugin(mdInstance) {
|
|
|
1904
2044
|
});
|
|
1905
2045
|
}
|
|
1906
2046
|
|
|
2047
|
+
/**
|
|
2048
|
+
* markdown-it plugin (core rule): a `{.class .class #id}` block immediately
|
|
2049
|
+
* following an inline element (link, image, em/strong, code) attaches those
|
|
2050
|
+
* classes / id to that element. Lets a link be styled as a button without a
|
|
2051
|
+
* wrapper container. The block must directly follow the element (no space), and
|
|
2052
|
+
* `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
|
|
2053
|
+
* Issue #18.
|
|
2054
|
+
* @param {MarkdownIt} mdInstance
|
|
2055
|
+
* @returns {void}
|
|
2056
|
+
*/
|
|
2057
|
+
function attrsPlugin(mdInstance) {
|
|
2058
|
+
mdInstance.core.ruler.push("wd_attrs", (state) => {
|
|
2059
|
+
for (const block of state.tokens) {
|
|
2060
|
+
if (block.type !== "inline" || !block.children) continue;
|
|
2061
|
+
const children = block.children;
|
|
2062
|
+
for (let i = 0; i < children.length; i++) {
|
|
2063
|
+
const tok = children[i];
|
|
2064
|
+
if (tok.type !== "text") continue;
|
|
2065
|
+
const m = /^\{([.#][^}]*)\}/.exec(tok.content);
|
|
2066
|
+
if (!m) continue;
|
|
2067
|
+
const target = attrTarget(children, i);
|
|
2068
|
+
if (!target) continue;
|
|
2069
|
+
for (const part of m[1].split(/\s+/).filter(Boolean)) {
|
|
2070
|
+
if (part[0] === ".") target.attrJoin("class", part.slice(1));
|
|
2071
|
+
else if (part[0] === "#") target.attrSet("id", part.slice(1));
|
|
2072
|
+
}
|
|
2073
|
+
tok.content = tok.content.slice(m[0].length);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
return false;
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
/**
|
|
2081
|
+
* The inline token an attr block attaches to: the immediately-preceding image
|
|
2082
|
+
* (self-closing) or the open token matching the immediately-preceding close
|
|
2083
|
+
* (link/em/strong/…). Returns null when nothing valid precedes.
|
|
2084
|
+
* @param {any[]} children markdown-it inline child tokens
|
|
2085
|
+
* @param {number} i Index of the attr-block text token.
|
|
2086
|
+
* @returns {any} the matching markdown-it Token, or null
|
|
2087
|
+
*/
|
|
2088
|
+
function attrTarget(children, i) {
|
|
2089
|
+
const prev = children[i - 1];
|
|
2090
|
+
if (!prev) return null;
|
|
2091
|
+
if (prev.type === "image") return prev;
|
|
2092
|
+
if (prev.nesting === -1) {
|
|
2093
|
+
const openType = prev.type.replace(/_close$/, "_open");
|
|
2094
|
+
let depth = 0;
|
|
2095
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
2096
|
+
const t = children[j];
|
|
2097
|
+
if (t.type === prev.type) depth++;
|
|
2098
|
+
else if (t.type === openType) { depth--; if (depth === 0) return t; }
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
return null;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
1907
2104
|
/**
|
|
1908
2105
|
* Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
|
|
1909
2106
|
* a reactive bind span. Returns null when nothing in scope matches.
|