@zvndev/darkmown 0.8.2 → 0.10.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 +21 -0
- package/README.md +16 -10
- package/docs/cli.md +24 -2
- package/docs/spec-alignment.md +4 -4
- package/package.json +21 -4
- package/site/_/roadmap.json +2 -2
- package/site/pages/docs/index.wd +2 -2
- package/site/pages/index.skin +158 -0
- package/site/pages/index.wd +94 -6
- package/src/builder.js +60 -1
- package/src/cli.js +28 -3
- package/src/compiler.js +503 -14
- package/src/config.js +19 -0
- package/src/dev.js +9 -0
- package/src/router.js +26 -0
- package/src/runtime.js +102 -26
- package/src/scaffold.js +32 -1
- package/src/skin.js +31 -3
- package/src/statics.js +20 -0
- package/types/builder.d.ts +39 -0
- package/types/compiler.d.ts +184 -0
- package/types/config.d.ts +45 -0
- package/types/dev.d.ts +13 -0
- package/types/index.d.ts +33 -0
- package/types/router.d.ts +39 -0
- package/types/scaffold.d.ts +8 -0
- package/types/skin.d.ts +21 -0
- package/types/statics.d.ts +23 -0
- package/docs/okf-integration.md +0 -120
package/src/compiler.js
CHANGED
|
@@ -2,13 +2,90 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import MarkdownIt from "markdown-it";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {import("./config.js").Paths} Paths
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Frontmatter values are scalars or inline arrays of scalars.
|
|
11
|
+
* @typedef {string | number | boolean | null | Array<string | number | boolean | null>} FrontmatterValue
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parsed page frontmatter, keyed by field name.
|
|
16
|
+
* @typedef {Record<string, FrontmatterValue>} Meta
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collected output assets for a compiled document.
|
|
21
|
+
* @typedef {object} Assets
|
|
22
|
+
* @property {Set<string>} skins Public hrefs of compiled skin stylesheets.
|
|
23
|
+
* @property {Set<string>} scripts Public hrefs of colocated page scripts.
|
|
24
|
+
* @property {Map<string, string>} files Source path → public href for emitted assets.
|
|
25
|
+
* @property {boolean} runtime Whether the reactive runtime is required.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Per-document compilation accumulator shared across includes/sections.
|
|
30
|
+
* @typedef {object} Compilation
|
|
31
|
+
* @property {Assets} assets
|
|
32
|
+
* @property {Map<string, unknown>} state Declared state keys → initial values.
|
|
33
|
+
* @property {string[]} warnings Non-fatal authoring hints.
|
|
34
|
+
* @property {number} sectionCounter Counter for auto-generated section ids.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A lexical scope chain for static interpolation values (include args, loop vars).
|
|
39
|
+
* @typedef {object} Scope
|
|
40
|
+
* @property {Scope | null} parent
|
|
41
|
+
* @property {Record<string, unknown>} vars
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-file compile context threaded through the directive handlers.
|
|
46
|
+
* @typedef {object} Ctx
|
|
47
|
+
* @property {string} file Absolute path to the file being compiled.
|
|
48
|
+
* @property {Paths} context Resolved project paths.
|
|
49
|
+
* @property {string[]} stack Include stack (real paths) for cycle detection.
|
|
50
|
+
* @property {Scope} scope Static value scope chain.
|
|
51
|
+
* @property {Compilation} comp Shared compilation accumulator.
|
|
52
|
+
* @property {string[]} sections Active section-id scope chain.
|
|
53
|
+
* @property {string | null} loopItem Name of the current reactive loop item, if any.
|
|
54
|
+
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
55
|
+
* @property {MarkdownIt} [md] Markdown-it instance selected for this file.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A compiled page document.
|
|
60
|
+
* @typedef {object} CompiledDocument
|
|
61
|
+
* @property {Meta} meta
|
|
62
|
+
* @property {string} html
|
|
63
|
+
* @property {Assets} assets
|
|
64
|
+
* @property {string[]} warnings
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A compiled page: the document plus its full HTML shell.
|
|
69
|
+
* @typedef {object} CompiledPage
|
|
70
|
+
* @property {Meta} meta
|
|
71
|
+
* @property {string} html
|
|
72
|
+
* @property {Assets} assets
|
|
73
|
+
* @property {string[]} warnings
|
|
74
|
+
*/
|
|
75
|
+
|
|
5
76
|
const md = new MarkdownIt({ html: true });
|
|
6
77
|
md.use(bindingPlugin);
|
|
7
78
|
|
|
8
79
|
// Raw HTML in markdown passes through by default (a documented design choice).
|
|
9
80
|
// Pages can opt out with frontmatter `html: false` for untrusted content; the
|
|
10
81
|
// stricter instance is built lazily so the default path stays a single instance.
|
|
82
|
+
/** @type {MarkdownIt | null} */
|
|
11
83
|
let mdNoHtml = null;
|
|
84
|
+
/**
|
|
85
|
+
* Pick the markdown-it instance for a page (raw HTML on by default, off with `html: false`).
|
|
86
|
+
* @param {Meta} [meta]
|
|
87
|
+
* @returns {MarkdownIt}
|
|
88
|
+
*/
|
|
12
89
|
function selectMd(meta) {
|
|
13
90
|
// Frontmatter scalars stay strings (no coercion), so accept both forms.
|
|
14
91
|
if (meta?.html !== false && meta?.html !== "false") return md;
|
|
@@ -21,6 +98,12 @@ function selectMd(meta) {
|
|
|
21
98
|
|
|
22
99
|
const pageIncludeExtensions = [".md", ".wd"];
|
|
23
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Compile a page source file into a full HTML document plus its assets.
|
|
103
|
+
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
104
|
+
* @param {Paths} context Resolved project paths.
|
|
105
|
+
* @returns {CompiledPage}
|
|
106
|
+
*/
|
|
24
107
|
export function compilePage(file, context) {
|
|
25
108
|
const compiled = compileDocument(file, context);
|
|
26
109
|
const title = compiled.meta.title || "Darkmown";
|
|
@@ -62,12 +145,21 @@ ${scripts}
|
|
|
62
145
|
};
|
|
63
146
|
}
|
|
64
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Compile a source file into its body HTML, frontmatter, and assets (no page shell).
|
|
150
|
+
* @param {string} file Absolute path to the source file.
|
|
151
|
+
* @param {Paths} context Resolved project paths.
|
|
152
|
+
* @param {string[]} [stack] Include stack for cycle detection.
|
|
153
|
+
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
154
|
+
* @returns {CompiledDocument}
|
|
155
|
+
*/
|
|
65
156
|
export function compileDocument(file, context, stack = [], vars = {}) {
|
|
66
157
|
const comp = createCompilation();
|
|
67
158
|
const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
|
|
68
159
|
return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
|
|
69
160
|
}
|
|
70
161
|
|
|
162
|
+
/** @returns {Compilation} */
|
|
71
163
|
function createCompilation() {
|
|
72
164
|
return {
|
|
73
165
|
assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
|
|
@@ -77,10 +169,26 @@ function createCompilation() {
|
|
|
77
169
|
};
|
|
78
170
|
}
|
|
79
171
|
|
|
172
|
+
/**
|
|
173
|
+
* @param {Scope | null} parent
|
|
174
|
+
* @param {Record<string, unknown>} [vars]
|
|
175
|
+
* @returns {Scope}
|
|
176
|
+
*/
|
|
80
177
|
function createScope(parent, vars = {}) {
|
|
81
178
|
return { parent, vars: { ...vars } };
|
|
82
179
|
}
|
|
83
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Compile a single file: parse frontmatter, collect assets, render body.
|
|
183
|
+
* @param {string} file
|
|
184
|
+
* @param {Paths} context
|
|
185
|
+
* @param {string[]} stack
|
|
186
|
+
* @param {Scope} scope
|
|
187
|
+
* @param {Compilation} comp
|
|
188
|
+
* @param {string[]} sections
|
|
189
|
+
* @param {string | null} loopItem
|
|
190
|
+
* @returns {{ meta: Meta, html: string }}
|
|
191
|
+
*/
|
|
84
192
|
function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
85
193
|
const real = fs.realpathSync(file);
|
|
86
194
|
if (stack.includes(real)) {
|
|
@@ -98,10 +206,17 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
|
98
206
|
|
|
99
207
|
// Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
|
|
100
208
|
// `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
|
|
209
|
+
/** @type {Ctx} */
|
|
101
210
|
const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem, md: selectMd(meta) };
|
|
102
211
|
return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
|
|
103
212
|
}
|
|
104
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Split a raw file into its frontmatter `meta` and `body`.
|
|
216
|
+
* @param {string} raw Full file contents.
|
|
217
|
+
* @param {string} [file] Source path, used only for error messages.
|
|
218
|
+
* @returns {{ meta: Meta, body: string }}
|
|
219
|
+
*/
|
|
105
220
|
export function parseFrontmatter(raw, file) {
|
|
106
221
|
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
107
222
|
const end = raw.indexOf("\n---", 3);
|
|
@@ -111,6 +226,7 @@ export function parseFrontmatter(raw, file) {
|
|
|
111
226
|
}
|
|
112
227
|
const front = raw.slice(4, end).trim();
|
|
113
228
|
const body = raw.slice(end + 4).replace(/^\n/, "");
|
|
229
|
+
/** @type {Meta} */
|
|
114
230
|
const meta = {};
|
|
115
231
|
for (const line of front.split("\n")) {
|
|
116
232
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
@@ -122,15 +238,24 @@ export function parseFrontmatter(raw, file) {
|
|
|
122
238
|
// Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
|
|
123
239
|
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
124
240
|
// the parser stays single-pass and line-based.
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} raw
|
|
243
|
+
* @returns {FrontmatterValue}
|
|
244
|
+
*/
|
|
125
245
|
function parseFrontmatterValue(raw) {
|
|
126
246
|
const value = raw.trim();
|
|
127
247
|
if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
|
|
128
248
|
return stripQuotes(value);
|
|
129
249
|
}
|
|
130
250
|
|
|
251
|
+
/**
|
|
252
|
+
* @param {string} raw
|
|
253
|
+
* @returns {string[]}
|
|
254
|
+
*/
|
|
131
255
|
function parseInlineArray(raw) {
|
|
132
256
|
const inner = raw.slice(1, -1);
|
|
133
257
|
if (inner.trim() === "") return [];
|
|
258
|
+
/** @type {string[]} */
|
|
134
259
|
const items = [];
|
|
135
260
|
let buf = "";
|
|
136
261
|
let quote = null;
|
|
@@ -162,8 +287,16 @@ function parseInlineArray(raw) {
|
|
|
162
287
|
// Block parser: directives + prose segments
|
|
163
288
|
// ---------------------------------------------------------------------------
|
|
164
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Parse a `.wd` body line-by-line into HTML, mixing directives and prose.
|
|
292
|
+
* @param {string[]} lines
|
|
293
|
+
* @param {Ctx} ctx
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
165
296
|
function compileBody(lines, ctx) {
|
|
297
|
+
/** @type {string[]} */
|
|
166
298
|
const out = [];
|
|
299
|
+
/** @type {string[]} */
|
|
167
300
|
let prose = [];
|
|
168
301
|
let fence = null;
|
|
169
302
|
|
|
@@ -280,7 +413,17 @@ function compileBody(lines, ctx) {
|
|
|
280
413
|
return out.join("\n");
|
|
281
414
|
}
|
|
282
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Capture lines from an opener until a matching close token, honoring nesting and fences.
|
|
418
|
+
* @param {string[]} lines
|
|
419
|
+
* @param {number} start Index of the opening line.
|
|
420
|
+
* @param {RegExp} openRe Pattern that re-opens a nested block.
|
|
421
|
+
* @param {string} endToken Literal closing token (e.g. `@endloop`).
|
|
422
|
+
* @param {string} file Source path for errors.
|
|
423
|
+
* @returns {{ body: string[], end: number }}
|
|
424
|
+
*/
|
|
283
425
|
function scanBlock(lines, start, openRe, endToken, file) {
|
|
426
|
+
/** @type {string[]} */
|
|
284
427
|
const body = [];
|
|
285
428
|
let depth = 0;
|
|
286
429
|
let fence = null;
|
|
@@ -307,7 +450,15 @@ function scanBlock(lines, start, openRe, endToken, file) {
|
|
|
307
450
|
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
|
|
308
451
|
}
|
|
309
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Capture the body of a `:::` container up to its matching close, honoring nesting.
|
|
455
|
+
* @param {string[]} lines
|
|
456
|
+
* @param {number} start Index of the opening line.
|
|
457
|
+
* @param {string} file Source path for errors.
|
|
458
|
+
* @returns {{ body: string[], end: number }}
|
|
459
|
+
*/
|
|
310
460
|
function scanContainer(lines, start, file) {
|
|
461
|
+
/** @type {string[]} */
|
|
311
462
|
const body = [];
|
|
312
463
|
let depth = 0;
|
|
313
464
|
let fence = null;
|
|
@@ -339,8 +490,17 @@ function scanContainer(lines, start, file) {
|
|
|
339
490
|
throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
|
|
340
491
|
}
|
|
341
492
|
|
|
493
|
+
/**
|
|
494
|
+
* Split an `:if … :else … :endif` block into its two branches.
|
|
495
|
+
* @param {string[]} lines
|
|
496
|
+
* @param {number} start Index of the `:if` line.
|
|
497
|
+
* @param {string} file Source path for errors.
|
|
498
|
+
* @returns {{ truthy: string[], falsy: string[], end: number }}
|
|
499
|
+
*/
|
|
342
500
|
function scanConditional(lines, start, file) {
|
|
501
|
+
/** @type {string[]} */
|
|
343
502
|
const truthy = [];
|
|
503
|
+
/** @type {string[]} */
|
|
344
504
|
const falsy = [];
|
|
345
505
|
let current = truthy;
|
|
346
506
|
let depth = 0;
|
|
@@ -376,6 +536,11 @@ function scanConditional(lines, start, file) {
|
|
|
376
536
|
// Directive handlers
|
|
377
537
|
// ---------------------------------------------------------------------------
|
|
378
538
|
|
|
539
|
+
/**
|
|
540
|
+
* @param {string} line
|
|
541
|
+
* @param {Ctx} ctx
|
|
542
|
+
* @returns {string}
|
|
543
|
+
*/
|
|
379
544
|
function handleInclude(line, ctx) {
|
|
380
545
|
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
381
546
|
if (!match) throw new Error(`Malformed @include in ${ctx.file}: ${line}`);
|
|
@@ -386,7 +551,13 @@ function handleInclude(line, ctx) {
|
|
|
386
551
|
return child.html;
|
|
387
552
|
}
|
|
388
553
|
|
|
554
|
+
/**
|
|
555
|
+
* @param {string} raw
|
|
556
|
+
* @param {Ctx} ctx
|
|
557
|
+
* @returns {Record<string, unknown>}
|
|
558
|
+
*/
|
|
389
559
|
function parseIncludeArgs(raw, ctx) {
|
|
560
|
+
/** @type {Record<string, unknown>} */
|
|
390
561
|
const args = {};
|
|
391
562
|
const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
|
|
392
563
|
for (const match of raw.matchAll(re)) {
|
|
@@ -405,12 +576,19 @@ function parseIncludeArgs(raw, ctx) {
|
|
|
405
576
|
return args;
|
|
406
577
|
}
|
|
407
578
|
|
|
579
|
+
/**
|
|
580
|
+
* @param {string} header
|
|
581
|
+
* @param {string[]} bodyLines
|
|
582
|
+
* @param {Ctx} ctx
|
|
583
|
+
* @returns {string}
|
|
584
|
+
*/
|
|
408
585
|
function handleContainer(header, bodyLines, ctx) {
|
|
409
586
|
const tokens = header.split(/\s+/);
|
|
410
587
|
let tag = "section";
|
|
588
|
+
/** @type {string[]} */
|
|
411
589
|
let extraClass = [];
|
|
412
590
|
let id = "";
|
|
413
|
-
let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() : "section";
|
|
591
|
+
let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() ?? "section" : "section";
|
|
414
592
|
if (nameToken !== "section") {
|
|
415
593
|
tag = "div";
|
|
416
594
|
extraClass.push(nameToken);
|
|
@@ -435,6 +613,11 @@ function handleContainer(header, bodyLines, ctx) {
|
|
|
435
613
|
return `<${tag}${idAttr}${classAttr}>\n${inner}\n</${tag}>`;
|
|
436
614
|
}
|
|
437
615
|
|
|
616
|
+
/**
|
|
617
|
+
* @param {string} line
|
|
618
|
+
* @param {Ctx} ctx
|
|
619
|
+
* @returns {string}
|
|
620
|
+
*/
|
|
438
621
|
function handleState(line, ctx) {
|
|
439
622
|
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
440
623
|
if (!match) throw new Error(`Malformed :state in ${ctx.file}: ${line}`);
|
|
@@ -444,6 +627,13 @@ function handleState(line, ctx) {
|
|
|
444
627
|
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
445
628
|
}
|
|
446
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Register a state key in the current section scope, enabling the runtime.
|
|
632
|
+
* @param {string} name
|
|
633
|
+
* @param {unknown} value
|
|
634
|
+
* @param {Ctx} ctx
|
|
635
|
+
* @returns {string} The fully-qualified state key.
|
|
636
|
+
*/
|
|
447
637
|
function declareState(name, value, ctx) {
|
|
448
638
|
if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
449
639
|
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
@@ -453,6 +643,11 @@ function declareState(name, value, ctx) {
|
|
|
453
643
|
return key;
|
|
454
644
|
}
|
|
455
645
|
|
|
646
|
+
/**
|
|
647
|
+
* @param {string} line
|
|
648
|
+
* @param {Ctx} ctx
|
|
649
|
+
* @returns {string}
|
|
650
|
+
*/
|
|
456
651
|
function handleFetch(line, ctx) {
|
|
457
652
|
const match = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+?)(\s+when=visible)?\s*$/);
|
|
458
653
|
if (!match) {
|
|
@@ -465,17 +660,29 @@ function handleFetch(line, ctx) {
|
|
|
465
660
|
return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
|
|
466
661
|
}
|
|
467
662
|
|
|
663
|
+
/**
|
|
664
|
+
* @param {string} key
|
|
665
|
+
* @param {Ctx} ctx
|
|
666
|
+
* @returns {void}
|
|
667
|
+
*/
|
|
468
668
|
function declareErrorState(key, ctx) {
|
|
469
669
|
const errorKey = `${key}_error`;
|
|
470
670
|
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
471
671
|
}
|
|
472
672
|
|
|
673
|
+
/**
|
|
674
|
+
* @param {string} line
|
|
675
|
+
* @param {Ctx} ctx
|
|
676
|
+
* @returns {string}
|
|
677
|
+
*/
|
|
473
678
|
function handleComputed(line, ctx) {
|
|
474
679
|
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
475
680
|
if (!match) throw new Error(`Malformed :computed in ${ctx.file}: ${line}. Use: :computed total = items.length * 4`);
|
|
476
681
|
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
682
|
+
/** @type {unknown} */
|
|
477
683
|
let initial;
|
|
478
684
|
try {
|
|
685
|
+
/** @param {string} key @param {string} [path] */
|
|
479
686
|
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
480
687
|
initial = new Function("S", `return (${expr});`)(read);
|
|
481
688
|
} catch {
|
|
@@ -486,7 +693,14 @@ function handleComputed(line, ctx) {
|
|
|
486
693
|
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>`;
|
|
487
694
|
}
|
|
488
695
|
|
|
696
|
+
/**
|
|
697
|
+
* Compile a `:computed` expression into safe JS over the `S(key, path)` reader.
|
|
698
|
+
* @param {string} raw
|
|
699
|
+
* @param {Ctx} ctx
|
|
700
|
+
* @returns {string}
|
|
701
|
+
*/
|
|
489
702
|
function compileComputedExpr(raw, ctx) {
|
|
703
|
+
/** @type {string[]} */
|
|
490
704
|
const strings = [];
|
|
491
705
|
let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
|
|
492
706
|
strings.push(`"${literal.slice(1, -1)}"`);
|
|
@@ -520,6 +734,12 @@ function compileComputedExpr(raw, ctx) {
|
|
|
520
734
|
return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
|
|
521
735
|
}
|
|
522
736
|
|
|
737
|
+
/**
|
|
738
|
+
* @param {string} line
|
|
739
|
+
* @param {string[]} bodyLines
|
|
740
|
+
* @param {Ctx} ctx
|
|
741
|
+
* @returns {string}
|
|
742
|
+
*/
|
|
523
743
|
function handleForm(line, bodyLines, ctx) {
|
|
524
744
|
let rest = line.slice(":form".length).trim();
|
|
525
745
|
const action = rest.match(/action="([^"]+)"/)?.[1];
|
|
@@ -545,6 +765,11 @@ function handleForm(line, bodyLines, ctx) {
|
|
|
545
765
|
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
|
|
546
766
|
}
|
|
547
767
|
|
|
768
|
+
/**
|
|
769
|
+
* @param {string} line
|
|
770
|
+
* @param {Ctx} ctx
|
|
771
|
+
* @returns {string}
|
|
772
|
+
*/
|
|
548
773
|
function handleInput(line, ctx) {
|
|
549
774
|
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
550
775
|
if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
|
|
@@ -575,6 +800,11 @@ function handleInput(line, ctx) {
|
|
|
575
800
|
// :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
|
|
576
801
|
// declared :state. Updates state on every keystroke; reflects state back when
|
|
577
802
|
// not focused. The state must be declared first (with :state).
|
|
803
|
+
/**
|
|
804
|
+
* @param {string} line
|
|
805
|
+
* @param {Ctx} ctx
|
|
806
|
+
* @returns {string}
|
|
807
|
+
*/
|
|
578
808
|
function handleBind(line, ctx) {
|
|
579
809
|
const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
580
810
|
if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
|
|
@@ -604,12 +834,22 @@ function handleBind(line, ctx) {
|
|
|
604
834
|
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
605
835
|
}
|
|
606
836
|
|
|
837
|
+
/**
|
|
838
|
+
* @param {string} line
|
|
839
|
+
* @param {Ctx} ctx
|
|
840
|
+
* @returns {string}
|
|
841
|
+
*/
|
|
607
842
|
function handleSubmit(line, ctx) {
|
|
608
843
|
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
609
844
|
if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
|
|
610
845
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
611
846
|
}
|
|
612
847
|
|
|
848
|
+
/**
|
|
849
|
+
* @param {string} line
|
|
850
|
+
* @param {Ctx} ctx
|
|
851
|
+
* @returns {string}
|
|
852
|
+
*/
|
|
613
853
|
function handleButton(line, ctx) {
|
|
614
854
|
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
615
855
|
if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
|
|
@@ -619,6 +859,13 @@ function handleButton(line, ctx) {
|
|
|
619
859
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
620
860
|
}
|
|
621
861
|
|
|
862
|
+
/**
|
|
863
|
+
* @param {string} line
|
|
864
|
+
* @param {string[]} truthyLines
|
|
865
|
+
* @param {string[]} falsyLines
|
|
866
|
+
* @param {Ctx} ctx
|
|
867
|
+
* @returns {string}
|
|
868
|
+
*/
|
|
622
869
|
function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
623
870
|
const match = line.match(/^:if\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
|
|
624
871
|
if (!match) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" with a :state or in-scope value.`);
|
|
@@ -653,6 +900,12 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
|
653
900
|
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>`;
|
|
654
901
|
}
|
|
655
902
|
|
|
903
|
+
/**
|
|
904
|
+
* @param {string} line
|
|
905
|
+
* @param {string[]} bodyLines
|
|
906
|
+
* @param {Ctx} ctx
|
|
907
|
+
* @returns {string}
|
|
908
|
+
*/
|
|
656
909
|
function handleLoop(line, bodyLines, ctx) {
|
|
657
910
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
|
|
658
911
|
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
|
|
@@ -688,6 +941,21 @@ function handleLoop(line, bodyLines, ctx) {
|
|
|
688
941
|
// A static source (JSON file / in-scope value). No `where`, or a `where` that
|
|
689
942
|
// only reads the loop item → filter at build time and stay zero-JS. A `where`
|
|
690
943
|
// that reads :state → becomes a reactive filtered loop with the rows baked in.
|
|
944
|
+
/**
|
|
945
|
+
* A compiled `@loop … where` predicate.
|
|
946
|
+
* @typedef {object} Predicate
|
|
947
|
+
* @property {string} body Safe JS boolean expression over `I()`/`S()`/`C()`.
|
|
948
|
+
* @property {boolean} refsState Whether the predicate reads declared state.
|
|
949
|
+
*/
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* @param {unknown[]} rows
|
|
953
|
+
* @param {string} itemName
|
|
954
|
+
* @param {string[]} bodyLines
|
|
955
|
+
* @param {Ctx} ctx
|
|
956
|
+
* @param {Predicate | null} where
|
|
957
|
+
* @returns {string}
|
|
958
|
+
*/
|
|
691
959
|
function loopOverData(rows, itemName, bodyLines, ctx, where) {
|
|
692
960
|
if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
|
|
693
961
|
if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
|
|
@@ -697,8 +965,15 @@ function loopOverData(rows, itemName, bodyLines, ctx, where) {
|
|
|
697
965
|
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
698
966
|
// Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
|
|
699
967
|
// paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
|
|
968
|
+
/**
|
|
969
|
+
* @param {string} raw
|
|
970
|
+
* @param {string} itemName
|
|
971
|
+
* @param {Ctx} ctx
|
|
972
|
+
* @returns {Predicate}
|
|
973
|
+
*/
|
|
700
974
|
function compilePredicate(raw, itemName, ctx) {
|
|
701
975
|
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
976
|
+
/** @type {string[]} */
|
|
702
977
|
const pieces = [];
|
|
703
978
|
let refsState = false;
|
|
704
979
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -710,6 +985,12 @@ function compilePredicate(raw, itemName, ctx) {
|
|
|
710
985
|
return { body: pieces.join(" "), refsState };
|
|
711
986
|
}
|
|
712
987
|
|
|
988
|
+
/**
|
|
989
|
+
* @param {string} cond
|
|
990
|
+
* @param {string} itemName
|
|
991
|
+
* @param {Ctx} ctx
|
|
992
|
+
* @returns {{ expr: string, usesState: boolean }}
|
|
993
|
+
*/
|
|
713
994
|
function compileCondition(cond, itemName, ctx) {
|
|
714
995
|
const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
715
996
|
if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
|
|
@@ -721,6 +1002,12 @@ function compileCondition(cond, itemName, ctx) {
|
|
|
721
1002
|
return { expr: `${left.code} ${op} ${right.code}`, usesState };
|
|
722
1003
|
}
|
|
723
1004
|
|
|
1005
|
+
/**
|
|
1006
|
+
* @param {string} tok
|
|
1007
|
+
* @param {string} itemName
|
|
1008
|
+
* @param {Ctx} ctx
|
|
1009
|
+
* @returns {{ code: string, usesState: boolean }}
|
|
1010
|
+
*/
|
|
724
1011
|
function compileOperand(tok, itemName, ctx) {
|
|
725
1012
|
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
|
|
726
1013
|
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
|
|
@@ -741,11 +1028,21 @@ function compileOperand(tok, itemName, ctx) {
|
|
|
741
1028
|
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
|
|
742
1029
|
}
|
|
743
1030
|
|
|
1031
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
744
1032
|
const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
|
|
745
1033
|
|
|
1034
|
+
/**
|
|
1035
|
+
* Evaluate a compiled predicate against a row at build time.
|
|
1036
|
+
* @param {string} body
|
|
1037
|
+
* @param {unknown} item
|
|
1038
|
+
* @param {Ctx} ctx
|
|
1039
|
+
* @returns {boolean}
|
|
1040
|
+
*/
|
|
746
1041
|
function evalPredicate(body, item, ctx) {
|
|
747
1042
|
try {
|
|
1043
|
+
/** @param {string} [p] */
|
|
748
1044
|
const I = (p) => getPath(item, p ? p.split(".") : []);
|
|
1045
|
+
/** @param {string} k @param {string} [r] */
|
|
749
1046
|
const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
|
|
750
1047
|
return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
|
|
751
1048
|
} catch {
|
|
@@ -754,6 +1051,13 @@ function evalPredicate(body, item, ctx) {
|
|
|
754
1051
|
}
|
|
755
1052
|
}
|
|
756
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* @param {unknown[]} rows
|
|
1056
|
+
* @param {string} itemName
|
|
1057
|
+
* @param {string[]} bodyLines
|
|
1058
|
+
* @param {Ctx} ctx
|
|
1059
|
+
* @returns {string}
|
|
1060
|
+
*/
|
|
757
1061
|
function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
758
1062
|
const out = [];
|
|
759
1063
|
for (const row of rows) {
|
|
@@ -763,9 +1067,19 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
|
763
1067
|
return out.join("\n");
|
|
764
1068
|
}
|
|
765
1069
|
|
|
1070
|
+
/**
|
|
1071
|
+
* Emit a reactive loop region (template + initial rows) for the runtime.
|
|
1072
|
+
* @param {string | null} key State key of the list, or null for baked data.
|
|
1073
|
+
* @param {string} itemName
|
|
1074
|
+
* @param {string[]} bodyLines
|
|
1075
|
+
* @param {Ctx} ctx
|
|
1076
|
+
* @param {{ where?: Predicate, data?: unknown[] } | null} [opts]
|
|
1077
|
+
* @returns {string}
|
|
1078
|
+
*/
|
|
766
1079
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
767
1080
|
ctx.comp.assets.runtime = true;
|
|
768
|
-
|
|
1081
|
+
/** @type {Ctx} */
|
|
1082
|
+
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, scope: createScope(ctx.scope) };
|
|
769
1083
|
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
770
1084
|
|
|
771
1085
|
let wrapperTag = "div";
|
|
@@ -780,11 +1094,14 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
780
1094
|
|
|
781
1095
|
const where = opts?.where || null;
|
|
782
1096
|
const baked = opts?.data || null; // rows for a static source filtered by state
|
|
783
|
-
const
|
|
784
|
-
|
|
1097
|
+
const stateRows = key ? ctx.comp.state.get(key) : null;
|
|
1098
|
+
/** @type {unknown[]} */
|
|
1099
|
+
const allRows = key ? (Array.isArray(stateRows) ? stateRows : []) : (baked || []);
|
|
1100
|
+
const rows = where ? allRows.filter((/** @type {unknown} */ row) => evalPredicate(where.body, row, ctx)) : allRows;
|
|
1101
|
+
/** @type {Map<string, number>} */
|
|
785
1102
|
const counts = new Map();
|
|
786
1103
|
const initial = rows
|
|
787
|
-
.map((item) => {
|
|
1104
|
+
.map((/** @type {unknown} */ item) => {
|
|
788
1105
|
const itemKey = loopKeyOf(item, counts);
|
|
789
1106
|
return fillTemplateString(withLoopKey(itemTemplate, itemKey), item);
|
|
790
1107
|
})
|
|
@@ -795,10 +1112,20 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
795
1112
|
return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
|
|
796
1113
|
}
|
|
797
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* @param {string} template
|
|
1117
|
+
* @param {string} itemKey
|
|
1118
|
+
* @returns {string}
|
|
1119
|
+
*/
|
|
798
1120
|
function withLoopKey(template, itemKey) {
|
|
799
1121
|
return template.replace(/^<([a-zA-Z][a-zA-Z0-9-]*)/, `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`);
|
|
800
1122
|
}
|
|
801
1123
|
|
|
1124
|
+
/**
|
|
1125
|
+
* @param {string} template
|
|
1126
|
+
* @param {unknown} item
|
|
1127
|
+
* @returns {string}
|
|
1128
|
+
*/
|
|
802
1129
|
function fillTemplateString(template, item) {
|
|
803
1130
|
// Resolve per-item :if regions first (recursively, so nested conditionals are
|
|
804
1131
|
// pre-rendered for the initial paint), then fill the plain text bindings.
|
|
@@ -808,6 +1135,11 @@ function fillTemplateString(template, item) {
|
|
|
808
1135
|
// Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
|
|
809
1136
|
// chosen branch is recursed so nested conditionals resolve too. The <template>
|
|
810
1137
|
// markup is left pristine so the runtime can keep toggling branches.
|
|
1138
|
+
/**
|
|
1139
|
+
* @param {string} str
|
|
1140
|
+
* @param {unknown} item
|
|
1141
|
+
* @returns {string}
|
|
1142
|
+
*/
|
|
811
1143
|
function fillEachIfRegions(str, item) {
|
|
812
1144
|
const marker = '<span data-wd-each-if ';
|
|
813
1145
|
let result = "";
|
|
@@ -822,8 +1154,13 @@ function fillEachIfRegions(str, item) {
|
|
|
822
1154
|
}
|
|
823
1155
|
}
|
|
824
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* @param {string} region
|
|
1159
|
+
* @param {unknown} item
|
|
1160
|
+
* @returns {string}
|
|
1161
|
+
*/
|
|
825
1162
|
function fillOneEachIf(region, item) {
|
|
826
|
-
const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || [, ""])[1];
|
|
1163
|
+
const path = (region.match(/^<span data-wd-each-if data-wd-path="([^"]*)">/) || ["", ""])[1];
|
|
827
1164
|
const trueStart = region.indexOf("<template data-wd-if-true>");
|
|
828
1165
|
const trueEnd = matchElement(region, trueStart, "template");
|
|
829
1166
|
const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
|
|
@@ -837,6 +1174,11 @@ function fillOneEachIf(region, item) {
|
|
|
837
1174
|
return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
|
|
838
1175
|
}
|
|
839
1176
|
|
|
1177
|
+
/**
|
|
1178
|
+
* @param {string} str
|
|
1179
|
+
* @param {unknown} item
|
|
1180
|
+
* @returns {string}
|
|
1181
|
+
*/
|
|
840
1182
|
function fillEachText(str, item) {
|
|
841
1183
|
return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
|
|
842
1184
|
const value = p ? getPath(item, p.split(".")) : item;
|
|
@@ -848,6 +1190,12 @@ function fillEachText(str, item) {
|
|
|
848
1190
|
// Return the index just past the balanced close of the element of `tag` that
|
|
849
1191
|
// begins at `start`. Counts nested same-tag opens/closes so regions that embed
|
|
850
1192
|
// their own spans/templates (nested :if) match correctly.
|
|
1193
|
+
/**
|
|
1194
|
+
* @param {string} str
|
|
1195
|
+
* @param {number} start
|
|
1196
|
+
* @param {string} tag
|
|
1197
|
+
* @returns {number}
|
|
1198
|
+
*/
|
|
851
1199
|
function matchElement(str, start, tag) {
|
|
852
1200
|
const openPrefix = `<${tag}`;
|
|
853
1201
|
const closeTag = `</${tag}>`;
|
|
@@ -869,16 +1217,27 @@ function matchElement(str, start, tag) {
|
|
|
869
1217
|
return str.length;
|
|
870
1218
|
}
|
|
871
1219
|
|
|
1220
|
+
/**
|
|
1221
|
+
* Stable per-render key for a loop row, disambiguating duplicates with `#n`.
|
|
1222
|
+
* @param {unknown} item
|
|
1223
|
+
* @param {Map<string, number>} counts Mutable seen-count accumulator.
|
|
1224
|
+
* @returns {string}
|
|
1225
|
+
*/
|
|
872
1226
|
export function loopKeyOf(item, counts) {
|
|
873
1227
|
const base =
|
|
874
1228
|
item && typeof item === "object"
|
|
875
|
-
? String(item.id ?? item.key ?? JSON.stringify(item))
|
|
1229
|
+
? String(/** @type {Record<string, unknown>} */ (item).id ?? /** @type {Record<string, unknown>} */ (item).key ?? JSON.stringify(item))
|
|
876
1230
|
: String(item);
|
|
877
1231
|
const seen = counts.get(base) || 0;
|
|
878
1232
|
counts.set(base, seen + 1);
|
|
879
1233
|
return seen ? `${base}#${seen}` : base;
|
|
880
1234
|
}
|
|
881
1235
|
|
|
1236
|
+
/**
|
|
1237
|
+
* Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
|
|
1238
|
+
* @param {string} line
|
|
1239
|
+
* @returns {string}
|
|
1240
|
+
*/
|
|
882
1241
|
function renderDemoDirective(line) {
|
|
883
1242
|
const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
|
|
884
1243
|
if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
@@ -896,10 +1255,21 @@ function renderDemoDirective(line) {
|
|
|
896
1255
|
// Interpolation: one syntax, { name } / { name.path }
|
|
897
1256
|
// ---------------------------------------------------------------------------
|
|
898
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* @param {string} text
|
|
1260
|
+
* @param {Ctx} ctx
|
|
1261
|
+
* @returns {string}
|
|
1262
|
+
*/
|
|
899
1263
|
function renderProse(text, ctx) {
|
|
900
|
-
return (ctx.md ?? md).render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
|
|
1264
|
+
return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
|
|
901
1265
|
}
|
|
902
1266
|
|
|
1267
|
+
/**
|
|
1268
|
+
* markdown-it plugin: turns `{ name.path }` into an inline `html_inline` token
|
|
1269
|
+
* whose content comes from the env's `resolveBinding` callback.
|
|
1270
|
+
* @param {MarkdownIt} mdInstance
|
|
1271
|
+
* @returns {void}
|
|
1272
|
+
*/
|
|
903
1273
|
function bindingPlugin(mdInstance) {
|
|
904
1274
|
mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
|
|
905
1275
|
if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
|
|
@@ -917,6 +1287,13 @@ function bindingPlugin(mdInstance) {
|
|
|
917
1287
|
});
|
|
918
1288
|
}
|
|
919
1289
|
|
|
1290
|
+
/**
|
|
1291
|
+
* Resolve a `{ name.path }` binding to HTML: loop item span, static value, or
|
|
1292
|
+
* a reactive bind span. Returns null when nothing in scope matches.
|
|
1293
|
+
* @param {string} expr
|
|
1294
|
+
* @param {Ctx} ctx
|
|
1295
|
+
* @returns {string | null}
|
|
1296
|
+
*/
|
|
920
1297
|
function resolveBindingHtml(expr, ctx) {
|
|
921
1298
|
const segs = expr.split(".");
|
|
922
1299
|
const head = segs[0];
|
|
@@ -929,8 +1306,7 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
929
1306
|
const staticValue = lookupVar(ctx.scope, head);
|
|
930
1307
|
if (staticValue.found) {
|
|
931
1308
|
const resolved = getPath(staticValue.value, segs.slice(1));
|
|
932
|
-
|
|
933
|
-
return escapeHtml(resolved ?? "");
|
|
1309
|
+
return escapeHtml(interpolateLeaf(resolved, expr, ctx));
|
|
934
1310
|
}
|
|
935
1311
|
|
|
936
1312
|
const key = resolveStateKey(head, ctx);
|
|
@@ -939,19 +1315,51 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
939
1315
|
const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
|
|
940
1316
|
const rest = segs.slice(1).join(".");
|
|
941
1317
|
const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
|
|
942
|
-
return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(initial
|
|
1318
|
+
return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(interpolateLeaf(initial, expr, ctx))}</span>`;
|
|
943
1319
|
}
|
|
944
1320
|
|
|
945
1321
|
return null;
|
|
946
1322
|
}
|
|
947
1323
|
|
|
1324
|
+
/**
|
|
1325
|
+
* Render an interpolated value as text. Arrays join with ", " (matching the
|
|
1326
|
+
* documented `{ meta.tags }` behavior); a bare object is a mistake — fail
|
|
1327
|
+
* loudly with a fix rather than emitting "[object Object]".
|
|
1328
|
+
* @param {unknown} value
|
|
1329
|
+
* @param {string} expr
|
|
1330
|
+
* @param {Ctx} ctx
|
|
1331
|
+
* @returns {string}
|
|
1332
|
+
*/
|
|
1333
|
+
function interpolateLeaf(value, expr, ctx) {
|
|
1334
|
+
if (value === null || value === undefined) return "";
|
|
1335
|
+
if (Array.isArray(value)) return value.join(", ");
|
|
1336
|
+
if (typeof value === "object") {
|
|
1337
|
+
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.`);
|
|
1338
|
+
}
|
|
1339
|
+
return String(value);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* Walk the scope chain for a top-level variable.
|
|
1344
|
+
* @param {Scope} scope
|
|
1345
|
+
* @param {string} name
|
|
1346
|
+
* @returns {{ found: true, value: unknown } | { found: false }}
|
|
1347
|
+
*/
|
|
948
1348
|
function lookupVar(scope, name) {
|
|
949
|
-
|
|
1349
|
+
/** @type {Scope | null} */
|
|
1350
|
+
let current = scope;
|
|
1351
|
+
for (; current; current = current.parent) {
|
|
950
1352
|
if (name in current.vars) return { found: true, value: current.vars[name] };
|
|
951
1353
|
}
|
|
952
1354
|
return { found: false };
|
|
953
1355
|
}
|
|
954
1356
|
|
|
1357
|
+
/**
|
|
1358
|
+
* Resolve a dotted path against the static scope chain.
|
|
1359
|
+
* @param {string} expr
|
|
1360
|
+
* @param {Ctx} ctx
|
|
1361
|
+
* @returns {{ found: true, value: unknown } | { found: false }}
|
|
1362
|
+
*/
|
|
955
1363
|
function lookupPath(expr, ctx) {
|
|
956
1364
|
const segs = expr.split(".");
|
|
957
1365
|
const head = lookupVar(ctx.scope, segs[0]);
|
|
@@ -959,7 +1367,14 @@ function lookupPath(expr, ctx) {
|
|
|
959
1367
|
return { found: true, value: getPath(head.value, segs.slice(1)) };
|
|
960
1368
|
}
|
|
961
1369
|
|
|
1370
|
+
/**
|
|
1371
|
+
* Safely read a dotted path off a value, rejecting prototype-pollution segments.
|
|
1372
|
+
* @param {unknown} value
|
|
1373
|
+
* @param {string[]} segments
|
|
1374
|
+
* @returns {unknown}
|
|
1375
|
+
*/
|
|
962
1376
|
function getPath(value, segments) {
|
|
1377
|
+
/** @type {any} */
|
|
963
1378
|
let current = value;
|
|
964
1379
|
for (const segment of segments) {
|
|
965
1380
|
if (current == null) return undefined;
|
|
@@ -969,6 +1384,12 @@ function getPath(value, segments) {
|
|
|
969
1384
|
return current;
|
|
970
1385
|
}
|
|
971
1386
|
|
|
1387
|
+
/**
|
|
1388
|
+
* Resolve a bare state name to its fully-qualified key, walking section scopes.
|
|
1389
|
+
* @param {string} name
|
|
1390
|
+
* @param {Ctx} ctx
|
|
1391
|
+
* @returns {string | null}
|
|
1392
|
+
*/
|
|
972
1393
|
function resolveStateKey(name, ctx) {
|
|
973
1394
|
for (let i = ctx.sections.length - 1; i >= 0; i--) {
|
|
974
1395
|
const key = `${ctx.sections[i]}:${name}`;
|
|
@@ -981,8 +1402,23 @@ function resolveStateKey(name, ctx) {
|
|
|
981
1402
|
// Actions
|
|
982
1403
|
// ---------------------------------------------------------------------------
|
|
983
1404
|
|
|
1405
|
+
/**
|
|
1406
|
+
* A parsed `:button` action.
|
|
1407
|
+
* @typedef {object} Action
|
|
1408
|
+
* @property {string} op Runtime operation (inc/dec/add/append/set/remove/append-row).
|
|
1409
|
+
* @property {string} target State key the action mutates.
|
|
1410
|
+
* @property {unknown} [value] Literal value for value-carrying ops.
|
|
1411
|
+
*/
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* Parse a `:button` action expression into a validated `{ op, target, value }`.
|
|
1415
|
+
* @param {string} raw
|
|
1416
|
+
* @param {Ctx} ctx
|
|
1417
|
+
* @returns {Action}
|
|
1418
|
+
*/
|
|
984
1419
|
function parseAction(raw, ctx) {
|
|
985
1420
|
const expression = raw.trim();
|
|
1421
|
+
/** @param {string} name @returns {string} */
|
|
986
1422
|
const resolveTarget = (name) => {
|
|
987
1423
|
const key = resolveStateKey(name, ctx);
|
|
988
1424
|
if (!key) {
|
|
@@ -1011,7 +1447,7 @@ function parseAction(raw, ctx) {
|
|
|
1011
1447
|
const value = parseActionLiteral(rhs);
|
|
1012
1448
|
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
1013
1449
|
if (typeof value === "number") return { op: "add", target, value };
|
|
1014
|
-
throw new Error(`Unsupported button action "${raw}". += with non-number values requires a list state target.`);
|
|
1450
|
+
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`);
|
|
1015
1451
|
}
|
|
1016
1452
|
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
1017
1453
|
const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
|
|
@@ -1029,9 +1465,13 @@ function parseAction(raw, ctx) {
|
|
|
1029
1465
|
}
|
|
1030
1466
|
const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
1031
1467
|
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
1032
|
-
throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
|
|
1468
|
+
throw new Error(`Unsupported button action "${raw}" in ${ctx.file}. Supported actions: count++, count--, count += 1, items += "value", name = "value", list remove item (in a loop), cart += item (in a loop).`);
|
|
1033
1469
|
}
|
|
1034
1470
|
|
|
1471
|
+
/**
|
|
1472
|
+
* @param {string} raw
|
|
1473
|
+
* @returns {unknown}
|
|
1474
|
+
*/
|
|
1035
1475
|
function parseActionLiteral(raw) {
|
|
1036
1476
|
const value = raw.trim();
|
|
1037
1477
|
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
@@ -1053,6 +1493,13 @@ function parseActionLiteral(raw) {
|
|
|
1053
1493
|
// Includes / assets
|
|
1054
1494
|
// ---------------------------------------------------------------------------
|
|
1055
1495
|
|
|
1496
|
+
/**
|
|
1497
|
+
* Register a page's colocated `.skin`/`.js` siblings as emitted assets.
|
|
1498
|
+
* @param {string} file
|
|
1499
|
+
* @param {Paths} context
|
|
1500
|
+
* @param {Assets} assets
|
|
1501
|
+
* @returns {void}
|
|
1502
|
+
*/
|
|
1056
1503
|
function collectColocatedAssets(file, context, assets) {
|
|
1057
1504
|
const ext = path.extname(file);
|
|
1058
1505
|
const stem = file.slice(0, -ext.length);
|
|
@@ -1068,6 +1515,14 @@ function collectColocatedAssets(file, context, assets) {
|
|
|
1068
1515
|
}
|
|
1069
1516
|
}
|
|
1070
1517
|
|
|
1518
|
+
/**
|
|
1519
|
+
* Resolve an include spec to an absolute path inside `site/pages` or `site/_`.
|
|
1520
|
+
* @param {string} spec Include target (may be quoted).
|
|
1521
|
+
* @param {string} fromFile File requesting the include.
|
|
1522
|
+
* @param {Paths} context
|
|
1523
|
+
* @param {boolean} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
|
|
1524
|
+
* @returns {string}
|
|
1525
|
+
*/
|
|
1071
1526
|
function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
1072
1527
|
const clean = stripQuotes(spec);
|
|
1073
1528
|
const candidates = [];
|
|
@@ -1089,6 +1544,11 @@ function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
|
1089
1544
|
throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
|
|
1090
1545
|
}
|
|
1091
1546
|
|
|
1547
|
+
/**
|
|
1548
|
+
* @param {string} file
|
|
1549
|
+
* @param {Paths} context
|
|
1550
|
+
* @returns {boolean}
|
|
1551
|
+
*/
|
|
1092
1552
|
function isAllowedInclude(file, context) {
|
|
1093
1553
|
const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
|
|
1094
1554
|
return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
|
|
@@ -1098,6 +1558,13 @@ function isAllowedInclude(file, context) {
|
|
|
1098
1558
|
// Plain .md hints
|
|
1099
1559
|
// ---------------------------------------------------------------------------
|
|
1100
1560
|
|
|
1561
|
+
/**
|
|
1562
|
+
* Warn when a plain `.md` file contains `.wd`-only syntax that stays inert.
|
|
1563
|
+
* @param {string} body
|
|
1564
|
+
* @param {string} file
|
|
1565
|
+
* @param {Compilation} comp
|
|
1566
|
+
* @returns {void}
|
|
1567
|
+
*/
|
|
1101
1568
|
function scanMarkdownHints(body, file, comp) {
|
|
1102
1569
|
let fence = null;
|
|
1103
1570
|
for (const line of body.split("\n")) {
|
|
@@ -1124,10 +1591,19 @@ function scanMarkdownHints(body, file, comp) {
|
|
|
1124
1591
|
// Literals
|
|
1125
1592
|
// ---------------------------------------------------------------------------
|
|
1126
1593
|
|
|
1594
|
+
/**
|
|
1595
|
+
* JSON-encode a value for an inline `<script>`, escaping `<` to stay HTML-safe.
|
|
1596
|
+
* @param {unknown} value
|
|
1597
|
+
* @returns {string}
|
|
1598
|
+
*/
|
|
1127
1599
|
function safeScriptJson(value) {
|
|
1128
1600
|
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
|
1129
1601
|
}
|
|
1130
1602
|
|
|
1603
|
+
/**
|
|
1604
|
+
* @param {string} raw
|
|
1605
|
+
* @returns {unknown}
|
|
1606
|
+
*/
|
|
1131
1607
|
function parseStateValue(raw) {
|
|
1132
1608
|
const value = raw.trim();
|
|
1133
1609
|
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
@@ -1142,6 +1618,10 @@ function parseStateValue(raw) {
|
|
|
1142
1618
|
}
|
|
1143
1619
|
}
|
|
1144
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* @param {string} raw
|
|
1623
|
+
* @returns {string | number | boolean}
|
|
1624
|
+
*/
|
|
1145
1625
|
function parseScalar(raw) {
|
|
1146
1626
|
const trimmed = raw.trim();
|
|
1147
1627
|
if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
|
|
@@ -1151,10 +1631,19 @@ function parseScalar(raw) {
|
|
|
1151
1631
|
return trimmed;
|
|
1152
1632
|
}
|
|
1153
1633
|
|
|
1634
|
+
/**
|
|
1635
|
+
* @param {string} [value]
|
|
1636
|
+
* @returns {string}
|
|
1637
|
+
*/
|
|
1154
1638
|
function stripQuotes(value) {
|
|
1155
1639
|
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
1156
1640
|
}
|
|
1157
1641
|
|
|
1642
|
+
/**
|
|
1643
|
+
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
1644
|
+
* @param {unknown} value
|
|
1645
|
+
* @returns {string}
|
|
1646
|
+
*/
|
|
1158
1647
|
export function escapeHtml(value) {
|
|
1159
1648
|
return String(value)
|
|
1160
1649
|
.replaceAll("&", "&")
|