@zvndev/darkmown 0.4.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/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/docs/cli.md +51 -0
- package/docs/spec-alignment.md +60 -0
- package/package.json +50 -0
- package/site/_/feature-card.wd +7 -0
- package/site/_/features.json +14 -0
- package/site/_/logo.svg +1 -0
- package/site/_/nav.skin +84 -0
- package/site/_/nav.wd +8 -0
- package/site/_/quotes.json +6 -0
- package/site/_/sprint-loop.wd +11 -0
- package/site/_/team.json +7 -0
- package/site/pages/-draft.wd +3 -0
- package/site/pages/data.js +8 -0
- package/site/pages/data.skin +98 -0
- package/site/pages/data.wd +110 -0
- package/site/pages/docs/-relative-note.wd +5 -0
- package/site/pages/docs/.secret.wd +3 -0
- package/site/pages/docs/index.skin +39 -0
- package/site/pages/docs/index.wd +125 -0
- package/site/pages/index.js +10 -0
- package/site/pages/index.skin +134 -0
- package/site/pages/index.wd +64 -0
- package/site/pages/markdown.md +36 -0
- package/site/pages/markdown.skin +63 -0
- package/site/pages/reactive.skin +62 -0
- package/site/pages/reactive.wd +68 -0
- package/src/builder.js +87 -0
- package/src/cli.js +156 -0
- package/src/compiler.js +907 -0
- package/src/config.js +19 -0
- package/src/dev.js +30 -0
- package/src/router.js +51 -0
- package/src/runtime.js +222 -0
- package/src/scaffold.js +95 -0
- package/src/skin.js +68 -0
- package/src/statics.js +31 -0
package/src/compiler.js
ADDED
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import MarkdownIt from "markdown-it";
|
|
4
|
+
|
|
5
|
+
const md = new MarkdownIt({ html: true });
|
|
6
|
+
md.use(bindingPlugin);
|
|
7
|
+
|
|
8
|
+
const pageIncludeExtensions = [".md", ".wd"];
|
|
9
|
+
|
|
10
|
+
export function compilePage(file, context) {
|
|
11
|
+
const compiled = compileDocument(file, context);
|
|
12
|
+
const title = compiled.meta.title || "Darkmown";
|
|
13
|
+
const favicon = "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2032%2032'%3E%3Crect%20width='32'%20height='32'%20rx='6'%20fill='%2318221d'/%3E%3Ctext%20x='16'%20y='23'%20text-anchor='middle'%20font-family='Georgia,serif'%20font-size='19'%20font-weight='bold'%20fill='%23f7f3ea'%3ED%3C/text%3E%3C/svg%3E";
|
|
14
|
+
const cssLinks = [...compiled.assets.skins].map((href) => `<link rel="stylesheet" href="${href}">`).join("\n");
|
|
15
|
+
const scriptSrcs = compiled.assets.runtime ? ["/__wd/runtime.js", ...compiled.assets.scripts] : [...compiled.assets.scripts];
|
|
16
|
+
const scripts = scriptSrcs.map((src) => `<script type="module" src="${src}"></script>`).join("\n");
|
|
17
|
+
// View transitions are temporarily disabled: cross-document @view-transition
|
|
18
|
+
// render-blocked deployed pages (rAF stalled, navigation hangs). Tracked for
|
|
19
|
+
// reintroduction with proper activation fallbacks.
|
|
20
|
+
const transitions = "";
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
meta: compiled.meta,
|
|
24
|
+
html: `<!doctype html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
+
<title>${escapeHtml(title)}</title>
|
|
30
|
+
<link rel="icon" href="${favicon}">
|
|
31
|
+
${cssLinks}${transitions}
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
${compiled.html}
|
|
35
|
+
${scripts}
|
|
36
|
+
</body>
|
|
37
|
+
</html>`,
|
|
38
|
+
assets: compiled.assets,
|
|
39
|
+
warnings: compiled.warnings
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function compileDocument(file, context, stack = [], vars = {}) {
|
|
44
|
+
const comp = createCompilation();
|
|
45
|
+
const result = compileFile(file, context, stack, createScope(null, vars), comp, [], null);
|
|
46
|
+
return { meta: result.meta, html: result.html, assets: comp.assets, warnings: comp.warnings };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createCompilation() {
|
|
50
|
+
return {
|
|
51
|
+
assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
|
|
52
|
+
state: new Map(),
|
|
53
|
+
warnings: [],
|
|
54
|
+
sectionCounter: 0
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createScope(parent, vars = {}) {
|
|
59
|
+
return { parent, vars: { ...vars } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function compileFile(file, context, stack, scope, comp, sections, loopItem) {
|
|
63
|
+
const real = fs.realpathSync(file);
|
|
64
|
+
if (stack.includes(real)) {
|
|
65
|
+
throw new Error(`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
69
|
+
const { meta, body } = parseFrontmatter(raw);
|
|
70
|
+
collectColocatedAssets(file, context, comp.assets);
|
|
71
|
+
|
|
72
|
+
if (path.extname(file) === ".md") {
|
|
73
|
+
scanMarkdownHints(body, file, comp);
|
|
74
|
+
return { meta, html: md.render(body, {}) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ctx = { file, context, stack: [...stack, real], scope, comp, sections, loopItem };
|
|
78
|
+
return { meta, html: compileBody(body.replace(/\r\n?/g, "\n").split("\n"), ctx) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function parseFrontmatter(raw) {
|
|
82
|
+
if (!raw.startsWith("---\n")) return { meta: {}, body: raw };
|
|
83
|
+
const end = raw.indexOf("\n---", 4);
|
|
84
|
+
if (end === -1) return { meta: {}, body: raw };
|
|
85
|
+
const front = raw.slice(4, end).trim();
|
|
86
|
+
const body = raw.slice(end + 4).replace(/^\n/, "");
|
|
87
|
+
const meta = {};
|
|
88
|
+
for (const line of front.split("\n")) {
|
|
89
|
+
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
90
|
+
if (match) meta[match[1]] = stripQuotes(match[2]);
|
|
91
|
+
}
|
|
92
|
+
return { meta, body };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Block parser: directives + prose segments
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
function compileBody(lines, ctx) {
|
|
100
|
+
const out = [];
|
|
101
|
+
let prose = [];
|
|
102
|
+
let fence = null;
|
|
103
|
+
|
|
104
|
+
const flush = () => {
|
|
105
|
+
if (!prose.length) return;
|
|
106
|
+
const text = prose.join("\n");
|
|
107
|
+
prose = [];
|
|
108
|
+
if (text.trim()) out.push(renderProse(text, ctx));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const line = lines[i];
|
|
113
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
114
|
+
if (fence) {
|
|
115
|
+
prose.push(line);
|
|
116
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (fenceMatch) {
|
|
120
|
+
fence = fenceMatch[1];
|
|
121
|
+
prose.push(line);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (/^@include\s/.test(line)) {
|
|
126
|
+
flush();
|
|
127
|
+
out.push(handleInclude(line, ctx));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (/^@loop\s/.test(line)) {
|
|
131
|
+
flush();
|
|
132
|
+
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx.file);
|
|
133
|
+
out.push(handleLoop(line, block.body, ctx));
|
|
134
|
+
i = block.end;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const container = line.match(/^:::\s*(.*)$/);
|
|
138
|
+
if (container) {
|
|
139
|
+
flush();
|
|
140
|
+
if (!container[1].trim()) throw new Error(`Stray ::: close with no open container in ${ctx.file}`);
|
|
141
|
+
const block = scanContainer(lines, i, ctx.file);
|
|
142
|
+
out.push(handleContainer(container[1].trim(), block.body, ctx));
|
|
143
|
+
i = block.end;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (/^:state\s/.test(line)) {
|
|
147
|
+
flush();
|
|
148
|
+
out.push(handleState(line, ctx));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (/^:fetch\s/.test(line)) {
|
|
152
|
+
flush();
|
|
153
|
+
out.push(handleFetch(line, ctx));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (/^:computed\s/.test(line)) {
|
|
157
|
+
flush();
|
|
158
|
+
out.push(handleComputed(line, ctx));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (/^:form\s/.test(line)) {
|
|
162
|
+
flush();
|
|
163
|
+
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
164
|
+
out.push(handleForm(line, block.body, ctx));
|
|
165
|
+
i = block.end;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/^:input\s/.test(line)) {
|
|
169
|
+
flush();
|
|
170
|
+
out.push(handleInput(line, ctx));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (/^:submit\s/.test(line)) {
|
|
174
|
+
flush();
|
|
175
|
+
out.push(handleSubmit(line, ctx));
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (/^:button\s/.test(line)) {
|
|
179
|
+
flush();
|
|
180
|
+
out.push(handleButton(line, ctx));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (/^:if\s/.test(line)) {
|
|
184
|
+
flush();
|
|
185
|
+
const block = scanConditional(lines, i, ctx.file);
|
|
186
|
+
out.push(handleIf(line, block.truthy, block.falsy, ctx));
|
|
187
|
+
i = block.end;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const demo = renderDemoDirective(line);
|
|
191
|
+
if (demo) {
|
|
192
|
+
flush();
|
|
193
|
+
out.push(demo);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (/^@repeat\b/.test(line)) {
|
|
197
|
+
throw new Error(`@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop`);
|
|
198
|
+
}
|
|
199
|
+
if (/^:for\b/.test(line)) {
|
|
200
|
+
throw new Error(`:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop`);
|
|
201
|
+
}
|
|
202
|
+
if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
|
|
203
|
+
throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
|
|
204
|
+
}
|
|
205
|
+
prose.push(line);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
flush();
|
|
209
|
+
return out.join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function scanBlock(lines, start, openRe, endToken, file) {
|
|
213
|
+
const body = [];
|
|
214
|
+
let depth = 0;
|
|
215
|
+
let fence = null;
|
|
216
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
217
|
+
const line = lines[i];
|
|
218
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
219
|
+
if (fence) {
|
|
220
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
221
|
+
body.push(line);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (fenceMatch) {
|
|
225
|
+
fence = fenceMatch[1];
|
|
226
|
+
body.push(line);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (openRe.test(line)) depth++;
|
|
230
|
+
if (line.trim() === endToken) {
|
|
231
|
+
if (depth === 0) return { body, end: i };
|
|
232
|
+
depth--;
|
|
233
|
+
}
|
|
234
|
+
body.push(line);
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${file}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function scanContainer(lines, start, file) {
|
|
240
|
+
const body = [];
|
|
241
|
+
let depth = 0;
|
|
242
|
+
let fence = null;
|
|
243
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
244
|
+
const line = lines[i];
|
|
245
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
246
|
+
if (fence) {
|
|
247
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
248
|
+
body.push(line);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (fenceMatch) {
|
|
252
|
+
fence = fenceMatch[1];
|
|
253
|
+
body.push(line);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const marker = line.match(/^:::\s*(.*)$/);
|
|
257
|
+
if (marker) {
|
|
258
|
+
if (marker[1].trim()) {
|
|
259
|
+
depth++;
|
|
260
|
+
} else if (depth === 0) {
|
|
261
|
+
return { body, end: i };
|
|
262
|
+
} else {
|
|
263
|
+
depth--;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
body.push(line);
|
|
267
|
+
}
|
|
268
|
+
throw new Error(`Missing closing ::: for "${lines[start]}" in ${file}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function scanConditional(lines, start, file) {
|
|
272
|
+
const truthy = [];
|
|
273
|
+
const falsy = [];
|
|
274
|
+
let current = truthy;
|
|
275
|
+
let depth = 0;
|
|
276
|
+
let fence = null;
|
|
277
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
278
|
+
const line = lines[i];
|
|
279
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
280
|
+
if (fence) {
|
|
281
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
282
|
+
current.push(line);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (fenceMatch) {
|
|
286
|
+
fence = fenceMatch[1];
|
|
287
|
+
current.push(line);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (/^:if\s/.test(line)) depth++;
|
|
291
|
+
if (line.trim() === ":endif") {
|
|
292
|
+
if (depth === 0) return { truthy, falsy, end: i };
|
|
293
|
+
depth--;
|
|
294
|
+
}
|
|
295
|
+
if (line.trim() === ":else" && depth === 0) {
|
|
296
|
+
current = falsy;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
current.push(line);
|
|
300
|
+
}
|
|
301
|
+
throw new Error(`Missing :endif for "${lines[start]}" in ${file}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Directive handlers
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
function handleInclude(line, ctx) {
|
|
309
|
+
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
310
|
+
if (!match) throw new Error(`Malformed @include in ${ctx.file}: ${line}`);
|
|
311
|
+
const target = resolveInclude(match[1], ctx.file, ctx.context);
|
|
312
|
+
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
313
|
+
const scope = createScope(ctx.scope, args);
|
|
314
|
+
const child = compileFile(target, ctx.context, ctx.stack, scope, ctx.comp, ctx.sections, ctx.loopItem);
|
|
315
|
+
return child.html;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function parseIncludeArgs(raw, ctx) {
|
|
319
|
+
const args = {};
|
|
320
|
+
const re = /([A-Za-z0-9_-]+)=("[^"]*"|'[^']*'|\{[^}]*\}|\S+)/g;
|
|
321
|
+
for (const match of raw.matchAll(re)) {
|
|
322
|
+
const value = match[2];
|
|
323
|
+
if (value.startsWith("{")) {
|
|
324
|
+
const expr = value.slice(1, -1).trim();
|
|
325
|
+
const resolved = lookupPath(expr, ctx);
|
|
326
|
+
if (!resolved.found) {
|
|
327
|
+
throw new Error(`@include argument ${match[1]}={ ${expr} } in ${ctx.file} does not match any value in scope`);
|
|
328
|
+
}
|
|
329
|
+
args[match[1]] = resolved.value;
|
|
330
|
+
} else {
|
|
331
|
+
args[match[1]] = parseScalar(value);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return args;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function handleContainer(header, bodyLines, ctx) {
|
|
338
|
+
const tokens = header.split(/\s+/);
|
|
339
|
+
let tag = "section";
|
|
340
|
+
let extraClass = [];
|
|
341
|
+
let id = "";
|
|
342
|
+
let nameToken = tokens[0] && !tokens[0].startsWith("#") && !tokens[0].startsWith(".") ? tokens.shift() : "section";
|
|
343
|
+
if (nameToken !== "section") {
|
|
344
|
+
tag = "div";
|
|
345
|
+
extraClass.push(nameToken);
|
|
346
|
+
}
|
|
347
|
+
for (const token of tokens) {
|
|
348
|
+
if (token.startsWith("#")) id = token.slice(1);
|
|
349
|
+
else if (token.startsWith(".")) extraClass.push(token.slice(1));
|
|
350
|
+
else throw new Error(`Unexpected token "${token}" in container "::: ${header}" in ${ctx.file}`);
|
|
351
|
+
}
|
|
352
|
+
const explicitId = Boolean(id);
|
|
353
|
+
if (!id) id = `wd-s${++ctx.comp.sectionCounter}`;
|
|
354
|
+
|
|
355
|
+
ctx.sections.push(id);
|
|
356
|
+
let inner;
|
|
357
|
+
try {
|
|
358
|
+
inner = compileBody(bodyLines, ctx);
|
|
359
|
+
} finally {
|
|
360
|
+
ctx.sections.pop();
|
|
361
|
+
}
|
|
362
|
+
const idAttr = explicitId ? ` id="${escapeHtml(id)}"` : "";
|
|
363
|
+
const classAttr = extraClass.length ? ` class="${escapeHtml(extraClass.join(" "))}"` : "";
|
|
364
|
+
return `<${tag}${idAttr}${classAttr}>\n${inner}\n</${tag}>`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function handleState(line, ctx) {
|
|
368
|
+
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
369
|
+
if (!match) throw new Error(`Malformed :state in ${ctx.file}: ${line}`);
|
|
370
|
+
const value = parseStateValue(match[2]);
|
|
371
|
+
const key = declareState(match[1], value, ctx);
|
|
372
|
+
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
373
|
+
return `<script type="application/json" data-wd-state${persistAttr}>${safeScriptJson({ [key]: value })}</script>`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function declareState(name, value, ctx) {
|
|
377
|
+
if (ctx.loopItem) throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
378
|
+
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
379
|
+
if (ctx.comp.state.has(key)) throw new Error(`State "${name}" is declared twice in the same scope (${ctx.file})`);
|
|
380
|
+
ctx.comp.state.set(key, value);
|
|
381
|
+
ctx.comp.assets.runtime = true;
|
|
382
|
+
return key;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function handleFetch(line, ctx) {
|
|
386
|
+
const match = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+?)(\s+when=visible)?\s*$/);
|
|
387
|
+
if (!match) {
|
|
388
|
+
throw new Error(`Malformed :fetch in ${ctx.file}: ${line}. Use: :fetch posts from "/api/posts.json" [when=visible]`);
|
|
389
|
+
}
|
|
390
|
+
const key = declareState(match[1], null, ctx);
|
|
391
|
+
declareErrorState(key, ctx);
|
|
392
|
+
const url = stripQuotes(match[2]);
|
|
393
|
+
const when = match[3] ? ` data-wd-fetch-when="visible"` : "";
|
|
394
|
+
return `<span data-wd-fetch data-wd-fetch-key="${key}" data-wd-fetch-url="${escapeHtml(url)}"${when}></span>`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function declareErrorState(key, ctx) {
|
|
398
|
+
const errorKey = `${key}_error`;
|
|
399
|
+
if (!ctx.comp.state.has(errorKey)) ctx.comp.state.set(errorKey, null);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function handleComputed(line, ctx) {
|
|
403
|
+
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
404
|
+
if (!match) throw new Error(`Malformed :computed in ${ctx.file}: ${line}. Use: :computed total = items.length * 4`);
|
|
405
|
+
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
406
|
+
let initial;
|
|
407
|
+
try {
|
|
408
|
+
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
409
|
+
initial = new Function("S", `return (${expr});`)(read);
|
|
410
|
+
} catch {
|
|
411
|
+
initial = null;
|
|
412
|
+
}
|
|
413
|
+
const key = declareState(match[1], initial ?? null, ctx);
|
|
414
|
+
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>`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function compileComputedExpr(raw, ctx) {
|
|
418
|
+
const strings = [];
|
|
419
|
+
let expr = raw.replace(/"[^"\\]*"|'[^'\\]*'/g, (literal) => {
|
|
420
|
+
strings.push(`"${literal.slice(1, -1)}"`);
|
|
421
|
+
return `__WDSTR${strings.length - 1}__`;
|
|
422
|
+
});
|
|
423
|
+
if (/["'\\`]/.test(expr)) {
|
|
424
|
+
throw new Error(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`);
|
|
425
|
+
}
|
|
426
|
+
if (!/^[\w$.\s+\-*/%()<>=!&|]*$/.test(expr)) {
|
|
427
|
+
throw new Error(
|
|
428
|
+
`Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (/(^|[^=!<>])=(?!=)/.test(expr)) {
|
|
432
|
+
throw new Error(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`);
|
|
433
|
+
}
|
|
434
|
+
expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
|
|
435
|
+
if (/^__WDSTR\d+__$/.test(ref)) return ref;
|
|
436
|
+
if (["true", "false", "null"].includes(ref)) return ref;
|
|
437
|
+
const segs = ref.split(".");
|
|
438
|
+
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
439
|
+
throw new Error(`Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`);
|
|
440
|
+
}
|
|
441
|
+
const key = resolveStateKey(segs[0], ctx);
|
|
442
|
+
if (!key) {
|
|
443
|
+
throw new Error(`:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`);
|
|
444
|
+
}
|
|
445
|
+
const rest = segs.slice(1).join(".");
|
|
446
|
+
return `S(${JSON.stringify(key)}${rest ? `,${JSON.stringify(rest)}` : ""})`;
|
|
447
|
+
});
|
|
448
|
+
return expr.replace(/__WDSTR(\d+)__/g, (_, index) => strings[Number(index)]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function handleForm(line, bodyLines, ctx) {
|
|
452
|
+
let rest = line.slice(":form".length).trim();
|
|
453
|
+
const action = rest.match(/action="([^"]+)"/)?.[1];
|
|
454
|
+
const method = rest.match(/method="([^"]+)"/)?.[1] || "post";
|
|
455
|
+
const into = rest.match(/(?:^|\s)into\s+([A-Za-z_$][\w$]*)/)?.[1];
|
|
456
|
+
const leftover = rest
|
|
457
|
+
.replace(/action="[^"]+"/, "")
|
|
458
|
+
.replace(/method="[^"]+"/, "")
|
|
459
|
+
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
460
|
+
.trim();
|
|
461
|
+
if ((!action && !into) || leftover) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
`Malformed :form in ${ctx.file}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
const inner = compileBody(bodyLines, ctx).trim();
|
|
467
|
+
if (!into) {
|
|
468
|
+
return `<form action="${escapeHtml(action)}" method="${escapeHtml(method)}">${inner}</form>`;
|
|
469
|
+
}
|
|
470
|
+
const key = declareState(into, null, ctx);
|
|
471
|
+
declareErrorState(key, ctx);
|
|
472
|
+
const actionAttrs = action ? ` action="${escapeHtml(action)}" method="${escapeHtml(method)}"` : "";
|
|
473
|
+
return `<script type="application/json" data-wd-state>${safeScriptJson({ [key]: null })}</script><form data-wd-form="${key}"${actionAttrs}>${inner}</form>`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function handleInput(line, ctx) {
|
|
477
|
+
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
478
|
+
if (!match) throw new Error(`Malformed :input in ${ctx.file}: ${line}`);
|
|
479
|
+
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
480
|
+
let type = "text";
|
|
481
|
+
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
482
|
+
for (const token of (match[2] || "").matchAll(re)) {
|
|
483
|
+
if (token[3]) {
|
|
484
|
+
if (!["required", "autofocus", "disabled", "readonly"].includes(token[3])) {
|
|
485
|
+
throw new Error(`Unknown :input flag "${token[3]}" in ${ctx.file}`);
|
|
486
|
+
}
|
|
487
|
+
attrs.push(token[3]);
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const value = stripQuotes(token[2]);
|
|
491
|
+
if (token[1] === "type") {
|
|
492
|
+
type = value;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (!["placeholder", "value", "min", "max", "step", "pattern", "autocomplete"].includes(token[1])) {
|
|
496
|
+
throw new Error(`Unknown :input attribute "${token[1]}" in ${ctx.file}`);
|
|
497
|
+
}
|
|
498
|
+
attrs.push(`${token[1]}="${escapeHtml(value)}"`);
|
|
499
|
+
}
|
|
500
|
+
return `<input type="${escapeHtml(type)}" ${attrs.join(" ")}>`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function handleSubmit(line, ctx) {
|
|
504
|
+
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
505
|
+
if (!match) throw new Error(`Malformed :submit in ${ctx.file}: ${line}. Use: :submit "Label"`);
|
|
506
|
+
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function handleButton(line, ctx) {
|
|
510
|
+
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
511
|
+
if (!match) throw new Error(`Malformed :button in ${ctx.file}: ${line}`);
|
|
512
|
+
ctx.comp.assets.runtime = true;
|
|
513
|
+
const action = parseAction(match[2], ctx);
|
|
514
|
+
const valueAttr = action.value === undefined ? "" : ` data-wd-value="${escapeHtml(JSON.stringify(action.value))}"`;
|
|
515
|
+
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function handleIf(line, truthyLines, falsyLines, ctx) {
|
|
519
|
+
const match = line.match(/^:if\s+([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*$/);
|
|
520
|
+
if (!match) throw new Error(`Malformed :if in ${ctx.file}: ${line}. Use ":if name" with a :state or in-scope value.`);
|
|
521
|
+
const segs = match[1].split(".");
|
|
522
|
+
const head = segs[0];
|
|
523
|
+
|
|
524
|
+
const staticValue = lookupVar(ctx.scope, head);
|
|
525
|
+
if (staticValue.found) {
|
|
526
|
+
const active = Boolean(getPath(staticValue.value, segs.slice(1)));
|
|
527
|
+
return compileBody(active ? truthyLines : falsyLines, ctx);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (ctx.loopItem && head === ctx.loopItem) {
|
|
531
|
+
const truthy = compileBody(truthyLines, ctx).trim();
|
|
532
|
+
const falsy = compileBody(falsyLines, ctx).trim();
|
|
533
|
+
if (truthy.includes("data-wd-each-if") || falsy.includes("data-wd-each-if")) {
|
|
534
|
+
throw new Error(`Nested :if over loop items is not supported yet (${ctx.file})`);
|
|
535
|
+
}
|
|
536
|
+
const rest = segs.slice(1).join(".");
|
|
537
|
+
const pathAttr = ` data-wd-path="${escapeHtml(rest)}"`;
|
|
538
|
+
return `<span data-wd-each-if${pathAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const key = resolveStateKey(head, ctx);
|
|
542
|
+
if (!key) {
|
|
543
|
+
throw new Error(`:if ${match[1]} in ${ctx.file} does not match a :state or in-scope value. Declare it first.`);
|
|
544
|
+
}
|
|
545
|
+
ctx.comp.assets.runtime = true;
|
|
546
|
+
const truthy = compileBody(truthyLines, ctx).trim();
|
|
547
|
+
const falsy = compileBody(falsyLines, ctx).trim();
|
|
548
|
+
const restPath = segs.slice(1).join(".");
|
|
549
|
+
const pathAttr = restPath ? ` data-wd-path="${escapeHtml(restPath)}"` : "";
|
|
550
|
+
const initialTruthy = Boolean(getPath(ctx.comp.state.get(key), segs.slice(1)));
|
|
551
|
+
const active = initialTruthy ? truthy : falsy;
|
|
552
|
+
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>`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function handleLoop(line, bodyLines, ctx) {
|
|
556
|
+
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)\s*$/);
|
|
557
|
+
if (!match) throw new Error(`Malformed @loop in ${ctx.file}: ${line}. Use: @loop <things> into <thing>`);
|
|
558
|
+
const source = stripQuotes(match[1].trim());
|
|
559
|
+
const itemName = match[2];
|
|
560
|
+
|
|
561
|
+
if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../") || source.endsWith(".json")) {
|
|
562
|
+
const dataFile = resolveInclude(source, ctx.file, ctx.context, true);
|
|
563
|
+
const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
|
|
564
|
+
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
565
|
+
return staticUnroll(rows, itemName, bodyLines, ctx);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const resolved = lookupPath(source, ctx);
|
|
569
|
+
if (resolved.found) {
|
|
570
|
+
if (!Array.isArray(resolved.value)) {
|
|
571
|
+
throw new Error(`@loop ${source} in ${ctx.file} found an in-scope value, but it is not a list`);
|
|
572
|
+
}
|
|
573
|
+
return staticUnroll(resolved.value, itemName, bodyLines, ctx);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (/^[A-Za-z_$][\w$]*$/.test(source)) {
|
|
577
|
+
const key = resolveStateKey(source, ctx);
|
|
578
|
+
if (key) return reactiveLoop(key, itemName, bodyLines, ctx);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
throw new Error(
|
|
582
|
+
`@loop source "${source}" in ${ctx.file} was not found. Loop over a JSON file (@loop /data.json into row), an in-scope value, or a :state list.`
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function staticUnroll(rows, itemName, bodyLines, ctx) {
|
|
587
|
+
const out = [];
|
|
588
|
+
for (const row of rows) {
|
|
589
|
+
const rowCtx = { ...ctx, scope: createScope(ctx.scope, { [itemName]: row }) };
|
|
590
|
+
out.push(compileBody(bodyLines, rowCtx));
|
|
591
|
+
}
|
|
592
|
+
return out.join("\n");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function reactiveLoop(key, itemName, bodyLines, ctx) {
|
|
596
|
+
ctx.comp.assets.runtime = true;
|
|
597
|
+
const templateCtx = { ...ctx, loopItem: itemName, scope: createScope(ctx.scope) };
|
|
598
|
+
const templateHtml = compileBody(bodyLines, templateCtx).trim();
|
|
599
|
+
|
|
600
|
+
let wrapperTag = "div";
|
|
601
|
+
let itemTemplate = templateHtml;
|
|
602
|
+
const listMatch = templateHtml.match(/^<(ul|ol)>\s*([\s\S]*?)\s*<\/\1>$/);
|
|
603
|
+
if (listMatch && (listMatch[2].match(/<li>/g) || []).length === 1) {
|
|
604
|
+
wrapperTag = listMatch[1];
|
|
605
|
+
itemTemplate = listMatch[2].trim();
|
|
606
|
+
} else {
|
|
607
|
+
itemTemplate = `<div data-wd-loop-piece>${templateHtml}</div>`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const rows = Array.isArray(ctx.comp.state.get(key)) ? ctx.comp.state.get(key) : [];
|
|
611
|
+
const counts = new Map();
|
|
612
|
+
const initial = rows
|
|
613
|
+
.map((item) => {
|
|
614
|
+
const itemKey = loopKeyOf(item, counts);
|
|
615
|
+
return fillTemplateString(withLoopKey(itemTemplate, itemKey), item);
|
|
616
|
+
})
|
|
617
|
+
.join("");
|
|
618
|
+
|
|
619
|
+
return `<div data-wd-loop="${key}"><template data-wd-loop-template>${itemTemplate}</template><${wrapperTag} data-wd-loop-out>${initial}</${wrapperTag}></div>`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function withLoopKey(template, itemKey) {
|
|
623
|
+
return template.replace(/^<([a-zA-Z][a-zA-Z0-9-]*)/, `<$1 data-wd-loop-key="${escapeHtml(itemKey)}"`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function fillTemplateString(template, item) {
|
|
627
|
+
const withConditionals = template.replace(
|
|
628
|
+
/<span data-wd-each-if data-wd-path="([^"]*)"><template data-wd-if-true>([\s\S]*?)<\/template><template data-wd-if-false>([\s\S]*?)<\/template><span data-wd-each-if-out><\/span><\/span>/g,
|
|
629
|
+
(region, p, truthy, falsy) => {
|
|
630
|
+
const branch = getPath(item, p ? p.split(".") : []) ? truthy : falsy;
|
|
631
|
+
return region.replace("<span data-wd-each-if-out></span>", `<span data-wd-each-if-out>${branch}</span>`);
|
|
632
|
+
}
|
|
633
|
+
);
|
|
634
|
+
return withConditionals.replace(/<span data-wd-each(?: data-wd-path="([^"]*)")?><\/span>/g, (_, p) => {
|
|
635
|
+
const value = p ? getPath(item, p.split(".")) : item;
|
|
636
|
+
const pathAttr = p ? ` data-wd-path="${p}"` : "";
|
|
637
|
+
return `<span data-wd-each${pathAttr}>${escapeHtml(value ?? "")}</span>`;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function loopKeyOf(item, counts) {
|
|
642
|
+
const base =
|
|
643
|
+
item && typeof item === "object"
|
|
644
|
+
? String(item.id ?? item.key ?? JSON.stringify(item))
|
|
645
|
+
: String(item);
|
|
646
|
+
const seen = counts.get(base) || 0;
|
|
647
|
+
counts.set(base, seen + 1);
|
|
648
|
+
return seen ? `${base}#${seen}` : base;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function renderDemoDirective(line) {
|
|
652
|
+
const tryMatch = line.match(/^:try\s+"([^"]+)"\s+href="([^"]+)"$/);
|
|
653
|
+
if (tryMatch) return `<a class="try-card" href="${tryMatch[2]}"><span>Try</span>${escapeHtml(tryMatch[1])}</a>`;
|
|
654
|
+
const note = line.match(/^:note\s+"([^"]+)"$/);
|
|
655
|
+
if (note) return `<aside class="note">${escapeHtml(note[1])}</aside>`;
|
|
656
|
+
const sprint = line.match(/^:sprint\s+min=(\d+)\s+max=(\d+)\s+roles="([^"]+)"$/);
|
|
657
|
+
if (sprint) {
|
|
658
|
+
const roles = sprint[3].split(",").map((role) => role.trim()).filter(Boolean);
|
|
659
|
+
return `<section class="sprint-board" data-min="${sprint[1]}" data-max="${sprint[2]}">${roles.map((role) => `<article><strong>${escapeHtml(role)}</strong><span>active lane</span></article>`).join("")}</section>`;
|
|
660
|
+
}
|
|
661
|
+
return "";
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// Interpolation: one syntax, { name } / { name.path }
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
|
|
668
|
+
function renderProse(text, ctx) {
|
|
669
|
+
return md.render(text, { resolveBinding: (expr) => resolveBindingHtml(expr, ctx) });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function bindingPlugin(mdInstance) {
|
|
673
|
+
mdInstance.inline.ruler.push("wd_binding", (state, silent) => {
|
|
674
|
+
if (state.src.charCodeAt(state.pos) !== 0x7b /* { */) return false;
|
|
675
|
+
const match = /^\{\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*)\s*\}/.exec(state.src.slice(state.pos));
|
|
676
|
+
if (!match) return false;
|
|
677
|
+
const resolve = state.env?.resolveBinding;
|
|
678
|
+
const html = resolve ? resolve(match[1]) : null;
|
|
679
|
+
if (typeof html !== "string") return false;
|
|
680
|
+
if (!silent) {
|
|
681
|
+
const token = state.push("html_inline", "", 0);
|
|
682
|
+
token.content = html;
|
|
683
|
+
}
|
|
684
|
+
state.pos += match[0].length;
|
|
685
|
+
return true;
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function resolveBindingHtml(expr, ctx) {
|
|
690
|
+
const segs = expr.split(".");
|
|
691
|
+
const head = segs[0];
|
|
692
|
+
|
|
693
|
+
if (ctx.loopItem && head === ctx.loopItem) {
|
|
694
|
+
const rest = segs.slice(1).join(".");
|
|
695
|
+
return `<span data-wd-each${rest ? ` data-wd-path="${escapeHtml(rest)}"` : ""}></span>`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const staticValue = lookupVar(ctx.scope, head);
|
|
699
|
+
if (staticValue.found) {
|
|
700
|
+
return escapeHtml(getPath(staticValue.value, segs.slice(1)) ?? "");
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const key = resolveStateKey(head, ctx);
|
|
704
|
+
if (key) {
|
|
705
|
+
ctx.comp.assets.runtime = true;
|
|
706
|
+
const initial = getPath(ctx.comp.state.get(key), segs.slice(1));
|
|
707
|
+
const rest = segs.slice(1).join(".");
|
|
708
|
+
const pathAttr = rest ? ` data-wd-path="${escapeHtml(rest)}"` : "";
|
|
709
|
+
return `<span data-wd-bind="${key}"${pathAttr}>${escapeHtml(initial ?? "")}</span>`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function lookupVar(scope, name) {
|
|
716
|
+
for (let current = scope; current; current = current.parent) {
|
|
717
|
+
if (name in current.vars) return { found: true, value: current.vars[name] };
|
|
718
|
+
}
|
|
719
|
+
return { found: false };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function lookupPath(expr, ctx) {
|
|
723
|
+
const segs = expr.split(".");
|
|
724
|
+
const head = lookupVar(ctx.scope, segs[0]);
|
|
725
|
+
if (!head.found) return { found: false };
|
|
726
|
+
return { found: true, value: getPath(head.value, segs.slice(1)) };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function getPath(value, segments) {
|
|
730
|
+
let current = value;
|
|
731
|
+
for (const segment of segments) {
|
|
732
|
+
if (current == null) return undefined;
|
|
733
|
+
if (["constructor", "prototype", "__proto__"].includes(segment)) return undefined;
|
|
734
|
+
current = current[segment];
|
|
735
|
+
}
|
|
736
|
+
return current;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function resolveStateKey(name, ctx) {
|
|
740
|
+
for (let i = ctx.sections.length - 1; i >= 0; i--) {
|
|
741
|
+
const key = `${ctx.sections[i]}:${name}`;
|
|
742
|
+
if (ctx.comp.state.has(key)) return key;
|
|
743
|
+
}
|
|
744
|
+
return ctx.comp.state.has(name) ? name : null;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// Actions
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
function parseAction(raw, ctx) {
|
|
752
|
+
const expression = raw.trim();
|
|
753
|
+
const resolveTarget = (name) => {
|
|
754
|
+
const key = resolveStateKey(name, ctx);
|
|
755
|
+
if (!key) {
|
|
756
|
+
throw new Error(`Button action targets unknown state "${name}" in ${ctx.file}. Declare it first with :state ${name} = ...`);
|
|
757
|
+
}
|
|
758
|
+
return key;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const increment = expression.match(/^([A-Za-z_$][\w$]*)\+\+$/);
|
|
762
|
+
if (increment) return { op: "inc", target: resolveTarget(increment[1]) };
|
|
763
|
+
const decrement = expression.match(/^([A-Za-z_$][\w$]*)--$/);
|
|
764
|
+
if (decrement) return { op: "dec", target: resolveTarget(decrement[1]) };
|
|
765
|
+
const add = expression.match(/^([A-Za-z_$][\w$]*)\s*\+=\s*(.+)$/);
|
|
766
|
+
if (add) {
|
|
767
|
+
const target = resolveTarget(add[1]);
|
|
768
|
+
const value = parseActionLiteral(add[2]);
|
|
769
|
+
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
770
|
+
if (typeof value === "number") return { op: "add", target, value };
|
|
771
|
+
throw new Error(`Unsupported button action "${raw}". += with non-number values requires a list state target.`);
|
|
772
|
+
}
|
|
773
|
+
const assign = expression.match(/^([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
774
|
+
if (assign) return { op: "set", target: resolveTarget(assign[1]), value: parseActionLiteral(assign[2]) };
|
|
775
|
+
throw new Error(`Unsupported button action "${raw}". Supported actions: count++, count--, count += 1, items += "value", name = "value".`);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function parseActionLiteral(raw) {
|
|
779
|
+
const value = raw.trim();
|
|
780
|
+
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
781
|
+
if (value === "true") return true;
|
|
782
|
+
if (value === "false") return false;
|
|
783
|
+
if (value === "null") return null;
|
|
784
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
785
|
+
if (/^[\[{]/.test(value)) {
|
|
786
|
+
try {
|
|
787
|
+
return JSON.parse(value);
|
|
788
|
+
} catch {
|
|
789
|
+
throw new Error(`Unsupported action literal "${raw}". Use a quoted string, number, boolean, null, or valid JSON.`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
throw new Error(`Unsupported action literal "${raw}". Use a quoted string, number, boolean, null, or valid JSON.`);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
// Includes / assets
|
|
797
|
+
// ---------------------------------------------------------------------------
|
|
798
|
+
|
|
799
|
+
function collectColocatedAssets(file, context, assets) {
|
|
800
|
+
const ext = path.extname(file);
|
|
801
|
+
const stem = file.slice(0, -ext.length);
|
|
802
|
+
for (const [assetExt, folder] of [[".skin", "styles"], [".js", "scripts"]]) {
|
|
803
|
+
const candidate = `${stem}${assetExt}`;
|
|
804
|
+
if (!fs.existsSync(candidate)) continue;
|
|
805
|
+
const rel = path.relative(context.cwd, candidate).replaceAll(path.sep, "/");
|
|
806
|
+
const outputExt = assetExt === ".skin" ? ".css" : ".js";
|
|
807
|
+
const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
|
|
808
|
+
assets.files.set(candidate, publicPath);
|
|
809
|
+
if (assetExt === ".skin") assets.skins.add(publicPath);
|
|
810
|
+
if (assetExt === ".js") assets.scripts.add(publicPath);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
815
|
+
const clean = stripQuotes(spec);
|
|
816
|
+
const candidates = [];
|
|
817
|
+
if (clean.startsWith("/")) {
|
|
818
|
+
candidates.push(path.join(context.shelfRoot, clean.slice(1)));
|
|
819
|
+
} else {
|
|
820
|
+
candidates.push(path.resolve(path.dirname(fromFile), clean));
|
|
821
|
+
candidates.push(path.join(context.shelfRoot, clean));
|
|
822
|
+
}
|
|
823
|
+
for (const candidate of candidates) {
|
|
824
|
+
const resolved = path.resolve(candidate);
|
|
825
|
+
if (!isAllowedInclude(resolved, context)) {
|
|
826
|
+
throw new Error(`Include "${spec}" from ${fromFile} resolves outside site/pages or site/_`);
|
|
827
|
+
}
|
|
828
|
+
if (!fs.existsSync(resolved)) continue;
|
|
829
|
+
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
830
|
+
return resolved;
|
|
831
|
+
}
|
|
832
|
+
throw new Error(`Could not resolve include "${spec}" from ${fromFile}`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function isAllowedInclude(file, context) {
|
|
836
|
+
const roots = [context.routesRoot, context.shelfRoot].map((root) => path.resolve(root));
|
|
837
|
+
return roots.some((root) => file === root || file.startsWith(`${root}${path.sep}`));
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ---------------------------------------------------------------------------
|
|
841
|
+
// Plain .md hints
|
|
842
|
+
// ---------------------------------------------------------------------------
|
|
843
|
+
|
|
844
|
+
function scanMarkdownHints(body, file, comp) {
|
|
845
|
+
let fence = null;
|
|
846
|
+
for (const line of body.split("\n")) {
|
|
847
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
848
|
+
if (fence) {
|
|
849
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
if (fenceMatch) {
|
|
853
|
+
fence = fenceMatch[1];
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const hit = line.match(/^(@include|@loop|@repeat|:state|:button|:if|:for|:try|:note|:sprint|:::)(\s|$)/);
|
|
857
|
+
if (hit) {
|
|
858
|
+
comp.warnings.push(
|
|
859
|
+
`${file}: "${hit[1]}" is .wd syntax and stays plain text in .md — rename the file to .wd to activate it.`
|
|
860
|
+
);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// Literals
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
function safeScriptJson(value) {
|
|
871
|
+
return JSON.stringify(value).replaceAll("<", "\\u003c");
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function parseStateValue(raw) {
|
|
875
|
+
const value = raw.trim();
|
|
876
|
+
if (/^["'].*["']$/.test(value)) return stripQuotes(value);
|
|
877
|
+
if (value === "true") return true;
|
|
878
|
+
if (value === "false") return false;
|
|
879
|
+
if (value === "null") return null;
|
|
880
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) return Number(value);
|
|
881
|
+
try {
|
|
882
|
+
return JSON.parse(value);
|
|
883
|
+
} catch {
|
|
884
|
+
return value;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function parseScalar(raw) {
|
|
889
|
+
const trimmed = raw.trim();
|
|
890
|
+
if (/^["']/.test(trimmed)) return stripQuotes(trimmed);
|
|
891
|
+
if (trimmed === "true") return true;
|
|
892
|
+
if (trimmed === "false") return false;
|
|
893
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
894
|
+
return trimmed;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function stripQuotes(value) {
|
|
898
|
+
return value?.replace(/^["']|["']$/g, "") ?? "";
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export function escapeHtml(value) {
|
|
902
|
+
return String(value)
|
|
903
|
+
.replaceAll("&", "&")
|
|
904
|
+
.replaceAll("<", "<")
|
|
905
|
+
.replaceAll(">", ">")
|
|
906
|
+
.replaceAll('"', """);
|
|
907
|
+
}
|