@zvndev/darkmown 0.16.0 → 0.17.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 +8 -0
- package/README.md +7 -1
- package/docs/spec-alignment.md +4 -0
- package/package.json +1 -1
- package/site/pages/data.wd +8 -2
- package/src/compiler.js +70 -0
- 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,14 @@
|
|
|
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.17.0 — 2026-06-23
|
|
6
|
+
|
|
7
|
+
Multi-select and single-choice form groups: **`:checkbox` and `:radio`**.
|
|
8
|
+
|
|
9
|
+
- **`: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.
|
|
10
|
+
- **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.
|
|
11
|
+
- 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).
|
|
12
|
+
|
|
5
13
|
## 0.16.0 — 2026-06-23
|
|
6
14
|
|
|
7
15
|
Cross-document **view transitions** return as a per-page opt-in.
|
package/README.md
CHANGED
|
@@ -495,6 +495,12 @@ Fetched data and a form live happily on the same page:
|
|
|
495
495
|
:select topic
|
|
496
496
|
- General
|
|
497
497
|
- Billing
|
|
498
|
+
:checkbox channels
|
|
499
|
+
- Email
|
|
500
|
+
- SMS
|
|
501
|
+
:radio plan
|
|
502
|
+
- Basic
|
|
503
|
+
- Pro
|
|
498
504
|
:textarea note placeholder="Anything else?"
|
|
499
505
|
:submit "Save"
|
|
500
506
|
:endform
|
|
@@ -504,7 +510,7 @@ Fetched data and a form live happily on the same page:
|
|
|
504
510
|
|
|
505
511
|
- `: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
512
|
- `: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 `:
|
|
513
|
+
- 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
514
|
- `: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
515
|
- `:computed total = items.length * 4` derives state from state with a compile-time-checked expression (names, numbers, arithmetic, comparisons — nothing else).
|
|
510
516
|
- `: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,10 @@ 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 13 additions (2026-06-23, v0.17.0)
|
|
83
|
+
|
|
84
|
+
- **`: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.
|
|
85
|
+
|
|
82
86
|
## Stage 11 additions (2026-06-19, v0.14.1)
|
|
83
87
|
|
|
84
88
|
- **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
package/site/pages/data.wd
CHANGED
|
@@ -150,7 +150,7 @@ Scroll me into view and I will load.
|
|
|
150
150
|
|
|
151
151
|
## Richer form controls
|
|
152
152
|
|
|
153
|
-
`:textarea
|
|
153
|
+
`: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
154
|
|
|
155
155
|
:form into inquiry
|
|
156
156
|
:input fullname placeholder="Your full name"
|
|
@@ -158,12 +158,18 @@ Scroll me into view and I will load.
|
|
|
158
158
|
- General question
|
|
159
159
|
- New case
|
|
160
160
|
- Billing
|
|
161
|
+
:checkbox services
|
|
162
|
+
- Cleaning
|
|
163
|
+
- Whitening
|
|
164
|
+
:radio urgency
|
|
165
|
+
- Routine
|
|
166
|
+
- Urgent
|
|
161
167
|
:textarea message placeholder="How can we help?"
|
|
162
168
|
:submit "Send inquiry"
|
|
163
169
|
:endform
|
|
164
170
|
|
|
165
171
|
:if inquiry
|
|
166
|
-
Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note.
|
|
172
|
+
Thanks **{ inquiry.fullname }** — we logged your "{ inquiry.topic }" note. Services: { inquiry.services }. Urgency: { inquiry.urgency }.
|
|
167
173
|
:else
|
|
168
174
|
Pick a topic and send — it is captured client-side, no backend.
|
|
169
175
|
:endif
|
package/src/compiler.js
CHANGED
|
@@ -413,6 +413,16 @@ function compileBody(lines, ctx) {
|
|
|
413
413
|
i = j - 1;
|
|
414
414
|
continue;
|
|
415
415
|
}
|
|
416
|
+
const choiceMatch = line.match(/^:(checkbox|radio)\s/);
|
|
417
|
+
if (choiceMatch) {
|
|
418
|
+
flush();
|
|
419
|
+
const opts = [];
|
|
420
|
+
let j = i + 1;
|
|
421
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
|
|
422
|
+
out.push(handleChoiceGroup(line, opts, ctx, /** @type {"checkbox" | "radio"} */ (choiceMatch[1])));
|
|
423
|
+
i = j - 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
416
426
|
if (/^:bind\s/.test(line)) {
|
|
417
427
|
flush();
|
|
418
428
|
out.push(handleBind(line, ctx));
|
|
@@ -1209,6 +1219,66 @@ function handleSelect(line, optionLines, ctx) {
|
|
|
1209
1219
|
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
1210
1220
|
}
|
|
1211
1221
|
|
|
1222
|
+
/**
|
|
1223
|
+
* `:checkbox name [required]` / `:radio name [required]` followed by `- Label`
|
|
1224
|
+
* lines → a labelled group of `<input type=checkbox|radio>`, each sharing `name`
|
|
1225
|
+
* with value === label and wrapped in its own `<label>`. The group is a
|
|
1226
|
+
* `role="group"` / `role="radiogroup"` container with an aria-label (explicit or
|
|
1227
|
+
* humanized from the name). Checkbox groups are marked `data-wd-multi="name"` so
|
|
1228
|
+
* the runtime captures every checked value as an array (`FormData.getAll`); radio
|
|
1229
|
+
* groups capture a single value like `:input`. Flags: `required` (radio → the
|
|
1230
|
+
* group; not emitted on checkboxes, where "at least one" has no native HTML form),
|
|
1231
|
+
* `disabled` (whole group), `autofocus` (first control).
|
|
1232
|
+
* @param {string} line
|
|
1233
|
+
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
1234
|
+
* @param {Ctx} ctx
|
|
1235
|
+
* @param {"checkbox" | "radio"} kind
|
|
1236
|
+
* @returns {string}
|
|
1237
|
+
*/
|
|
1238
|
+
function handleChoiceGroup(line, optionLines, ctx, kind) {
|
|
1239
|
+
const directive = `:${kind}`;
|
|
1240
|
+
const match = line.match(kind === "checkbox" ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/ : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1241
|
+
if (!match) throw new Error(`Malformed ${directive} in ${ctx.file}: ${line}. Use: ${directive} name [required] then "- Label" lines`);
|
|
1242
|
+
const name = match[1];
|
|
1243
|
+
const flags = [];
|
|
1244
|
+
let ariaLabel = null;
|
|
1245
|
+
let ariaDescribedby = null;
|
|
1246
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1247
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
1248
|
+
if (token[3]) {
|
|
1249
|
+
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
1250
|
+
throw new Error(`Unknown ${directive} flag "${token[3]}" in ${ctx.file}`);
|
|
1251
|
+
}
|
|
1252
|
+
flags.push(token[3]);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
const value = stripQuotes(token[2]);
|
|
1256
|
+
if (token[1] === "aria-label") ariaLabel = value;
|
|
1257
|
+
else if (token[1] === "aria-describedby") ariaDescribedby = value;
|
|
1258
|
+
else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${ctx.file}`);
|
|
1259
|
+
}
|
|
1260
|
+
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
1261
|
+
if (!options.length) {
|
|
1262
|
+
throw new Error(`${directive} "${name}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
|
|
1263
|
+
}
|
|
1264
|
+
const isCheckbox = kind === "checkbox";
|
|
1265
|
+
const groupAttrs = [`class="wd-${isCheckbox ? "checkboxes" : "radios"}"`, `role="${isCheckbox ? "group" : "radiogroup"}"`];
|
|
1266
|
+
if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
|
|
1267
|
+
groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
|
|
1268
|
+
if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
|
|
1269
|
+
const disabled = flags.includes("disabled");
|
|
1270
|
+
const required = flags.includes("required");
|
|
1271
|
+
const autofocus = flags.includes("autofocus");
|
|
1272
|
+
const controls = options.map((opt, idx) => {
|
|
1273
|
+
const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
|
|
1274
|
+
if (disabled) a.push("disabled");
|
|
1275
|
+
if (required && !isCheckbox) a.push("required");
|
|
1276
|
+
if (autofocus && idx === 0) a.push("autofocus");
|
|
1277
|
+
return `<label><input ${a.join(" ")}> ${escapeHtml(opt)}</label>`;
|
|
1278
|
+
}).join("");
|
|
1279
|
+
return `<div ${groupAttrs.join(" ")}>${controls}</div>`;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1212
1282
|
/**
|
|
1213
1283
|
* @param {string} line
|
|
1214
1284
|
* @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;
|