@zvndev/darkmown 0.8.1 → 0.9.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 +24 -2
- package/README.md +15 -5
- package/docs/cli.md +2 -0
- package/docs/spec-alignment.md +8 -4
- package/package.json +17 -3
- package/site/_/nav.skin +7 -0
- package/site/pages/docs/index.wd +1 -1
- package/site/pages/index.skin +165 -6
- package/site/pages/index.wd +95 -6
- package/src/builder.js +60 -1
- package/src/cli.js +14 -1
- package/src/compiler.js +530 -21
- package/src/config.js +19 -0
- package/src/dev.js +9 -0
- package/src/router.js +26 -0
- package/src/runtime.js +129 -29
- package/src/scaffold.js +12 -0
- 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,11 +2,108 @@ 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
|
|
|
79
|
+
// Raw HTML in markdown passes through by default (a documented design choice).
|
|
80
|
+
// Pages can opt out with frontmatter `html: false` for untrusted content; the
|
|
81
|
+
// stricter instance is built lazily so the default path stays a single instance.
|
|
82
|
+
/** @type {MarkdownIt | null} */
|
|
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
|
+
*/
|
|
89
|
+
function selectMd(meta) {
|
|
90
|
+
// Frontmatter scalars stay strings (no coercion), so accept both forms.
|
|
91
|
+
if (meta?.html !== false && meta?.html !== "false") return md;
|
|
92
|
+
if (!mdNoHtml) {
|
|
93
|
+
mdNoHtml = new MarkdownIt({ html: false });
|
|
94
|
+
mdNoHtml.use(bindingPlugin);
|
|
95
|
+
}
|
|
96
|
+
return mdNoHtml;
|
|
97
|
+
}
|
|
98
|
+
|
|
8
99
|
const pageIncludeExtensions = [".md", ".wd"];
|
|
9
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
|
+
*/
|
|
10
107
|
export function compilePage(file, context) {
|
|
11
108
|
const compiled = compileDocument(file, context);
|
|
12
109
|
const title = compiled.meta.title || "Darkmown";
|
|
@@ -48,12 +145,21 @@ ${scripts}
|
|
|
48
145
|
};
|
|
49
146
|
}
|
|
50
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
|
+
*/
|
|
51
156
|
export function compileDocument(file, context, stack = [], vars = {}) {
|
|
52
157
|
const comp = createCompilation();
|
|
53
158
|
const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
|
|
54
159
|
return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
|
|
55
160
|
}
|
|
56
161
|
|
|
162
|
+
/** @returns {Compilation} */
|
|
57
163
|
function createCompilation() {
|
|
58
164
|
return {
|
|
59
165
|
assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
|
|
@@ -63,10 +169,26 @@ function createCompilation() {
|
|
|
63
169
|
};
|
|
64
170
|
}
|
|
65
171
|
|
|
172
|
+
/**
|
|
173
|
+
* @param {Scope | null} parent
|
|
174
|
+
* @param {Record<string, unknown>} [vars]
|
|
175
|
+
* @returns {Scope}
|
|
176
|
+
*/
|
|
66
177
|
function createScope(parent, vars = {}) {
|
|
67
178
|
return { parent, vars: { ...vars } };
|
|
68
179
|
}
|
|
69
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
|
+
*/
|
|
70
192
|
function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
71
193
|
const real = fs.realpathSync(file);
|
|
72
194
|
if (stack.includes(real)) {
|
|
@@ -74,26 +196,37 @@ function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
|
74
196
|
}
|
|
75
197
|
|
|
76
198
|
const raw = fs.readFileSync(file, "utf8");
|
|
77
|
-
const { meta, body } = parseFrontmatter(raw);
|
|
199
|
+
const { meta, body } = parseFrontmatter(raw, file);
|
|
78
200
|
collectColocatedAssets(file, context, comp.assets);
|
|
79
201
|
|
|
80
202
|
if (path.extname(file) === ".md") {
|
|
81
203
|
scanMarkdownHints(body, file, comp);
|
|
82
|
-
return { meta, html:
|
|
204
|
+
return { meta, html: selectMd(meta).render(body, {}) };
|
|
83
205
|
}
|
|
84
206
|
|
|
85
207
|
// Expose this file's frontmatter to the body under `meta` so `{ meta.title }`,
|
|
86
208
|
// `{ meta.tags }`, and `@loop meta.tags into tag` resolve as static values.
|
|
87
|
-
|
|
209
|
+
/** @type {Ctx} */
|
|
210
|
+
const ctx = { file, context, stack: [...stack, real], scope: createScope(scope, { meta }), comp, sections, loopItem, md: selectMd(meta) };
|
|
88
211
|
return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
|
|
89
212
|
}
|
|
90
213
|
|
|
91
|
-
|
|
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
|
+
*/
|
|
220
|
+
export function parseFrontmatter(raw, file) {
|
|
92
221
|
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
93
|
-
const end = raw.indexOf("\n---",
|
|
94
|
-
if (end === -1)
|
|
222
|
+
const end = raw.indexOf("\n---", 3);
|
|
223
|
+
if (end === -1) {
|
|
224
|
+
const where = file ? ` in ${file}` : "";
|
|
225
|
+
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.`);
|
|
226
|
+
}
|
|
95
227
|
const front = raw.slice(4, end).trim();
|
|
96
228
|
const body = raw.slice(end + 4).replace(/^\n/, "");
|
|
229
|
+
/** @type {Meta} */
|
|
97
230
|
const meta = {};
|
|
98
231
|
for (const line of front.split("\n")) {
|
|
99
232
|
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
@@ -105,15 +238,24 @@ export function parseFrontmatter(raw) {
|
|
|
105
238
|
// Frontmatter values are scalars, except an inline flow array `[a, b, c]`.
|
|
106
239
|
// Block sequences (`- item` on following lines) are intentionally out of scope —
|
|
107
240
|
// the parser stays single-pass and line-based.
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} raw
|
|
243
|
+
* @returns {FrontmatterValue}
|
|
244
|
+
*/
|
|
108
245
|
function parseFrontmatterValue(raw) {
|
|
109
246
|
const value = raw.trim();
|
|
110
247
|
if (value.startsWith("[") && value.endsWith("]")) return parseInlineArray(value);
|
|
111
248
|
return stripQuotes(value);
|
|
112
249
|
}
|
|
113
250
|
|
|
251
|
+
/**
|
|
252
|
+
* @param {string} raw
|
|
253
|
+
* @returns {string[]}
|
|
254
|
+
*/
|
|
114
255
|
function parseInlineArray(raw) {
|
|
115
256
|
const inner = raw.slice(1, -1);
|
|
116
257
|
if (inner.trim() === "") return [];
|
|
258
|
+
/** @type {string[]} */
|
|
117
259
|
const items = [];
|
|
118
260
|
let buf = "";
|
|
119
261
|
let quote = null;
|
|
@@ -145,8 +287,16 @@ function parseInlineArray(raw) {
|
|
|
145
287
|
// Block parser: directives + prose segments
|
|
146
288
|
// ---------------------------------------------------------------------------
|
|
147
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
|
+
*/
|
|
148
296
|
function compileBody(lines, ctx) {
|
|
297
|
+
/** @type {string[]} */
|
|
149
298
|
const out = [];
|
|
299
|
+
/** @type {string[]} */
|
|
150
300
|
let prose = [];
|
|
151
301
|
let fence = null;
|
|
152
302
|
|
|
@@ -263,7 +413,17 @@ function compileBody(lines, ctx) {
|
|
|
263
413
|
return out.join("\n");
|
|
264
414
|
}
|
|
265
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
|
+
*/
|
|
266
425
|
function scanBlock(lines, start, openRe, endToken, file) {
|
|
426
|
+
/** @type {string[]} */
|
|
267
427
|
const body = [];
|
|
268
428
|
let depth = 0;
|
|
269
429
|
let fence = null;
|
|
@@ -290,7 +450,15 @@ function scanBlock(lines, start, openRe, endToken, file) {
|
|
|
290
450
|
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
|
|
291
451
|
}
|
|
292
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
|
+
*/
|
|
293
460
|
function scanContainer(lines, start, file) {
|
|
461
|
+
/** @type {string[]} */
|
|
294
462
|
const body = [];
|
|
295
463
|
let depth = 0;
|
|
296
464
|
let fence = null;
|
|
@@ -322,8 +490,17 @@ function scanContainer(lines, start, file) {
|
|
|
322
490
|
throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
|
|
323
491
|
}
|
|
324
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
|
+
*/
|
|
325
500
|
function scanConditional(lines, start, file) {
|
|
501
|
+
/** @type {string[]} */
|
|
326
502
|
const truthy = [];
|
|
503
|
+
/** @type {string[]} */
|
|
327
504
|
const falsy = [];
|
|
328
505
|
let current = truthy;
|
|
329
506
|
let depth = 0;
|
|
@@ -359,6 +536,11 @@ function scanConditional(lines, start, file) {
|
|
|
359
536
|
// Directive handlers
|
|
360
537
|
// ---------------------------------------------------------------------------
|
|
361
538
|
|
|
539
|
+
/**
|
|
540
|
+
* @param {string} line
|
|
541
|
+
* @param {Ctx} ctx
|
|
542
|
+
* @returns {string}
|
|
543
|
+
*/
|
|
362
544
|
function handleInclude(line, ctx) {
|
|
363
545
|
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
364
546
|
if (!match) throw new Error(`Malformed @include in ${ctx.file}: ${line}`);
|
|
@@ -369,7 +551,13 @@ function handleInclude(line, ctx) {
|
|
|
369
551
|
return child.html;
|
|
370
552
|
}
|
|
371
553
|
|
|
554
|
+
/**
|
|
555
|
+
* @param {string} raw
|
|
556
|
+
* @param {Ctx} ctx
|
|
557
|
+
* @returns {Record<string, unknown>}
|
|
558
|
+
*/
|
|
372
559
|
function parseIncludeArgs(raw, ctx) {
|
|
560
|
+
/** @type {Record<string, unknown>} */
|
|
373
561
|
const args = {};
|
|
374
562
|
const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
|
|
375
563
|
for (const match of raw.matchAll(re)) {
|
|
@@ -388,12 +576,19 @@ function parseIncludeArgs(raw, ctx) {
|
|
|
388
576
|
return args;
|
|
389
577
|
}
|
|
390
578
|
|
|
579
|
+
/**
|
|
580
|
+
* @param {string} header
|
|
581
|
+
* @param {string[]} bodyLines
|
|
582
|
+
* @param {Ctx} ctx
|
|
583
|
+
* @returns {string}
|
|
584
|
+
*/
|
|
391
585
|
function handleContainer(header, bodyLines, ctx) {
|
|
392
586
|
const tokens = header.split(/\s+/);
|
|
393
587
|
let tag = "section";
|
|
588
|
+
/** @type {string[]} */
|
|
394
589
|
let extraClass = [];
|
|
395
590
|
let id = "";
|
|
396
|
-
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";
|
|
397
592
|
if (nameToken !== "section") {
|
|
398
593
|
tag = "div";
|
|
399
594
|
extraClass.push(nameToken);
|
|
@@ -418,6 +613,11 @@ function handleContainer(header, bodyLines, ctx) {
|
|
|
418
613
|
return `<${tag}${idAttr}${classAttr}>\n${inner}\n</${tag}>`;
|
|
419
614
|
}
|
|
420
615
|
|
|
616
|
+
/**
|
|
617
|
+
* @param {string} line
|
|
618
|
+
* @param {Ctx} ctx
|
|
619
|
+
* @returns {string}
|
|
620
|
+
*/
|
|
421
621
|
function handleState(line, ctx) {
|
|
422
622
|
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
423
623
|
if (!match) throw new Error(`Malformed :state in ${ctx.file}: ${line}`);
|
|
@@ -427,6 +627,13 @@ function handleState(line, ctx) {
|
|
|
427
627
|
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
428
628
|
}
|
|
429
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
|
+
*/
|
|
430
637
|
function declareState(name, value, ctx) {
|
|
431
638
|
if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
432
639
|
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
@@ -436,6 +643,11 @@ function declareState(name, value, ctx) {
|
|
|
436
643
|
return key;
|
|
437
644
|
}
|
|
438
645
|
|
|
646
|
+
/**
|
|
647
|
+
* @param {string} line
|
|
648
|
+
* @param {Ctx} ctx
|
|
649
|
+
* @returns {string}
|
|
650
|
+
*/
|
|
439
651
|
function handleFetch(line, ctx) {
|
|
440
652
|
const match = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+?)(\s+when=visible)?\s*$/);
|
|
441
653
|
if (!match) {
|
|
@@ -448,27 +660,47 @@ function handleFetch(line, ctx) {
|
|
|
448
660
|
return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
|
|
449
661
|
}
|
|
450
662
|
|
|
663
|
+
/**
|
|
664
|
+
* @param {string} key
|
|
665
|
+
* @param {Ctx} ctx
|
|
666
|
+
* @returns {void}
|
|
667
|
+
*/
|
|
451
668
|
function declareErrorState(key, ctx) {
|
|
452
669
|
const errorKey = `${key}_error`;
|
|
453
670
|
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
454
671
|
}
|
|
455
672
|
|
|
673
|
+
/**
|
|
674
|
+
* @param {string} line
|
|
675
|
+
* @param {Ctx} ctx
|
|
676
|
+
* @returns {string}
|
|
677
|
+
*/
|
|
456
678
|
function handleComputed(line, ctx) {
|
|
457
679
|
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
458
680
|
if (!match) throw new Error(`Malformed :computed in ${ctx.file}: ${line}. Use: :computed total = items.length * 4`);
|
|
459
681
|
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
682
|
+
/** @type {unknown} */
|
|
460
683
|
let initial;
|
|
461
684
|
try {
|
|
685
|
+
/** @param {string} key @param {string} [path] */
|
|
462
686
|
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
463
687
|
initial = new Function("S", `return (${expr});`)(read);
|
|
464
688
|
} catch {
|
|
689
|
+
console.warn(`:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`);
|
|
465
690
|
initial = null;
|
|
466
691
|
}
|
|
467
692
|
const key = declareState(match[1], initial ?? null, ctx);
|
|
468
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>`;
|
|
469
694
|
}
|
|
470
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
|
+
*/
|
|
471
702
|
function compileComputedExpr(raw, ctx) {
|
|
703
|
+
/** @type {string[]} */
|
|
472
704
|
const strings = [];
|
|
473
705
|
let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
|
|
474
706
|
strings.push(`"${literal.slice(1, -1)}"`);
|
|
@@ -502,6 +734,12 @@ function compileComputedExpr(raw, ctx) {
|
|
|
502
734
|
return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
|
|
503
735
|
}
|
|
504
736
|
|
|
737
|
+
/**
|
|
738
|
+
* @param {string} line
|
|
739
|
+
* @param {string[]} bodyLines
|
|
740
|
+
* @param {Ctx} ctx
|
|
741
|
+
* @returns {string}
|
|
742
|
+
*/
|
|
505
743
|
function handleForm(line, bodyLines, ctx) {
|
|
506
744
|
let rest = line.slice(":form".length).trim();
|
|
507
745
|
const action = rest.match(/action="([^"]+)"/)?.[1];
|
|
@@ -527,6 +765,11 @@ function handleForm(line, bodyLines, ctx) {
|
|
|
527
765
|
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
|
|
528
766
|
}
|
|
529
767
|
|
|
768
|
+
/**
|
|
769
|
+
* @param {string} line
|
|
770
|
+
* @param {Ctx} ctx
|
|
771
|
+
* @returns {string}
|
|
772
|
+
*/
|
|
530
773
|
function handleInput(line, ctx) {
|
|
531
774
|
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
532
775
|
if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
|
|
@@ -557,6 +800,11 @@ function handleInput(line, ctx) {
|
|
|
557
800
|
// :bind <state> [placeholder="…"] [type=…] — a live two-way input bound to a
|
|
558
801
|
// declared :state. Updates state on every keystroke; reflects state back when
|
|
559
802
|
// not focused. The state must be declared first (with :state).
|
|
803
|
+
/**
|
|
804
|
+
* @param {string} line
|
|
805
|
+
* @param {Ctx} ctx
|
|
806
|
+
* @returns {string}
|
|
807
|
+
*/
|
|
560
808
|
function handleBind(line, ctx) {
|
|
561
809
|
const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
562
810
|
if (!match) throw new Error(`Malformed :bind in ${ctx.file}: ${line}. Use: :bind query placeholder="Search"`);
|
|
@@ -586,12 +834,22 @@ function handleBind(line, ctx) {
|
|
|
586
834
|
return `<input type="${escapeHtml(type)}" data-wd-bind-input="${key}"${valueAttr} ${attrs.join(" ")}>`;
|
|
587
835
|
}
|
|
588
836
|
|
|
837
|
+
/**
|
|
838
|
+
* @param {string} line
|
|
839
|
+
* @param {Ctx} ctx
|
|
840
|
+
* @returns {string}
|
|
841
|
+
*/
|
|
589
842
|
function handleSubmit(line, ctx) {
|
|
590
843
|
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
591
844
|
if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
|
|
592
845
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
593
846
|
}
|
|
594
847
|
|
|
848
|
+
/**
|
|
849
|
+
* @param {string} line
|
|
850
|
+
* @param {Ctx} ctx
|
|
851
|
+
* @returns {string}
|
|
852
|
+
*/
|
|
595
853
|
function handleButton(line, ctx) {
|
|
596
854
|
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
597
855
|
if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
|
|
@@ -601,6 +859,13 @@ function handleButton(line, ctx) {
|
|
|
601
859
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
602
860
|
}
|
|
603
861
|
|
|
862
|
+
/**
|
|
863
|
+
* @param {string} line
|
|
864
|
+
* @param {string[]} truthyLines
|
|
865
|
+
* @param {string[]} falsyLines
|
|
866
|
+
* @param {Ctx} ctx
|
|
867
|
+
* @returns {string}
|
|
868
|
+
*/
|
|
604
869
|
function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
605
870
|
const match = line.match(/^:if\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
|
|
606
871
|
if (!match) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" with a :state or in-scope value.`);
|
|
@@ -635,6 +900,12 @@ function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
|
635
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>`;
|
|
636
901
|
}
|
|
637
902
|
|
|
903
|
+
/**
|
|
904
|
+
* @param {string} line
|
|
905
|
+
* @param {string[]} bodyLines
|
|
906
|
+
* @param {Ctx} ctx
|
|
907
|
+
* @returns {string}
|
|
908
|
+
*/
|
|
638
909
|
function handleLoop(line, bodyLines, ctx) {
|
|
639
910
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(?:\s+where\s+(.+?))?\s*$/);
|
|
640
911
|
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing> [where <predicate>]`);
|
|
@@ -670,6 +941,21 @@ function handleLoop(line, bodyLines, ctx) {
|
|
|
670
941
|
// A static source (JSON file / in-scope value). No `where`, or a `where` that
|
|
671
942
|
// only reads the loop item → filter at build time and stay zero-JS. A `where`
|
|
672
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
|
+
*/
|
|
673
959
|
function loopOverData(rows, itemName, bodyLines, ctx, where) {
|
|
674
960
|
if (!where) return staticUnroll(rows, itemName, bodyLines, ctx);
|
|
675
961
|
if (!where.refsState) return staticUnroll(rows.filter((row) => evalPredicate(where.body, row, ctx)), itemName, bodyLines, ctx);
|
|
@@ -679,8 +965,15 @@ function loopOverData(rows, itemName, bodyLines, ctx, where) {
|
|
|
679
965
|
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
680
966
|
// Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
|
|
681
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
|
+
*/
|
|
682
974
|
function compilePredicate(raw, itemName, ctx) {
|
|
683
975
|
const parts = raw.split(/\s+(and|or)\s+/i);
|
|
976
|
+
/** @type {string[]} */
|
|
684
977
|
const pieces = [];
|
|
685
978
|
let refsState = false;
|
|
686
979
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -692,6 +985,12 @@ function compilePredicate(raw, itemName, ctx) {
|
|
|
692
985
|
return { body: pieces.join(" "), refsState };
|
|
693
986
|
}
|
|
694
987
|
|
|
988
|
+
/**
|
|
989
|
+
* @param {string} cond
|
|
990
|
+
* @param {string} itemName
|
|
991
|
+
* @param {Ctx} ctx
|
|
992
|
+
* @returns {{ expr: string, usesState: boolean }}
|
|
993
|
+
*/
|
|
695
994
|
function compileCondition(cond, itemName, ctx) {
|
|
696
995
|
const m = cond.match(/^(.+?)\s+(contains|==|!=|>=|<=|>|<)\s+(.+)$/i);
|
|
697
996
|
if (!m) throw new Error(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${itemName}.field contains state, or ${itemName}.field <op> value.`);
|
|
@@ -703,6 +1002,12 @@ function compileCondition(cond, itemName, ctx) {
|
|
|
703
1002
|
return { expr: `${left.code} ${op} ${right.code}`, usesState };
|
|
704
1003
|
}
|
|
705
1004
|
|
|
1005
|
+
/**
|
|
1006
|
+
* @param {string} tok
|
|
1007
|
+
* @param {string} itemName
|
|
1008
|
+
* @param {Ctx} ctx
|
|
1009
|
+
* @returns {{ code: string, usesState: boolean }}
|
|
1010
|
+
*/
|
|
706
1011
|
function compileOperand(tok, itemName, ctx) {
|
|
707
1012
|
if (/^"[^"]*"$/.test(tok) || /^'[^']*'$/.test(tok)) return { code: JSON.stringify(tok.slice(1, -1)), usesState: false };
|
|
708
1013
|
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
|
|
@@ -723,18 +1028,36 @@ function compileOperand(tok, itemName, ctx) {
|
|
|
723
1028
|
return { code: `S(${JSON.stringify(key)}${rest ? `, ${JSON.stringify(rest)}` : ""})`, usesState: true };
|
|
724
1029
|
}
|
|
725
1030
|
|
|
1031
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
726
1032
|
const containsHelper = (a, b) => String(a ?? "").toLowerCase().includes(String(b ?? "").toLowerCase());
|
|
727
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
|
+
*/
|
|
728
1041
|
function evalPredicate(body, item, ctx) {
|
|
729
1042
|
try {
|
|
1043
|
+
/** @param {string} [p] */
|
|
730
1044
|
const I = (p) => getPath(item, p ? p.split(".") : []);
|
|
1045
|
+
/** @param {string} k @param {string} [r] */
|
|
731
1046
|
const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
|
|
732
1047
|
return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
|
|
733
1048
|
} catch {
|
|
1049
|
+
console.warn(`@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`);
|
|
734
1050
|
return false;
|
|
735
1051
|
}
|
|
736
1052
|
}
|
|
737
1053
|
|
|
1054
|
+
/**
|
|
1055
|
+
* @param {unknown[]} rows
|
|
1056
|
+
* @param {string} itemName
|
|
1057
|
+
* @param {string[]} bodyLines
|
|
1058
|
+
* @param {Ctx} ctx
|
|
1059
|
+
* @returns {string}
|
|
1060
|
+
*/
|
|
738
1061
|
function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
739
1062
|
const out = [];
|
|
740
1063
|
for (const row of rows) {
|
|
@@ -744,9 +1067,19 @@ function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
|
744
1067
|
return out.join("\n");
|
|
745
1068
|
}
|
|
746
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
|
+
*/
|
|
747
1079
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
748
1080
|
ctx.comp.assets.runtime = true;
|
|
749
|
-
|
|
1081
|
+
/** @type {Ctx} */
|
|
1082
|
+
const templateCtx = { ...ctx, loopItem: itemName, loopKey: key ?? undefined, scope: createScope(ctx.scope) };
|
|
750
1083
|
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
751
1084
|
|
|
752
1085
|
let wrapperTag = "div";
|
|
@@ -761,11 +1094,14 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
761
1094
|
|
|
762
1095
|
const where = opts?.where || null;
|
|
763
1096
|
const baked = opts?.data || null; // rows for a static source filtered by state
|
|
764
|
-
const
|
|
765
|
-
|
|
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>} */
|
|
766
1102
|
const counts = new Map();
|
|
767
1103
|
const initial = rows
|
|
768
|
-
.map((item) => {
|
|
1104
|
+
.map((/** @type {unknown} */ item) => {
|
|
769
1105
|
const itemKey = loopKeyOf(item, counts);
|
|
770
1106
|
return fillTemplateString(withLoopKey(itemTemplate, itemKey), item);
|
|
771
1107
|
})
|
|
@@ -776,10 +1112,20 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
776
1112
|
return `<div data-wd-loop="${key || ""}"${whereAttr}${dataAttr}><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
|
|
777
1113
|
}
|
|
778
1114
|
|
|
1115
|
+
/**
|
|
1116
|
+
* @param {string} template
|
|
1117
|
+
* @param {string} itemKey
|
|
1118
|
+
* @returns {string}
|
|
1119
|
+
*/
|
|
779
1120
|
function withLoopKey(template, itemKey) {
|
|
780
1121
|
return template.replace(/^<([a-zA-Z][a-zA-Z0-9-]*)/, `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`);
|
|
781
1122
|
}
|
|
782
1123
|
|
|
1124
|
+
/**
|
|
1125
|
+
* @param {string} template
|
|
1126
|
+
* @param {unknown} item
|
|
1127
|
+
* @returns {string}
|
|
1128
|
+
*/
|
|
783
1129
|
function fillTemplateString(template, item) {
|
|
784
1130
|
// Resolve per-item :if regions first (recursively, so nested conditionals are
|
|
785
1131
|
// pre-rendered for the initial paint), then fill the plain text bindings.
|
|
@@ -789,6 +1135,11 @@ function fillTemplateString(template, item) {
|
|
|
789
1135
|
// Walk the string resolving only the OUTERMOST data-wd-each-if regions; each
|
|
790
1136
|
// chosen branch is recursed so nested conditionals resolve too. The <template>
|
|
791
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
|
+
*/
|
|
792
1143
|
function fillEachIfRegions(str, item) {
|
|
793
1144
|
const marker = '<span data-wd-each-if ';
|
|
794
1145
|
let result = "";
|
|
@@ -803,8 +1154,13 @@ function fillEachIfRegions(str, item) {
|
|
|
803
1154
|
}
|
|
804
1155
|
}
|
|
805
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* @param {string} region
|
|
1159
|
+
* @param {unknown} item
|
|
1160
|
+
* @returns {string}
|
|
1161
|
+
*/
|
|
806
1162
|
function fillOneEachIf(region, item) {
|
|
807
|
-
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];
|
|
808
1164
|
const trueStart = region.indexOf("<template data-wd-if-true>");
|
|
809
1165
|
const trueEnd = matchElement(region, trueStart, "template");
|
|
810
1166
|
const falseStart = region.indexOf("<template data-wd-if-false>", trueEnd);
|
|
@@ -818,6 +1174,11 @@ function fillOneEachIf(region, item) {
|
|
|
818
1174
|
return `${head}<span data-wd-each-if-out>${fillEachIfRegions(branch, item)}</span></span>`;
|
|
819
1175
|
}
|
|
820
1176
|
|
|
1177
|
+
/**
|
|
1178
|
+
* @param {string} str
|
|
1179
|
+
* @param {unknown} item
|
|
1180
|
+
* @returns {string}
|
|
1181
|
+
*/
|
|
821
1182
|
function fillEachText(str, item) {
|
|
822
1183
|
return str.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
|
|
823
1184
|
const value = p ? getPath(item, p.split(".")) : item;
|
|
@@ -829,6 +1190,12 @@ function fillEachText(str, item) {
|
|
|
829
1190
|
// Return the index just past the balanced close of the element of `tag` that
|
|
830
1191
|
// begins at `start`. Counts nested same-tag opens/closes so regions that embed
|
|
831
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
|
+
*/
|
|
832
1199
|
function matchElement(str, start, tag) {
|
|
833
1200
|
const openPrefix = `<${tag}`;
|
|
834
1201
|
const closeTag = `</${tag}>`;
|
|
@@ -850,16 +1217,27 @@ function matchElement(str, start, tag) {
|
|
|
850
1217
|
return str.length;
|
|
851
1218
|
}
|
|
852
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
|
+
*/
|
|
853
1226
|
export function loopKeyOf(item, counts) {
|
|
854
1227
|
const base =
|
|
855
1228
|
item && typeof item === "object"
|
|
856
|
-
? 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))
|
|
857
1230
|
: String(item);
|
|
858
1231
|
const seen = counts.get(base) || 0;
|
|
859
1232
|
counts.set(base, seen + 1);
|
|
860
1233
|
return seen ? `${base}#${seen}` : base;
|
|
861
1234
|
}
|
|
862
1235
|
|
|
1236
|
+
/**
|
|
1237
|
+
* Render the documentation-demo directives (`:try`, `:note`, `:sprint`).
|
|
1238
|
+
* @param {string} line
|
|
1239
|
+
* @returns {string}
|
|
1240
|
+
*/
|
|
863
1241
|
function renderDemoDirective(line) {
|
|
864
1242
|
const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
|
|
865
1243
|
if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
@@ -877,10 +1255,21 @@ function renderDemoDirective(line) {
|
|
|
877
1255
|
// Interpolation: one syntax, { name } / { name.path }
|
|
878
1256
|
// ---------------------------------------------------------------------------
|
|
879
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* @param {string} text
|
|
1260
|
+
* @param {Ctx} ctx
|
|
1261
|
+
* @returns {string}
|
|
1262
|
+
*/
|
|
880
1263
|
function renderProse(text, ctx) {
|
|
881
|
-
return md.render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
|
|
1264
|
+
return (ctx.md ?? md).render(text, { resolveBinding: (/** @type {string} */ expr) => resolveBindingHtml(expr, ctx) });
|
|
882
1265
|
}
|
|
883
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
|
+
*/
|
|
884
1273
|
function bindingPlugin(mdInstance) {
|
|
885
1274
|
mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
|
|
886
1275
|
if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
|
|
@@ -898,6 +1287,13 @@ function bindingPlugin(mdInstance) {
|
|
|
898
1287
|
});
|
|
899
1288
|
}
|
|
900
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
|
+
*/
|
|
901
1297
|
function resolveBindingHtml(expr, ctx) {
|
|
902
1298
|
const segs = expr.split(".");
|
|
903
1299
|
const head = segs[0];
|
|
@@ -910,8 +1306,7 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
910
1306
|
const staticValue = lookupVar(ctx.scope, head);
|
|
911
1307
|
if (staticValue.found) {
|
|
912
1308
|
const resolved = getPath(staticValue.value, segs.slice(1));
|
|
913
|
-
|
|
914
|
-
return escapeHtml(resolved ?? "");
|
|
1309
|
+
return escapeHtml(interpolateLeaf(resolved, expr, ctx));
|
|
915
1310
|
}
|
|
916
1311
|
|
|
917
1312
|
const key = resolveStateKey(head, ctx);
|
|
@@ -920,19 +1315,51 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
920
1315
|
const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
|
|
921
1316
|
const rest = segs.slice(1).join(".");
|
|
922
1317
|
const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
|
|
923
|
-
return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(initial
|
|
1318
|
+
return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(interpolateLeaf(initial, expr, ctx))}</span>`;
|
|
924
1319
|
}
|
|
925
1320
|
|
|
926
1321
|
return null;
|
|
927
1322
|
}
|
|
928
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
|
+
*/
|
|
929
1348
|
function lookupVar(scope, name) {
|
|
930
|
-
|
|
1349
|
+
/** @type {Scope | null} */
|
|
1350
|
+
let current = scope;
|
|
1351
|
+
for (; current; current = current.parent) {
|
|
931
1352
|
if (name in current.vars) return { found: true, value: current.vars[name] };
|
|
932
1353
|
}
|
|
933
1354
|
return { found: false };
|
|
934
1355
|
}
|
|
935
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
|
+
*/
|
|
936
1363
|
function lookupPath(expr, ctx) {
|
|
937
1364
|
const segs = expr.split(".");
|
|
938
1365
|
const head = lookupVar(ctx.scope, segs[0]);
|
|
@@ -940,7 +1367,14 @@ function lookupPath(expr, ctx) {
|
|
|
940
1367
|
return { found: true, value: getPath(head.value, segs.slice(1)) };
|
|
941
1368
|
}
|
|
942
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
|
+
*/
|
|
943
1376
|
function getPath(value, segments) {
|
|
1377
|
+
/** @type {any} */
|
|
944
1378
|
let current = value;
|
|
945
1379
|
for (const segment of segments) {
|
|
946
1380
|
if (current == null) return undefined;
|
|
@@ -950,6 +1384,12 @@ function getPath(value, segments) {
|
|
|
950
1384
|
return current;
|
|
951
1385
|
}
|
|
952
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
|
+
*/
|
|
953
1393
|
function resolveStateKey(name, ctx) {
|
|
954
1394
|
for (let i = ctx.sections.length - 1; i >= 0; i--) {
|
|
955
1395
|
const key = `${ctx.sections[i]}:${name}`;
|
|
@@ -962,8 +1402,23 @@ function resolveStateKey(name, ctx) {
|
|
|
962
1402
|
// Actions
|
|
963
1403
|
// ---------------------------------------------------------------------------
|
|
964
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
|
+
*/
|
|
965
1419
|
function parseAction(raw, ctx) {
|
|
966
1420
|
const expression = raw.trim();
|
|
1421
|
+
/** @param {string} name @returns {string} */
|
|
967
1422
|
const resolveTarget = (name) => {
|
|
968
1423
|
const key = resolveStateKey(name, ctx);
|
|
969
1424
|
if (!key) {
|
|
@@ -992,7 +1447,7 @@ function parseAction(raw, ctx) {
|
|
|
992
1447
|
const value = parseActionLiteral(rhs);
|
|
993
1448
|
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
994
1449
|
if (typeof value === "number") return { op: "add", target, value };
|
|
995
|
-
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]} = []".`);
|
|
996
1451
|
}
|
|
997
1452
|
// Per-row: remove the current row from the list being looped — `todos remove todo`.
|
|
998
1453
|
const remove = expression.match(/^([A-Za-z_$][\w$]*)\s+remove\s+([A-Za-z_$][\w$]*)$/);
|
|
@@ -1010,9 +1465,13 @@ function parseAction(raw, ctx) {
|
|
|
1010
1465
|
}
|
|
1011
1466
|
const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
1012
1467
|
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
1013
|
-
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).`);
|
|
1014
1469
|
}
|
|
1015
1470
|
|
|
1471
|
+
/**
|
|
1472
|
+
* @param {string} raw
|
|
1473
|
+
* @returns {unknown}
|
|
1474
|
+
*/
|
|
1016
1475
|
function parseActionLiteral(raw) {
|
|
1017
1476
|
const value = raw.trim();
|
|
1018
1477
|
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
@@ -1034,6 +1493,13 @@ function parseActionLiteral(raw) {
|
|
|
1034
1493
|
// Includes / assets
|
|
1035
1494
|
// ---------------------------------------------------------------------------
|
|
1036
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
|
+
*/
|
|
1037
1503
|
function collectColocatedAssets(file, context, assets) {
|
|
1038
1504
|
const ext = path.extname(file);
|
|
1039
1505
|
const stem = file.slice(0, -ext.length);
|
|
@@ -1049,6 +1515,14 @@ function collectColocatedAssets(file, context, assets) {
|
|
|
1049
1515
|
}
|
|
1050
1516
|
}
|
|
1051
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
|
+
*/
|
|
1052
1526
|
function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
1053
1527
|
const clean = stripQuotes(spec);
|
|
1054
1528
|
const candidates = [];
|
|
@@ -1070,6 +1544,11 @@ function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
|
1070
1544
|
throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
|
|
1071
1545
|
}
|
|
1072
1546
|
|
|
1547
|
+
/**
|
|
1548
|
+
* @param {string} file
|
|
1549
|
+
* @param {Paths} context
|
|
1550
|
+
* @returns {boolean}
|
|
1551
|
+
*/
|
|
1073
1552
|
function isAllowedInclude(file, context) {
|
|
1074
1553
|
const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
|
|
1075
1554
|
return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
|
|
@@ -1079,6 +1558,13 @@ function isAllowedInclude(file, context) {
|
|
|
1079
1558
|
// Plain .md hints
|
|
1080
1559
|
// ---------------------------------------------------------------------------
|
|
1081
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
|
+
*/
|
|
1082
1568
|
function scanMarkdownHints(body, file, comp) {
|
|
1083
1569
|
let fence = null;
|
|
1084
1570
|
for (const line of body.split("\n")) {
|
|
@@ -1105,10 +1591,19 @@ function scanMarkdownHints(body, file, comp) {
|
|
|
1105
1591
|
// Literals
|
|
1106
1592
|
// ---------------------------------------------------------------------------
|
|
1107
1593
|
|
|
1594
|
+
/**
|
|
1595
|
+
* JSON-encode a value for an inline `<script>`, escaping `<` to stay HTML-safe.
|
|
1596
|
+
* @param {unknown} value
|
|
1597
|
+
* @returns {string}
|
|
1598
|
+
*/
|
|
1108
1599
|
function safeScriptJson(value) {
|
|
1109
1600
|
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
|
1110
1601
|
}
|
|
1111
1602
|
|
|
1603
|
+
/**
|
|
1604
|
+
* @param {string} raw
|
|
1605
|
+
* @returns {unknown}
|
|
1606
|
+
*/
|
|
1112
1607
|
function parseStateValue(raw) {
|
|
1113
1608
|
const value = raw.trim();
|
|
1114
1609
|
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
@@ -1123,6 +1618,10 @@ function parseStateValue(raw) {
|
|
|
1123
1618
|
}
|
|
1124
1619
|
}
|
|
1125
1620
|
|
|
1621
|
+
/**
|
|
1622
|
+
* @param {string} raw
|
|
1623
|
+
* @returns {string | number | boolean}
|
|
1624
|
+
*/
|
|
1126
1625
|
function parseScalar(raw) {
|
|
1127
1626
|
const trimmed = raw.trim();
|
|
1128
1627
|
if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
|
|
@@ -1132,14 +1631,24 @@ function parseScalar(raw) {
|
|
|
1132
1631
|
return trimmed;
|
|
1133
1632
|
}
|
|
1134
1633
|
|
|
1634
|
+
/**
|
|
1635
|
+
* @param {string} [value]
|
|
1636
|
+
* @returns {string}
|
|
1637
|
+
*/
|
|
1135
1638
|
function stripQuotes(value) {
|
|
1136
1639
|
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
1137
1640
|
}
|
|
1138
1641
|
|
|
1642
|
+
/**
|
|
1643
|
+
* Escape a value for safe inclusion in HTML text and attribute contexts.
|
|
1644
|
+
* @param {unknown} value
|
|
1645
|
+
* @returns {string}
|
|
1646
|
+
*/
|
|
1139
1647
|
export function escapeHtml(value) {
|
|
1140
1648
|
return String(value)
|
|
1141
1649
|
.replaceAll("&", "&")
|
|
1142
1650
|
.replaceAll("<", "<")
|
|
1143
1651
|
.replaceAll(">", ">")
|
|
1144
|
-
.replaceAll('"', """)
|
|
1652
|
+
.replaceAll('"', """)
|
|
1653
|
+
.replaceAll("'", "'");
|
|
1145
1654
|
}
|