@zvndev/darkmown 1.4.0 → 2.0.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.
Files changed (106) hide show
  1. package/AGENTS.md +17 -3
  2. package/CHANGELOG.md +38 -0
  3. package/README.md +114 -17
  4. package/SECURITY.md +21 -18
  5. package/docs/cli.md +5 -1
  6. package/docs/spec-alignment.md +31 -4
  7. package/package.json +1 -1
  8. package/site/_/base.skin +239 -18
  9. package/site/_/base.wd +4 -0
  10. package/site/_/feature-card.wd +4 -0
  11. package/site/_/footer.wd +4 -0
  12. package/site/_/nav.wd +5 -1
  13. package/site/_/scoped-promo.wd +4 -0
  14. package/site/_/scoped-stat.wd +4 -0
  15. package/site/pages/404.wd +1 -0
  16. package/site/pages/app.skin +27 -5
  17. package/site/pages/app.wd +1 -0
  18. package/site/pages/blog/_schema.wd +15 -0
  19. package/site/pages/blog/collections-and-pagination.md +38 -0
  20. package/site/pages/blog/collections-and-pagination.skin +127 -0
  21. package/site/pages/blog/drafts-that-never-leak.md +2 -0
  22. package/site/pages/blog/drafts-that-never-leak.skin +127 -0
  23. package/site/pages/blog/index.skin +144 -0
  24. package/site/pages/blog/index.wd +74 -0
  25. package/site/pages/blog/shipping-feeds.md +1 -0
  26. package/site/pages/blog/shipping-feeds.skin +127 -0
  27. package/site/pages/blog/typed-frontmatter.md +45 -0
  28. package/site/pages/blog/typed-frontmatter.skin +127 -0
  29. package/site/pages/blog/unfinished-thoughts.skin +127 -0
  30. package/site/pages/blog/zero-js-by-default.md +31 -0
  31. package/site/pages/blog/zero-js-by-default.skin +127 -0
  32. package/site/pages/carousel/index.wd +1 -0
  33. package/site/pages/compass/index.wd +1 -0
  34. package/site/pages/data.skin +50 -9
  35. package/site/pages/data.wd +3 -0
  36. package/site/pages/docs/-relative-note.wd +4 -0
  37. package/site/pages/docs/index.skin +27 -0
  38. package/site/pages/docs/index.wd +115 -11
  39. package/site/pages/folio/-style.wd +4 -0
  40. package/site/pages/folio/cart.wd +1 -0
  41. package/site/pages/folio/index.wd +1 -0
  42. package/site/pages/forge/index.wd +1 -0
  43. package/site/pages/highlight/index.wd +1 -0
  44. package/site/pages/index.wd +1 -0
  45. package/site/pages/interactions/index.wd +1 -0
  46. package/site/pages/ledger/index.wd +1 -0
  47. package/site/pages/loops.skin +43 -6
  48. package/site/pages/loops.wd +1 -0
  49. package/site/pages/media/index.wd +1 -0
  50. package/site/pages/pulse/index.wd +1 -0
  51. package/site/pages/reactive.skin +38 -6
  52. package/site/pages/reactive.wd +1 -0
  53. package/site/pages/scoped/index.wd +1 -0
  54. package/site/pages/showcase/index.wd +1 -0
  55. package/site/pages/store.skin +43 -6
  56. package/site/pages/store.wd +1 -0
  57. package/site/pages/theme.wd +1 -0
  58. package/src/builder.js +535 -48
  59. package/src/cli.js +96 -18
  60. package/src/compiler/actions.js +303 -0
  61. package/src/compiler/body.js +36 -24
  62. package/src/compiler/collections.js +316 -0
  63. package/src/compiler/context.js +83 -6
  64. package/src/compiler/directives.js +31 -1470
  65. package/src/compiler/fetch.js +159 -0
  66. package/src/compiler/format.js +17 -4
  67. package/src/compiler/forms.js +456 -0
  68. package/src/compiler/frontmatter.js +10 -5
  69. package/src/compiler/includes.js +66 -1
  70. package/src/compiler/index.js +15 -3
  71. package/src/compiler/loops.js +181 -31
  72. package/src/compiler/markdown.js +65 -15
  73. package/src/compiler/media.js +133 -0
  74. package/src/compiler/page.js +78 -41
  75. package/src/compiler/state.js +168 -0
  76. package/src/compiler/structure.js +377 -0
  77. package/src/dev.js +76 -0
  78. package/src/headers.js +56 -17
  79. package/src/router.js +12 -1
  80. package/src/runtime.js +5 -3
  81. package/src/statics.js +63 -12
  82. package/src/templates/blog/README.md +13 -4
  83. package/src/templates/blog/site/_/nav.wd +4 -0
  84. package/src/templates/blog/site/pages/index.skin +87 -0
  85. package/src/templates/blog/site/pages/index.wd +10 -4
  86. package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
  87. package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
  88. package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
  89. package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
  90. package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
  91. package/src/templates/dashboard/site/_/nav.wd +4 -0
  92. package/src/templates/dashboard/site/pages/index.skin +84 -0
  93. package/src/templates/dashboard/site/pages/index.wd +1 -0
  94. package/src/templates/landing/site/_/nav.wd +4 -0
  95. package/src/templates/landing/site/pages/index.skin +113 -6
  96. package/src/templates/landing/site/pages/index.wd +1 -0
  97. package/src/templates/starter/site/_/nav.wd +4 -0
  98. package/src/templates/starter/site/pages/about.md +18 -1
  99. package/src/templates/starter/site/pages/about.skin +69 -0
  100. package/src/templates/starter/site/pages/index.skin +81 -3
  101. package/src/templates/starter/site/pages/index.wd +1 -0
  102. package/src/templates/store/site/_/nav.wd +4 -0
  103. package/src/templates/store/site/pages/index.skin +93 -11
  104. package/src/templates/store/site/pages/index.wd +1 -0
  105. package/site/pages/blog/index.md +0 -13
  106. package/src/templates/blog/site/_/posts.json +0 -16
@@ -0,0 +1,159 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `:fetch`: the lifecycle-aware data-loading directive, plus the compile-time
3
+ // URL scheme guard (`validateFetchUrl`) the form/media/embed handlers reuse.
4
+ //
5
+ // Every handler receives the 0-based line `index` so its malformed/invalid
6
+ // errors report `file:line` (via `at`) — matching the unclosed-block errors —
7
+ // while keeping the message text and `Use:` hints intact.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import { at } from "./context.js";
11
+ import {
12
+ escapeHtml,
13
+ resolveStateKey,
14
+ safeScriptJson,
15
+ stripQuotes,
16
+ validatePath
17
+ } from "./interpolation.js";
18
+ import { declareState } from "./state.js";
19
+
20
+ /**
21
+ * @typedef {import("./context.js").Ctx} Ctx
22
+ */
23
+
24
+ const FETCH_USE =
25
+ 'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
26
+
27
+ /**
28
+ * Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
29
+ * Auto-declares four state keys (value/error/loading/empty), seeds them, and
30
+ * emits `data-wd-fetch-*` attributes (url/method/when/timeout/retry/headers/
31
+ * body/deps) consumed by the runtime's `startFetch`.
32
+ * @param {string} line
33
+ * @param {Ctx} ctx
34
+ * @param {number} index 0-based line index for `file:line` errors.
35
+ * @returns {string}
36
+ */
37
+ export function handleFetch(line, ctx, index) {
38
+ const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
39
+ if (!head) throw new Error(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`);
40
+ const name = head[1];
41
+ const url = validateFetchUrl(stripQuotes(head[2]), ctx);
42
+ /** @type {Record<string, string>} */
43
+ const opts = {};
44
+ for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
45
+ const kv = part.match(/^([A-Za-z]+)=(.+)$/);
46
+ if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${at(ctx, index)}. ${FETCH_USE}`);
47
+ const optName = kv[1];
48
+ if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
49
+ throw new Error(`Unknown :fetch option "${optName}" in ${at(ctx, index)}. ${FETCH_USE}`);
50
+ }
51
+ opts[optName] = stripQuotes(kv[2]);
52
+ }
53
+
54
+ if (
55
+ opts.method &&
56
+ !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())
57
+ ) {
58
+ throw new Error(
59
+ `:fetch method "${opts.method}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`
60
+ );
61
+ }
62
+ if (opts.when && !["load", "visible"].includes(opts.when)) {
63
+ throw new Error(`:fetch when "${opts.when}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`);
64
+ }
65
+ for (const n of ["timeout", "retry"]) {
66
+ if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
67
+ throw new Error(
68
+ `:fetch ${n} must be a non-negative integer in ${at(ctx, index)}. ${FETCH_USE}`
69
+ );
70
+ }
71
+ }
72
+ if (opts.refresh !== undefined) {
73
+ // Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
74
+ // The new token is written back into the `headers=` state, so it is required.
75
+ if (!opts.headers) {
76
+ throw new Error(
77
+ `:fetch refresh= needs headers= (the state key holding the token to renew) in ${at(ctx, index)}. ${FETCH_USE}`
78
+ );
79
+ }
80
+ opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
81
+ }
82
+
83
+ // Extract `{ path }` dependency keys from the URL (validated against poison
84
+ // segments). The runtime fills them from state on each (re)fetch.
85
+ /** @type {string[]} */
86
+ const deps = [];
87
+ for (const dep of url.matchAll(/\{\s*([A-Za-z_$][\w$.]*)\s*\}/g)) {
88
+ validatePath(dep[1], ctx, FETCH_USE);
89
+ const headKey = dep[1].split(".")[0];
90
+ if (!deps.includes(headKey)) deps.push(headKey);
91
+ }
92
+
93
+ // Auto-declare the four lifecycle keys. `name` is declared via declareState
94
+ // (collision-checked); the derived keys are seeded directly.
95
+ const key = declareState(name, null, ctx);
96
+ ctx.comp.fetchKeys.add(key);
97
+ /** @type {Record<string, unknown>} */
98
+ const seeds = { [key]: null };
99
+ for (const [suffix, seed] of [
100
+ ["_error", null],
101
+ ["_loading", false],
102
+ ["_empty", false]
103
+ ]) {
104
+ const k = `${key}${suffix}`;
105
+ if (!ctx.comp.state.has(k)) ctx.comp.state.set(k, seed);
106
+ seeds[k] = seed;
107
+ }
108
+
109
+ /** @param {string} n @param {string | null | false | undefined} v */
110
+ const attr = (n, v) =>
111
+ v != null && v !== false && v !== "" ? ` data-wd-fetch-${n}="${escapeHtml(v)}"` : "";
112
+ const marker =
113
+ `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"` +
114
+ attr("method", opts.method && opts.method.toUpperCase()) +
115
+ attr("when", opts.when === "visible" ? "visible" : "") +
116
+ attr("timeout", opts.timeout) +
117
+ attr("retry", opts.retry) +
118
+ attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
119
+ attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
120
+ attr("refresh", opts.refresh) +
121
+ attr("deps", deps.join(",")) +
122
+ `></span>`;
123
+ return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
124
+ }
125
+
126
+ /**
127
+ * Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
128
+ * `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
129
+ * URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
130
+ * `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
131
+ * rejected. Interpolated values are percent-encoded by the runtime, so a scheme
132
+ * cannot be injected through state at request time.
133
+ * @param {string} url
134
+ * @param {Ctx} ctx
135
+ * @param {string} [what] Option name for the error message.
136
+ * @returns {string}
137
+ */
138
+ export function validateFetchUrl(url, ctx, what = ":fetch") {
139
+ const value = url.trim();
140
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
141
+ if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
142
+ throw new Error(
143
+ `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`
144
+ );
145
+ }
146
+ if (value.startsWith("//")) {
147
+ throw new Error(
148
+ `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`
149
+ );
150
+ }
151
+ if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
152
+ const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
153
+ if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
154
+ throw new Error(
155
+ `Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`
156
+ );
157
+ }
158
+ return value;
159
+ }
@@ -48,6 +48,17 @@ function toDate(v) {
48
48
  const fmtDate = (d, opts, fallback) =>
49
49
  Number.isNaN(d.getTime()) ? String(fallback) : new Intl.DateTimeFormat(undefined, opts).format(d);
50
50
 
51
+ /**
52
+ * A date-ONLY ISO string (`2026-06-22`, no time) parses as UTC midnight, so
53
+ * formatting it in the build machine's local zone can shift the calendar date a
54
+ * day (e.g. `Jun 21` west of UTC). Format those in UTC so the output is
55
+ * deterministic and matches the written date; full datetimes keep local behavior.
56
+ * @param {unknown} v
57
+ * @returns {{ timeZone: "UTC" } | undefined}
58
+ */
59
+ const dateOnlyTZ = (v) =>
60
+ typeof v === "string" && /^\d{4}-\d{2}-\d{2}$/.test(v.trim()) ? { timeZone: "UTC" } : undefined;
61
+
51
62
  // --- aggregates (shared by pipes and `:computed`) --------------------------
52
63
 
53
64
  /** @type {Record<string, (list: any[], field?: string) => number>} */
@@ -89,13 +100,15 @@ export const FORMATTERS = {
89
100
  const p = 10 ** +dec;
90
101
  return Math.round(toNum(v) * p) / p;
91
102
  },
92
- // dates (format a given instant — pure, no "now")
93
- date: (v, [style = "medium"]) => fmtDate(toDate(v), { dateStyle: /** @type {any} */ (style) }, v),
94
- time: (v, [style = "short"]) => fmtDate(toDate(v), { timeStyle: /** @type {any} */ (style) }, v),
103
+ // dates (format a given instant — pure, no "now"; date-only strings in UTC)
104
+ date: (v, [style = "medium"]) =>
105
+ fmtDate(toDate(v), { dateStyle: /** @type {any} */ (style), ...dateOnlyTZ(v) }, v),
106
+ time: (v, [style = "short"]) =>
107
+ fmtDate(toDate(v), { timeStyle: /** @type {any} */ (style), ...dateOnlyTZ(v) }, v),
95
108
  datetime: (v, [ds = "medium", ts = "short"]) =>
96
109
  fmtDate(
97
110
  toDate(v),
98
- { dateStyle: /** @type {any} */ (ds), timeStyle: /** @type {any} */ (ts) },
111
+ { dateStyle: /** @type {any} */ (ds), timeStyle: /** @type {any} */ (ts), ...dateOnlyTZ(v) },
99
112
  v
100
113
  ),
101
114
  // text
@@ -0,0 +1,456 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Forms + inputs: `:form` (client state / native post / fetch round-trip) and
3
+ // the field directives that feed it — `:input`, `:textarea`, `:select`,
4
+ // `:checkbox`/`:radio` groups, `:submit` — plus the two-way bound controls
5
+ // `:bind` and `:slider`.
6
+ //
7
+ // Every handler receives the 0-based line `index` so its malformed/invalid
8
+ // errors report `file:line` (via `at`) — matching the unclosed-block errors —
9
+ // while keeping the message text and `Use:` hints intact.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ import { at, nestedCtx } from "./context.js";
13
+ import { validateFetchUrl } from "./fetch.js";
14
+ import {
15
+ escapeHtml,
16
+ humanizeName,
17
+ parseStateValue,
18
+ resolveStateKey,
19
+ safeScriptJson,
20
+ stripQuotes
21
+ } from "./interpolation.js";
22
+ import { declareErrorState, declareState } from "./state.js";
23
+
24
+ /**
25
+ * @typedef {import("./context.js").Ctx} Ctx
26
+ */
27
+
28
+ /**
29
+ * @param {string} line
30
+ * @param {string[]} bodyLines
31
+ * @param {Ctx} ctx
32
+ * @param {number} index 0-based line index for `file:line` errors.
33
+ * @returns {string}
34
+ */
35
+ export function handleForm(line, bodyLines, ctx, index) {
36
+ const rest = line.slice(":form".length).trim();
37
+ const rawAction = rest.match(/action="([^"]+)"/)?.[1];
38
+ const method = rest.match(/method="([^"]+)"/)?.[1] || "post";
39
+ const into = rest.match(/(?:^|\s)into\s+([A-Za-z_$][\w$]*)/)?.[1];
40
+ const leftover = rest
41
+ .replace(/action="[^"]+"/, "")
42
+ .replace(/method="[^"]+"/, "")
43
+ .replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
44
+ .trim();
45
+ if ((!rawAction && !into) || leftover) {
46
+ throw new Error(
47
+ `Malformed :form in ${at(ctx, index)}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
48
+ );
49
+ }
50
+ const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
51
+ const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1)).trim();
52
+ if (!into) {
53
+ return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
54
+ }
55
+ const key = declareState(into, null, ctx);
56
+ declareErrorState(key, ctx);
57
+ const actionAttrs = action
58
+ ? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"`
59
+ : "";
60
+ return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
61
+ }
62
+
63
+ /**
64
+ * @param {string} line
65
+ * @param {Ctx} ctx
66
+ * @param {number} index 0-based line index for `file:line` errors.
67
+ * @returns {string}
68
+ */
69
+ export function handleInput(line, ctx, index) {
70
+ const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
71
+ if (!match) throw new Error(`Malformed :input in ${at(ctx, index)}: ${line}`);
72
+ const attrs = [`name="${escapeHtml(match[1])}"`];
73
+ let type = "text";
74
+ let placeholder;
75
+ let hasAria = false;
76
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
77
+ for (const token of (match[2] || "").matchAll(re)) {
78
+ if (token[3]) {
79
+ if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
80
+ throw new Error(`Unknown :input flag "${token[3]}" in ${at(ctx, index)}`);
81
+ }
82
+ attrs.push(token[3]);
83
+ continue;
84
+ }
85
+ const value = stripQuotes(token[2]);
86
+ if (token[1] === "type") {
87
+ type = value;
88
+ continue;
89
+ }
90
+ if (
91
+ ![
92
+ "placeholder",
93
+ "value",
94
+ "min",
95
+ "max",
96
+ "step",
97
+ "pattern",
98
+ "autocomplete",
99
+ "aria-label",
100
+ "aria-describedby"
101
+ ].includes(token[1])
102
+ ) {
103
+ throw new Error(`Unknown :input attribute "${token[1]}" in ${at(ctx, index)}`);
104
+ }
105
+ if (token[1] === "placeholder") placeholder = value;
106
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
107
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
108
+ }
109
+ // Accessible name: if the author supplied neither aria-label nor aria-describedby,
110
+ // derive a non-visual aria-label from the placeholder, else from the field name.
111
+ // Never overrides an author-supplied aria attribute (zero layout impact).
112
+ if (!hasAria) {
113
+ attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
114
+ }
115
+ return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
116
+ }
117
+
118
+ // :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
119
+ // declared :state. Updates state on every keystroke; reflects state back when
120
+ // not focused. The state must be declared first (with :state).
121
+ /**
122
+ * @param {string} line
123
+ * @param {Ctx} ctx
124
+ * @param {number} index 0-based line index for `file:line` errors.
125
+ * @returns {string}
126
+ */
127
+ export function handleBind(line, ctx, index) {
128
+ const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
129
+ if (!match)
130
+ throw new Error(
131
+ `Malformed :bind in ${at(ctx, index)}: ${line}. Use: :bind query placeholder="Search"`
132
+ );
133
+ const key = resolveStateKey(match[1], ctx);
134
+ if (!key) {
135
+ throw new Error(
136
+ `:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`
137
+ );
138
+ }
139
+ ctx.comp.assets.runtime = true;
140
+ let type = "text";
141
+ let placeholder;
142
+ let hasAria = false;
143
+ const attrs = [];
144
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
145
+ for (const token of (match[2] || "").matchAll(re)) {
146
+ if (token[3]) {
147
+ if (!["required", "autofocus"].includes(token[3]))
148
+ throw new Error(`Unknown :bind flag "${token[3]}" in ${at(ctx, index)}`);
149
+ attrs.push(token[3]);
150
+ continue;
151
+ }
152
+ const value = stripQuotes(token[2]);
153
+ if (token[1] === "type") {
154
+ type = value;
155
+ continue;
156
+ }
157
+ if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
158
+ throw new Error(`Unknown :bind attribute "${token[1]}" in ${at(ctx, index)}`);
159
+ }
160
+ if (token[1] === "placeholder") placeholder = value;
161
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
162
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
163
+ }
164
+ // Accessible name (see handleInput): default a non-visual aria-label from the
165
+ // placeholder, else a humanized version of the bound state key. The author's own
166
+ // aria-label/aria-describedby always wins.
167
+ if (!hasAria) {
168
+ attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
169
+ }
170
+ const initial = ctx.comp.state.get(key);
171
+ const valueAttr =
172
+ initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
173
+ return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
174
+ }
175
+
176
+ /**
177
+ * `:slider name [= value] [min=N] [max=N] [step=N] [aria-label="…"] [persist]` — a
178
+ * range input two-way bound to a NUMBER :state. With `= value` it declares the state
179
+ * inline (seeding it numeric) and may `persist`; without `=` it binds to an already-
180
+ * declared :state. Pure compile-time: it reuses the runtime's input binding (range
181
+ * values coerce to Number), so it ships NO behavior module.
182
+ * @param {string} line
183
+ * @param {Ctx} ctx
184
+ * @param {number} index 0-based line index for `file:line` errors.
185
+ * @returns {string}
186
+ */
187
+ export function handleSlider(line, ctx, index) {
188
+ const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
189
+ if (!head)
190
+ throw new Error(
191
+ `Malformed :slider in ${at(ctx, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
192
+ );
193
+ const name = head[1];
194
+ let rest = head[2].trim();
195
+
196
+ /** @type {string | undefined} */
197
+ let initialRaw;
198
+ if (rest.startsWith("=")) {
199
+ const valueMatch = rest.match(/^=\s*(\S+)\s*(.*)$/);
200
+ if (!valueMatch)
201
+ throw new Error(
202
+ `Malformed :slider initial value in ${at(ctx, index)}: ${line}. Use: :slider ${name} = 50`
203
+ );
204
+ initialRaw = valueMatch[1];
205
+ rest = valueMatch[2].trim();
206
+ }
207
+
208
+ let min = "0";
209
+ let max = "100";
210
+ let step = "1";
211
+ /** @type {string | undefined} */
212
+ let ariaLabel;
213
+ let persist = false;
214
+ for (const token of rest.matchAll(/([A-Za-z-]+)=("[^"]*"|\S+)|(\bpersist\b)/g)) {
215
+ if (token[3]) {
216
+ persist = true;
217
+ continue;
218
+ }
219
+ const value = stripQuotes(token[2]);
220
+ if (token[1] === "min") min = value;
221
+ else if (token[1] === "max") max = value;
222
+ else if (token[1] === "step") step = value;
223
+ else if (token[1] === "aria-label") ariaLabel = value;
224
+ else throw new Error(`Unknown :slider attribute "${token[1]}" in ${at(ctx, index)}`);
225
+ }
226
+ for (const [label, raw] of [
227
+ ["min", min],
228
+ ["max", max],
229
+ ["step", step]
230
+ ]) {
231
+ if (!/^-?\d+(?:\.\d+)?$/.test(raw))
232
+ throw new Error(`:slider ${label} must be a number in ${at(ctx, index)}: ${raw}`);
233
+ }
234
+
235
+ ctx.comp.assets.runtime = true;
236
+ /** @type {string} */
237
+ let key;
238
+ let seed = "";
239
+ if (initialRaw !== undefined) {
240
+ const value = parseStateValue(initialRaw, at(ctx, index));
241
+ if (typeof value !== "number")
242
+ throw new Error(
243
+ `:slider ${name} initial value must be a number in ${at(ctx, index)}: ${initialRaw}`
244
+ );
245
+ key = declareState(name, value, ctx);
246
+ const persistAttr = persist ? ` data-wd-persist="${key}"` : "";
247
+ seed = `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
248
+ } else {
249
+ if (persist)
250
+ throw new Error(
251
+ `:slider persist only applies when declaring state inline (:slider ${name} = 0 … persist) in ${at(ctx, index)}`
252
+ );
253
+ const resolved = resolveStateKey(name, ctx);
254
+ if (!resolved)
255
+ throw new Error(
256
+ `:slider ${name} in ${ctx.file} has no matching state. Declare it: :slider ${name} = 0 min=0 max=100`
257
+ );
258
+ key = resolved;
259
+ }
260
+
261
+ const initial = ctx.comp.state.get(key);
262
+ const valueAttr =
263
+ initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
264
+ const ariaAttr = ` aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`;
265
+ return `${seed}<input type="range" data-wd-bind-input="${key}" min="${escapeHtml(min)}" max="${escapeHtml(max)}" step="${escapeHtml(step)}"${valueAttr}${ariaAttr}>`;
266
+ }
267
+
268
+ /**
269
+ * @param {string} line
270
+ * @param {Ctx} ctx
271
+ * @param {number} index 0-based line index for `file:line` errors.
272
+ * @returns {string}
273
+ */
274
+ export function handleSubmit(line, ctx, index) {
275
+ const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
276
+ if (!match)
277
+ throw new Error(`Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label"`);
278
+ return `<button type="submit">${escapeHtml(match[1])}</button>`;
279
+ }
280
+
281
+ /**
282
+ * `:textarea name [placeholder="…"] [rows=N] [required]` → a `<textarea>`. Like
283
+ * `:input`, it derives a non-visual aria-label from the placeholder (else the
284
+ * humanized name) when the author supplies none. Captured by the runtime's
285
+ * FormData exactly like `:input` — no runtime change needed.
286
+ * @param {string} line
287
+ * @param {Ctx} ctx
288
+ * @param {number} index 0-based line index for `file:line` errors.
289
+ * @returns {string}
290
+ */
291
+ export function handleTextarea(line, ctx, index) {
292
+ const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
293
+ if (!match)
294
+ throw new Error(
295
+ `Malformed :textarea in ${at(ctx, index)}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`
296
+ );
297
+ const attrs = [`name="${escapeHtml(match[1])}"`];
298
+ let placeholder;
299
+ let hasAria = false;
300
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
301
+ for (const token of (match[2] || "").matchAll(re)) {
302
+ if (token[3]) {
303
+ if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
304
+ throw new Error(`Unknown :textarea flag "${token[3]}" in ${at(ctx, index)}`);
305
+ }
306
+ attrs.push(token[3]);
307
+ continue;
308
+ }
309
+ const value = stripQuotes(token[2]);
310
+ if (
311
+ ![
312
+ "placeholder",
313
+ "rows",
314
+ "cols",
315
+ "minlength",
316
+ "maxlength",
317
+ "autocomplete",
318
+ "aria-label",
319
+ "aria-describedby"
320
+ ].includes(token[1])
321
+ ) {
322
+ throw new Error(`Unknown :textarea attribute "${token[1]}" in ${at(ctx, index)}`);
323
+ }
324
+ if (token[1] === "placeholder") placeholder = value;
325
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
326
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
327
+ }
328
+ if (!hasAria) {
329
+ attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
330
+ }
331
+ return `<textarea ${attrs.join(" ")}></textarea>`;
332
+ }
333
+
334
+ /**
335
+ * `:select name [required]` followed by `- Label` list lines → a `<select>` with
336
+ * one `<option>` per label (value === label). Derives an aria-label from the
337
+ * humanized name when none is given. Captured by FormData like the other fields.
338
+ * @param {string} line
339
+ * @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
340
+ * @param {Ctx} ctx
341
+ * @param {number} index 0-based line index for `file:line` errors.
342
+ * @returns {string}
343
+ */
344
+ export function handleSelect(line, optionLines, ctx, index) {
345
+ const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
346
+ if (!match)
347
+ throw new Error(
348
+ `Malformed :select in ${at(ctx, index)}: ${line}. Use: :select name [required] then "- Label" lines`
349
+ );
350
+ const attrs = [`name="${escapeHtml(match[1])}"`];
351
+ let hasAria = false;
352
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
353
+ for (const token of (match[2] || "").matchAll(re)) {
354
+ if (token[3]) {
355
+ if (!["required", "disabled", "autofocus"].includes(token[3])) {
356
+ throw new Error(`Unknown :select flag "${token[3]}" in ${at(ctx, index)}`);
357
+ }
358
+ attrs.push(token[3]);
359
+ continue;
360
+ }
361
+ const value = stripQuotes(token[2]);
362
+ if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
363
+ throw new Error(`Unknown :select attribute "${token[1]}" in ${at(ctx, index)}`);
364
+ }
365
+ if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
366
+ attrs.push(`${token[1]}="${escapeHtml(value)}"`);
367
+ }
368
+ if (!hasAria) {
369
+ attrs.push(`aria-label="${escapeHtml(humanizeName(match[1]))}"`);
370
+ }
371
+ const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
372
+ if (!options.length) {
373
+ throw new Error(
374
+ `:select "${match[1]}" in ${at(ctx, index)} has no options. Add "- Label" lines beneath it.`
375
+ );
376
+ }
377
+ const opts = options
378
+ .map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`)
379
+ .join("");
380
+ return `<select ${attrs.join(" ")}>${opts}</select>`;
381
+ }
382
+
383
+ /**
384
+ * `:checkbox name [required]` / `:radio name [required]` followed by `- Label`
385
+ * lines → a labelled group of `<input type=checkbox|radio>`, each sharing `name`
386
+ * with value === label and wrapped in its own `<label>`. The group is a
387
+ * `role="group"` / `role="radiogroup"` container with an aria-label (explicit or
388
+ * humanized from the name). Checkbox groups are marked `data-wd-multi="name"` so
389
+ * the runtime captures every checked value as an array (`FormData.getAll`); radio
390
+ * groups capture a single value like `:input`. Flags: `required` (radio → the
391
+ * group; not emitted on checkboxes, where "at least one" has no native HTML form),
392
+ * `disabled` (whole group), `autofocus` (first control).
393
+ * @param {string} line
394
+ * @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
395
+ * @param {Ctx} ctx
396
+ * @param {"checkbox" | "radio"} kind
397
+ * @param {number} index 0-based line index for `file:line` errors.
398
+ * @returns {string}
399
+ */
400
+ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
401
+ const directive = `:${kind}`;
402
+ const match = line.match(
403
+ kind === "checkbox"
404
+ ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/
405
+ : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/
406
+ );
407
+ if (!match)
408
+ throw new Error(
409
+ `Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${directive} name [required] then "- Label" lines`
410
+ );
411
+ const name = match[1];
412
+ const flags = [];
413
+ let ariaLabel = null;
414
+ let ariaDescribedby = null;
415
+ const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
416
+ for (const token of (match[2] || "").matchAll(re)) {
417
+ if (token[3]) {
418
+ if (!["required", "disabled", "autofocus"].includes(token[3])) {
419
+ throw new Error(`Unknown ${directive} flag "${token[3]}" in ${at(ctx, index)}`);
420
+ }
421
+ flags.push(token[3]);
422
+ continue;
423
+ }
424
+ const value = stripQuotes(token[2]);
425
+ if (token[1] === "aria-label") ariaLabel = value;
426
+ else if (token[1] === "aria-describedby") ariaDescribedby = value;
427
+ else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${at(ctx, index)}`);
428
+ }
429
+ const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
430
+ if (!options.length) {
431
+ throw new Error(
432
+ `${directive} "${name}" in ${at(ctx, index)} has no options. Add "- Label" lines beneath it.`
433
+ );
434
+ }
435
+ const isCheckbox = kind === "checkbox";
436
+ const groupAttrs = [
437
+ `class="wd-${isCheckbox ? "checkboxes" : "radios"}"`,
438
+ `role="${isCheckbox ? "group" : "radiogroup"}"`
439
+ ];
440
+ if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
441
+ groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
442
+ if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
443
+ const disabled = flags.includes("disabled");
444
+ const required = flags.includes("required");
445
+ const autofocus = flags.includes("autofocus");
446
+ const controls = options
447
+ .map((opt, idx) => {
448
+ const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
449
+ if (disabled) a.push("disabled");
450
+ if (required && !isCheckbox) a.push("required");
451
+ if (autofocus && idx === 0) a.push("autofocus");
452
+ return `<label><input ${a.join(" ")}> ${escapeHtml(opt)}</label>`;
453
+ })
454
+ .join("");
455
+ return `<div ${groupAttrs.join(" ")}>${controls}</div>`;
456
+ }