@zvndev/darkmown 0.19.0 → 1.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +22 -4
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/builder.js +20 -14
- package/src/cli.js +216 -77
- package/src/compiler/body.js +457 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1345 -0
- package/src/compiler/format.js +244 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +223 -0
- package/src/compiler/loops.js +715 -0
- package/src/compiler/markdown.js +242 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +314 -0
- package/src/compiler.js +26 -2932
- package/src/dev.js +3 -1
- package/src/headers.js +8 -4
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +168 -14
- package/src/scaffold.js +158 -131
- package/src/skin.js +11 -2
- package/site/_/og.png +0 -0
package/src/compiler.js
CHANGED
|
@@ -1,2932 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
12
|
-
* @typedef {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Per-document compilation accumulator shared across includes/sections.
|
|
31
|
-
* @typedef {object} Compilation
|
|
32
|
-
* @property {Assets} assets
|
|
33
|
-
* @property {Map<string, unknown>} state Declared state keys → initial values.
|
|
34
|
-
* @property {Set<string>} stores Page-global store names (a subset of state keys).
|
|
35
|
-
* @property {string[]} warnings Non-fatal authoring hints.
|
|
36
|
-
* @property {number} sectionCounter Counter for auto-generated section ids.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* A lexical scope chain for static interpolation values (include args, loop vars).
|
|
41
|
-
* @typedef {object} Scope
|
|
42
|
-
* @property {Scope | null} parent
|
|
43
|
-
* @property {Record<string, unknown>} vars
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Per-file compile context threaded through the directive handlers.
|
|
48
|
-
* @typedef {object} Ctx
|
|
49
|
-
* @property {string} file Absolute path to the file being compiled.
|
|
50
|
-
* @property {Paths} context Resolved project paths.
|
|
51
|
-
* @property {string[]} stack Include stack (real paths) for cycle detection.
|
|
52
|
-
* @property {Scope} scope Static value scope chain.
|
|
53
|
-
* @property {Compilation} comp Shared compilation accumulator.
|
|
54
|
-
* @property {string[]} sections Active section-id scope chain.
|
|
55
|
-
* @property {string | null} loopItem Name of the current reactive loop item, if any.
|
|
56
|
-
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
57
|
-
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
58
|
-
* @property {MarkdownIt} [md] Markdown-it instance selected for this file.
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* A compiled page document.
|
|
63
|
-
* @typedef {object} CompiledDocument
|
|
64
|
-
* @property {Meta} meta
|
|
65
|
-
* @property {string} html
|
|
66
|
-
* @property {Assets} assets
|
|
67
|
-
* @property {string[]} warnings
|
|
68
|
-
*/
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* A compiled page: the document plus its full HTML shell.
|
|
72
|
-
* @typedef {object} CompiledPage
|
|
73
|
-
* @property {Meta} meta
|
|
74
|
-
* @property {string} html
|
|
75
|
-
* @property {Assets} assets
|
|
76
|
-
* @property {string[]} warnings
|
|
77
|
-
*/
|
|
78
|
-
|
|
79
|
-
const md = new MarkdownIt({ html: true });
|
|
80
|
-
md.use(bindingPlugin);
|
|
81
|
-
md.use(attrsPlugin);
|
|
82
|
-
|
|
83
|
-
// Raw HTML in markdown passes through by default (a documented design choice).
|
|
84
|
-
// Pages can opt out with frontmatter `html: false` for untrusted content; the
|
|
85
|
-
// stricter instance is built lazily so the default path stays a single instance.
|
|
86
|
-
/** @type {MarkdownIt | null} */
|
|
87
|
-
let mdNoHtml = null;
|
|
88
|
-
/**
|
|
89
|
-
* Pick the markdown-it instance for a page (raw HTML on by default, off with `html: false`).
|
|
90
|
-
* @param {Meta} [meta]
|
|
91
|
-
* @returns {MarkdownIt}
|
|
92
|
-
*/
|
|
93
|
-
function selectMd(meta) {
|
|
94
|
-
// Frontmatter scalars stay strings (no coercion), so accept both forms.
|
|
95
|
-
if (meta?.html !== false && meta?.html !== "false") return md;
|
|
96
|
-
if (!mdNoHtml) {
|
|
97
|
-
mdNoHtml = new MarkdownIt({ html: false });
|
|
98
|
-
mdNoHtml.use(bindingPlugin);
|
|
99
|
-
mdNoHtml.use(attrsPlugin);
|
|
100
|
-
}
|
|
101
|
-
return mdNoHtml;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const pageIncludeExtensions = [".md", ".wd"];
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Compile a page source file into a full HTML document plus its assets.
|
|
108
|
-
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
109
|
-
* @param {Paths} context Resolved project paths.
|
|
110
|
-
* @returns {CompiledPage}
|
|
111
|
-
*/
|
|
112
|
-
export function compilePage(file, context) {
|
|
113
|
-
const compiled = compileDocument(file, context);
|
|
114
|
-
const title = compiled.meta.title || "Darkmown";
|
|
115
|
-
const description = compiled.meta.description || "";
|
|
116
|
-
// Optional `image:` frontmatter sets the social-share preview (absolute URL).
|
|
117
|
-
const image = typeof compiled.meta.image === "string" ? compiled.meta.image : "";
|
|
118
|
-
/** @type {string[]} */
|
|
119
|
-
const social = [];
|
|
120
|
-
if (description) social.push(`<meta name="description" content="${escapeHtml(description)}">`);
|
|
121
|
-
if (description || image) {
|
|
122
|
-
social.push(`<meta property="og:title" content="${escapeHtml(title)}">`);
|
|
123
|
-
if (description) social.push(`<meta property="og:description" content="${escapeHtml(description)}">`);
|
|
124
|
-
social.push(`<meta property="og:type" content="website">`);
|
|
125
|
-
}
|
|
126
|
-
if (image) {
|
|
127
|
-
social.push(`<meta property="og:image" content="${escapeHtml(image)}">`);
|
|
128
|
-
social.push(`<meta name="twitter:image" content="${escapeHtml(image)}">`);
|
|
129
|
-
}
|
|
130
|
-
if (description || image) {
|
|
131
|
-
social.push(`<meta name="twitter:card" content="${image ? "summary_large_image" : "summary"}">`);
|
|
132
|
-
}
|
|
133
|
-
const descriptionTag = social.length ? `\n ${social.join("\n ")}` : "";
|
|
134
|
-
const favicon = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
|
|
135
|
-
const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
|
|
136
|
-
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
137
|
-
const scripts = scriptSrcs.map((src) => `<script type="module" src="${src}"></script>`).join("\n");
|
|
138
|
-
// Cross-document view transitions: opt in per page with `transitions: true` in
|
|
139
|
-
// frontmatter to emit a CSS-only stylesheet — a smooth same-origin transition
|
|
140
|
-
// on navigation, with zero JavaScript. Ignored by browsers without support
|
|
141
|
-
// (graceful, transition-free fallback) and only applies to same-origin
|
|
142
|
-
// navigations where both the outgoing and incoming page opt in.
|
|
143
|
-
//
|
|
144
|
-
// The default UA animation cross-fades the root: outgoing opacity 1→0 while
|
|
145
|
-
// incoming 0→1, both at once. Mid-navigation that leaves the two pages
|
|
146
|
-
// superimposed at ~50% opacity — headings ghost over headings. We override it
|
|
147
|
-
// with a directional fade+slide (old lifts up and out, new rises up and in) so
|
|
148
|
-
// the pages move past each other instead of stacking. Short + eased = peppy.
|
|
149
|
-
const wantTransitions = compiled.meta.transitions === true || compiled.meta.transitions === "true";
|
|
150
|
-
const transitions = wantTransitions
|
|
151
|
-
? `\n <style>
|
|
152
|
-
@view-transition { navigation: auto; }
|
|
153
|
-
::view-transition-old(root) { animation: wd-nav-out 200ms cubic-bezier(0.4, 0, 1, 1) both; }
|
|
154
|
-
::view-transition-new(root) { animation: wd-nav-in 200ms cubic-bezier(0, 0, 0.2, 1) both; }
|
|
155
|
-
@keyframes wd-nav-out { to { opacity: 0; transform: translateY(-1rem); } }
|
|
156
|
-
@keyframes wd-nav-in { from { opacity: 0; transform: translateY(1rem); } }
|
|
157
|
-
@media (prefers-reduced-motion: reduce) {
|
|
158
|
-
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 1ms; }
|
|
159
|
-
@keyframes wd-nav-out { to { opacity: 0; } }
|
|
160
|
-
@keyframes wd-nav-in { from { opacity: 0; } }
|
|
161
|
-
}
|
|
162
|
-
</style>`
|
|
163
|
-
: "";
|
|
164
|
-
|
|
165
|
-
// The latency half of smooth navigation — and what kills the white flash. A
|
|
166
|
-
// declarative speculationrules script (the browser interprets it — not
|
|
167
|
-
// framework runtime JS, so the zero-JS invariant holds) *prerenders* the next
|
|
168
|
-
// same-origin page on hover/pointerdown. Prefetch only warms the cache, so the
|
|
169
|
-
// page still has to render on click — that render gap is the white flash. A
|
|
170
|
-
// prerender renders the whole page in a hidden tab ahead of the click, so
|
|
171
|
-
// activation is instant: no render gap, and the view transition fires on the
|
|
172
|
-
// already-painted page. Safe now that pages are light. Eagerness `moderate` =
|
|
173
|
-
// ~200ms hover / pointerdown, capped at two. Mark a link `{.no-prefetch}` to
|
|
174
|
-
// opt it out; `rel=nofollow` links are never speculated. Browsers without
|
|
175
|
-
// support (or with preloading disabled) ignore the tag and navigate normally.
|
|
176
|
-
const speculation = wantTransitions
|
|
177
|
-
? `\n <script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"selector_matches":".no-prefetch"}},{"not":{"selector_matches":"[rel~=nofollow]"}}]},"eagerness":"moderate"}]}</script>`
|
|
178
|
-
: "";
|
|
179
|
-
|
|
180
|
-
const body = enhanceImages(compiled.html, context);
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
meta: compiled.meta,
|
|
184
|
-
html: `<!doctype html>
|
|
185
|
-
<html lang="en">
|
|
186
|
-
<head>
|
|
187
|
-
<meta charset="utf-8">
|
|
188
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
189
|
-
<title>${escapeHtml(title)}</title>${descriptionTag}
|
|
190
|
-
<link rel="icon" href="${favicon}">
|
|
191
|
-
${cssLinks}${transitions}${speculation}
|
|
192
|
-
</head>
|
|
193
|
-
<body>
|
|
194
|
-
${body}
|
|
195
|
-
${scripts}
|
|
196
|
-
</body>
|
|
197
|
-
</html>`,
|
|
198
|
-
assets: compiled.assets,
|
|
199
|
-
warnings: compiled.warnings
|
|
200
|
-
};
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Harden every `<img>` in the assembled page body — compile-time only, zero
|
|
205
|
-
* runtime JS. Bare markdown images reflow the page as they decode (cumulative
|
|
206
|
-
* layout shift — the literal "jump" on navigation) and load eagerly. This
|
|
207
|
-
* stamps intrinsic `width`/`height` (read from the file on disk so the browser
|
|
208
|
-
* reserves space), `decoding="async"` on all, and a load-priority split: the
|
|
209
|
-
* first image is the LCP candidate (eager + `fetchpriority="high"`), the rest
|
|
210
|
-
* lazy-load. Author-set attributes are never overwritten.
|
|
211
|
-
* @param {string} html Assembled page body HTML.
|
|
212
|
-
* @param {Paths} paths Resolved project paths.
|
|
213
|
-
* @returns {string}
|
|
214
|
-
*/
|
|
215
|
-
export function enhanceImages(html, paths) {
|
|
216
|
-
let index = 0;
|
|
217
|
-
return html.replace(/<img\b[^>]*?\/?>/g, (tag) => {
|
|
218
|
-
const i = index++;
|
|
219
|
-
const has = (/** @type {string} */ attr) => new RegExp(`\\b${attr}\\s*=`, "i").test(tag);
|
|
220
|
-
/** @type {string[]} */
|
|
221
|
-
const add = [];
|
|
222
|
-
// Dimensions only when the author hasn't sized it — adding one axis to a
|
|
223
|
-
// manually-sized image would distort its aspect ratio.
|
|
224
|
-
if (!has("width") && !has("height")) {
|
|
225
|
-
const dim = measureImage(srcOf(tag), paths);
|
|
226
|
-
if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
|
|
227
|
-
}
|
|
228
|
-
if (!has("decoding")) add.push(`decoding="async"`);
|
|
229
|
-
if (i === 0) {
|
|
230
|
-
// First image is the LCP candidate: stays eager, gets a priority hint.
|
|
231
|
-
if (!has("fetchpriority")) add.push(`fetchpriority="high"`);
|
|
232
|
-
} else if (!has("loading")) {
|
|
233
|
-
add.push(`loading="lazy"`);
|
|
234
|
-
}
|
|
235
|
-
if (!add.length) return tag;
|
|
236
|
-
return `${tag.replace(/\s*\/?>\s*$/, "")} ${add.join(" ")}>`;
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Extract the `src` attribute value from an `<img>` tag, or "" if absent.
|
|
242
|
-
* @param {string} tag
|
|
243
|
-
* @returns {string}
|
|
244
|
-
*/
|
|
245
|
-
function srcOf(tag) {
|
|
246
|
-
const m = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
|
|
247
|
-
return m ? m[1] : "";
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Read an image's intrinsic dimensions from disk, or null when the src is
|
|
252
|
-
* remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
|
|
253
|
-
* comes from the shelf (`site/_`), other absolute paths from `site/pages`.
|
|
254
|
-
* @param {string} src
|
|
255
|
-
* @param {Paths} paths
|
|
256
|
-
* @returns {{ width: number, height: number } | null}
|
|
257
|
-
*/
|
|
258
|
-
function measureImage(src, paths) {
|
|
259
|
-
if (!src || /^(https?:)?\/\//i.test(src) || src.startsWith("data:")) return null;
|
|
260
|
-
let filePath;
|
|
261
|
-
if (src.startsWith("/__wd/media/")) {
|
|
262
|
-
filePath = path.join(paths.shelfRoot, src.slice("/__wd/media/".length));
|
|
263
|
-
} else if (src.startsWith("/")) {
|
|
264
|
-
filePath = path.join(paths.routesRoot, src.slice(1));
|
|
265
|
-
} else {
|
|
266
|
-
return null; // page-relative: the source directory is lost after assembly
|
|
267
|
-
}
|
|
268
|
-
try {
|
|
269
|
-
const { width, height } = imageSize(fs.readFileSync(filePath));
|
|
270
|
-
if (typeof width === "number" && typeof height === "number") return { width, height };
|
|
271
|
-
} catch {
|
|
272
|
-
/* missing or unreadable — degrade to no dimensions */
|
|
273
|
-
}
|
|
274
|
-
return null;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Compile a source file into its body HTML, frontmatter, and assets (no page shell).
|
|
279
|
-
* @param {string} file Absolute path to the source file.
|
|
280
|
-
* @param {Paths} context Resolved project paths.
|
|
281
|
-
* @param {string[]} [stack] Include stack for cycle detection.
|
|
282
|
-
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
283
|
-
* @returns {CompiledDocument}
|
|
284
|
-
*/
|
|
285
|
-
export function compileDocument(file, context, stack = [], vars = {}) {
|
|
286
|
-
const comp = createCompilation();
|
|
287
|
-
const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
|
|
288
|
-
return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** @returns {Compilation} */
|
|
292
|
-
function createCompilation() {
|
|
293
|
-
return {
|
|
294
|
-
assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
|
|
295
|
-
state: new Map(),
|
|
296
|
-
stores: new Set(),
|
|
297
|
-
warnings: [],
|
|
298
|
-
sectionCounter: 0
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* @param {Scope | null} parent
|
|
304
|
-
* @param {Record<string, unknown>} [vars]
|
|
305
|
-
* @returns {Scope}
|
|
306
|
-
*/
|
|
307
|
-
function createScope(parent, vars = {}) {
|
|
308
|
-
return { parent, vars: { ...vars } };
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Compile a single file: parse frontmatter, collect assets, render body.
|
|
313
|
-
* @param {string} file
|
|
314
|
-
* @param {Paths} context
|
|
315
|
-
* @param {string[]} stack
|
|
316
|
-
* @param {Scope} scope
|
|
317
|
-
* @param {Compilation} comp
|
|
318
|
-
* @param {string[]} sections
|
|
319
|
-
* @param {string | null} loopItem
|
|
320
|
-
* @returns {{ meta: Meta, html: string }}
|
|
321
|
-
*/
|
|
322
|
-
function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
323
|
-
const real = fs.realpathSync(file);
|
|
324
|
-
if (stack.includes(real)) {
|
|
325
|
-
throw new Error(`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
const raw = fs.readFileSync(file, "utf8");
|
|
329
|
-
const { meta, body } = parseFrontmatter(raw, file);
|
|
330
|
-
warnLikelyFrontmatter(raw, file, comp);
|
|
331
|
-
collectColocatedAssets(file, context, comp.assets);
|
|
332
|
-
|
|
333
|
-
if (path.extname(file) === ".md") {
|
|
334
|
-
scanMarkdownHints(body, file, comp);
|
|
335
|
-
return { meta, html: selectMd(meta).render(body, {}) };
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
|
|
339
|
-
// `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
|
|
340
|
-
/** @type {Ctx} */
|
|
341
|
-
const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem, md: selectMd(meta) };
|
|
342
|
-
return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/**
|
|
346
|
-
* Split a raw file into its frontmatter `meta` and `body`.
|
|
347
|
-
* @param {string} raw Full file contents.
|
|
348
|
-
* @param {string} [file] Source path, used only for error messages.
|
|
349
|
-
* @returns {{ meta: Meta, body: string }}
|
|
350
|
-
*/
|
|
351
|
-
export function parseFrontmatter(raw, file) {
|
|
352
|
-
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
353
|
-
const end = raw.indexOf("\n---", 3);
|
|
354
|
-
if (end === -1) {
|
|
355
|
-
const where = file ? ` in ${file}` : "";
|
|
356
|
-
throw new Error(`Unterminated frontmatter${where}: opening "---" has no closing "---". Use: --- on its own line to open and another --- to close, then the page body.`);
|
|
357
|
-
}
|
|
358
|
-
const front = raw.slice(4, end).trim();
|
|
359
|
-
const body = raw.slice(end + 4).replace(/^\n/, "");
|
|
360
|
-
/** @type {Meta} */
|
|
361
|
-
const meta = {};
|
|
362
|
-
for (const line of front.split("\n")) {
|
|
363
|
-
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
364
|
-
if (match) meta[match[1]] = parseFrontmatterValue(match[2]);
|
|
365
|
-
}
|
|
366
|
-
return { meta, body };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Warn (non-fatal) when a file looks like it MEANT to open frontmatter but forgot
|
|
371
|
-
* the leading `---`: the first content line is a `key: value` pair and a bare
|
|
372
|
-
* `---` fence appears within the opening lines. Conservative — a normal markdown
|
|
373
|
-
* body whose first line happens to contain a colon (with no early `---`) is left
|
|
374
|
-
* alone, since `---` is also a valid horizontal rule.
|
|
375
|
-
* @param {string} raw
|
|
376
|
-
* @param {string} file
|
|
377
|
-
* @param {Compilation} comp
|
|
378
|
-
* @returns {void}
|
|
379
|
-
*/
|
|
380
|
-
function warnLikelyFrontmatter(raw, file, comp) {
|
|
381
|
-
if (raw.startsWith("---\n")) return; // a real opener: parseFrontmatter handled it
|
|
382
|
-
const lines = raw.replace(/\r\n?/g, "\n").split("\n");
|
|
383
|
-
let i = 0;
|
|
384
|
-
while (i < lines.length && !lines[i].trim()) i++; // skip leading blanks
|
|
385
|
-
if (i >= lines.length || !/^[A-Za-z][\w-]*:\s*\S/.test(lines[i])) return;
|
|
386
|
-
// Only warn when the run of lines up to a bare `---` is ALL `key: value` pairs:
|
|
387
|
-
// a forgotten opener, not prose that merely contains a colon and a later rule.
|
|
388
|
-
for (let j = i; j < lines.length && j < i + 12; j++) {
|
|
389
|
-
const t = lines[j].trim();
|
|
390
|
-
if (t === "---") {
|
|
391
|
-
comp.warnings.push(
|
|
392
|
-
`${file}: this looks like frontmatter missing its opening "---". Use: a "---" line before the fields (and another "---" to close) — otherwise the block renders as page text.`
|
|
393
|
-
);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
if (t && !/^[A-Za-z][\w-]*:\s*\S/.test(t)) return; // hit prose → not frontmatter
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
// Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
|
|
401
|
-
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
402
|
-
// the parser stays single-pass and line-based.
|
|
403
|
-
/**
|
|
404
|
-
* @param {string} raw
|
|
405
|
-
* @returns {FrontmatterValue}
|
|
406
|
-
*/
|
|
407
|
-
function parseFrontmatterValue(raw) {
|
|
408
|
-
const value = raw.trim();
|
|
409
|
-
if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
|
|
410
|
-
return stripQuotes(value);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/**
|
|
414
|
-
* @param {string} raw
|
|
415
|
-
* @returns {string[]}
|
|
416
|
-
*/
|
|
417
|
-
function parseInlineArray(raw) {
|
|
418
|
-
const inner = raw.slice(1, -1);
|
|
419
|
-
if (inner.trim() === "") return [];
|
|
420
|
-
/** @type {string[]} */
|
|
421
|
-
const items = [];
|
|
422
|
-
let buf = "";
|
|
423
|
-
let quote = null;
|
|
424
|
-
let quoted = false;
|
|
425
|
-
const push = () => {
|
|
426
|
-
items.push(quoted ? buf : buf.trim());
|
|
427
|
-
buf = "";
|
|
428
|
-
quoted = false;
|
|
429
|
-
};
|
|
430
|
-
for (const char of inner) {
|
|
431
|
-
if (quote) {
|
|
432
|
-
if (char === quote) quote = null;
|
|
433
|
-
else buf += char;
|
|
434
|
-
} else if ((char === '"' || char === "'") && buf.trim() === "") {
|
|
435
|
-
quote = char;
|
|
436
|
-
quoted = true;
|
|
437
|
-
buf = ""; // drop any whitespace between the comma and the opening quote
|
|
438
|
-
} else if (char === ",") {
|
|
439
|
-
push();
|
|
440
|
-
} else {
|
|
441
|
-
buf += char;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
push();
|
|
445
|
-
return items;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// ---------------------------------------------------------------------------
|
|
449
|
-
// Block parser: directives + prose segments
|
|
450
|
-
// ---------------------------------------------------------------------------
|
|
451
|
-
|
|
452
|
-
/**
|
|
453
|
-
* Parse a `.wd` body line-by-line into HTML, mixing directives and prose.
|
|
454
|
-
* @param {string[]} lines
|
|
455
|
-
* @param {Ctx} ctx
|
|
456
|
-
* @returns {string}
|
|
457
|
-
*/
|
|
458
|
-
function compileBody(lines, ctx) {
|
|
459
|
-
/** @type {string[]} */
|
|
460
|
-
const out = [];
|
|
461
|
-
/** @type {string[]} */
|
|
462
|
-
let prose = [];
|
|
463
|
-
let fence = null;
|
|
464
|
-
|
|
465
|
-
const flush = () => {
|
|
466
|
-
if (!prose.length) return;
|
|
467
|
-
const text = prose.join("\n");
|
|
468
|
-
prose = [];
|
|
469
|
-
if (text.trim()) out.push(renderProse(text, ctx));
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
for (let i = 0; i < lines.length; i++) {
|
|
473
|
-
const line = lines[i];
|
|
474
|
-
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
475
|
-
if (fence) {
|
|
476
|
-
prose.push(line);
|
|
477
|
-
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
478
|
-
continue;
|
|
479
|
-
}
|
|
480
|
-
if (fenceMatch) {
|
|
481
|
-
fence = fenceMatch[1];
|
|
482
|
-
prose.push(line);
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (/^@include\s/.test(line)) {
|
|
487
|
-
flush();
|
|
488
|
-
out.push(handleInclude(line, ctx));
|
|
489
|
-
continue;
|
|
490
|
-
}
|
|
491
|
-
if (/^@loop\s/.test(line)) {
|
|
492
|
-
flush();
|
|
493
|
-
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx.file);
|
|
494
|
-
const split = splitEmptyBranch(block.body);
|
|
495
|
-
out.push(handleLoop(line, split.body, split.empty, ctx));
|
|
496
|
-
i = block.end;
|
|
497
|
-
continue;
|
|
498
|
-
}
|
|
499
|
-
const container = line.match(/^:::\s*(.*)$/);
|
|
500
|
-
if (container) {
|
|
501
|
-
flush();
|
|
502
|
-
if (!container[1].trim()) throw new Error(`Stray ::: close with no open container in ${ctx.file}`);
|
|
503
|
-
const block = scanContainer(lines, i, ctx.file);
|
|
504
|
-
out.push(handleContainer(container[1].trim(), block.body, ctx));
|
|
505
|
-
i = block.end;
|
|
506
|
-
continue;
|
|
507
|
-
}
|
|
508
|
-
if (/^:state\s/.test(line)) {
|
|
509
|
-
flush();
|
|
510
|
-
out.push(handleState(line, ctx));
|
|
511
|
-
continue;
|
|
512
|
-
}
|
|
513
|
-
if (/^:store\s/.test(line)) {
|
|
514
|
-
flush();
|
|
515
|
-
out.push(handleStore(line, ctx));
|
|
516
|
-
continue;
|
|
517
|
-
}
|
|
518
|
-
if (/^:fetch\s/.test(line)) {
|
|
519
|
-
flush();
|
|
520
|
-
out.push(handleFetch(line, ctx));
|
|
521
|
-
continue;
|
|
522
|
-
}
|
|
523
|
-
if (/^:computed\s/.test(line)) {
|
|
524
|
-
flush();
|
|
525
|
-
out.push(handleComputed(line, ctx));
|
|
526
|
-
continue;
|
|
527
|
-
}
|
|
528
|
-
if (/^:effect\s/.test(line)) {
|
|
529
|
-
flush();
|
|
530
|
-
out.push(handleEffect(line, ctx));
|
|
531
|
-
continue;
|
|
532
|
-
}
|
|
533
|
-
if (/^:form\s/.test(line)) {
|
|
534
|
-
flush();
|
|
535
|
-
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
536
|
-
out.push(handleForm(line, block.body, ctx));
|
|
537
|
-
i = block.end;
|
|
538
|
-
continue;
|
|
539
|
-
}
|
|
540
|
-
if (/^:input\s/.test(line)) {
|
|
541
|
-
flush();
|
|
542
|
-
out.push(handleInput(line, ctx));
|
|
543
|
-
continue;
|
|
544
|
-
}
|
|
545
|
-
if (/^:textarea\s/.test(line)) {
|
|
546
|
-
flush();
|
|
547
|
-
out.push(handleTextarea(line, ctx));
|
|
548
|
-
continue;
|
|
549
|
-
}
|
|
550
|
-
if (/^:select\s/.test(line)) {
|
|
551
|
-
flush();
|
|
552
|
-
const opts = [];
|
|
553
|
-
let j = i + 1;
|
|
554
|
-
while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
|
|
555
|
-
out.push(handleSelect(line, opts, ctx));
|
|
556
|
-
i = j - 1;
|
|
557
|
-
continue;
|
|
558
|
-
}
|
|
559
|
-
const choiceMatch = line.match(/^:(checkbox|radio)\s/);
|
|
560
|
-
if (choiceMatch) {
|
|
561
|
-
flush();
|
|
562
|
-
const opts = [];
|
|
563
|
-
let j = i + 1;
|
|
564
|
-
while (j < lines.length && /^\s*-\s+/.test(lines[j])) { opts.push(lines[j]); j++; }
|
|
565
|
-
out.push(handleChoiceGroup(line, opts, ctx, /** @type {"checkbox" | "radio"} */ (choiceMatch[1])));
|
|
566
|
-
i = j - 1;
|
|
567
|
-
continue;
|
|
568
|
-
}
|
|
569
|
-
if (/^:bind\s/.test(line)) {
|
|
570
|
-
flush();
|
|
571
|
-
out.push(handleBind(line, ctx));
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
574
|
-
if (/^:submit\s/.test(line)) {
|
|
575
|
-
flush();
|
|
576
|
-
out.push(handleSubmit(line, ctx));
|
|
577
|
-
continue;
|
|
578
|
-
}
|
|
579
|
-
if (/^:button\s/.test(line)) {
|
|
580
|
-
flush();
|
|
581
|
-
out.push(handleButton(line, ctx));
|
|
582
|
-
continue;
|
|
583
|
-
}
|
|
584
|
-
if (/^:if\s/.test(line)) {
|
|
585
|
-
flush();
|
|
586
|
-
const block = scanConditional(lines, i, ctx.file);
|
|
587
|
-
out.push(handleIf(line, block.truthy, block.falsy, ctx));
|
|
588
|
-
i = block.end;
|
|
589
|
-
continue;
|
|
590
|
-
}
|
|
591
|
-
const demo = renderDemoDirective(line, ctx);
|
|
592
|
-
if (demo) {
|
|
593
|
-
flush();
|
|
594
|
-
out.push(demo);
|
|
595
|
-
continue;
|
|
596
|
-
}
|
|
597
|
-
if (/^@repeat\b/.test(line)) {
|
|
598
|
-
throw new Error(`@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop`);
|
|
599
|
-
}
|
|
600
|
-
if (/^:for\b/.test(line)) {
|
|
601
|
-
throw new Error(`:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop`);
|
|
602
|
-
}
|
|
603
|
-
if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
|
|
604
|
-
throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
|
|
605
|
-
}
|
|
606
|
-
warnUnknownDirective(line, ctx);
|
|
607
|
-
prose.push(line);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
flush();
|
|
611
|
-
return out.join("\n");
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// Lines that reached prose but LOOK like a directive (a `@word`/`:word` token at
|
|
615
|
-
// the very start, followed by a space or end of line) and match no handler are
|
|
616
|
-
// almost always a typo — every real directive was intercepted above. Warn, never
|
|
617
|
-
// throw: prose legitimately contains `@` and `:`, so the match is deliberately
|
|
618
|
-
// narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
|
|
619
|
-
// followed by punctuation do not match).
|
|
620
|
-
const KNOWN_DIRECTIVE =
|
|
621
|
-
/^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
|
|
622
|
-
/**
|
|
623
|
-
* @param {string} line
|
|
624
|
-
* @param {Ctx} ctx
|
|
625
|
-
* @returns {void}
|
|
626
|
-
*/
|
|
627
|
-
function warnUnknownDirective(line, ctx) {
|
|
628
|
-
// Catch capitalized (`:State`) and hyphenated (`:end-loop`) typos too — the
|
|
629
|
-
// known-directive whitelist stays case-sensitive, so a mis-cased real
|
|
630
|
-
// directive correctly trips the warning. Still unindented-only, to keep prose
|
|
631
|
-
// that legitimately starts lines with `@`/`:` from false-positiving.
|
|
632
|
-
const m = line.match(/^([@:][A-Za-z][A-Za-z-]*)(?:\s|$)/);
|
|
633
|
-
if (m && !KNOWN_DIRECTIVE.test(line)) {
|
|
634
|
-
ctx.comp.warnings.push(
|
|
635
|
-
`${ctx.file}: "${m[1]}" looks like a directive but matches none — it will render as literal text. Check the spelling (e.g. @loop, @include, :state, :if).`
|
|
636
|
-
);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/**
|
|
641
|
-
* Format a source location as `file:line` (1-based) for compile errors. Keeps the
|
|
642
|
-
* file path intact so existing message matchers still pass, while pointing at the
|
|
643
|
-
* unclosed opener's line.
|
|
644
|
-
* @param {string} file
|
|
645
|
-
* @param {number} index 0-based line index of the opener.
|
|
646
|
-
* @returns {string}
|
|
647
|
-
*/
|
|
648
|
-
function at(file, index) {
|
|
649
|
-
return `${file}:${index + 1}`;
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
/**
|
|
653
|
-
* Capture lines from an opener until a matching close token, honoring nesting and fences.
|
|
654
|
-
* @param {string[]} lines
|
|
655
|
-
* @param {number} start Index of the opening line.
|
|
656
|
-
* @param {RegExp} openRe Pattern that re-opens a nested block.
|
|
657
|
-
* @param {string} endToken Literal closing token (e.g. `@endloop`).
|
|
658
|
-
* @param {string} file Source path for errors.
|
|
659
|
-
* @returns {{ body: string[], end: number }}
|
|
660
|
-
*/
|
|
661
|
-
function scanBlock(lines, start, openRe, endToken, file) {
|
|
662
|
-
/** @type {string[]} */
|
|
663
|
-
const body = [];
|
|
664
|
-
let depth = 0;
|
|
665
|
-
let fence = null;
|
|
666
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
667
|
-
const line = lines[i];
|
|
668
|
-
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
669
|
-
if (fence) {
|
|
670
|
-
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
671
|
-
body.push(line);
|
|
672
|
-
continue;
|
|
673
|
-
}
|
|
674
|
-
if (fenceMatch) {
|
|
675
|
-
fence = fenceMatch[1];
|
|
676
|
-
body.push(line);
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
if (openRe.test(line)) depth++;
|
|
680
|
-
if (line.trim() === endToken) {
|
|
681
|
-
if (depth === 0) return { body, end: i };
|
|
682
|
-
depth--;
|
|
683
|
-
}
|
|
684
|
-
body.push(line);
|
|
685
|
-
}
|
|
686
|
-
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(file, start)}`);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Split a `@loop` body at a top-level `@empty` into the rows body and the empty
|
|
691
|
-
* branch. Honors nested `@loop … @endloop` (so an inner loop's `@empty` is not
|
|
692
|
-
* mistaken for the outer one) and fenced code.
|
|
693
|
-
* @param {string[]} body
|
|
694
|
-
* @returns {{ body: string[], empty: string[] | null }}
|
|
695
|
-
*/
|
|
696
|
-
function splitEmptyBranch(body) {
|
|
697
|
-
/** @type {string[]} */
|
|
698
|
-
const rows = [];
|
|
699
|
-
/** @type {string[] | null} */
|
|
700
|
-
let empty = null;
|
|
701
|
-
let target = rows;
|
|
702
|
-
let depth = 0;
|
|
703
|
-
let fence = null;
|
|
704
|
-
for (const line of body) {
|
|
705
|
-
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
706
|
-
if (fence) {
|
|
707
|
-
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
708
|
-
target.push(line);
|
|
709
|
-
continue;
|
|
710
|
-
}
|
|
711
|
-
if (fenceMatch) { fence = fenceMatch[1]; target.push(line); continue; }
|
|
712
|
-
if (/^@loop\s/.test(line)) depth++;
|
|
713
|
-
if (line.trim() === "@endloop") depth--;
|
|
714
|
-
if (depth === 0 && line.trim() === "@empty") { empty = []; target = empty; continue; }
|
|
715
|
-
target.push(line);
|
|
716
|
-
}
|
|
717
|
-
return { body: rows, empty };
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/**
|
|
721
|
-
* Capture the body of a `:::` container up to its matching close, honoring nesting.
|
|
722
|
-
* @param {string[]} lines
|
|
723
|
-
* @param {number} start Index of the opening line.
|
|
724
|
-
* @param {string} file Source path for errors.
|
|
725
|
-
* @returns {{ body: string[], end: number }}
|
|
726
|
-
*/
|
|
727
|
-
function scanContainer(lines, start, file) {
|
|
728
|
-
/** @type {string[]} */
|
|
729
|
-
const body = [];
|
|
730
|
-
let depth = 0;
|
|
731
|
-
let fence = null;
|
|
732
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
733
|
-
const line = lines[i];
|
|
734
|
-
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
735
|
-
if (fence) {
|
|
736
|
-
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
737
|
-
body.push(line);
|
|
738
|
-
continue;
|
|
739
|
-
}
|
|
740
|
-
if (fenceMatch) {
|
|
741
|
-
fence = fenceMatch[1];
|
|
742
|
-
body.push(line);
|
|
743
|
-
continue;
|
|
744
|
-
}
|
|
745
|
-
const marker = line.match(/^:::\s*(.*)$/);
|
|
746
|
-
if (marker) {
|
|
747
|
-
if (marker[1].trim()) {
|
|
748
|
-
depth++;
|
|
749
|
-
} else if (depth === 0) {
|
|
750
|
-
return { body, end: i };
|
|
751
|
-
} else {
|
|
752
|
-
depth--;
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
body.push(line);
|
|
756
|
-
}
|
|
757
|
-
throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(file, start)}`);
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
/**
|
|
761
|
-
* Split an `:if … :else if … :else … :endif` chain into the first condition's
|
|
762
|
-
* truthy body and a falsy body. A bare `:else` works as before. The first
|
|
763
|
-
* `:else if B` is **desugared** into a nested `:if` that lives in the falsy
|
|
764
|
-
* branch: the falsy body becomes `[":if B", …rest…, ":endif"]`, which re-enters
|
|
765
|
-
* `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
|
|
766
|
-
* therefore compiles to nested `data-wd-if` regions the runtime already drives —
|
|
767
|
-
* identical behavior for static, reactive, and loop-row conditionals.
|
|
768
|
-
* @param {string[]} lines
|
|
769
|
-
* @param {number} start Index of the `:if` line.
|
|
770
|
-
* @param {string} file Source path for errors.
|
|
771
|
-
* @returns {{ truthy: string[], falsy: string[], end: number }}
|
|
772
|
-
*/
|
|
773
|
-
function scanConditional(lines, start, file) {
|
|
774
|
-
/** @type {string[]} */
|
|
775
|
-
const truthy = [];
|
|
776
|
-
/** @type {string[]} */
|
|
777
|
-
const falsy = [];
|
|
778
|
-
let current = truthy;
|
|
779
|
-
// "truthy": collecting the :if body. "else": a bare :else closed the chain —
|
|
780
|
-
// no further branches allowed. "elseif": the first :else if was desugared into
|
|
781
|
-
// a nested :if inside `falsy`; subsequent depth-0 :else if/:else lines belong to
|
|
782
|
-
// that nested chain and are emitted verbatim for the recursive compile to split.
|
|
783
|
-
let mode = "truthy";
|
|
784
|
-
let depth = 0;
|
|
785
|
-
let fence = null;
|
|
786
|
-
for (let i = start + 1; i < lines.length; i++) {
|
|
787
|
-
const line = lines[i];
|
|
788
|
-
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
789
|
-
if (fence) {
|
|
790
|
-
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
791
|
-
current.push(line);
|
|
792
|
-
continue;
|
|
793
|
-
}
|
|
794
|
-
if (fenceMatch) {
|
|
795
|
-
fence = fenceMatch[1];
|
|
796
|
-
current.push(line);
|
|
797
|
-
continue;
|
|
798
|
-
}
|
|
799
|
-
if (/^:if\s/.test(line)) depth++;
|
|
800
|
-
if (line.trim() === ":endif") {
|
|
801
|
-
if (depth === 0) {
|
|
802
|
-
if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
|
|
803
|
-
return { truthy, falsy, end: i };
|
|
804
|
-
}
|
|
805
|
-
depth--;
|
|
806
|
-
}
|
|
807
|
-
if (depth === 0 && mode !== "elseif") {
|
|
808
|
-
const t = line.trim();
|
|
809
|
-
const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
|
|
810
|
-
if (elseIf) {
|
|
811
|
-
if (mode === "else") throw new Error(`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`);
|
|
812
|
-
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
813
|
-
current = falsy;
|
|
814
|
-
mode = "elseif";
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
if (t === ":else") {
|
|
818
|
-
if (mode === "else") throw new Error(`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`);
|
|
819
|
-
current = falsy;
|
|
820
|
-
mode = "else";
|
|
821
|
-
continue;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
current.push(line);
|
|
825
|
-
}
|
|
826
|
-
throw new Error(`Missing :endif for "${lines[start]}" in ${at(file, start)}`);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
// ---------------------------------------------------------------------------
|
|
830
|
-
// Directive handlers
|
|
831
|
-
// ---------------------------------------------------------------------------
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* @param {string} line
|
|
835
|
-
* @param {Ctx} ctx
|
|
836
|
-
* @returns {string}
|
|
837
|
-
*/
|
|
838
|
-
function handleInclude(line, ctx) {
|
|
839
|
-
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
840
|
-
if (!match) throw new Error(`Malformed @include in ${ctx.file}: ${line}`);
|
|
841
|
-
const target = resolveInclude(match[1], ctx.file, ctx.context);
|
|
842
|
-
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
843
|
-
const scope = createScope(ctx.scope, args);
|
|
844
|
-
const child = compileFile(target, ctx.context, ctx.stack, scope, ctx.comp, ctx.sections, ctx.loopItem);
|
|
845
|
-
return child.html;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* @param {string} raw
|
|
850
|
-
* @param {Ctx} ctx
|
|
851
|
-
* @returns {Record<string, unknown>}
|
|
852
|
-
*/
|
|
853
|
-
function parseIncludeArgs(raw, ctx) {
|
|
854
|
-
/** @type {Record<string, unknown>} */
|
|
855
|
-
const args = {};
|
|
856
|
-
const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
|
|
857
|
-
for (const match of raw.matchAll(re)) {
|
|
858
|
-
const value = match[2];
|
|
859
|
-
if (value.startsWith("{")) {
|
|
860
|
-
const expr = value.slice(1, -1).trim();
|
|
861
|
-
const resolved = lookupPath(expr, ctx);
|
|
862
|
-
if (!resolved.found) {
|
|
863
|
-
throw new Error(`@include argument ${match[1]}={ ${expr} } in ${ctx.file} does not match any value in scope`);
|
|
864
|
-
}
|
|
865
|
-
args[match[1]] = resolved.value;
|
|
866
|
-
} else {
|
|
867
|
-
args[match[1]] = parseScalar(value);
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
return args;
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
/**
|
|
874
|
-
* @param {string} header
|
|
875
|
-
* @param {string[]} bodyLines
|
|
876
|
-
* @param {Ctx} ctx
|
|
877
|
-
* @returns {string}
|
|
878
|
-
*/
|
|
879
|
-
function handleContainer(header, bodyLines, ctx) {
|
|
880
|
-
let rest = header.trim();
|
|
881
|
-
let tag = "section";
|
|
882
|
-
/** @type {string[]} Static classes baked into class="". */
|
|
883
|
-
const extraClass = [];
|
|
884
|
-
/** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
|
|
885
|
-
const eachClasses = [];
|
|
886
|
-
/** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
|
|
887
|
-
const stateClasses = [];
|
|
888
|
-
let id = "";
|
|
889
|
-
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
890
|
-
// the <section> tag; any other name becomes a <div> and also a class.
|
|
891
|
-
const lead = rest.match(/^([^\s.#]\S*)/);
|
|
892
|
-
let nameToken = "section";
|
|
893
|
-
if (lead) { nameToken = lead[1]; rest = rest.slice(lead[0].length).trim(); }
|
|
894
|
-
if (nameToken !== "section") { tag = "div"; extraClass.push(nameToken); }
|
|
895
|
-
while (rest.length) {
|
|
896
|
-
if (rest.startsWith("#")) {
|
|
897
|
-
const m = rest.match(/^#(\S+)/);
|
|
898
|
-
id = (m ? m[1] : "");
|
|
899
|
-
rest = rest.slice((m ? m[0] : rest).length).trim();
|
|
900
|
-
continue;
|
|
901
|
-
}
|
|
902
|
-
const cm = rest.match(/^\.([A-Za-z_][\w-]*)/);
|
|
903
|
-
if (!cm) throw new Error(`Unexpected token "${rest.split(/\s+/)[0]}" in container "::: ${header}" in ${ctx.file}`);
|
|
904
|
-
const cls = cm[1];
|
|
905
|
-
rest = rest.slice(cm[0].length).trim();
|
|
906
|
-
// Optional `when <predicate>` makes the class reactive. The predicate runs to
|
|
907
|
-
// the next ` .`/` #` token or the end of the header.
|
|
908
|
-
const whenMatch = rest.match(/^when\s+(.+?)(?=\s+[.#]|$)/);
|
|
909
|
-
if (whenMatch) {
|
|
910
|
-
rest = rest.slice(whenMatch[0].length).trim();
|
|
911
|
-
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
912
|
-
if (compiled.static) { if (compiled.value) extraClass.push(cls); }
|
|
913
|
-
else if (compiled.item) eachClasses.push([cls, compiled.body]);
|
|
914
|
-
else stateClasses.push([cls, compiled.body]);
|
|
915
|
-
} else {
|
|
916
|
-
extraClass.push(cls);
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
const explicitId = Boolean(id);
|
|
920
|
-
if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
|
|
921
|
-
|
|
922
|
-
ctx.sections.push(id);
|
|
923
|
-
let inner;
|
|
924
|
-
try {
|
|
925
|
-
inner = compileBody(bodyLines, ctx);
|
|
926
|
-
} finally {
|
|
927
|
-
ctx.sections.pop();
|
|
928
|
-
}
|
|
929
|
-
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
930
|
-
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
931
|
-
let classBind = "";
|
|
932
|
-
if (eachClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-each-class="${escapeHtml(JSON.stringify(eachClasses))}"`; }
|
|
933
|
-
if (stateClasses.length) { ctx.comp.assets.runtime = true; classBind += ` data-wd-class="${escapeHtml(JSON.stringify(stateClasses))}"`; }
|
|
934
|
-
return `<${tag}${idAttr}${classAttr}${classBind}>\n${inner}\n</${tag}>`;
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* @param {string} line
|
|
939
|
-
* @param {Ctx} ctx
|
|
940
|
-
* @returns {string}
|
|
941
|
-
*/
|
|
942
|
-
function handleState(line, ctx) {
|
|
943
|
-
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
944
|
-
if (!match) throw new Error(`Malformed :state in ${ctx.file}: ${line}`);
|
|
945
|
-
const value = parseStateValue(match[2]);
|
|
946
|
-
const key = declareState(match[1], value, ctx);
|
|
947
|
-
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
948
|
-
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
/**
|
|
952
|
-
* Register a state key in the current section scope, enabling the runtime.
|
|
953
|
-
* @param {string} name
|
|
954
|
-
* @param {unknown} value
|
|
955
|
-
* @param {Ctx} ctx
|
|
956
|
-
* @returns {string} The fully-qualified state key.
|
|
957
|
-
*/
|
|
958
|
-
function declareState(name, value, ctx) {
|
|
959
|
-
if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
960
|
-
if (ctx.comp.stores.has(name)) throw new Error(`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`);
|
|
961
|
-
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
962
|
-
if (ctx.comp.state.has(key)) throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
|
|
963
|
-
ctx.comp.state.set(key, value);
|
|
964
|
-
ctx.comp.assets.runtime = true;
|
|
965
|
-
return key;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
/**
|
|
969
|
-
* @param {string} line
|
|
970
|
-
* @param {Ctx} ctx
|
|
971
|
-
* @returns {string}
|
|
972
|
-
*/
|
|
973
|
-
function handleStore(line, ctx) {
|
|
974
|
-
const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
|
|
975
|
-
if (!match) throw new Error(`Malformed :store in ${ctx.file}: ${line}. Use: :store name = value [ephemeral]`);
|
|
976
|
-
const value = parseStateValue(match[2]);
|
|
977
|
-
const name = declareStore(match[1], value, ctx);
|
|
978
|
-
const ephemeral = match[3] ? " data-wd-store-ephemeral" : "";
|
|
979
|
-
return `<script type="application/json" data-wd-store="${name}"${ephemeral}>${safeScriptJson(value)}</script>`;
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
/**
|
|
983
|
-
* Register a page-global store. The bare name is added to `comp.state` so every
|
|
984
|
-
* resolver (interpolation, :if, @loop, :computed, actions) sees it, and tracked
|
|
985
|
-
* in `comp.stores` for collision checks. Never section-scoped.
|
|
986
|
-
* @param {string} name
|
|
987
|
-
* @param {unknown} value
|
|
988
|
-
* @param {Ctx} ctx
|
|
989
|
-
* @returns {string} The store name (also its bare state key).
|
|
990
|
-
*/
|
|
991
|
-
function declareStore(name, value, ctx) {
|
|
992
|
-
if (ctx.comp.stores.has(name)) throw new Error(`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value`);
|
|
993
|
-
if (ctx.comp.state.has(name)) throw new Error(`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one.`);
|
|
994
|
-
ctx.comp.stores.add(name);
|
|
995
|
-
ctx.comp.state.set(name, value);
|
|
996
|
-
ctx.comp.assets.runtime = true;
|
|
997
|
-
return name;
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/**
|
|
1001
|
-
* Seed a `<name>_error` state key (null) if absent. Shared by :fetch and the
|
|
1002
|
-
* round-trip :form so error fallbacks have a key to bind.
|
|
1003
|
-
* @param {string} key
|
|
1004
|
-
* @param {Ctx} ctx
|
|
1005
|
-
* @returns {void}
|
|
1006
|
-
*/
|
|
1007
|
-
function declareErrorState(key, ctx) {
|
|
1008
|
-
const errorKey = `${key}_error`;
|
|
1009
|
-
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
const FETCH_USE =
|
|
1013
|
-
'Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url]';
|
|
1014
|
-
|
|
1015
|
-
/**
|
|
1016
|
-
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
1017
|
-
* Auto-declares four state keys (value/error/loading/empty), seeds them, and
|
|
1018
|
-
* emits `data-wd-fetch-*` attributes (url/method/when/timeout/retry/headers/
|
|
1019
|
-
* body/deps) consumed by the runtime's `startFetch`.
|
|
1020
|
-
* @param {string} line
|
|
1021
|
-
* @param {Ctx} ctx
|
|
1022
|
-
* @returns {string}
|
|
1023
|
-
*/
|
|
1024
|
-
function handleFetch(line, ctx) {
|
|
1025
|
-
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
1026
|
-
if (!head) throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. ${FETCH_USE}`);
|
|
1027
|
-
const name = head[1];
|
|
1028
|
-
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
1029
|
-
/** @type {Record<string, string>} */
|
|
1030
|
-
const opts = {};
|
|
1031
|
-
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
1032
|
-
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
1033
|
-
if (!kv) throw new Error(`Unknown :fetch option "${part}" in ${ctx.file}. ${FETCH_USE}`);
|
|
1034
|
-
const optName = kv[1];
|
|
1035
|
-
if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
|
|
1036
|
-
throw new Error(`Unknown :fetch option "${optName}" in ${ctx.file}. ${FETCH_USE}`);
|
|
1037
|
-
}
|
|
1038
|
-
opts[optName] = stripQuotes(kv[2]);
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
if (opts.method && !["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())) {
|
|
1042
|
-
throw new Error(`:fetch method "${opts.method}" is not allowed in ${ctx.file}. ${FETCH_USE}`);
|
|
1043
|
-
}
|
|
1044
|
-
if (opts.when && !["load", "visible"].includes(opts.when)) {
|
|
1045
|
-
throw new Error(`:fetch when "${opts.when}" is not allowed in ${ctx.file}. ${FETCH_USE}`);
|
|
1046
|
-
}
|
|
1047
|
-
for (const n of ["timeout", "retry"]) {
|
|
1048
|
-
if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
|
|
1049
|
-
throw new Error(`:fetch ${n} must be a non-negative integer in ${ctx.file}. ${FETCH_USE}`);
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
if (opts.refresh !== undefined) {
|
|
1053
|
-
// Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
|
|
1054
|
-
// The new token is written back into the `headers=` state, so it is required.
|
|
1055
|
-
if (!opts.headers) {
|
|
1056
|
-
throw new Error(`:fetch refresh= needs headers= (the state key holding the token to renew) in ${ctx.file}. ${FETCH_USE}`);
|
|
1057
|
-
}
|
|
1058
|
-
opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
// Extract `{ path }` dependency keys from the URL (validated against poison
|
|
1062
|
-
// segments). The runtime fills them from state on each (re)fetch.
|
|
1063
|
-
/** @type {string[]} */
|
|
1064
|
-
const deps = [];
|
|
1065
|
-
for (const dep of url.matchAll(/\{\s*([A-Za-z_$][\w$.]*)\s*\}/g)) {
|
|
1066
|
-
validatePath(dep[1], ctx, FETCH_USE);
|
|
1067
|
-
const headKey = dep[1].split(".")[0];
|
|
1068
|
-
if (!deps.includes(headKey)) deps.push(headKey);
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
// Auto-declare the four lifecycle keys. `name` is declared via declareState
|
|
1072
|
-
// (collision-checked); the derived keys are seeded directly.
|
|
1073
|
-
const key = declareState(name, null, ctx);
|
|
1074
|
-
/** @type {Record<string, unknown>} */
|
|
1075
|
-
const seeds = { [key]: null };
|
|
1076
|
-
for (const [suffix, seed] of [["_error", null], ["_loading", false], ["_empty", false]]) {
|
|
1077
|
-
const k = `${key}${suffix}`;
|
|
1078
|
-
if (!ctx.comp.state.has(k)) ctx.comp.state.set(k, seed);
|
|
1079
|
-
seeds[k] = seed;
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
/** @param {string} n @param {string | null | false | undefined} v */
|
|
1083
|
-
const attr = (n, v) => (v != null && v !== false && v !== "" ? ` data-wd-fetch-${n}="${escapeHtml(v)}"` : "");
|
|
1084
|
-
const marker =
|
|
1085
|
-
`<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"` +
|
|
1086
|
-
attr("method", opts.method && opts.method.toUpperCase()) +
|
|
1087
|
-
attr("when", opts.when === "visible" ? "visible" : "") +
|
|
1088
|
-
attr("timeout", opts.timeout) +
|
|
1089
|
-
attr("retry", opts.retry) +
|
|
1090
|
-
attr("headers", opts.headers && resolveStateKey(opts.headers, ctx)) +
|
|
1091
|
-
attr("body", opts.body && resolveStateKey(opts.body, ctx)) +
|
|
1092
|
-
attr("refresh", opts.refresh) +
|
|
1093
|
-
attr("deps", deps.join(",")) +
|
|
1094
|
-
`></span>`;
|
|
1095
|
-
return `<script type="application/json" data-wd-state>${safeScriptJson(seeds)}</script>${marker}`;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* Validate a `:fetch` (or `refresh=`) URL's scheme at compile time. Mirrors the
|
|
1100
|
-
* `:try` href guard: relative paths (`/`, `./`, `../`, bare), an `http(s)://`
|
|
1101
|
-
* URL, or a leading `{ state }` interpolation are allowed; a protocol-relative
|
|
1102
|
-
* `//host` or any non-http(s) scheme (`file:`, `data:`, `javascript:`, …) is
|
|
1103
|
-
* rejected. Interpolated values are percent-encoded by the runtime, so a scheme
|
|
1104
|
-
* cannot be injected through state at request time.
|
|
1105
|
-
* @param {string} url
|
|
1106
|
-
* @param {Ctx} ctx
|
|
1107
|
-
* @param {string} [what] Option name for the error message.
|
|
1108
|
-
* @returns {string}
|
|
1109
|
-
*/
|
|
1110
|
-
function validateFetchUrl(url, ctx, what = ":fetch") {
|
|
1111
|
-
const value = url.trim();
|
|
1112
|
-
if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
1113
|
-
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`);
|
|
1114
|
-
}
|
|
1115
|
-
if (value.startsWith("//")) {
|
|
1116
|
-
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`);
|
|
1117
|
-
}
|
|
1118
|
-
if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
|
|
1119
|
-
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
1120
|
-
if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
|
|
1121
|
-
throw new Error(`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`);
|
|
1122
|
-
}
|
|
1123
|
-
return value;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/**
|
|
1127
|
-
* @param {string} line
|
|
1128
|
-
* @param {Ctx} ctx
|
|
1129
|
-
* @returns {string}
|
|
1130
|
-
*/
|
|
1131
|
-
function handleComputed(line, ctx) {
|
|
1132
|
-
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
1133
|
-
if (!match) throw new Error(`Malformed :computed in ${ctx.file}: ${line}. Use: :computed total = items.length * 4`);
|
|
1134
|
-
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
1135
|
-
/** @type {unknown} */
|
|
1136
|
-
let initial;
|
|
1137
|
-
try {
|
|
1138
|
-
/** @param {string} key @param {string} [path] */
|
|
1139
|
-
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
1140
|
-
initial = new Function("S", `return (${expr});`)(read);
|
|
1141
|
-
} catch {
|
|
1142
|
-
console.warn(`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`);
|
|
1143
|
-
initial = null;
|
|
1144
|
-
}
|
|
1145
|
-
const key = declareState(match[1], initial ?? null, ctx);
|
|
1146
|
-
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
/**
|
|
1150
|
-
* Compile a `:computed` expression into safe JS over the `S(key, path)` reader.
|
|
1151
|
-
* @param {string} raw
|
|
1152
|
-
* @param {Ctx} ctx
|
|
1153
|
-
* @returns {string}
|
|
1154
|
-
*/
|
|
1155
|
-
function compileComputedExpr(raw, ctx) {
|
|
1156
|
-
/** @type {string[]} */
|
|
1157
|
-
const strings = [];
|
|
1158
|
-
let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
|
|
1159
|
-
// JSON.stringify fully escapes embedded ", \, etc. so the re-inserted literal
|
|
1160
|
-
// (line below) is an inert JS string and cannot terminate early to smuggle in
|
|
1161
|
-
// live code. Mirrors the SAFE @loop where path (compileOperand).
|
|
1162
|
-
strings.push(JSON.stringify(literal.slice(1, -1)));
|
|
1163
|
-
return `__WDSTR${strings.length - 1}__`;
|
|
1164
|
-
});
|
|
1165
|
-
if (/["'\\`]/.test(expr)) {
|
|
1166
|
-
throw new Error(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`);
|
|
1167
|
-
}
|
|
1168
|
-
if (!/^[\w$.\s+\-*/%()<>=!&|]*$/.test(expr)) {
|
|
1169
|
-
throw new Error(
|
|
1170
|
-
`Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`
|
|
1171
|
-
);
|
|
1172
|
-
}
|
|
1173
|
-
if (/(^|[^=!<>])=(?!=)/.test(expr)) {
|
|
1174
|
-
throw new Error(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`);
|
|
1175
|
-
}
|
|
1176
|
-
expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
|
|
1177
|
-
if (/^__WDSTR\d+__$/.test(ref)) return ref;
|
|
1178
|
-
if (["true", "false", "null"].includes(ref)) return ref;
|
|
1179
|
-
const segs = ref.split(".");
|
|
1180
|
-
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1181
|
-
throw new Error(`Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`);
|
|
1182
|
-
}
|
|
1183
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
1184
|
-
if (!key) {
|
|
1185
|
-
throw new Error(`:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`);
|
|
1186
|
-
}
|
|
1187
|
-
const rest = segs.slice(1).join(".");
|
|
1188
|
-
return `S(${JSON.stringify(key)}${rest ? `,${JSON.stringify(rest)}` : ""})`;
|
|
1189
|
-
});
|
|
1190
|
-
return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
/**
|
|
1194
|
-
* @param {string} line
|
|
1195
|
-
* @param {string[]} bodyLines
|
|
1196
|
-
* @param {Ctx} ctx
|
|
1197
|
-
* @returns {string}
|
|
1198
|
-
*/
|
|
1199
|
-
function handleForm(line, bodyLines, ctx) {
|
|
1200
|
-
let rest = line.slice(":form".length).trim();
|
|
1201
|
-
const rawAction = rest.match(/action="([^"]+)"/)?.[1];
|
|
1202
|
-
const method = rest.match(/method="([^"]+)"/)?.[1] || "post";
|
|
1203
|
-
const into = rest.match(/(?:^|\s)into\s+([A-Za-z_$][\w$]*)/)?.[1];
|
|
1204
|
-
const leftover = rest
|
|
1205
|
-
.replace(/action="[^"]+"/, "")
|
|
1206
|
-
.replace(/method="[^"]+"/, "")
|
|
1207
|
-
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
1208
|
-
.trim();
|
|
1209
|
-
if ((!rawAction && !into) || leftover) {
|
|
1210
|
-
throw new Error(
|
|
1211
|
-
`Malformed :form in ${ctx.file}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
|
|
1212
|
-
);
|
|
1213
|
-
}
|
|
1214
|
-
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
1215
|
-
const inner = compileBody(bodyLines, ctx).trim();
|
|
1216
|
-
if (!into) {
|
|
1217
|
-
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
|
|
1218
|
-
}
|
|
1219
|
-
const key = declareState(into, null, ctx);
|
|
1220
|
-
declareErrorState(key, ctx);
|
|
1221
|
-
const actionAttrs = action ? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"` : "";
|
|
1222
|
-
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/**
|
|
1226
|
-
* @param {string} line
|
|
1227
|
-
* @param {Ctx} ctx
|
|
1228
|
-
* @returns {string}
|
|
1229
|
-
*/
|
|
1230
|
-
function handleInput(line, ctx) {
|
|
1231
|
-
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1232
|
-
if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
|
|
1233
|
-
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1234
|
-
let type = "text";
|
|
1235
|
-
let placeholder;
|
|
1236
|
-
let hasAria = false;
|
|
1237
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1238
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1239
|
-
if (token[3]) {
|
|
1240
|
-
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
1241
|
-
throw new Error(`Unknown :input flag "${token[3]}" in ${ctx.file}`);
|
|
1242
|
-
}
|
|
1243
|
-
attrs.push(token[3]);
|
|
1244
|
-
continue;
|
|
1245
|
-
}
|
|
1246
|
-
const value = stripQuotes(token[2]);
|
|
1247
|
-
if (token[1] === "type") {
|
|
1248
|
-
type = value;
|
|
1249
|
-
continue;
|
|
1250
|
-
}
|
|
1251
|
-
if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1252
|
-
throw new Error(`Unknown :input attribute "${token[1]}" in ${ctx.file}`);
|
|
1253
|
-
}
|
|
1254
|
-
if (token[1] === "placeholder") placeholder = value;
|
|
1255
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1256
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1257
|
-
}
|
|
1258
|
-
// Accessible name: if the author supplied neither aria-label nor aria-describedby,
|
|
1259
|
-
// derive a non-visual aria-label from the placeholder, else from the field name.
|
|
1260
|
-
// Never overrides an author-supplied aria attribute (zero layout impact).
|
|
1261
|
-
if (!hasAria) {
|
|
1262
|
-
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
1263
|
-
}
|
|
1264
|
-
return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
|
|
1265
|
-
}
|
|
1266
|
-
|
|
1267
|
-
// :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
|
|
1268
|
-
// declared :state. Updates state on every keystroke; reflects state back when
|
|
1269
|
-
// not focused. The state must be declared first (with :state).
|
|
1270
|
-
/**
|
|
1271
|
-
* @param {string} line
|
|
1272
|
-
* @param {Ctx} ctx
|
|
1273
|
-
* @returns {string}
|
|
1274
|
-
*/
|
|
1275
|
-
function handleBind(line, ctx) {
|
|
1276
|
-
const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
1277
|
-
if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
|
|
1278
|
-
const key = resolveStateKey(match[1], ctx);
|
|
1279
|
-
if (!key) {
|
|
1280
|
-
throw new Error(`:bind ${match[1]} in ${ctx.file} has no matching state. Declare it first: :state ${match[1]} = ""`);
|
|
1281
|
-
}
|
|
1282
|
-
ctx.comp.assets.runtime = true;
|
|
1283
|
-
let type = "text";
|
|
1284
|
-
let placeholder;
|
|
1285
|
-
let hasAria = false;
|
|
1286
|
-
const attrs = [];
|
|
1287
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1288
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1289
|
-
if (token[3]) {
|
|
1290
|
-
if (!["required", "autofocus"].includes(token[3])) throw new Error(`Unknown :bind flag "${token[3]}" in ${ctx.file}`);
|
|
1291
|
-
attrs.push(token[3]);
|
|
1292
|
-
continue;
|
|
1293
|
-
}
|
|
1294
|
-
const value = stripQuotes(token[2]);
|
|
1295
|
-
if (token[1] === "type") { type = value; continue; }
|
|
1296
|
-
if (!["placeholder", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1297
|
-
throw new Error(`Unknown :bind attribute "${token[1]}" in ${ctx.file}`);
|
|
1298
|
-
}
|
|
1299
|
-
if (token[1] === "placeholder") placeholder = value;
|
|
1300
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1301
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1302
|
-
}
|
|
1303
|
-
// Accessible name (see handleInput): default a non-visual aria-label from the
|
|
1304
|
-
// placeholder, else a humanized version of the bound state key. The author's own
|
|
1305
|
-
// aria-label/aria-describedby always wins.
|
|
1306
|
-
if (!hasAria) {
|
|
1307
|
-
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
1308
|
-
}
|
|
1309
|
-
const initial = ctx.comp.state.get(key);
|
|
1310
|
-
const valueAttr = initial === undefined || initial === null ? "" : ` value="${escapeHtml(String(initial))}"`;
|
|
1311
|
-
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
/**
|
|
1315
|
-
* @param {string} line
|
|
1316
|
-
* @param {Ctx} ctx
|
|
1317
|
-
* @returns {string}
|
|
1318
|
-
*/
|
|
1319
|
-
function handleSubmit(line, ctx) {
|
|
1320
|
-
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
1321
|
-
if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
|
|
1322
|
-
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
/**
|
|
1326
|
-
* `:textarea name [placeholder="…"] [rows=N] [required]` → a `<textarea>`. Like
|
|
1327
|
-
* `:input`, it derives a non-visual aria-label from the placeholder (else the
|
|
1328
|
-
* humanized name) when the author supplies none. Captured by the runtime's
|
|
1329
|
-
* FormData exactly like `:input` — no runtime change needed.
|
|
1330
|
-
* @param {string} line
|
|
1331
|
-
* @param {Ctx} ctx
|
|
1332
|
-
* @returns {string}
|
|
1333
|
-
*/
|
|
1334
|
-
function handleTextarea(line, ctx) {
|
|
1335
|
-
const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1336
|
-
if (!match) throw new Error(`Malformed :textarea in ${ctx.file}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`);
|
|
1337
|
-
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1338
|
-
let placeholder;
|
|
1339
|
-
let hasAria = false;
|
|
1340
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1341
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1342
|
-
if (token[3]) {
|
|
1343
|
-
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
1344
|
-
throw new Error(`Unknown :textarea flag "${token[3]}" in ${ctx.file}`);
|
|
1345
|
-
}
|
|
1346
|
-
attrs.push(token[3]);
|
|
1347
|
-
continue;
|
|
1348
|
-
}
|
|
1349
|
-
const value = stripQuotes(token[2]);
|
|
1350
|
-
if (!["placeholder", "rows", "cols", "minlength", "maxlength", "autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1351
|
-
throw new Error(`Unknown :textarea attribute "${token[1]}" in ${ctx.file}`);
|
|
1352
|
-
}
|
|
1353
|
-
if (token[1] === "placeholder") placeholder = value;
|
|
1354
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1355
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1356
|
-
}
|
|
1357
|
-
if (!hasAria) {
|
|
1358
|
-
attrs.push(`aria-label="${escapeHtml(placeholder || humanizeName(match[1]))}"`);
|
|
1359
|
-
}
|
|
1360
|
-
return `<textarea ${attrs.join(" ")}></textarea>`;
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
/**
|
|
1364
|
-
* `:select name [required]` followed by `- Label` list lines → a `<select>` with
|
|
1365
|
-
* one `<option>` per label (value === label). Derives an aria-label from the
|
|
1366
|
-
* humanized name when none is given. Captured by FormData like the other fields.
|
|
1367
|
-
* @param {string} line
|
|
1368
|
-
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
1369
|
-
* @param {Ctx} ctx
|
|
1370
|
-
* @returns {string}
|
|
1371
|
-
*/
|
|
1372
|
-
function handleSelect(line, optionLines, ctx) {
|
|
1373
|
-
const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1374
|
-
if (!match) throw new Error(`Malformed :select in ${ctx.file}: ${line}. Use: :select name [required] then "- Label" lines`);
|
|
1375
|
-
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
1376
|
-
let hasAria = false;
|
|
1377
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1378
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1379
|
-
if (token[3]) {
|
|
1380
|
-
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
1381
|
-
throw new Error(`Unknown :select flag "${token[3]}" in ${ctx.file}`);
|
|
1382
|
-
}
|
|
1383
|
-
attrs.push(token[3]);
|
|
1384
|
-
continue;
|
|
1385
|
-
}
|
|
1386
|
-
const value = stripQuotes(token[2]);
|
|
1387
|
-
if (!["autocomplete", "aria-label", "aria-describedby"].includes(token[1])) {
|
|
1388
|
-
throw new Error(`Unknown :select attribute "${token[1]}" in ${ctx.file}`);
|
|
1389
|
-
}
|
|
1390
|
-
if (token[1] === "aria-label" || token[1] === "aria-describedby") hasAria = true;
|
|
1391
|
-
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
1392
|
-
}
|
|
1393
|
-
if (!hasAria) {
|
|
1394
|
-
attrs.push(`aria-label="${escapeHtml(humanizeName(match[1]))}"`);
|
|
1395
|
-
}
|
|
1396
|
-
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
1397
|
-
if (!options.length) {
|
|
1398
|
-
throw new Error(`:select "${match[1]}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
|
|
1399
|
-
}
|
|
1400
|
-
const opts = options.map((o) => `<option value="${escapeHtml(o)}">${escapeHtml(o)}</option>`).join("");
|
|
1401
|
-
return `<select ${attrs.join(" ")}>${opts}</select>`;
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
/**
|
|
1405
|
-
* `:checkbox name [required]` / `:radio name [required]` followed by `- Label`
|
|
1406
|
-
* lines → a labelled group of `<input type=checkbox|radio>`, each sharing `name`
|
|
1407
|
-
* with value === label and wrapped in its own `<label>`. The group is a
|
|
1408
|
-
* `role="group"` / `role="radiogroup"` container with an aria-label (explicit or
|
|
1409
|
-
* humanized from the name). Checkbox groups are marked `data-wd-multi="name"` so
|
|
1410
|
-
* the runtime captures every checked value as an array (`FormData.getAll`); radio
|
|
1411
|
-
* groups capture a single value like `:input`. Flags: `required` (radio → the
|
|
1412
|
-
* group; not emitted on checkboxes, where "at least one" has no native HTML form),
|
|
1413
|
-
* `disabled` (whole group), `autofocus` (first control).
|
|
1414
|
-
* @param {string} line
|
|
1415
|
-
* @param {string[]} optionLines The following `- Label` lines consumed by dispatch.
|
|
1416
|
-
* @param {Ctx} ctx
|
|
1417
|
-
* @param {"checkbox" | "radio"} kind
|
|
1418
|
-
* @returns {string}
|
|
1419
|
-
*/
|
|
1420
|
-
function handleChoiceGroup(line, optionLines, ctx, kind) {
|
|
1421
|
-
const directive = `:${kind}`;
|
|
1422
|
-
const match = line.match(kind === "checkbox" ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/ : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
1423
|
-
if (!match) throw new Error(`Malformed ${directive} in ${ctx.file}: ${line}. Use: ${directive} name [required] then "- Label" lines`);
|
|
1424
|
-
const name = match[1];
|
|
1425
|
-
const flags = [];
|
|
1426
|
-
let ariaLabel = null;
|
|
1427
|
-
let ariaDescribedby = null;
|
|
1428
|
-
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
1429
|
-
for (const token of (match[2] || "").matchAll(re)) {
|
|
1430
|
-
if (token[3]) {
|
|
1431
|
-
if (!["required", "disabled", "autofocus"].includes(token[3])) {
|
|
1432
|
-
throw new Error(`Unknown ${directive} flag "${token[3]}" in ${ctx.file}`);
|
|
1433
|
-
}
|
|
1434
|
-
flags.push(token[3]);
|
|
1435
|
-
continue;
|
|
1436
|
-
}
|
|
1437
|
-
const value = stripQuotes(token[2]);
|
|
1438
|
-
if (token[1] === "aria-label") ariaLabel = value;
|
|
1439
|
-
else if (token[1] === "aria-describedby") ariaDescribedby = value;
|
|
1440
|
-
else throw new Error(`Unknown ${directive} attribute "${token[1]}" in ${ctx.file}`);
|
|
1441
|
-
}
|
|
1442
|
-
const options = optionLines.map((l) => l.replace(/^\s*-\s+/, "").trim()).filter(Boolean);
|
|
1443
|
-
if (!options.length) {
|
|
1444
|
-
throw new Error(`${directive} "${name}" in ${ctx.file} has no options. Add "- Label" lines beneath it.`);
|
|
1445
|
-
}
|
|
1446
|
-
const isCheckbox = kind === "checkbox";
|
|
1447
|
-
const groupAttrs = [`class="wd-${isCheckbox ? "checkboxes" : "radios"}"`, `role="${isCheckbox ? "group" : "radiogroup"}"`];
|
|
1448
|
-
if (isCheckbox) groupAttrs.push(`data-wd-multi="${escapeHtml(name)}"`);
|
|
1449
|
-
groupAttrs.push(`aria-label="${escapeHtml(ariaLabel || humanizeName(name))}"`);
|
|
1450
|
-
if (ariaDescribedby) groupAttrs.push(`aria-describedby="${escapeHtml(ariaDescribedby)}"`);
|
|
1451
|
-
const disabled = flags.includes("disabled");
|
|
1452
|
-
const required = flags.includes("required");
|
|
1453
|
-
const autofocus = flags.includes("autofocus");
|
|
1454
|
-
const controls = options.map((opt, idx) => {
|
|
1455
|
-
const a = [`type="${kind}"`, `name="${escapeHtml(name)}"`, `value="${escapeHtml(opt)}"`];
|
|
1456
|
-
if (disabled) a.push("disabled");
|
|
1457
|
-
if (required && !isCheckbox) a.push("required");
|
|
1458
|
-
if (autofocus && idx === 0) a.push("autofocus");
|
|
1459
|
-
return `<label><input ${a.join(" ")}> ${escapeHtml(opt)}</label>`;
|
|
1460
|
-
}).join("");
|
|
1461
|
-
return `<div ${groupAttrs.join(" ")}>${controls}</div>`;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* @param {string} line
|
|
1466
|
-
* @param {Ctx} ctx
|
|
1467
|
-
* @returns {string}
|
|
1468
|
-
*/
|
|
1469
|
-
function handleButton(line, ctx) {
|
|
1470
|
-
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
1471
|
-
if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
|
|
1472
|
-
ctx.comp.assets.runtime = true;
|
|
1473
|
-
const action = parseAction(match[2], ctx);
|
|
1474
|
-
if (Array.isArray(action)) {
|
|
1475
|
-
return `<button type="button" data-wd-actions="${escapeHtml(JSON.stringify(action))}">${escapeHtml(match[1])}</button>`;
|
|
1476
|
-
}
|
|
1477
|
-
const valueAttr = action.value === undefined ? "" : ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
|
|
1478
|
-
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
const EFFECT_USE = "Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary).";
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
1485
|
-
* watches: when `<watched>` state changes, it runs `<actions>` (the same `:button`
|
|
1486
|
-
* action vocabulary, `;`-chained). For arbitrary side effects beyond `:computed`
|
|
1487
|
-
* (derive state) and fetch deps (auto-refetch).
|
|
1488
|
-
* @param {string} line
|
|
1489
|
-
* @param {Ctx} ctx
|
|
1490
|
-
* @returns {string}
|
|
1491
|
-
*/
|
|
1492
|
-
function handleEffect(line, ctx) {
|
|
1493
|
-
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
1494
|
-
if (!match) throw new Error(`Malformed :effect in ${ctx.file}: ${line}. ${EFFECT_USE}`);
|
|
1495
|
-
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
1496
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
1497
|
-
if (!key) throw new Error(`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
|
|
1498
|
-
ctx.comp.assets.runtime = true;
|
|
1499
|
-
const watch = [key, ...segs.slice(1)].join(".");
|
|
1500
|
-
const action = parseAction(match[2], ctx);
|
|
1501
|
-
const actions = Array.isArray(action) ? action : [action];
|
|
1502
|
-
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
/**
|
|
1506
|
-
* @param {string} line
|
|
1507
|
-
* @param {string[]} truthyLines
|
|
1508
|
-
* @param {string[]} falsyLines
|
|
1509
|
-
* @param {Ctx} ctx
|
|
1510
|
-
* @returns {string}
|
|
1511
|
-
*/
|
|
1512
|
-
function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
1513
|
-
const condition = line.replace(/^:if\s+/, "").trim();
|
|
1514
|
-
// Fast path: a bare truthy dotted path (`:if open`, `:if item.done`). Keeps the
|
|
1515
|
-
// existing markup/behavior identical. Anything with operators (`>`, `==`, `and`,
|
|
1516
|
-
// `not`, …) falls through to the predicate path below.
|
|
1517
|
-
const match = condition.match(/^([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)$/);
|
|
1518
|
-
if (match) {
|
|
1519
|
-
const segs = match[1].split(".");
|
|
1520
|
-
const head = segs[0];
|
|
1521
|
-
|
|
1522
|
-
// Per-row meta vars in :if — only valid inside a loop.
|
|
1523
|
-
if (LOOP_META[head]) {
|
|
1524
|
-
if (!ctx.loopMeta) throw new Error(`":if ${match[1]}" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Use it inside a loop body.`);
|
|
1525
|
-
if (ctx.loopItem) {
|
|
1526
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1527
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1528
|
-
return `<span data-wd-each-if data-wd-meta="${LOOP_META[head]}"><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1529
|
-
}
|
|
1530
|
-
// static: the meta boolean is in scope — fall through to the static branch below.
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
const staticValue = lookupVar(ctx.scope, head);
|
|
1534
|
-
if (staticValue.found) {
|
|
1535
|
-
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
1536
|
-
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
if (ctx.loopItem && head === ctx.loopItem) {
|
|
1540
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1541
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1542
|
-
const rest = segs.slice(1).join(".");
|
|
1543
|
-
const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
|
|
1544
|
-
return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
const key = resolveStateKey(head, ctx);
|
|
1548
|
-
if (!key) {
|
|
1549
|
-
throw new Error(`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`);
|
|
1550
|
-
}
|
|
1551
|
-
ctx.comp.assets.runtime = true;
|
|
1552
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1553
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1554
|
-
const restPath = segs.slice(1).join(".");
|
|
1555
|
-
const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
|
|
1556
|
-
const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
|
|
1557
|
-
const active = initialTruthy ? truthy : falsy;
|
|
1558
|
-
return `<div data-wd-if="${key}"${pathAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
// Predicate path: a comparison / logical condition. Compiles through the same
|
|
1562
|
-
// whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
|
|
1563
|
-
// build when static, drives a per-row each-if when it reads the loop item, and
|
|
1564
|
-
// a global if-region (evaluated each render) when it reads state. No raw eval.
|
|
1565
|
-
if (!condition) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" or ":if a <op> b [and|or|not …]".`);
|
|
1566
|
-
const compiled = compileWhen(condition, ctx, '":if"');
|
|
1567
|
-
if (compiled.static) return compileBody(compiled.value ? truthyLines : falsyLines, ctx);
|
|
1568
|
-
ctx.comp.assets.runtime = true;
|
|
1569
|
-
const truthy = compileBody(truthyLines, ctx).trim();
|
|
1570
|
-
const falsy = compileBody(falsyLines, ctx).trim();
|
|
1571
|
-
const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
|
|
1572
|
-
if (compiled.item) {
|
|
1573
|
-
return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
1574
|
-
}
|
|
1575
|
-
const initialTruthy = evalPredicate(compiled.body, undefined, ctx);
|
|
1576
|
-
const active = initialTruthy ? truthy : falsy;
|
|
1577
|
-
return `<div data-wd-if=""${exprAttr} data-wd-if-active="${initialTruthy}"><template data-wd-true>${truthy}</template><template data-wd-false>${falsy}</template><div data-wd-if-out>${active}</div></div>`;
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
1581
|
-
const LOOP_USAGE = "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N]";
|
|
1582
|
-
|
|
1583
|
-
// Reserved per-row meta variables, mapped to their runtime marker token.
|
|
1584
|
-
/** @type {Record<string, string>} */
|
|
1585
|
-
const LOOP_META = { $index: "index", $number: "number", $first: "first", $last: "last", $count: "count" };
|
|
1586
|
-
|
|
1587
|
-
/**
|
|
1588
|
-
* Parse the optional clause tail of a `@loop` header in FIXED order:
|
|
1589
|
-
* `[where P] [sort by key [asc|desc]] [reverse] [offset N] [limit N]`.
|
|
1590
|
-
* @param {string} tail Everything after `@loop src into item`.
|
|
1591
|
-
* @param {string} itemName
|
|
1592
|
-
* @param {Ctx} ctx
|
|
1593
|
-
* @returns {{ where: string|null, sort: {key:string,dir:string}|null, reverse: boolean, offset: NumArg|null, limit: NumArg|null, refsState: boolean }}
|
|
1594
|
-
*/
|
|
1595
|
-
function parseLoopClauses(tail, itemName, ctx) {
|
|
1596
|
-
// Peel clauses off the END in reverse fixed-order (limit, offset, reverse,
|
|
1597
|
-
// sort by). Parsing from the tail avoids mistaking a state operand named
|
|
1598
|
-
// `limit`/`offset` inside the `where` predicate for a clause keyword. Whatever
|
|
1599
|
-
// remains must be `where …` (or empty); a clause keyword surviving in the
|
|
1600
|
-
// remainder means the author wrote the clauses out of order.
|
|
1601
|
-
let s = tail.trim();
|
|
1602
|
-
/** @type {{key:string,dir:string}|null} */ let sort = null;
|
|
1603
|
-
let reverse = false;
|
|
1604
|
-
/** @type {NumArg|null} */ let offset = null;
|
|
1605
|
-
/** @type {NumArg|null} */ let limit = null;
|
|
1606
|
-
let refsState = false;
|
|
1607
|
-
/** @returns {never} */
|
|
1608
|
-
const bad = () => { throw new Error(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`); };
|
|
1609
|
-
|
|
1610
|
-
let m = s.match(/(^|\s)limit\s+(\d+|[A-Za-z_$][\w$]*)$/);
|
|
1611
|
-
if (m) { limit = parseNumArg(m[2], ctx); refsState = refsState || limit.kind === "key"; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
|
|
1612
|
-
|
|
1613
|
-
m = s.match(/(^|\s)offset\s+(\d+|[A-Za-z_$][\w$]*)$/);
|
|
1614
|
-
if (m) { offset = parseNumArg(m[2], ctx); refsState = refsState || offset.kind === "key"; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
|
|
1615
|
-
|
|
1616
|
-
m = s.match(/(^|\s)reverse$/);
|
|
1617
|
-
if (m) { reverse = true; s = s.slice(0, s.length - m[0].length + m[1].length).trim(); }
|
|
1618
|
-
|
|
1619
|
-
m = s.match(/(^|\s)sort\s+by\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)(?:\s+(asc|desc))?$/);
|
|
1620
|
-
if (m) {
|
|
1621
|
-
const segs = m[2].split(".");
|
|
1622
|
-
if (segs[0] !== itemName) throw new Error(`@loop sort key "${m[2]}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`);
|
|
1623
|
-
const rest = segs.slice(1);
|
|
1624
|
-
if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1625
|
-
throw new Error(`Sort key "${m[2]}" is not allowed in @loop (${ctx.file})`);
|
|
1626
|
-
}
|
|
1627
|
-
sort = { key: rest.join("."), dir: m[3] || "asc" };
|
|
1628
|
-
s = s.slice(0, s.length - m[0].length + m[1].length).trim();
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
/** @type {string|null} */ let where = null;
|
|
1632
|
-
if (s.length) {
|
|
1633
|
-
m = s.match(/^where\s+(.+)$/);
|
|
1634
|
-
if (!m) return bad(); // leftover non-where text → clauses written out of order
|
|
1635
|
-
where = m[1].trim();
|
|
1636
|
-
// `sort by`/`reverse` are never valid where operands; their presence in the
|
|
1637
|
-
// predicate means a clause was written before `where` finished (wrong order).
|
|
1638
|
-
if (/(^|\s)(sort\s+by\s|reverse(\s|$))/.test(where)) bad();
|
|
1639
|
-
}
|
|
1640
|
-
return { where, sort, reverse, offset, limit, refsState };
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
/**
|
|
1644
|
-
* A loop offset/limit argument: a non-negative integer literal or a state key.
|
|
1645
|
-
* @typedef {{ kind: "literal", value: number } | { kind: "key", value: string }} NumArg
|
|
1646
|
-
*/
|
|
1647
|
-
|
|
1648
|
-
/**
|
|
1649
|
-
* @param {string} tok
|
|
1650
|
-
* @param {Ctx} ctx
|
|
1651
|
-
* @returns {NumArg}
|
|
1652
|
-
*/
|
|
1653
|
-
function parseNumArg(tok, ctx) {
|
|
1654
|
-
if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
|
|
1655
|
-
if (/^[A-Za-z_$][\w$]*$/.test(tok)) {
|
|
1656
|
-
const key = resolveStateKey(tok, ctx);
|
|
1657
|
-
if (!key) throw new Error(`@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`);
|
|
1658
|
-
return { kind: "key", value: key };
|
|
1659
|
-
}
|
|
1660
|
-
throw new Error(`@loop offset/limit "${tok}" in ${ctx.file} is invalid. ${LOOP_USAGE}`);
|
|
1661
|
-
}
|
|
1662
|
-
|
|
1663
|
-
/** @param {NumArg} arg @returns {string} */
|
|
1664
|
-
function numArgAttr(arg) {
|
|
1665
|
-
return arg.kind === "literal" ? String(arg.value) : `key:${arg.value}`;
|
|
1666
|
-
}
|
|
1667
|
-
|
|
1668
|
-
/**
|
|
1669
|
-
* @param {string} line
|
|
1670
|
-
* @param {string[]} bodyLines
|
|
1671
|
-
* @param {string[] | null} emptyLines
|
|
1672
|
-
* @param {Ctx} ctx
|
|
1673
|
-
* @returns {string}
|
|
1674
|
-
*/
|
|
1675
|
-
function handleLoop(line, bodyLines, emptyLines, ctx) {
|
|
1676
|
-
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
1677
|
-
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. ${LOOP_USAGE}`);
|
|
1678
|
-
const source = stripQuotes(match[1].trim());
|
|
1679
|
-
const itemName = match[2];
|
|
1680
|
-
const clauses = match[3] ? parseLoopClauses(match[3], itemName, ctx) : { where: null, sort: null, reverse: false, offset: null, limit: null, refsState: false };
|
|
1681
|
-
const where = clauses.where ? compilePredicate(clauses.where, itemName, ctx) : null;
|
|
1682
|
-
/** @type {LoopOpts} */
|
|
1683
|
-
const opts = { where, sort: clauses.sort, reverse: clauses.reverse, offset: clauses.offset, limit: clauses.limit, empty: emptyLines, clauseRefsState: clauses.refsState };
|
|
1684
|
-
|
|
1685
|
-
if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.endsWith(".json")) {
|
|
1686
|
-
const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
|
|
1687
|
-
const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
|
1688
|
-
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
1689
|
-
return loopOverData(rows, itemName, bodyLines, ctx, opts);
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
const resolved = lookupPath(source, ctx);
|
|
1693
|
-
if (resolved.found) {
|
|
1694
|
-
if (!Array.isArray(resolved.value)) {
|
|
1695
|
-
throw new Error(`@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`);
|
|
1696
|
-
}
|
|
1697
|
-
return loopOverData(resolved.value, itemName, bodyLines, ctx, opts);
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
// A bare name OR a dotted path resolving to declared :state (e.g. team.members).
|
|
1701
|
-
const segs = source.split(".");
|
|
1702
|
-
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
|
|
1703
|
-
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1704
|
-
throw new Error(`@loop source "${source}" is not allowed in ${ctx.file}`);
|
|
1705
|
-
}
|
|
1706
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
1707
|
-
if (key) {
|
|
1708
|
-
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
1709
|
-
return reactiveLoop(fullKey, itemName, bodyLines, ctx, opts);
|
|
1710
|
-
}
|
|
1711
|
-
// Inside a reactive loop, `@loop <outerItem>.<path> into x` loops a field of
|
|
1712
|
-
// the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
|
|
1713
|
-
if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
|
|
1714
|
-
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, ctx, opts);
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
|
|
1718
|
-
throw new Error(
|
|
1719
|
-
`@loop source "${source}" in ${ctx.file} was not found. Loop over a JSON file (@loop /data.json into row), an in-scope value, or a :state list.`
|
|
1720
|
-
);
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// A static source (JSON file / in-scope value). No `where`, or a `where` that
|
|
1724
|
-
// only reads the loop item → filter at build time and stay zero-JS. A `where`
|
|
1725
|
-
// that reads :state → becomes a reactive filtered loop with the rows baked in.
|
|
1726
|
-
/**
|
|
1727
|
-
* A compiled `@loop … where` predicate.
|
|
1728
|
-
* @typedef {object} Predicate
|
|
1729
|
-
* @property {string} body Safe JS boolean expression over `I()`/`S()`/`C()`.
|
|
1730
|
-
* @property {boolean} refsState Whether the predicate reads declared state.
|
|
1731
|
-
*/
|
|
1732
|
-
|
|
1733
|
-
/**
|
|
1734
|
-
* Loop clause configuration shared by the static and reactive paths.
|
|
1735
|
-
* @typedef {object} LoopOpts
|
|
1736
|
-
* @property {Predicate | null} where
|
|
1737
|
-
* @property {{ key: string, dir: string } | null} sort
|
|
1738
|
-
* @property {boolean} reverse
|
|
1739
|
-
* @property {NumArg | null} offset
|
|
1740
|
-
* @property {NumArg | null} limit
|
|
1741
|
-
* @property {string[] | null} empty Empty-branch body lines, if any.
|
|
1742
|
-
* @property {boolean} clauseRefsState Whether offset/limit reference state.
|
|
1743
|
-
*/
|
|
1744
|
-
|
|
1745
|
-
/**
|
|
1746
|
-
* Build-time pipeline over already-resolved rows: filter → sort → reverse →
|
|
1747
|
-
* offset → limit. Used when source + every clause arg are static.
|
|
1748
|
-
* @param {unknown[]} rows
|
|
1749
|
-
* @param {Predicate | null} where
|
|
1750
|
-
* @param {LoopOpts} opts
|
|
1751
|
-
* @param {Ctx} ctx
|
|
1752
|
-
* @returns {unknown[]}
|
|
1753
|
-
*/
|
|
1754
|
-
function pipelineRows(rows, where, opts, ctx) {
|
|
1755
|
-
let list = where ? rows.filter((row) => evalPredicate(where.body, row, ctx)) : rows.slice();
|
|
1756
|
-
if (opts.sort) {
|
|
1757
|
-
const k = opts.sort.key;
|
|
1758
|
-
const dir = opts.sort.dir === "desc" ? -1 : 1;
|
|
1759
|
-
list = list.map((value, index) => ({ value, index })).sort((a, b) => {
|
|
1760
|
-
const av = getPath(a.value, k ? k.split(".") : []);
|
|
1761
|
-
const bv = getPath(b.value, k ? k.split(".") : []);
|
|
1762
|
-
let c = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
|
|
1763
|
-
return (c || a.index - b.index) * dir;
|
|
1764
|
-
}).map((w) => w.value);
|
|
1765
|
-
}
|
|
1766
|
-
if (opts.reverse) list.reverse();
|
|
1767
|
-
const off = opts.offset && opts.offset.kind === "literal" ? opts.offset.value : 0;
|
|
1768
|
-
if (off) list = list.slice(off);
|
|
1769
|
-
if (opts.limit && opts.limit.kind === "literal") list = list.slice(0, opts.limit.value);
|
|
1770
|
-
return list;
|
|
1771
|
-
}
|
|
1772
|
-
|
|
1773
|
-
/**
|
|
1774
|
-
* @param {unknown[]} rows
|
|
1775
|
-
* @param {string} itemName
|
|
1776
|
-
* @param {string[]} bodyLines
|
|
1777
|
-
* @param {Ctx} ctx
|
|
1778
|
-
* @param {LoopOpts} opts
|
|
1779
|
-
* @returns {string}
|
|
1780
|
-
*/
|
|
1781
|
-
function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
1782
|
-
// Reactive when the where reads state OR an offset/limit references a state key.
|
|
1783
|
-
const reactive = (opts.where && opts.where.refsState) || opts.clauseRefsState;
|
|
1784
|
-
if (reactive) return reactiveLoop(null, itemName, bodyLines, ctx, { ...opts, data: rows });
|
|
1785
|
-
return staticUnroll(pipelineRows(rows, opts.where, opts, ctx), itemName, bodyLines, ctx, opts.empty);
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
1789
|
-
// Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
|
|
1790
|
-
// paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
|
|
1791
|
-
/**
|
|
1792
|
-
* @param {string} raw
|
|
1793
|
-
* @param {string} itemName
|
|
1794
|
-
* @param {Ctx} ctx
|
|
1795
|
-
* @returns {Predicate}
|
|
1796
|
-
*/
|
|
1797
|
-
function compilePredicate(raw, itemName, ctx) {
|
|
1798
|
-
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
1799
|
-
/** @type {string[]} */
|
|
1800
|
-
const pieces = [];
|
|
1801
|
-
let refsState = false;
|
|
1802
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1803
|
-
if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
|
|
1804
|
-
const cond = compileCondition(parts[i].trim(), itemName, ctx);
|
|
1805
|
-
refsState = refsState || cond.usesState;
|
|
1806
|
-
pieces.push(`(${cond.expr})`);
|
|
1807
|
-
}
|
|
1808
|
-
return { body: pieces.join(" "), refsState };
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
/**
|
|
1812
|
-
* @param {string} cond
|
|
1813
|
-
* @param {string} itemName
|
|
1814
|
-
* @param {Ctx} ctx
|
|
1815
|
-
* @returns {{ expr: string, usesState: boolean }}
|
|
1816
|
-
*/
|
|
1817
|
-
function compileCondition(cond, itemName, ctx) {
|
|
1818
|
-
const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
1819
|
-
if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
|
|
1820
|
-
const left = compileOperand(m[1].trim(), itemName, ctx);
|
|
1821
|
-
const right = compileOperand(m[3].trim(), itemName, ctx);
|
|
1822
|
-
const usesState = left.usesState || right.usesState;
|
|
1823
|
-
const op = m[2].toLowerCase();
|
|
1824
|
-
if (op === "contains") return { expr: `C(${left.code}, ${right.code})`, usesState };
|
|
1825
|
-
return { expr: `${left.code} ${op} ${right.code}`, usesState };
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
/**
|
|
1829
|
-
* @param {string} tok
|
|
1830
|
-
* @param {string} itemName
|
|
1831
|
-
* @param {Ctx} ctx
|
|
1832
|
-
* @returns {{ code: string, usesState: boolean }}
|
|
1833
|
-
*/
|
|
1834
|
-
function compileOperand(tok, itemName, ctx) {
|
|
1835
|
-
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
|
|
1836
|
-
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
|
|
1837
|
-
if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
|
|
1838
|
-
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
1839
|
-
throw new Error(`Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`);
|
|
1840
|
-
}
|
|
1841
|
-
const segs = tok.split(".");
|
|
1842
|
-
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1843
|
-
throw new Error(`Path "${tok}" is not allowed in @loop where (${ctx.file})`);
|
|
1844
|
-
}
|
|
1845
|
-
if (segs[0] === itemName) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
|
|
1846
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
1847
|
-
if (!key) {
|
|
1848
|
-
throw new Error(`@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`);
|
|
1849
|
-
}
|
|
1850
|
-
const rest = segs.slice(1).join(".");
|
|
1851
|
-
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
1855
|
-
const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
|
|
1856
|
-
|
|
1857
|
-
/**
|
|
1858
|
-
* Evaluate a compiled predicate against a row at build time.
|
|
1859
|
-
* @param {string} body
|
|
1860
|
-
* @param {unknown} item
|
|
1861
|
-
* @param {Ctx} ctx
|
|
1862
|
-
* @returns {boolean}
|
|
1863
|
-
*/
|
|
1864
|
-
function evalPredicate(body, item, ctx) {
|
|
1865
|
-
try {
|
|
1866
|
-
/** @param {string} [p] */
|
|
1867
|
-
const I = (p) => getPath(item, p ? p.split(".") : []);
|
|
1868
|
-
/** @param {string} k @param {string} [r] */
|
|
1869
|
-
const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
|
|
1870
|
-
return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
|
|
1871
|
-
} catch {
|
|
1872
|
-
console.warn(`@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`);
|
|
1873
|
-
return false;
|
|
1874
|
-
}
|
|
1875
|
-
}
|
|
1876
|
-
|
|
1877
|
-
/**
|
|
1878
|
-
* Compile one operand of a `::: … .class when <predicate>` expression. Like the
|
|
1879
|
-
* `@loop where` operand, but also folds a static-scope value (a loop-unrolled
|
|
1880
|
-
* item field or include arg) to its build-time literal, so a fully-static
|
|
1881
|
-
* predicate can be evaluated at compile time. Mirrors `:if`'s scope→item→state
|
|
1882
|
-
* resolution order.
|
|
1883
|
-
* @param {string} tok
|
|
1884
|
-
* @param {Ctx} ctx
|
|
1885
|
-
* @param {string} what Directive label for error messages.
|
|
1886
|
-
* @returns {{ code: string, state: boolean, item: boolean }}
|
|
1887
|
-
*/
|
|
1888
|
-
function compileWhenOperand(tok, ctx, what) {
|
|
1889
|
-
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), state: false, item: false };
|
|
1890
|
-
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
|
|
1891
|
-
if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
|
|
1892
|
-
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
1893
|
-
throw new Error(`Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`);
|
|
1894
|
-
}
|
|
1895
|
-
const segs = tok.split(".");
|
|
1896
|
-
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
1897
|
-
throw new Error(`Path "${tok}" is not allowed in ${what} (${ctx.file})`);
|
|
1898
|
-
}
|
|
1899
|
-
const scoped = lookupVar(ctx.scope, segs[0]);
|
|
1900
|
-
if (scoped.found) return { code: JSON.stringify(getPath(scoped.value, segs.slice(1)) ?? null), state: false, item: false };
|
|
1901
|
-
if (ctx.loopItem && segs[0] === ctx.loopItem) return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
|
|
1902
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
1903
|
-
if (!key) throw new Error(`${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`);
|
|
1904
|
-
const rest = segs.slice(1).join(".");
|
|
1905
|
-
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, state: true, item: false };
|
|
1906
|
-
}
|
|
1907
|
-
|
|
1908
|
-
/**
|
|
1909
|
-
* Compile a `::: … .class when <predicate>`. Allows `:if`-style bare truthy
|
|
1910
|
-
* paths and `where`-style `left <op> right` conditions joined by `and`/`or`.
|
|
1911
|
-
* Folds to a static verdict when every operand is build-known, else returns a
|
|
1912
|
-
* runtime body over I()/S()/C() plus whether it reads the reactive loop item
|
|
1913
|
-
* (which decides data-wd-each-class vs the global data-wd-class).
|
|
1914
|
-
* @param {string} raw
|
|
1915
|
-
* @param {Ctx} ctx
|
|
1916
|
-
* @param {string} [what] Directive label for error messages.
|
|
1917
|
-
* @returns {{ static: true, value: boolean } | { static: false, body: string, item: boolean }}
|
|
1918
|
-
*/
|
|
1919
|
-
function compileWhen(raw, ctx, what = '"::: … when"') {
|
|
1920
|
-
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
1921
|
-
/** @type {string[]} */
|
|
1922
|
-
const pieces = [];
|
|
1923
|
-
let usesState = false;
|
|
1924
|
-
let usesItem = false;
|
|
1925
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1926
|
-
if (i % 2 === 1) { pieces.push(parts[i].toLowerCase() === "and" ? "&&" : "||"); continue; }
|
|
1927
|
-
let seg = parts[i].trim();
|
|
1928
|
-
// Optional leading `not` negates the whole sub-condition.
|
|
1929
|
-
let negate = false;
|
|
1930
|
-
if (/^not\s+/i.test(seg)) { negate = true; seg = seg.replace(/^not\s+/i, "").trim(); }
|
|
1931
|
-
const opMatch = seg.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
1932
|
-
let expr;
|
|
1933
|
-
if (opMatch) {
|
|
1934
|
-
const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
|
|
1935
|
-
const right = compileWhenOperand(opMatch[3].trim(), ctx, what);
|
|
1936
|
-
usesState = usesState || left.state || right.state;
|
|
1937
|
-
usesItem = usesItem || left.item || right.item;
|
|
1938
|
-
const op = opMatch[2].toLowerCase();
|
|
1939
|
-
expr = op === "contains" ? `C(${left.code}, ${right.code})` : `${left.code} ${op} ${right.code}`;
|
|
1940
|
-
} else {
|
|
1941
|
-
const only = compileWhenOperand(seg, ctx, what);
|
|
1942
|
-
usesState = usesState || only.state;
|
|
1943
|
-
usesItem = usesItem || only.item;
|
|
1944
|
-
expr = only.code;
|
|
1945
|
-
}
|
|
1946
|
-
pieces.push(negate ? `(!(${expr}))` : `(${expr})`);
|
|
1947
|
-
}
|
|
1948
|
-
const body = pieces.join(" ");
|
|
1949
|
-
if (!usesState && !usesItem) return { static: true, value: evalPredicate(body, undefined, ctx) };
|
|
1950
|
-
return { static: false, body, item: usesItem };
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
/**
|
|
1954
|
-
* @param {unknown[]} rows
|
|
1955
|
-
* @param {string} itemName
|
|
1956
|
-
* @param {string[]} bodyLines
|
|
1957
|
-
* @param {Ctx} ctx
|
|
1958
|
-
* @param {string[] | null} [empty] Empty-branch body, rendered when 0 rows.
|
|
1959
|
-
* @returns {string}
|
|
1960
|
-
*/
|
|
1961
|
-
function staticUnroll(rows, itemName, bodyLines, ctx, empty = null) {
|
|
1962
|
-
if (rows.length === 0 && empty) return compileBody(empty, { ...ctx, loopMeta: true });
|
|
1963
|
-
const out = [];
|
|
1964
|
-
const count = rows.length;
|
|
1965
|
-
for (let i = 0; i < count; i++) {
|
|
1966
|
-
const meta = { $index: i, $number: i + 1, $first: i === 0, $last: i === count - 1, $count: count };
|
|
1967
|
-
const rowCtx = { ...ctx, loopMeta: true, scope: createScope(ctx.scope, { [itemName]: rows[i], ...meta }) };
|
|
1968
|
-
out.push(compileBody(bodyLines, rowCtx));
|
|
1969
|
-
}
|
|
1970
|
-
return out.join("\n");
|
|
1971
|
-
}
|
|
1972
|
-
|
|
1973
|
-
/**
|
|
1974
|
-
* Emit a reactive loop region (template + initial rows + clause config) for the
|
|
1975
|
-
* runtime. `key` may be a dotted path (e.g. `team.members`); the runtime reads
|
|
1976
|
-
* it via getPath. `opts` carries where/sort/reverse/offset/limit/empty.
|
|
1977
|
-
* @param {string | null} key State key/path of the list, or null for baked data.
|
|
1978
|
-
* @param {string} itemName
|
|
1979
|
-
* @param {string[]} bodyLines
|
|
1980
|
-
* @param {Ctx} ctx
|
|
1981
|
-
* @param {Partial<LoopOpts> & { data?: unknown[] } | null} [opts]
|
|
1982
|
-
* @returns {string}
|
|
1983
|
-
*/
|
|
1984
|
-
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
1985
|
-
ctx.comp.assets.runtime = true;
|
|
1986
|
-
/** @type {Ctx} */
|
|
1987
|
-
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, loopMeta: true, scope: createScope(ctx.scope) };
|
|
1988
|
-
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
1989
|
-
|
|
1990
|
-
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
1991
|
-
|
|
1992
|
-
const where = opts?.where || null;
|
|
1993
|
-
const sort = opts?.sort || null;
|
|
1994
|
-
const reverse = opts?.reverse || false;
|
|
1995
|
-
const offset = opts?.offset || null;
|
|
1996
|
-
const limit = opts?.limit || null;
|
|
1997
|
-
const emptyLines = opts?.empty || null;
|
|
1998
|
-
const baked = opts?.data || null; // rows for a static source filtered by state
|
|
1999
|
-
// Resolve the (possibly dotted) source for the initial paint.
|
|
2000
|
-
const segs = key ? key.split(".") : [];
|
|
2001
|
-
const stateRows = key ? getPath(ctx.comp.state.get(segs[0]), segs.slice(1)) : null;
|
|
2002
|
-
/** @type {unknown[]} */
|
|
2003
|
-
const allRows = key ? (Array.isArray(stateRows) ? stateRows : []) : (baked || []);
|
|
2004
|
-
// Initial paint runs the same pipeline the runtime will, reading state-key
|
|
2005
|
-
// offset/limit from their current declared values.
|
|
2006
|
-
/** @param {NumArg|null} a */
|
|
2007
|
-
const num = (a) => a ? (a.kind === "literal" ? a.value : Number(ctx.comp.state.get(a.value) ?? 0)) : null;
|
|
2008
|
-
const rows = pipelineRows(allRows, where, { where, sort, reverse, offset: offset && offset.kind === "key" ? { kind: "literal", value: num(offset) || 0 } : offset, limit: limit && limit.kind === "key" ? { kind: "literal", value: num(limit) ?? allRows.length } : limit, empty: null, clauseRefsState: false }, ctx);
|
|
2009
|
-
/** @type {Map<string, number>} */
|
|
2010
|
-
const counts = new Map();
|
|
2011
|
-
const count = rows.length;
|
|
2012
|
-
// Inner ITEM-RELATIVE loop regions are filled per row by the runtime, not at
|
|
2013
|
-
// build time; mask them so the outer initial-paint string fill leaves their
|
|
2014
|
-
// pristine <template>/empty-out markup untouched, then restore.
|
|
2015
|
-
const { masked, regions } = maskItemLoops(itemTemplate);
|
|
2016
|
-
const initial = rows
|
|
2017
|
-
.map((/** @type {unknown} */ item, i) => {
|
|
2018
|
-
const itemKey = loopKeyOf(item, counts);
|
|
2019
|
-
const meta = { index: i, number: i + 1, first: i === 0, last: i === count - 1, count };
|
|
2020
|
-
return unmaskItemLoops(fillTemplateString(withLoopKey(masked, itemKey), item, meta), regions);
|
|
2021
|
-
})
|
|
2022
|
-
.join("");
|
|
2023
|
-
|
|
2024
|
-
const emptyTemplate = loopEmptyTemplate(emptyLines, ctx);
|
|
2025
|
-
const initialOut = count === 0 && emptyLines ? compileBody(emptyLines, { ...ctx, loopMeta: true }).trim() : initial;
|
|
2026
|
-
|
|
2027
|
-
const attrs = loopClauseAttrs({ where, sort, reverse, offset, limit }) +
|
|
2028
|
-
(baked ? ` data-wd-loop-data="${escapeHtml(JSON.stringify(baked))}"` : "");
|
|
2029
|
-
return `<div data-wd-loop="${escapeHtml(key || "")}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out>${initialOut}</${wrapperTag}></div>`;
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
/**
|
|
2033
|
-
* Unwrap a compiled row body into a wrapper tag + row template. A body that is a
|
|
2034
|
-
* single-`<li>` `<ul>`/`<ol>` becomes an `<li>` row under that list wrapper;
|
|
2035
|
-
* anything else is wrapped in a `<div data-wd-loop-piece>` under a `<div>`. Shared
|
|
2036
|
-
* by top-level reactive loops and item-relative (nested) loops.
|
|
2037
|
-
* @param {string} templateHtml
|
|
2038
|
-
* @returns {{ wrapperTag: string, itemTemplate: string }}
|
|
2039
|
-
*/
|
|
2040
|
-
function wrapRowTemplate(templateHtml) {
|
|
2041
|
-
const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
|
|
2042
|
-
if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
|
|
2043
|
-
return { wrapperTag: listMatch[1], itemTemplate: listMatch[2].trim() };
|
|
2044
|
-
}
|
|
2045
|
-
return { wrapperTag: "div", itemTemplate: `<div data-wd-loop-piece>${templateHtml}</div>` };
|
|
2046
|
-
}
|
|
2047
|
-
|
|
2048
|
-
/**
|
|
2049
|
-
* Compile an `@empty` branch body into a `<template data-wd-loop-empty>` (or "" when
|
|
2050
|
-
* there is no empty branch). Shared by top-level and item-relative loops.
|
|
2051
|
-
* @param {string[] | null} emptyLines
|
|
2052
|
-
* @param {Ctx} ctx
|
|
2053
|
-
* @returns {string}
|
|
2054
|
-
*/
|
|
2055
|
-
function loopEmptyTemplate(emptyLines, ctx) {
|
|
2056
|
-
return emptyLines ? `<template data-wd-loop-empty>${compileBody(emptyLines, { ...ctx, loopMeta: true }).trim()}</template>` : "";
|
|
2057
|
-
}
|
|
2058
|
-
|
|
2059
|
-
/**
|
|
2060
|
-
* Serialize the shared `where`/`sort`/`reverse`/`offset`/`limit` clause config to
|
|
2061
|
-
* the `data-wd-loop-*` attribute string consumed by the runtime pipeline. Shared
|
|
2062
|
-
* by top-level reactive loops and item-relative (nested) loops.
|
|
2063
|
-
* @param {Pick<LoopOpts, "where" | "sort" | "reverse" | "offset" | "limit">} c
|
|
2064
|
-
* @returns {string}
|
|
2065
|
-
*/
|
|
2066
|
-
function loopClauseAttrs(c) {
|
|
2067
|
-
return (
|
|
2068
|
-
(c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
|
|
2069
|
-
(c.sort ? ` data-wd-loop-sort="${escapeHtml(c.sort.key)}" data-wd-loop-sort-dir="${c.sort.dir}"` : "") +
|
|
2070
|
-
(c.reverse ? ` data-wd-loop-reverse` : "") +
|
|
2071
|
-
(c.offset ? ` data-wd-loop-offset="${numArgAttr(c.offset)}"` : "") +
|
|
2072
|
-
(c.limit ? ` data-wd-loop-limit="${numArgAttr(c.limit)}"` : "")
|
|
2073
|
-
);
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
/**
|
|
2077
|
-
* Emit ONE level of nested reactive loop: a `@loop <outerItem>.<path> into x`
|
|
2078
|
-
* whose rows are read off the enclosing loop row at runtime. The region carries
|
|
2079
|
-
* its own row <template> + clause config but, unlike a top-level loop, its source
|
|
2080
|
-
* is the relative `path` (not a global state key) — `fillItem` resolves the rows
|
|
2081
|
-
* off the current outer item and reconciles per row. The output starts empty; the
|
|
2082
|
-
* runtime fills it on first render (the page already ships the runtime).
|
|
2083
|
-
* @param {string} path Dotted sub-path off the outer item (e.g. `items`).
|
|
2084
|
-
* @param {string} itemName Inner loop item name.
|
|
2085
|
-
* @param {string[]} bodyLines Inner loop body.
|
|
2086
|
-
* @param {Ctx} ctx
|
|
2087
|
-
* @param {Partial<LoopOpts>} opts
|
|
2088
|
-
* @returns {string}
|
|
2089
|
-
*/
|
|
2090
|
-
function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
2091
|
-
/** @type {Ctx} */
|
|
2092
|
-
// loopKey: undefined — an item-relative loop's source is a path off the outer
|
|
2093
|
-
// row, not a top-level state key, so a per-row `remove` inside it has no valid
|
|
2094
|
-
// target and must be rejected (rather than inheriting the outer loop's key).
|
|
2095
|
-
const templateCtx = { ...ctx, loopItem: itemName, loopKey: undefined, loopMeta: true, scope: createScope(ctx.scope) };
|
|
2096
|
-
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
2097
|
-
|
|
2098
|
-
const { wrapperTag, itemTemplate } = wrapRowTemplate(templateHtml);
|
|
2099
|
-
|
|
2100
|
-
const emptyTemplate = loopEmptyTemplate(opts.empty || null, ctx);
|
|
2101
|
-
const attrs = loopClauseAttrs({ where: opts.where || null, sort: opts.sort || null, reverse: opts.reverse || false, offset: opts.offset || null, limit: opts.limit || null });
|
|
2102
|
-
return `<div data-wd-loop-item="${escapeHtml(path)}"${attrs}><template data-wd-loop-template>${itemTemplate}</template>${emptyTemplate}<${wrapperTag} data-wd-loop-out></${wrapperTag}></div>`;
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
|
-
/**
|
|
2106
|
-
* Replace each top-level `data-wd-loop-item` region in a row template with an
|
|
2107
|
-
* opaque placeholder so the outer initial-paint string fill cannot reach inside
|
|
2108
|
-
* its pristine markup. Returns the masked string and the excised regions in order.
|
|
2109
|
-
* @param {string} template
|
|
2110
|
-
* @returns {{ masked: string, regions: string[] }}
|
|
2111
|
-
*/
|
|
2112
|
-
function maskItemLoops(template) {
|
|
2113
|
-
const marker = "<div data-wd-loop-item=";
|
|
2114
|
-
/** @type {string[]} */
|
|
2115
|
-
const regions = [];
|
|
2116
|
-
let masked = "";
|
|
2117
|
-
let i = 0;
|
|
2118
|
-
for (;;) {
|
|
2119
|
-
const start = template.indexOf(marker, i);
|
|
2120
|
-
if (start === -1) return { masked: masked + template.slice(i), regions };
|
|
2121
|
-
const end = matchElement(template, start, "div");
|
|
2122
|
-
masked += template.slice(i, start) + `\x00WDLI${regions.length}\x00`;
|
|
2123
|
-
regions.push(template.slice(start, end));
|
|
2124
|
-
i = end;
|
|
2125
|
-
}
|
|
2126
|
-
}
|
|
2127
|
-
|
|
2128
|
-
/**
|
|
2129
|
-
* Restore the masked `data-wd-loop-item` regions into a filled row string.
|
|
2130
|
-
* @param {string} str
|
|
2131
|
-
* @param {string[]} regions
|
|
2132
|
-
* @returns {string}
|
|
2133
|
-
*/
|
|
2134
|
-
function unmaskItemLoops(str, regions) {
|
|
2135
|
-
return regions.length ? str.replace(/\x00WDLI(\d+)\x00/g, (_, n) => regions[Number(n)]) : str;
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
/**
|
|
2139
|
-
* @param {string} template
|
|
2140
|
-
* @param {string} itemKey
|
|
2141
|
-
* @returns {string}
|
|
2142
|
-
*/
|
|
2143
|
-
function withLoopKey(template, itemKey) {
|
|
2144
|
-
return template.replace(/^<([a-zA-Z][a-zA-Z0-9-]*)/, `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`);
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
/**
|
|
2148
|
-
* @param {string} template
|
|
2149
|
-
* @param {unknown} item
|
|
2150
|
-
* @param {Record<string, unknown>} [meta] Per-row meta values for the initial paint.
|
|
2151
|
-
* @returns {string}
|
|
2152
|
-
*/
|
|
2153
|
-
function fillTemplateString(template, item, meta = {}) {
|
|
2154
|
-
// Resolve per-item :if regions first (recursively, so nested conditionals are
|
|
2155
|
-
// pre-rendered for the initial paint), then fill text binds and meta markers.
|
|
2156
|
-
return fillEachMeta(fillEachText(fillEachIfRegions(template, item, meta), item), meta);
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
/**
|
|
2160
|
-
* Fill `<span data-wd-each-meta="…">` markers with their per-row value.
|
|
2161
|
-
* @param {string} str
|
|
2162
|
-
* @param {Record<string, unknown>} meta
|
|
2163
|
-
* @returns {string}
|
|
2164
|
-
*/
|
|
2165
|
-
function fillEachMeta(str, meta) {
|
|
2166
|
-
return str.replace(/<span data-wd-each-meta="([a-z]+)"><\/span>/g, (_, name) => {
|
|
2167
|
-
const value = name in meta ? meta[name] : "";
|
|
2168
|
-
return `<span data-wd-each-meta="${name}">${escapeHtml(value ?? "")}</span>`;
|
|
2169
|
-
});
|
|
2170
|
-
}
|
|
2171
|
-
|
|
2172
|
-
// Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
|
|
2173
|
-
// chosen branch is recursed so nested conditionals resolve too. The <template>
|
|
2174
|
-
// markup is left pristine so the runtime can keep toggling branches.
|
|
2175
|
-
/**
|
|
2176
|
-
* @param {string} str
|
|
2177
|
-
* @param {unknown} item
|
|
2178
|
-
* @param {Record<string, unknown>} [meta]
|
|
2179
|
-
* @returns {string}
|
|
2180
|
-
*/
|
|
2181
|
-
function fillEachIfRegions(str, item, meta = {}) {
|
|
2182
|
-
const marker = '<span data-wd-each-if ';
|
|
2183
|
-
let result = "";
|
|
2184
|
-
let i = 0;
|
|
2185
|
-
for (;;) {
|
|
2186
|
-
const start = str.indexOf(marker, i);
|
|
2187
|
-
if (start === -1) return result + str.slice(i);
|
|
2188
|
-
result += str.slice(i, start);
|
|
2189
|
-
const end = matchElement(str, start, "span");
|
|
2190
|
-
result += fillOneEachIf(str.slice(start, end), item, meta);
|
|
2191
|
-
i = end;
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
|
|
2195
|
-
/**
|
|
2196
|
-
* @param {string} region
|
|
2197
|
-
* @param {unknown} item
|
|
2198
|
-
* @param {Record<string, unknown>} meta
|
|
2199
|
-
* @returns {string}
|
|
2200
|
-
*/
|
|
2201
|
-
function fillOneEachIf(region, item, meta) {
|
|
2202
|
-
const metaMatch = region.match(/^<span data-wd-each-if data-wd-meta="([a-z]+)">/);
|
|
2203
|
-
const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
|
|
2204
|
-
const trueStart = region.indexOf("<template data-wd-if-true>");
|
|
2205
|
-
const trueEnd = matchElement(region, trueStart, "template");
|
|
2206
|
-
const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
|
|
2207
|
-
const falseEnd = matchElement(region, falseStart, "template");
|
|
2208
|
-
const open = "<template data-wd-if-true>".length;
|
|
2209
|
-
const close = "</template>".length;
|
|
2210
|
-
const truthy = region.slice(trueStart + open, trueEnd - close);
|
|
2211
|
-
const falsy = region.slice(falseStart + "<template data-wd-if-false>".length, falseEnd - close);
|
|
2212
|
-
const test = metaMatch ? Boolean(meta[metaMatch[1]]) : Boolean(getPath(item, path ? path.split(".") : []));
|
|
2213
|
-
const branch = test ? truthy : falsy;
|
|
2214
|
-
const head = region.slice(0, falseEnd);
|
|
2215
|
-
return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item, meta)}</span></span>`;
|
|
2216
|
-
}
|
|
2217
|
-
|
|
2218
|
-
/**
|
|
2219
|
-
* @param {string} str
|
|
2220
|
-
* @param {unknown} item
|
|
2221
|
-
* @returns {string}
|
|
2222
|
-
*/
|
|
2223
|
-
function fillEachText(str, item) {
|
|
2224
|
-
return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
|
|
2225
|
-
const value = p ? getPath(item, p.split(".")) : item;
|
|
2226
|
-
const pathAttr = p ? ` data-wd-path="${p}"` : "";
|
|
2227
|
-
return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
|
|
2228
|
-
});
|
|
2229
|
-
}
|
|
2230
|
-
|
|
2231
|
-
// Return the index just past the balanced close of the element of `tag` that
|
|
2232
|
-
// begins at `start`. Counts nested same-tag opens/closes so regions that embed
|
|
2233
|
-
// their own spans/templates (nested :if) match correctly.
|
|
2234
|
-
/**
|
|
2235
|
-
* @param {string} str
|
|
2236
|
-
* @param {number} start
|
|
2237
|
-
* @param {string} tag
|
|
2238
|
-
* @returns {number}
|
|
2239
|
-
*/
|
|
2240
|
-
function matchElement(str, start, tag) {
|
|
2241
|
-
const openPrefix = `<${tag}`;
|
|
2242
|
-
const closeTag = `</${tag}>`;
|
|
2243
|
-
let depth = 0;
|
|
2244
|
-
let i = start;
|
|
2245
|
-
while (i < str.length) {
|
|
2246
|
-
if (str.startsWith(openPrefix, i) && (str[i + openPrefix.length] === " " || str[i + openPrefix.length] === ">")) {
|
|
2247
|
-
depth++;
|
|
2248
|
-
const gt = str.indexOf(">", i);
|
|
2249
|
-
i = gt === -1 ? str.length : gt + 1;
|
|
2250
|
-
} else if (str.startsWith(closeTag, i)) {
|
|
2251
|
-
depth--;
|
|
2252
|
-
i += closeTag.length;
|
|
2253
|
-
if (depth === 0) return i;
|
|
2254
|
-
} else {
|
|
2255
|
-
i++;
|
|
2256
|
-
}
|
|
2257
|
-
}
|
|
2258
|
-
return str.length;
|
|
2259
|
-
}
|
|
2260
|
-
|
|
2261
|
-
/**
|
|
2262
|
-
* Stable per-render key for a loop row, disambiguating duplicates with `#n`.
|
|
2263
|
-
* @param {unknown} item
|
|
2264
|
-
* @param {Map<string, number>} counts Mutable seen-count accumulator.
|
|
2265
|
-
* @returns {string}
|
|
2266
|
-
*/
|
|
2267
|
-
export function loopKeyOf(item, counts) {
|
|
2268
|
-
const base =
|
|
2269
|
-
item && typeof item === "object"
|
|
2270
|
-
? String(/** @type {Record<string, unknown>} */ (item).id ?? /** @type {Record<string, unknown>} */ (item).key ?? JSON.stringify(item))
|
|
2271
|
-
: String(item);
|
|
2272
|
-
const seen = counts.get(base) || 0;
|
|
2273
|
-
counts.set(base, seen + 1);
|
|
2274
|
-
return seen ? `${base}#${seen}` : base;
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
/**
|
|
2278
|
-
* Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
|
|
2279
|
-
* @param {string} line
|
|
2280
|
-
* @param {Ctx} ctx
|
|
2281
|
-
* @returns {string}
|
|
2282
|
-
*/
|
|
2283
|
-
function renderDemoDirective(line, ctx) {
|
|
2284
|
-
const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
|
|
2285
|
-
if (tryMatch) {
|
|
2286
|
-
const href = validateDemoHref(tryMatch[2], ctx);
|
|
2287
|
-
return `<a class="try-card" href="${escapeHtml(href)}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
2288
|
-
}
|
|
2289
|
-
const note = line.match(/^:note\s+"([^"]+)"$/);
|
|
2290
|
-
if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
|
|
2291
|
-
const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
|
|
2292
|
-
if (sprint) {
|
|
2293
|
-
const roles = sprint[3].split(",").map((role) => role.trim()).filter(Boolean);
|
|
2294
|
-
return `<section class="sprint-board" data-min="${sprint[1]}" data-max="${sprint[2]}">${roles.map((role) => `<article><strong>${escapeHtml(role)}</strong><span>active lane</span></article>`).join("")}</section>`;
|
|
2295
|
-
}
|
|
2296
|
-
return "";
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
/**
|
|
2300
|
-
* Validate demo-card links without adding arbitrary URL passthrough.
|
|
2301
|
-
* @param {string} href
|
|
2302
|
-
* @param {Ctx} ctx
|
|
2303
|
-
* @returns {string}
|
|
2304
|
-
*/
|
|
2305
|
-
function validateDemoHref(href, ctx) {
|
|
2306
|
-
const value = href.trim();
|
|
2307
|
-
if (value !== href || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
2308
|
-
throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
|
|
2309
|
-
}
|
|
2310
|
-
if (value.startsWith("//")) {
|
|
2311
|
-
throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http: or https: explicitly.`);
|
|
2312
|
-
}
|
|
2313
|
-
if (value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("#")) {
|
|
2314
|
-
return value;
|
|
2315
|
-
}
|
|
2316
|
-
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
2317
|
-
if (scheme && ["http", "https", "mailto"].includes(scheme[1].toLowerCase())) {
|
|
2318
|
-
return value;
|
|
2319
|
-
}
|
|
2320
|
-
throw new Error(`Unsafe :try href "${escapeHtml(href)}" in ${ctx.file}. Use a relative URL starting with /, ./, ../, or #, or an http:, https:, or mailto: URL.`);
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
// ---------------------------------------------------------------------------
|
|
2324
|
-
// Interpolation: one syntax, { name } / { name.path }
|
|
2325
|
-
// ---------------------------------------------------------------------------
|
|
2326
|
-
|
|
2327
|
-
/**
|
|
2328
|
-
* @param {string} text
|
|
2329
|
-
* @param {Ctx} ctx
|
|
2330
|
-
* @returns {string}
|
|
2331
|
-
*/
|
|
2332
|
-
function renderProse(text, ctx) {
|
|
2333
|
-
text = resolveDestinationBindings(text, ctx);
|
|
2334
|
-
return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
/**
|
|
2338
|
-
* Pre-resolve `{ expr }` interpolations sitting in a markdown link/image
|
|
2339
|
-
* destination — the `](…)` slot — so a build-time `@loop` (or any static scope)
|
|
2340
|
-
* can drive an href/src. A markdown destination cannot contain the spaces inside
|
|
2341
|
-
* `{ … }`, so markdown-it would otherwise never form the link; substituting the
|
|
2342
|
-
* static value here lets the normal link/image parser run. Only build-time
|
|
2343
|
-
* (static-scope) values are substituted — reactive `:state`/reactive-loop
|
|
2344
|
-
* bindings resolve to `null` and are left untouched (they cannot live in a
|
|
2345
|
-
* destination). Issue #19.
|
|
2346
|
-
* @param {string} text
|
|
2347
|
-
* @param {Ctx} ctx
|
|
2348
|
-
* @returns {string}
|
|
2349
|
-
*/
|
|
2350
|
-
function resolveDestinationBindings(text, ctx) {
|
|
2351
|
-
return text.replace(
|
|
2352
|
-
/(\]\(\s*)\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/g,
|
|
2353
|
-
(whole, prefix, expr) => {
|
|
2354
|
-
const raw = resolveBindingRaw(expr, ctx);
|
|
2355
|
-
return raw == null ? whole : prefix + raw;
|
|
2356
|
-
}
|
|
2357
|
-
);
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
/**
|
|
2361
|
-
* Resolve `{ expr }` to its raw static value (a plain string) for use in a
|
|
2362
|
-
* destination, or `null` when the binding is reactive or not in build-time
|
|
2363
|
-
* scope. Mirrors the static branch of {@link resolveBindingHtml} but returns the
|
|
2364
|
-
* unescaped value (markdown-it normalizes the URL).
|
|
2365
|
-
* @param {string} expr
|
|
2366
|
-
* @param {Ctx} ctx
|
|
2367
|
-
* @returns {string | null}
|
|
2368
|
-
*/
|
|
2369
|
-
function resolveBindingRaw(expr, ctx) {
|
|
2370
|
-
const segs = expr.split(".");
|
|
2371
|
-
const found = lookupVar(ctx.scope, segs[0]);
|
|
2372
|
-
if (!found.found) return null;
|
|
2373
|
-
const resolved = getPath(found.value, segs.slice(1));
|
|
2374
|
-
const leaf = interpolateLeaf(resolved, expr, ctx);
|
|
2375
|
-
return leaf == null ? null : String(leaf);
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
|
-
/**
|
|
2379
|
-
* markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
|
|
2380
|
-
* whose content comes from the env's `resolveBinding` callback.
|
|
2381
|
-
* @param {MarkdownIt} mdInstance
|
|
2382
|
-
* @returns {void}
|
|
2383
|
-
*/
|
|
2384
|
-
function bindingPlugin(mdInstance) {
|
|
2385
|
-
mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
|
|
2386
|
-
if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
|
|
2387
|
-
const match = /^\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/.exec(state.src.slice(state.pos));
|
|
2388
|
-
if (!match) return false;
|
|
2389
|
-
const resolve = state.env?.resolveBinding;
|
|
2390
|
-
const html = resolve ? resolve(match[1]) : null;
|
|
2391
|
-
if (typeof html !== "string") return false;
|
|
2392
|
-
if (!silent) {
|
|
2393
|
-
const token = state.push("html_inline", "", 0);
|
|
2394
|
-
token.content = html;
|
|
2395
|
-
}
|
|
2396
|
-
state.pos += match[0].length;
|
|
2397
|
-
return true;
|
|
2398
|
-
});
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
|
-
/**
|
|
2402
|
-
* markdown-it plugin (core rule): a `{.class .class #id}` block immediately
|
|
2403
|
-
* following an inline element (link, image, em/strong, code) attaches those
|
|
2404
|
-
* classes / id to that element. Lets a link be styled as a button without a
|
|
2405
|
-
* wrapper container. The block must directly follow the element (no space), and
|
|
2406
|
-
* `{ … }` interpolation is unaffected (its content starts with a name, not `.`/`#`).
|
|
2407
|
-
* Issue #18.
|
|
2408
|
-
* @param {MarkdownIt} mdInstance
|
|
2409
|
-
* @returns {void}
|
|
2410
|
-
*/
|
|
2411
|
-
function attrsPlugin(mdInstance) {
|
|
2412
|
-
mdInstance.core.ruler.push("wd_attrs", (state) => {
|
|
2413
|
-
for (const block of state.tokens) {
|
|
2414
|
-
if (block.type !== "inline" || !block.children) continue;
|
|
2415
|
-
const children = block.children;
|
|
2416
|
-
for (let i = 0; i < children.length; i++) {
|
|
2417
|
-
const tok = children[i];
|
|
2418
|
-
if (tok.type !== "text") continue;
|
|
2419
|
-
const m = /^\{([.#][^}]*)\}/.exec(tok.content);
|
|
2420
|
-
if (!m) continue;
|
|
2421
|
-
const target = attrTarget(children, i);
|
|
2422
|
-
if (!target) continue;
|
|
2423
|
-
for (const part of m[1].split(/\s+/).filter(Boolean)) {
|
|
2424
|
-
if (part[0] === ".") target.attrJoin("class", part.slice(1));
|
|
2425
|
-
else if (part[0] === "#") target.attrSet("id", part.slice(1));
|
|
2426
|
-
}
|
|
2427
|
-
tok.content = tok.content.slice(m[0].length);
|
|
2428
|
-
}
|
|
2429
|
-
}
|
|
2430
|
-
return false;
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
|
|
2434
|
-
/**
|
|
2435
|
-
* The inline token an attr block attaches to: the immediately-preceding image
|
|
2436
|
-
* (self-closing) or the open token matching the immediately-preceding close
|
|
2437
|
-
* (link/em/strong/…). Returns null when nothing valid precedes.
|
|
2438
|
-
* @param {any[]} children markdown-it inline child tokens
|
|
2439
|
-
* @param {number} i Index of the attr-block text token.
|
|
2440
|
-
* @returns {any} the matching markdown-it Token, or null
|
|
2441
|
-
*/
|
|
2442
|
-
function attrTarget(children, i) {
|
|
2443
|
-
const prev = children[i - 1];
|
|
2444
|
-
if (!prev) return null;
|
|
2445
|
-
if (prev.type === "image") return prev;
|
|
2446
|
-
if (prev.nesting === -1) {
|
|
2447
|
-
const openType = prev.type.replace(/_close$/, "_open");
|
|
2448
|
-
let depth = 0;
|
|
2449
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
2450
|
-
const t = children[j];
|
|
2451
|
-
if (t.type === prev.type) depth++;
|
|
2452
|
-
else if (t.type === openType) { depth--; if (depth === 0) return t; }
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
return null;
|
|
2456
|
-
}
|
|
2457
|
-
|
|
2458
|
-
/**
|
|
2459
|
-
* Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
|
|
2460
|
-
* a reactive bind span. Returns null when nothing in scope matches.
|
|
2461
|
-
* @param {string} expr
|
|
2462
|
-
* @param {Ctx} ctx
|
|
2463
|
-
* @returns {string | null}
|
|
2464
|
-
*/
|
|
2465
|
-
function resolveBindingHtml(expr, ctx) {
|
|
2466
|
-
const segs = expr.split(".");
|
|
2467
|
-
const head = segs[0];
|
|
2468
|
-
|
|
2469
|
-
// Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
|
|
2470
|
-
if (LOOP_META[head]) {
|
|
2471
|
-
if (!ctx.loopMeta) throw new Error(`"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`);
|
|
2472
|
-
if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"></span>`; // reactive: filled per row
|
|
2473
|
-
// static: the value is in scope (injected by staticUnroll); fall through.
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
if (ctx.loopItem && head === ctx.loopItem) {
|
|
2477
|
-
const rest = segs.slice(1).join(".");
|
|
2478
|
-
return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}></span>`;
|
|
2479
|
-
}
|
|
2480
|
-
|
|
2481
|
-
const staticValue = lookupVar(ctx.scope, head);
|
|
2482
|
-
if (staticValue.found) {
|
|
2483
|
-
const resolved = getPath(staticValue.value, segs.slice(1));
|
|
2484
|
-
return escapeHtml(interpolateLeaf(resolved, expr, ctx));
|
|
2485
|
-
}
|
|
2486
|
-
|
|
2487
|
-
const key = resolveStateKey(head, ctx);
|
|
2488
|
-
if (key) {
|
|
2489
|
-
ctx.comp.assets.runtime = true;
|
|
2490
|
-
const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
|
|
2491
|
-
const rest = segs.slice(1).join(".");
|
|
2492
|
-
const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
|
|
2493
|
-
return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(interpolateLeaf(initial, expr, ctx))}</span>`;
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
return null;
|
|
2497
|
-
}
|
|
2498
|
-
|
|
2499
|
-
/**
|
|
2500
|
-
* Render an interpolated value as text. Arrays join with ", " (matching the
|
|
2501
|
-
* documented `{ meta.tags }` behavior); a bare object is a mistake — fail
|
|
2502
|
-
* loudly with a fix rather than emitting "[object Object]".
|
|
2503
|
-
* @param {unknown} value
|
|
2504
|
-
* @param {string} expr
|
|
2505
|
-
* @param {Ctx} ctx
|
|
2506
|
-
* @returns {string}
|
|
2507
|
-
*/
|
|
2508
|
-
function interpolateLeaf(value, expr, ctx) {
|
|
2509
|
-
if (value === null || value === undefined) return "";
|
|
2510
|
-
if (Array.isArray(value)) return value.join(", ");
|
|
2511
|
-
if (typeof value === "object") {
|
|
2512
|
-
throw new Error(`Cannot interpolate the object value "{ ${expr} }" in ${ctx.file}. Interpolate a field instead — e.g. { ${expr}.title } — or iterate it with @loop ${expr} into item.`);
|
|
2513
|
-
}
|
|
2514
|
-
return String(value);
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
/**
|
|
2518
|
-
* Walk the scope chain for a top-level variable.
|
|
2519
|
-
* @param {Scope} scope
|
|
2520
|
-
* @param {string} name
|
|
2521
|
-
* @returns {{ found: true, value: unknown } | { found: false }}
|
|
2522
|
-
*/
|
|
2523
|
-
function lookupVar(scope, name) {
|
|
2524
|
-
/** @type {Scope | null} */
|
|
2525
|
-
let current = scope;
|
|
2526
|
-
for (; current; current = current.parent) {
|
|
2527
|
-
if (name in current.vars) return { found: true, value: current.vars[name] };
|
|
2528
|
-
}
|
|
2529
|
-
return { found: false };
|
|
2530
|
-
}
|
|
2531
|
-
|
|
2532
|
-
/**
|
|
2533
|
-
* Resolve a dotted path against the static scope chain.
|
|
2534
|
-
* @param {string} expr
|
|
2535
|
-
* @param {Ctx} ctx
|
|
2536
|
-
* @returns {{ found: true, value: unknown } | { found: false }}
|
|
2537
|
-
*/
|
|
2538
|
-
function lookupPath(expr, ctx) {
|
|
2539
|
-
const segs = expr.split(".");
|
|
2540
|
-
const head = lookupVar(ctx.scope, segs[0]);
|
|
2541
|
-
if (!head.found) return { found: false };
|
|
2542
|
-
return { found: true, value: getPath(head.value, segs.slice(1)) };
|
|
2543
|
-
}
|
|
2544
|
-
|
|
2545
|
-
/**
|
|
2546
|
-
* Safely read a dotted path off a value, rejecting prototype-pollution segments.
|
|
2547
|
-
* @param {unknown} value
|
|
2548
|
-
* @param {string[]} segments
|
|
2549
|
-
* @returns {unknown}
|
|
2550
|
-
*/
|
|
2551
|
-
function getPath(value, segments) {
|
|
2552
|
-
/** @type {any} */
|
|
2553
|
-
let current = value;
|
|
2554
|
-
for (const segment of segments) {
|
|
2555
|
-
if (current == null) return undefined;
|
|
2556
|
-
if (["constructor", "prototype", "__proto__"].includes(segment)) return undefined;
|
|
2557
|
-
current = current[segment];
|
|
2558
|
-
}
|
|
2559
|
-
return current;
|
|
2560
|
-
}
|
|
2561
|
-
|
|
2562
|
-
/**
|
|
2563
|
-
* Validate a dotted path shape, rejecting prototype-pollution segments at every
|
|
2564
|
-
* level. Shared by actions/loops/fetch. Throws with a corrective `Use:` hint.
|
|
2565
|
-
* @param {string} path Dotted path, e.g. `cart.items`.
|
|
2566
|
-
* @param {Ctx} ctx
|
|
2567
|
-
* @param {string} use Corrective suggestion for the error message.
|
|
2568
|
-
* @returns {string[]} The validated segments.
|
|
2569
|
-
*/
|
|
2570
|
-
function validatePath(path, ctx, use) {
|
|
2571
|
-
const segs = path.split(".");
|
|
2572
|
-
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
2573
|
-
throw new Error(`Path segment in "${path}" is not allowed in ${ctx.file}. Use: ${use}`);
|
|
2574
|
-
}
|
|
2575
|
-
return segs;
|
|
2576
|
-
}
|
|
2577
|
-
|
|
2578
|
-
/**
|
|
2579
|
-
* Resolve a bare state name to its fully-qualified key, walking section scopes.
|
|
2580
|
-
* @param {string} name
|
|
2581
|
-
* @param {Ctx} ctx
|
|
2582
|
-
* @returns {string | null}
|
|
2583
|
-
*/
|
|
2584
|
-
function resolveStateKey(name, ctx) {
|
|
2585
|
-
for (let i = ctx.sections.length - 1; i >= 0; i--) {
|
|
2586
|
-
const key = `${ctx.sections[i]}:${name}`;
|
|
2587
|
-
if (ctx.comp.state.has(key)) return key;
|
|
2588
|
-
}
|
|
2589
|
-
return ctx.comp.state.has(name) ? name : null;
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
// ---------------------------------------------------------------------------
|
|
2593
|
-
// Actions
|
|
2594
|
-
// ---------------------------------------------------------------------------
|
|
2595
|
-
|
|
2596
|
-
/**
|
|
2597
|
-
* A parsed `:button` action.
|
|
2598
|
-
* @typedef {object} Action
|
|
2599
|
-
* @property {string} op Runtime operation (inc/dec/add/sub/append/prepend/set/toggle/member-toggle/remove/remove-value/clear/merge/delete/reset/append-row).
|
|
2600
|
-
* @property {string} target State key (possibly dotted) the action mutates.
|
|
2601
|
-
* @property {unknown} [value] Literal value for value-carrying ops.
|
|
2602
|
-
*/
|
|
2603
|
-
|
|
2604
|
-
const ACTION_USE =
|
|
2605
|
-
'Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";".';
|
|
2606
|
-
|
|
2607
|
-
/**
|
|
2608
|
-
* Parse a `:button` action expression into a validated `{ op, target, value }`,
|
|
2609
|
-
* or — when `;`-separated — an ordered array of them applied in sequence.
|
|
2610
|
-
* @param {string} raw
|
|
2611
|
-
* @param {Ctx} ctx
|
|
2612
|
-
* @returns {Action | Action[]}
|
|
2613
|
-
*/
|
|
2614
|
-
function parseAction(raw, ctx) {
|
|
2615
|
-
const parts = raw.split(";").map((p) => p.trim()).filter(Boolean);
|
|
2616
|
-
if (parts.length > 1) return parts.map((part) => parseSingleAction(part, raw, ctx));
|
|
2617
|
-
return parseSingleAction(raw.trim(), raw, ctx);
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
/**
|
|
2621
|
-
* Parse one (non-`;`) action expression into a validated `{ op, target, value }`.
|
|
2622
|
-
* Targets may be dotted paths (`cart.count`); a bare name is a 1-segment path.
|
|
2623
|
-
* @param {string} expression
|
|
2624
|
-
* @param {string} raw The full original action source (for error context).
|
|
2625
|
-
* @param {Ctx} ctx
|
|
2626
|
-
* @returns {Action}
|
|
2627
|
-
*/
|
|
2628
|
-
function parseSingleAction(expression, raw, ctx) {
|
|
2629
|
-
const PATH = "[A-Za-z_$][\\w$]*(?:\\.[A-Za-z_$][\\w$]*)*";
|
|
2630
|
-
/** @param {string} name @returns {string} */
|
|
2631
|
-
const resolveTarget = (name) => {
|
|
2632
|
-
const segs = validatePath(name, ctx, ACTION_USE);
|
|
2633
|
-
const key = resolveStateKey(segs[0], ctx);
|
|
2634
|
-
if (!key) {
|
|
2635
|
-
throw new Error(`Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`);
|
|
2636
|
-
}
|
|
2637
|
-
return [key, ...segs.slice(1)].join(".");
|
|
2638
|
-
};
|
|
2639
|
-
|
|
2640
|
-
const increment = expression.match(new RegExp(`^(${PATH})\\+\\+$`));
|
|
2641
|
-
if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
|
|
2642
|
-
const decrement = expression.match(new RegExp(`^(${PATH})--$`));
|
|
2643
|
-
if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
|
|
2644
|
-
const sub = expression.match(new RegExp(`^(${PATH})\\s*-=\\s*(.+)$`));
|
|
2645
|
-
if (sub) return { op: "sub", target: resolveTarget(sub[1]), value: parseActionLiteral(sub[2]) };
|
|
2646
|
-
// Per-row: carry the current loop item into another list — `cart += product`.
|
|
2647
|
-
// Only inside a reactive @loop; the runtime resolves the row from the DOM.
|
|
2648
|
-
const add = expression.match(new RegExp(`^(${PATH})\\s*\\+=\\s*(.+)$`));
|
|
2649
|
-
if (add) {
|
|
2650
|
-
const rhs = add[2].trim();
|
|
2651
|
-
if (ctx.loopItem && rhs === ctx.loopItem) {
|
|
2652
|
-
const target = resolveTarget(add[1]);
|
|
2653
|
-
if (!Array.isArray(ctx.comp.state.get(target))) {
|
|
2654
|
-
throw new Error(`Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`);
|
|
2655
|
-
}
|
|
2656
|
-
return { op: "append-row", target };
|
|
2657
|
-
}
|
|
2658
|
-
const target = resolveTarget(add[1]);
|
|
2659
|
-
const value = parseActionLiteral(rhs);
|
|
2660
|
-
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
2661
|
-
if (typeof value === "number") return { op: "add", target, value };
|
|
2662
|
-
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`);
|
|
2663
|
-
}
|
|
2664
|
-
// `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
|
|
2665
|
-
const toggle = expression.match(new RegExp(`^(${PATH})\\s+toggle(?:\\s+(.+))?$`));
|
|
2666
|
-
if (toggle) {
|
|
2667
|
-
const target = resolveTarget(toggle[1]);
|
|
2668
|
-
if (toggle[2] === undefined) return { op: "toggle", target };
|
|
2669
|
-
return { op: "member-toggle", target, value: parseActionLiteral(toggle[2]) };
|
|
2670
|
-
}
|
|
2671
|
-
const prepend = expression.match(new RegExp(`^(${PATH})\\s+(?:append|prepend)\\s+(.+)$`));
|
|
2672
|
-
if (prepend) {
|
|
2673
|
-
const op = expression.includes(" prepend ") ? "prepend" : "append";
|
|
2674
|
-
return { op, target: resolveTarget(prepend[1]), value: parseActionLiteral(prepend[2]) };
|
|
2675
|
-
}
|
|
2676
|
-
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
2677
|
-
// Disambiguation: operand IS the loop item name → `remove` (current row);
|
|
2678
|
-
// otherwise the operand is a value → `remove-value`.
|
|
2679
|
-
const remove = expression.match(new RegExp(`^(${PATH})\\s+remove\\s+(.+)$`));
|
|
2680
|
-
if (remove) {
|
|
2681
|
-
const operand = remove[2].trim();
|
|
2682
|
-
if (ctx.loopItem && operand === ctx.loopItem) {
|
|
2683
|
-
// A nested (item-relative) loop has a loop item but no top-level state key —
|
|
2684
|
-
// its source is a path off the outer row, which the runtime's row-remove
|
|
2685
|
-
// can't target. Fail loud with the honest workaround.
|
|
2686
|
-
if (!ctx.loopKey) {
|
|
2687
|
-
throw new Error(`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`);
|
|
2688
|
-
}
|
|
2689
|
-
if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
2690
|
-
throw new Error(`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`);
|
|
2691
|
-
}
|
|
2692
|
-
return { op: "remove", target: ctx.loopKey };
|
|
2693
|
-
}
|
|
2694
|
-
return { op: "remove-value", target: resolveTarget(remove[1]), value: parseActionLiteral(operand) };
|
|
2695
|
-
}
|
|
2696
|
-
const clear = expression.match(new RegExp(`^(${PATH})\\s+clear$`));
|
|
2697
|
-
if (clear) return { op: "clear", target: resolveTarget(clear[1]) };
|
|
2698
|
-
const merge = expression.match(new RegExp(`^(${PATH})\\s+merge\\s+(.+)$`));
|
|
2699
|
-
if (merge) return { op: "merge", target: resolveTarget(merge[1]), value: parseMergeOperand(merge[2], ctx) };
|
|
2700
|
-
const del = expression.match(new RegExp(`^(${PATH})\\s+delete\\s+(.+)$`));
|
|
2701
|
-
if (del) return { op: "delete", target: resolveTarget(del[1]), value: parseActionLiteral(del[2]) };
|
|
2702
|
-
const reset = expression.match(new RegExp(`^(${PATH})\\s+reset$`));
|
|
2703
|
-
if (reset) return { op: "reset", target: resolveTarget(reset[1]) };
|
|
2704
|
-
// `name refetch` re-invokes the matching :fetch node. The target is the fetch
|
|
2705
|
-
// key (bare name); the runtime finds the [data-wd-fetch-key] node and re-runs.
|
|
2706
|
-
const refetch = expression.match(new RegExp(`^(${PATH})\\s+refetch$`));
|
|
2707
|
-
if (refetch) return { op: "refetch", target: resolveTarget(refetch[1]) };
|
|
2708
|
-
const assign = expression.match(new RegExp(`^(${PATH})\\s*=\\s*(.+)$`));
|
|
2709
|
-
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
2710
|
-
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`);
|
|
2711
|
-
}
|
|
2712
|
-
|
|
2713
|
-
/**
|
|
2714
|
-
* `merge` operand: either an inline JSON object literal, or a state/store key
|
|
2715
|
-
* name (emitted as a string the runtime resolves via getPath).
|
|
2716
|
-
* @param {string} raw
|
|
2717
|
-
* @param {Ctx} ctx
|
|
2718
|
-
* @returns {unknown}
|
|
2719
|
-
*/
|
|
2720
|
-
function parseMergeOperand(raw, ctx) {
|
|
2721
|
-
const value = raw.trim();
|
|
2722
|
-
if (/^[A-Za-z_$][\w$]*$/.test(value)) {
|
|
2723
|
-
const key = resolveStateKey(value, ctx);
|
|
2724
|
-
if (!key) throw new Error(`Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`);
|
|
2725
|
-
return key;
|
|
2726
|
-
}
|
|
2727
|
-
const parsed = parseActionLiteral(value);
|
|
2728
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2729
|
-
throw new Error(`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object).`);
|
|
2730
|
-
}
|
|
2731
|
-
|
|
2732
|
-
/**
|
|
2733
|
-
* @param {string} raw
|
|
2734
|
-
* @returns {unknown}
|
|
2735
|
-
*/
|
|
2736
|
-
function parseActionLiteral(raw) {
|
|
2737
|
-
const value = raw.trim();
|
|
2738
|
-
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
2739
|
-
if (value === "true") return true;
|
|
2740
|
-
if (value === "false") return false;
|
|
2741
|
-
if (value === "null") return null;
|
|
2742
|
-
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
2743
|
-
if (/^[\[{]/.test(value)) {
|
|
2744
|
-
try {
|
|
2745
|
-
return JSON.parse(value);
|
|
2746
|
-
} catch {
|
|
2747
|
-
throw new Error(`Unsupported action literal "${raw}". Use: a quoted string, number, boolean, null, or valid JSON.`);
|
|
2748
|
-
}
|
|
2749
|
-
}
|
|
2750
|
-
throw new Error(`Unsupported action literal "${raw}". Use: a quoted string, number, boolean, null, or valid JSON.`);
|
|
2751
|
-
}
|
|
2752
|
-
|
|
2753
|
-
// ---------------------------------------------------------------------------
|
|
2754
|
-
// Includes / assets
|
|
2755
|
-
// ---------------------------------------------------------------------------
|
|
2756
|
-
|
|
2757
|
-
/**
|
|
2758
|
-
* Register a page's colocated `.skin`/`.js` siblings as emitted assets.
|
|
2759
|
-
* @param {string} file
|
|
2760
|
-
* @param {Paths} context
|
|
2761
|
-
* @param {Assets} assets
|
|
2762
|
-
* @returns {void}
|
|
2763
|
-
*/
|
|
2764
|
-
function collectColocatedAssets(file, context, assets) {
|
|
2765
|
-
const ext = path.extname(file);
|
|
2766
|
-
const stem = file.slice(0, -ext.length);
|
|
2767
|
-
for (const [assetExt, folder] of [[".skin", "styles"], [".js", "scripts"]]) {
|
|
2768
|
-
const candidate = `${stem}${assetExt}`;
|
|
2769
|
-
if (!fs.existsSync(candidate)) continue;
|
|
2770
|
-
const rel = path.relative(context.cwd, candidate).replaceAll(path.sep, "/");
|
|
2771
|
-
const outputExt = assetExt === ".skin" ? ".css" : ".js";
|
|
2772
|
-
const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
|
|
2773
|
-
assets.files.set(candidate, publicPath);
|
|
2774
|
-
if (assetExt === ".skin") assets.skins.add(publicPath);
|
|
2775
|
-
if (assetExt === ".js") assets.scripts.add(publicPath);
|
|
2776
|
-
}
|
|
2777
|
-
}
|
|
2778
|
-
|
|
2779
|
-
/**
|
|
2780
|
-
* Resolve an include spec to an absolute path inside `site/pages` or `site/_`.
|
|
2781
|
-
* @param {string} spec Include target (may be quoted).
|
|
2782
|
-
* @param {string} fromFile File requesting the include.
|
|
2783
|
-
* @param {Paths} context
|
|
2784
|
-
* @param {boolean} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
|
|
2785
|
-
* @returns {string}
|
|
2786
|
-
*/
|
|
2787
|
-
function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
2788
|
-
const clean = stripQuotes(spec);
|
|
2789
|
-
const candidates = [];
|
|
2790
|
-
if (clean.startsWith("/")) {
|
|
2791
|
-
candidates.push(path.join(context.shelfRoot, clean.slice(1)));
|
|
2792
|
-
} else {
|
|
2793
|
-
candidates.push(path.resolve(path.dirname(fromFile), clean));
|
|
2794
|
-
candidates.push(path.join(context.shelfRoot, clean));
|
|
2795
|
-
}
|
|
2796
|
-
for (const candidate of candidates) {
|
|
2797
|
-
const resolved = path.resolve(candidate);
|
|
2798
|
-
if (!isAllowedInclude(resolved, context)) {
|
|
2799
|
-
throw new Error(`Include "${spec}" from ${fromFile} resolves outside site/pages or site/_`);
|
|
2800
|
-
}
|
|
2801
|
-
if (!fs.existsSync(resolved)) continue;
|
|
2802
|
-
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
2803
|
-
return resolved;
|
|
2804
|
-
}
|
|
2805
|
-
throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
|
|
2806
|
-
}
|
|
2807
|
-
|
|
2808
|
-
/**
|
|
2809
|
-
* @param {string} file
|
|
2810
|
-
* @param {Paths} context
|
|
2811
|
-
* @returns {boolean}
|
|
2812
|
-
*/
|
|
2813
|
-
function isAllowedInclude(file, context) {
|
|
2814
|
-
const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
|
|
2815
|
-
return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
|
|
2816
|
-
}
|
|
2817
|
-
|
|
2818
|
-
// ---------------------------------------------------------------------------
|
|
2819
|
-
// Plain .md hints
|
|
2820
|
-
// ---------------------------------------------------------------------------
|
|
2821
|
-
|
|
2822
|
-
/**
|
|
2823
|
-
* Warn when a plain `.md` file contains `.wd`-only syntax that stays inert.
|
|
2824
|
-
* @param {string} body
|
|
2825
|
-
* @param {string} file
|
|
2826
|
-
* @param {Compilation} comp
|
|
2827
|
-
* @returns {void}
|
|
2828
|
-
*/
|
|
2829
|
-
function scanMarkdownHints(body, file, comp) {
|
|
2830
|
-
let fence = null;
|
|
2831
|
-
for (const line of body.split("\n")) {
|
|
2832
|
-
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
2833
|
-
if (fence) {
|
|
2834
|
-
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
2835
|
-
continue;
|
|
2836
|
-
}
|
|
2837
|
-
if (fenceMatch) {
|
|
2838
|
-
fence = fenceMatch[1];
|
|
2839
|
-
continue;
|
|
2840
|
-
}
|
|
2841
|
-
const hit = line.match(/^(@include|@loop|@repeat|:state|:button|:if|:for|:try|:note|:sprint|:::)(\s|$)/);
|
|
2842
|
-
if (hit) {
|
|
2843
|
-
comp.warnings.push(
|
|
2844
|
-
`${file}: "${hit[1]}" is .wd syntax and stays plain text in .md — rename the file to .wd to activate it.`
|
|
2845
|
-
);
|
|
2846
|
-
return;
|
|
2847
|
-
}
|
|
2848
|
-
}
|
|
2849
|
-
}
|
|
2850
|
-
|
|
2851
|
-
// ---------------------------------------------------------------------------
|
|
2852
|
-
// Literals
|
|
2853
|
-
// ---------------------------------------------------------------------------
|
|
2854
|
-
|
|
2855
|
-
/**
|
|
2856
|
-
* JSON-encode a value for an inline `<script>`, escaping `<` to stay HTML-safe.
|
|
2857
|
-
* @param {unknown} value
|
|
2858
|
-
* @returns {string}
|
|
2859
|
-
*/
|
|
2860
|
-
function safeScriptJson(value) {
|
|
2861
|
-
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
|
2862
|
-
}
|
|
2863
|
-
|
|
2864
|
-
/**
|
|
2865
|
-
* @param {string} raw
|
|
2866
|
-
* @returns {unknown}
|
|
2867
|
-
*/
|
|
2868
|
-
function parseStateValue(raw) {
|
|
2869
|
-
const value = raw.trim();
|
|
2870
|
-
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
2871
|
-
if (value === "true") return true;
|
|
2872
|
-
if (value === "false") return false;
|
|
2873
|
-
if (value === "null") return null;
|
|
2874
|
-
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
2875
|
-
try {
|
|
2876
|
-
return JSON.parse(value);
|
|
2877
|
-
} catch {
|
|
2878
|
-
return value;
|
|
2879
|
-
}
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
|
-
/**
|
|
2883
|
-
* @param {string} raw
|
|
2884
|
-
* @returns {string | number | boolean}
|
|
2885
|
-
*/
|
|
2886
|
-
function parseScalar(raw) {
|
|
2887
|
-
const trimmed = raw.trim();
|
|
2888
|
-
if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
|
|
2889
|
-
if (trimmed === "true") return true;
|
|
2890
|
-
if (trimmed === "false") return false;
|
|
2891
|
-
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
2892
|
-
return trimmed;
|
|
2893
|
-
}
|
|
2894
|
-
|
|
2895
|
-
/**
|
|
2896
|
-
* @param {string} [value]
|
|
2897
|
-
* @returns {string}
|
|
2898
|
-
*/
|
|
2899
|
-
function stripQuotes(value) {
|
|
2900
|
-
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
|
-
/**
|
|
2904
|
-
* Turn a field name / state key into a human-readable accessible name, e.g.
|
|
2905
|
-
* `quest` → "Quest", `user-name` → "User name", `firstName` → "First name".
|
|
2906
|
-
* Hyphens, underscores, and camelCase boundaries become spaces; the first letter
|
|
2907
|
-
* is capitalized. Used to auto-derive aria-label on generated form inputs.
|
|
2908
|
-
* @param {string} name
|
|
2909
|
-
* @returns {string}
|
|
2910
|
-
*/
|
|
2911
|
-
function humanizeName(name) {
|
|
2912
|
-
const words = name
|
|
2913
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // split camelCase
|
|
2914
|
-
.replace(/[-_]+/g, " ") // hyphens/underscores → spaces
|
|
2915
|
-
.trim()
|
|
2916
|
-
.toLowerCase();
|
|
2917
|
-
return words ? words.charAt(0).toUpperCase() + words.slice(1) : name;
|
|
2918
|
-
}
|
|
2919
|
-
|
|
2920
|
-
/**
|
|
2921
|
-
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
2922
|
-
* @param {unknown} value
|
|
2923
|
-
* @returns {string}
|
|
2924
|
-
*/
|
|
2925
|
-
export function escapeHtml(value) {
|
|
2926
|
-
return String(value)
|
|
2927
|
-
.replaceAll("&", "&")
|
|
2928
|
-
.replaceAll("<", "<")
|
|
2929
|
-
.replaceAll(">", ">")
|
|
2930
|
-
.replaceAll('"', """)
|
|
2931
|
-
.replaceAll("'", "'");
|
|
2932
|
-
}
|
|
1
|
+
// Darkmown's compiler, split into cohesive modules under `src/compiler/`. This
|
|
2
|
+
// file is a thin barrel: it re-exports the public API from `src/compiler/index.js`
|
|
3
|
+
// so every existing `import … from "./compiler.js"` (builder, tests) keeps
|
|
4
|
+
// working unchanged. See `src/compiler/index.js` for the module map.
|
|
5
|
+
|
|
6
|
+
// Re-export the public typedefs so existing `import("./compiler.js").Type`
|
|
7
|
+
// references (e.g. `Assets` in src/builder.js) keep resolving after the split.
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {import("./compiler/context.js").Meta} Meta
|
|
10
|
+
* @typedef {import("./compiler/context.js").FrontmatterValue} FrontmatterValue
|
|
11
|
+
* @typedef {import("./compiler/context.js").Assets} Assets
|
|
12
|
+
* @typedef {import("./compiler/context.js").Compilation} Compilation
|
|
13
|
+
* @typedef {import("./compiler/context.js").Scope} Scope
|
|
14
|
+
* @typedef {import("./compiler/context.js").Ctx} Ctx
|
|
15
|
+
* @typedef {import("./compiler/context.js").CompiledDocument} CompiledDocument
|
|
16
|
+
* @typedef {import("./compiler/context.js").CompiledPage} CompiledPage
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
compileDocument,
|
|
21
|
+
compilePage,
|
|
22
|
+
enhanceImages,
|
|
23
|
+
escapeHtml,
|
|
24
|
+
loopKeyOf,
|
|
25
|
+
parseFrontmatter
|
|
26
|
+
} from "./compiler/index.js";
|