@zvndev/darkmown 1.5.0 → 2.0.1
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 +18 -4
- package/CHANGELOG.md +40 -0
- package/README.md +49 -19
- package/SECURITY.md +21 -18
- package/docs/cli.md +5 -1
- package/docs/spec-alignment.md +21 -7
- package/package.json +3 -2
- package/site/_/base.skin +8 -0
- package/site/_/base.wd +4 -0
- package/site/_/feature-card.wd +4 -0
- package/site/_/footer.wd +5 -1
- package/site/_/nav.wd +4 -0
- package/site/_/scoped-promo.wd +4 -0
- package/site/_/scoped-stat.wd +4 -0
- package/site/pages/404.wd +1 -0
- package/site/pages/app.wd +2 -1
- package/site/pages/blog/collections-and-pagination.skin +127 -0
- package/site/pages/blog/drafts-that-never-leak.skin +127 -0
- package/site/pages/blog/index.wd +1 -0
- package/site/pages/blog/shipping-feeds.skin +127 -0
- package/site/pages/blog/typed-frontmatter.skin +127 -0
- package/site/pages/blog/unfinished-thoughts.skin +127 -0
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/blog/zero-js-by-default.skin +127 -0
- package/site/pages/carousel/index.wd +1 -0
- package/site/pages/compass/index.wd +1 -0
- package/site/pages/data.wd +3 -0
- package/site/pages/docs/-relative-note.wd +4 -0
- package/site/pages/docs/index.skin +27 -0
- package/site/pages/docs/index.wd +85 -14
- package/site/pages/folio/-style.wd +4 -0
- package/site/pages/folio/cart.wd +1 -0
- package/site/pages/folio/index.wd +1 -0
- package/site/pages/forge/index.wd +1 -0
- package/site/pages/highlight/index.wd +1 -0
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +6 -3
- package/site/pages/interactions/index.wd +1 -0
- package/site/pages/ledger/index.wd +1 -0
- package/site/pages/loops.wd +1 -0
- package/site/pages/media/index.wd +1 -0
- package/site/pages/pulse/index.wd +1 -0
- package/site/pages/reactive.wd +1 -0
- package/site/pages/scoped/index.wd +1 -0
- package/site/pages/showcase/index.wd +2 -1
- package/site/pages/store.wd +1 -0
- package/site/pages/theme.wd +1 -0
- package/src/builder.js +444 -65
- package/src/cli.js +96 -18
- package/src/compiler/actions.js +303 -0
- package/src/compiler/body.js +36 -24
- package/src/compiler/context.js +65 -6
- package/src/compiler/directives.js +31 -1470
- package/src/compiler/fetch.js +159 -0
- package/src/compiler/format.js +17 -4
- package/src/compiler/forms.js +456 -0
- package/src/compiler/frontmatter.js +10 -5
- package/src/compiler/includes.js +75 -4
- package/src/compiler/index.js +15 -3
- package/src/compiler/loops.js +85 -23
- package/src/compiler/markdown.js +65 -15
- package/src/compiler/media.js +133 -0
- package/src/compiler/page.js +88 -77
- package/src/compiler/state.js +168 -0
- package/src/compiler/structure.js +399 -0
- package/src/dev.js +76 -0
- package/src/headers.js +56 -17
- package/src/router.js +12 -1
- package/src/runtime.js +5 -3
- package/src/statics.js +63 -12
- package/src/templates/blog/README.md +13 -4
- package/src/templates/blog/site/_/nav.wd +7 -6
- package/src/templates/blog/site/pages/index.skin +10 -4
- package/src/templates/blog/site/pages/index.wd +11 -6
- package/src/templates/blog/site/pages/posts/_schema.wd +16 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.md +2 -0
- package/src/templates/blog/site/pages/posts/hello-darkmown.skin +59 -0
- package/src/templates/blog/site/pages/posts/markdown-native.md +9 -4
- package/src/templates/blog/site/pages/posts/markdown-native.skin +59 -0
- package/src/templates/dashboard/site/_/nav.wd +7 -6
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -2
- package/src/templates/landing/site/_/nav.wd +7 -6
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -8
- package/src/templates/starter/site/_/nav.wd +8 -7
- package/src/templates/starter/site/pages/about.md +8 -2
- package/src/templates/starter/site/pages/about.skin +44 -0
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -2
- package/src/templates/store/site/_/nav.wd +7 -6
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -2
- 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
|
+
}
|
package/src/compiler/format.js
CHANGED
|
@@ -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"]) =>
|
|
94
|
-
|
|
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
|
+
}
|