@zvndev/darkmown 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 `![{ p.alt }]({ p.src })` — 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
 
@@ -489,6 +490,10 @@ Fetched data and a form live happily on the same page:
489
490
 
490
491
  :form into profile
491
492
  :input name placeholder="Your name" required
493
+ :select topic
494
+ - General
495
+ - Billing
496
+ :textarea note placeholder="Anything else?"
492
497
  :submit "Save"
493
498
  :endform
494
499
 
@@ -497,10 +502,22 @@ Fetched data and a form live happily on the same page:
497
502
 
498
503
  - `:form into name` captures submits straight into state (no backend). `:form action="/url"` emits a plain native form instead — zero JS, full progressive enhancement. Form actions use the same URL scheme guard as `:fetch`: relative paths, explicit `http(s)://`, or leading `{ state }` interpolation; protocol-relative and non-http(s) schemes are compile errors.
499
504
  - `:form action="/url" into reply` does both: with JS the submit posts urlencoded via fetch and the JSON reply lands in state `reply` (`reply_error` on failure); without JS it is the same native POST. Darkmown adapts to any backend — it does not own one.
505
+ - Field directives: `:input`, `:textarea name [rows=N]`, and `:select name` (with `- Label` option lines) all capture into `:form into` state the same way. Each derives a non-visual `aria-label` from its placeholder, else a humanized field name, unless you supply `aria-label`/`aria-describedby`.
500
506
  - `:state x = [] persist` keeps a single page's state in localStorage across reloads. (For state that is shared across pages and tabs, reach for [`:store`](#global-state--store) instead.)
501
507
  - `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
502
508
  - `:if item.path` works inside reactive loops for per-row branches, and nests — an inner `:if` resolves after the outer branch and stays reactive.
503
509
 
510
+ ## Inline attributes
511
+
512
+ A trailing `{.class .class #id}` attaches classes / an id to the inline element directly before it — most often to style a link as a button without a wrapper:
513
+
514
+ ```wd
515
+ [Get started](/start/){.btn .lg}
516
+ ![logo](/logo.svg){.brand}
517
+ ```
518
+
519
+ The block must follow the element with no space, and works on links, images, emphasis, and inline code. It never collides with `{ name }` interpolation — an interpolation always starts with a name, never a `.` or `#`.
520
+
504
521
  ## The escape hatch
505
522
 
506
523
  Reactive pages expose `window.wd` — `wd.get(key)`, `wd.set(key, value)`, `wd.state`, `wd.render()` — so colocated `.js` can do anything the directives can't. Section-scoped keys are addressed as `sectionId:name`.
@@ -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 })`, `![{ 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
+ - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zvndev/darkmown",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,5 @@
1
+ [
2
+ { "label": "Docs", "url": "/docs/" },
3
+ { "label": "Loops", "url": "/loops/" },
4
+ { "label": "Reactive", "url": "/reactive/" }
5
+ ]
@@ -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
  }
@@ -394,6 +396,20 @@ function compileBody(lines, ctx) {
394
396
  out.push(handleInput(line, ctx));
395
397
  continue;
396
398
  }
399
+ if (/^:textarea\s/.test(line)) {
400
+ flush();
401
+ out.push(handleTextarea(line, ctx));
402
+ continue;
403
+ }
404
+ if (/^:select\s/.test(line)) {
405
+ flush();
406
+ const opts = [];
407
+ let j = i + 1;
408
+ while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
409
+ out.push(handleSelect(line, opts, ctx));
410
+ i = j - 1;
411
+ continue;
412
+ }
397
413
  if (/^:bind\s/.test(line)) {
398
414
  flush();
399
415
  out.push(handleBind(line, ctx));
@@ -1111,6 +1127,85 @@ function handleSubmit(line, ctx) {
1111
1127
  return `<button type="submit">${escapeHtml(match[1])}</button>`;
1112
1128
  }
1113
1129
 
1130
+ /**
1131
+ * `:textarea name [placeholder="…"] [rows=N] [required]` → a `<textarea>`. Like
1132
+ * `:input`, it derives a non-visual aria-label from the placeholder (else the
1133
+ * humanized name) when the author supplies none. Captured by the runtime's
1134
+ * FormData exactly like `:input` — no runtime change needed.
1135
+ * @param {string} line
1136
+ * @param {Ctx} ctx
1137
+ * @returns {string}
1138
+ */
1139
+ function handleTextarea(line, ctx) {
1140
+ const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
1141
+ if (!match) throw new Error(`Malformed :textarea in ${ctx.file}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`);
1142
+ const attrs = [`name="${escapeHtml(match[1])}"`];
1143
+ let placeholder;
1144
+ let hasAria = false;
1145
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
1146
+ for (const token of (match[2] || "").matchAll(re)) {
1147
+ if (token[3]) {
1148
+ if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
1149
+ throw new Error(`Unknown :textarea flag "${token[3]}" in ${ctx.file}`);
1150
+ }
1151
+ attrs.push(token[3]);
1152
+ continue;
1153
+ }
1154
+ const value = stripQuotes(token[2]);
1155
+ if (!["placeholder", "rows", "cols", "minlength", "maxlength", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
1156
+ throw new Error(`Unknown :textarea attribute "${token[1]}" in ${ctx.file}`);
1157
+ }
1158
+ if (token[1] === "placeholder") placeholder = value;
1159
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
1160
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
1161
+ }
1162
+ if (!hasAria) {
1163
+ attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
1164
+ }
1165
+ return `<textarea ${attrs.join(" ")}></textarea>`;
1166
+ }
1167
+
1168
+ /**
1169
+ * `:select name [required]` followed by `- Label` list lines → a `<select>` with
1170
+ * one `<option>` per label (value === label). Derives an aria-label from the
1171
+ * humanized name when none is given. Captured by FormData like the other fields.
1172
+ * @param {string} line
1173
+ * @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
1174
+ * @param {Ctx} ctx
1175
+ * @returns {string}
1176
+ */
1177
+ function handleSelect(line, optionLines, ctx) {
1178
+ const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
1179
+ if (!match) throw new Error(`Malformed :select in ${ctx.file}: ${line}. Use: :select name [required] then "- Label" lines`);
1180
+ const attrs = [`name="${escapeHtml(match[1])}"`];
1181
+ let hasAria = false;
1182
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
1183
+ for (const token of (match[2] || "").matchAll(re)) {
1184
+ if (token[3]) {
1185
+ if (!["required", "disabled", "autofocus"].includes(token[3])) {
1186
+ throw new Error(`Unknown :select flag "${token[3]}" in ${ctx.file}`);
1187
+ }
1188
+ attrs.push(token[3]);
1189
+ continue;
1190
+ }
1191
+ const value = stripQuotes(token[2]);
1192
+ if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
1193
+ throw new Error(`Unknown :select attribute "${token[1]}" in ${ctx.file}`);
1194
+ }
1195
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
1196
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
1197
+ }
1198
+ if (!hasAria) {
1199
+ attrs.push(`aria-label="${escapeHtml(humanizeName(match[1]))}"`);
1200
+ }
1201
+ const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
1202
+ if (!options.length) {
1203
+ throw new Error(`:select "${match[1]}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
1204
+ }
1205
+ const opts = options.map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
1206
+ return `<select ${attrs.join(" ")}>${opts}</select>`;
1207
+ }
1208
+
1114
1209
  /**
1115
1210
  * @param {string} line
1116
1211
  * @param {Ctx} ctx
@@ -1878,9 +1973,51 @@ function validateDemoHref(href, ctx) {
1878
1973
  * @returns {string}
1879
1974
  */
1880
1975
  function renderProse(text, ctx) {
1976
+ text = resolveDestinationBindings(text, ctx);
1881
1977
  return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
1882
1978
  }
1883
1979
 
1980
+ /**
1981
+ * Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
1982
+ * destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
1983
+ * can drive an href/src. A markdown destination cannot contain the spaces inside
1984
+ * `{ … }`, so markdown-it would otherwise never form the link; substituting the
1985
+ * static value here lets the normal link/image parser run. Only build-time
1986
+ * (static-scope) values are substituted — reactive `:state`/reactive-loop
1987
+ * bindings resolve to `null` and are left untouched (they cannot live in a
1988
+ * destination). Issue #19.
1989
+ * @param {string} text
1990
+ * @param {Ctx} ctx
1991
+ * @returns {string}
1992
+ */
1993
+ function resolveDestinationBindings(text, ctx) {
1994
+ return text.replace(
1995
+ /(\]\(\s*)\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
1996
+ (whole, prefix, expr) => {
1997
+ const raw = resolveBindingRaw(expr, ctx);
1998
+ return raw == null ? whole : prefix + raw;
1999
+ }
2000
+ );
2001
+ }
2002
+
2003
+ /**
2004
+ * Resolve `{ expr }` to its raw static value (a plain string) for use in a
2005
+ * destination, or `null` when the binding is reactive or not in build-time
2006
+ * scope. Mirrors the static branch of {@link resolveBindingHtml} but returns the
2007
+ * unescaped value (markdown-it normalizes the URL).
2008
+ * @param {string} expr
2009
+ * @param {Ctx} ctx
2010
+ * @returns {string | null}
2011
+ */
2012
+ function resolveBindingRaw(expr, ctx) {
2013
+ const segs = expr.split(".");
2014
+ const found = lookupVar(ctx.scope, segs[0]);
2015
+ if (!found.found) return null;
2016
+ const resolved = getPath(found.value, segs.slice(1));
2017
+ const leaf = interpolateLeaf(resolved, expr, ctx);
2018
+ return leaf == null ? null : String(leaf);
2019
+ }
2020
+
1884
2021
  /**
1885
2022
  * markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
1886
2023
  * whose content comes from the env's `resolveBinding` callback.
@@ -1904,6 +2041,63 @@ function bindingPlugin(mdInstance) {
1904
2041
  });
1905
2042
  }
1906
2043
 
2044
+ /**
2045
+ * markdown-it plugin (core rule): a `{.class .class #id}` block immediately
2046
+ * following an inline element (link, image, em/strong, code) attaches those
2047
+ * classes / id to that element. Lets a link be styled as a button without a
2048
+ * wrapper container. The block must directly follow the element (no space), and
2049
+ * `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
2050
+ * Issue #18.
2051
+ * @param {MarkdownIt} mdInstance
2052
+ * @returns {void}
2053
+ */
2054
+ function attrsPlugin(mdInstance) {
2055
+ mdInstance.core.ruler.push("wd_attrs", (state) => {
2056
+ for (const block of state.tokens) {
2057
+ if (block.type !== "inline" || !block.children) continue;
2058
+ const children = block.children;
2059
+ for (let i = 0; i < children.length; i++) {
2060
+ const tok = children[i];
2061
+ if (tok.type !== "text") continue;
2062
+ const m = /^\{([.#][^}]*)\}/.exec(tok.content);
2063
+ if (!m) continue;
2064
+ const target = attrTarget(children, i);
2065
+ if (!target) continue;
2066
+ for (const part of m[1].split(/\s+/).filter(Boolean)) {
2067
+ if (part[0] === ".") target.attrJoin("class", part.slice(1));
2068
+ else if (part[0] === "#") target.attrSet("id", part.slice(1));
2069
+ }
2070
+ tok.content = tok.content.slice(m[0].length);
2071
+ }
2072
+ }
2073
+ return false;
2074
+ });
2075
+ }
2076
+
2077
+ /**
2078
+ * The inline token an attr block attaches to: the immediately-preceding image
2079
+ * (self-closing) or the open token matching the immediately-preceding close
2080
+ * (link/em/strong/…). Returns null when nothing valid precedes.
2081
+ * @param {any[]} children markdown-it inline child tokens
2082
+ * @param {number} i Index of the attr-block text token.
2083
+ * @returns {any} the matching markdown-it Token, or null
2084
+ */
2085
+ function attrTarget(children, i) {
2086
+ const prev = children[i - 1];
2087
+ if (!prev) return null;
2088
+ if (prev.type === "image") return prev;
2089
+ if (prev.nesting === -1) {
2090
+ const openType = prev.type.replace(/_close$/, "_open");
2091
+ let depth = 0;
2092
+ for (let j = i - 1; j >= 0; j--) {
2093
+ const t = children[j];
2094
+ if (t.type === prev.type) depth++;
2095
+ else if (t.type === openType) { depth--; if (depth === 0) return t; }
2096
+ }
2097
+ }
2098
+ return null;
2099
+ }
2100
+
1907
2101
  /**
1908
2102
  * Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
1909
2103
  * a reactive bind span. Returns null when nothing in scope matches.