@zvndev/darkmown 0.19.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +115 -5
- package/docs/spec-alignment.md +16 -3
- package/package.json +22 -4
- package/site/_/base.skin +372 -0
- package/site/_/base.wd +4 -0
- package/site/_/footer.wd +14 -0
- package/site/_/metrics.json +13 -0
- package/site/_/nav.skin +35 -12
- package/site/_/nav.wd +8 -10
- package/site/_/products.json +10 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/carousel/index.js +82 -0
- package/site/pages/carousel/index.skin +185 -0
- package/site/pages/carousel/index.wd +109 -0
- package/site/pages/carousel/slide-1.jpg +0 -0
- package/site/pages/carousel/slide-2.jpg +0 -0
- package/site/pages/carousel/slide-3.jpg +0 -0
- package/site/pages/carousel/slide-4.jpg +0 -0
- package/site/pages/carousel/slide-5.jpg +0 -0
- package/site/pages/compass/index.skin +247 -0
- package/site/pages/compass/index.wd +194 -0
- package/site/pages/docs/index.skin +48 -28
- package/site/pages/docs/index.wd +71 -5
- package/site/pages/folio/-style.skin +375 -0
- package/site/pages/folio/-style.wd +6 -0
- package/site/pages/folio/cart.skin +309 -0
- package/site/pages/folio/cart.wd +201 -0
- package/site/pages/folio/index.skin +24 -0
- package/site/pages/folio/index.wd +167 -0
- package/site/pages/forge/index.skin +676 -0
- package/site/pages/forge/index.wd +330 -0
- package/site/pages/index.skin +135 -144
- package/site/pages/index.wd +17 -5
- package/site/pages/ledger/index.skin +257 -0
- package/site/pages/ledger/index.wd +174 -0
- package/site/pages/media/clip-poster.jpg +0 -0
- package/site/pages/media/clip.mp4 +0 -0
- package/site/pages/media/index.skin +82 -0
- package/site/pages/media/index.wd +84 -0
- package/site/pages/media/tone.mp3 +0 -0
- package/site/pages/pulse/index.skin +434 -0
- package/site/pages/pulse/index.wd +178 -0
- package/site/pages/showcase/index.skin +130 -0
- package/site/pages/showcase/index.wd +168 -0
- package/src/builder.js +20 -14
- package/src/cli.js +216 -77
- package/src/compiler/body.js +457 -0
- package/src/compiler/context.js +160 -0
- package/src/compiler/directives.js +1345 -0
- package/src/compiler/format.js +244 -0
- package/src/compiler/frontmatter.js +117 -0
- package/src/compiler/includes.js +116 -0
- package/src/compiler/index.js +22 -0
- package/src/compiler/interpolation.js +223 -0
- package/src/compiler/loops.js +715 -0
- package/src/compiler/markdown.js +242 -0
- package/src/compiler/page.js +267 -0
- package/src/compiler/predicates.js +314 -0
- package/src/compiler.js +26 -2932
- package/src/dev.js +3 -1
- package/src/headers.js +8 -4
- package/src/index.js +22 -0
- package/src/router.js +3 -1
- package/src/runtime.js +168 -14
- package/src/scaffold.js +158 -131
- package/src/skin.js +11 -2
- package/site/_/og.png +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Block parser: directives + prose segments. `compileBody` walks a `.wd` body
|
|
3
|
+
// line-by-line, honoring fenced code, dispatching each directive opener to its
|
|
4
|
+
// handler (passing the 0-based line index so malformed errors report file:line)
|
|
5
|
+
// and flushing intervening prose through the markdown layer. The block scanners
|
|
6
|
+
// (`scanBlock`/`scanContainer`/`scanConditional`/`splitEmptyBranch`) capture
|
|
7
|
+
// multi-line directive bodies with nesting + fence awareness.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import { at } from "./context.js";
|
|
11
|
+
import {
|
|
12
|
+
handleBind,
|
|
13
|
+
handleButton,
|
|
14
|
+
handleChoiceGroup,
|
|
15
|
+
handleComputed,
|
|
16
|
+
handleContainer,
|
|
17
|
+
handleEffect,
|
|
18
|
+
handleEmbed,
|
|
19
|
+
handleEvery,
|
|
20
|
+
handleFetch,
|
|
21
|
+
handleForm,
|
|
22
|
+
handleIf,
|
|
23
|
+
handleInclude,
|
|
24
|
+
handleInput,
|
|
25
|
+
handleMedia,
|
|
26
|
+
handleSelect,
|
|
27
|
+
handleState,
|
|
28
|
+
handleStore,
|
|
29
|
+
handleSubmit,
|
|
30
|
+
handleTextarea,
|
|
31
|
+
handleTheme,
|
|
32
|
+
renderDemoDirective
|
|
33
|
+
} from "./directives.js";
|
|
34
|
+
import { handleLoop } from "./loops.js";
|
|
35
|
+
import { renderProse } from "./markdown.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @typedef {import("./context.js").Ctx} Ctx
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a `.wd` body line-by-line into HTML, mixing directives and prose.
|
|
43
|
+
* @param {string[]} lines
|
|
44
|
+
* @param {Ctx} ctx
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
export function compileBody(lines, ctx) {
|
|
48
|
+
/** @type {string[]} */
|
|
49
|
+
const out = [];
|
|
50
|
+
/** @type {string[]} */
|
|
51
|
+
let prose = [];
|
|
52
|
+
let fence = null;
|
|
53
|
+
|
|
54
|
+
const flush = () => {
|
|
55
|
+
if (!prose.length) return;
|
|
56
|
+
const text = prose.join("\n");
|
|
57
|
+
prose = [];
|
|
58
|
+
if (text.trim()) out.push(renderProse(text, ctx));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
64
|
+
if (fence) {
|
|
65
|
+
prose.push(line);
|
|
66
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
67
|
+
fence = null;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (fenceMatch) {
|
|
71
|
+
fence = fenceMatch[1];
|
|
72
|
+
prose.push(line);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (/^@include\s/.test(line)) {
|
|
77
|
+
flush();
|
|
78
|
+
out.push(handleInclude(line, ctx, i));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (/^@loop\s/.test(line)) {
|
|
82
|
+
flush();
|
|
83
|
+
const block = scanBlock(lines, i, /^@loop\s/, "@endloop", ctx.file);
|
|
84
|
+
const split = splitEmptyBranch(block.body);
|
|
85
|
+
out.push(handleLoop(line, split.body, split.empty, ctx));
|
|
86
|
+
i = block.end;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const container = line.match(/^:::\s*(.*)$/);
|
|
90
|
+
if (container) {
|
|
91
|
+
flush();
|
|
92
|
+
if (!container[1].trim())
|
|
93
|
+
throw new Error(`Stray ::: close with no open container in ${ctx.file}`);
|
|
94
|
+
const block = scanContainer(lines, i, ctx.file);
|
|
95
|
+
out.push(handleContainer(container[1].trim(), block.body, ctx, i));
|
|
96
|
+
i = block.end;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (/^:state\s/.test(line)) {
|
|
100
|
+
flush();
|
|
101
|
+
out.push(handleState(line, ctx, i));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (/^:store\s/.test(line)) {
|
|
105
|
+
flush();
|
|
106
|
+
out.push(handleStore(line, ctx, i));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (/^:fetch\s/.test(line)) {
|
|
110
|
+
flush();
|
|
111
|
+
out.push(handleFetch(line, ctx, i));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (/^:computed\s/.test(line)) {
|
|
115
|
+
flush();
|
|
116
|
+
out.push(handleComputed(line, ctx, i));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (/^:effect\s/.test(line)) {
|
|
120
|
+
flush();
|
|
121
|
+
out.push(handleEffect(line, ctx, i));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (/^:every\s/.test(line)) {
|
|
125
|
+
flush();
|
|
126
|
+
out.push(handleEvery(line, ctx, i));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (/^:theme(?:\s|$)/.test(line)) {
|
|
130
|
+
flush();
|
|
131
|
+
out.push(handleTheme(line, ctx, i));
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const media = line.match(/^:(video|audio)\s/);
|
|
135
|
+
if (media) {
|
|
136
|
+
flush();
|
|
137
|
+
out.push(handleMedia(line, /** @type {"video" | "audio"} */ (media[1]), ctx, i));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (/^:embed\s/.test(line)) {
|
|
141
|
+
flush();
|
|
142
|
+
out.push(handleEmbed(line, ctx, i));
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (/^:form\s/.test(line)) {
|
|
146
|
+
flush();
|
|
147
|
+
const block = scanBlock(lines, i, /^:form\s/, ":endform", ctx.file);
|
|
148
|
+
out.push(handleForm(line, block.body, ctx, i));
|
|
149
|
+
i = block.end;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (/^:input\s/.test(line)) {
|
|
153
|
+
flush();
|
|
154
|
+
out.push(handleInput(line, ctx, i));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (/^:textarea\s/.test(line)) {
|
|
158
|
+
flush();
|
|
159
|
+
out.push(handleTextarea(line, ctx, i));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (/^:select\s/.test(line)) {
|
|
163
|
+
flush();
|
|
164
|
+
const opts = [];
|
|
165
|
+
let j = i + 1;
|
|
166
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) {
|
|
167
|
+
opts.push(lines[j]);
|
|
168
|
+
j++;
|
|
169
|
+
}
|
|
170
|
+
out.push(handleSelect(line, opts, ctx, i));
|
|
171
|
+
i = j - 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const choiceMatch = line.match(/^:(checkbox|radio)\s/);
|
|
175
|
+
if (choiceMatch) {
|
|
176
|
+
flush();
|
|
177
|
+
const opts = [];
|
|
178
|
+
let j = i + 1;
|
|
179
|
+
while (j < lines.length && /^\s*-\s+/.test(lines[j])) {
|
|
180
|
+
opts.push(lines[j]);
|
|
181
|
+
j++;
|
|
182
|
+
}
|
|
183
|
+
out.push(
|
|
184
|
+
handleChoiceGroup(line, opts, ctx, /** @type {"checkbox" | "radio"} */ (choiceMatch[1]), i)
|
|
185
|
+
);
|
|
186
|
+
i = j - 1;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (/^:bind\s/.test(line)) {
|
|
190
|
+
flush();
|
|
191
|
+
out.push(handleBind(line, ctx, i));
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (/^:submit\s/.test(line)) {
|
|
195
|
+
flush();
|
|
196
|
+
out.push(handleSubmit(line, ctx, i));
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (/^:button\s/.test(line)) {
|
|
200
|
+
flush();
|
|
201
|
+
out.push(handleButton(line, ctx, i));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (/^:if\s/.test(line)) {
|
|
205
|
+
flush();
|
|
206
|
+
const block = scanConditional(lines, i, ctx.file);
|
|
207
|
+
out.push(handleIf(line, block.truthy, block.falsy, ctx, i));
|
|
208
|
+
i = block.end;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const demo = renderDemoDirective(line, ctx);
|
|
212
|
+
if (demo) {
|
|
213
|
+
flush();
|
|
214
|
+
out.push(demo);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (/^@repeat\b/.test(line)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
if (/^:for\b/.test(line)) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
|
|
228
|
+
throw new Error(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`);
|
|
229
|
+
}
|
|
230
|
+
warnUnknownDirective(line, ctx);
|
|
231
|
+
prose.push(line);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
flush();
|
|
235
|
+
return out.join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Lines that reached prose but LOOK like a directive (a `@word`/`:word` token at
|
|
239
|
+
// the very start, followed by a space or end of line) and match no handler are
|
|
240
|
+
// almost always a typo — every real directive was intercepted above. Warn, never
|
|
241
|
+
// throw: prose legitimately contains `@` and `:`, so the match is deliberately
|
|
242
|
+
// narrow (lowercase token + boundary; emoji shortcodes, times, and `@user`
|
|
243
|
+
// followed by punctuation do not match).
|
|
244
|
+
const KNOWN_DIRECTIVE =
|
|
245
|
+
/^(?:@(?:include|loop|empty|endloop)|:(?:state|store|fetch|computed|effect|every|theme|video|audio|embed|form|endform|input|textarea|select|checkbox|radio|bind|submit|button|if|endif|else|try|note|sprint))(?:\s|$)/;
|
|
246
|
+
/**
|
|
247
|
+
* @param {string} line
|
|
248
|
+
* @param {Ctx} ctx
|
|
249
|
+
* @returns {void}
|
|
250
|
+
*/
|
|
251
|
+
function warnUnknownDirective(line, ctx) {
|
|
252
|
+
// Catch capitalized (`:State`) and hyphenated (`:end-loop`) typos too — the
|
|
253
|
+
// known-directive whitelist stays case-sensitive, so a mis-cased real
|
|
254
|
+
// directive correctly trips the warning. Still unindented-only, to keep prose
|
|
255
|
+
// that legitimately starts lines with `@`/`:` from false-positiving.
|
|
256
|
+
const m = line.match(/^([@:][A-Za-z][A-Za-z-]*)(?:\s|$)/);
|
|
257
|
+
if (m && !KNOWN_DIRECTIVE.test(line)) {
|
|
258
|
+
ctx.comp.warnings.push(
|
|
259
|
+
`${ctx.file}: "${m[1]}" looks like a directive but matches none — it will render as literal text. Check the spelling (e.g. @loop, @include, :state, :if).`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Capture lines from an opener until a matching close token, honoring nesting and fences.
|
|
266
|
+
* @param {string[]} lines
|
|
267
|
+
* @param {number} start Index of the opening line.
|
|
268
|
+
* @param {RegExp} openRe Pattern that re-opens a nested block.
|
|
269
|
+
* @param {string} endToken Literal closing token (e.g. `@endloop`).
|
|
270
|
+
* @param {string} file Source path for errors.
|
|
271
|
+
* @returns {{ body: string[], end: number }}
|
|
272
|
+
*/
|
|
273
|
+
function scanBlock(lines, start, openRe, endToken, file) {
|
|
274
|
+
/** @type {string[]} */
|
|
275
|
+
const body = [];
|
|
276
|
+
let depth = 0;
|
|
277
|
+
let fence = null;
|
|
278
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
279
|
+
const line = lines[i];
|
|
280
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
281
|
+
if (fence) {
|
|
282
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
283
|
+
fence = null;
|
|
284
|
+
body.push(line);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (fenceMatch) {
|
|
288
|
+
fence = fenceMatch[1];
|
|
289
|
+
body.push(line);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (openRe.test(line)) depth++;
|
|
293
|
+
if (line.trim() === endToken) {
|
|
294
|
+
if (depth === 0) return { body, end: i };
|
|
295
|
+
depth--;
|
|
296
|
+
}
|
|
297
|
+
body.push(line);
|
|
298
|
+
}
|
|
299
|
+
throw new Error(`Missing ${endToken} for "${lines[start]}" in ${at(file, start)}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Split a `@loop` body at a top-level `@empty` into the rows body and the empty
|
|
304
|
+
* branch. Honors nested `@loop … @endloop` (so an inner loop's `@empty` is not
|
|
305
|
+
* mistaken for the outer one) and fenced code.
|
|
306
|
+
* @param {string[]} body
|
|
307
|
+
* @returns {{ body: string[], empty: string[] | null }}
|
|
308
|
+
*/
|
|
309
|
+
function splitEmptyBranch(body) {
|
|
310
|
+
/** @type {string[]} */
|
|
311
|
+
const rows = [];
|
|
312
|
+
/** @type {string[] | null} */
|
|
313
|
+
let empty = null;
|
|
314
|
+
let target = rows;
|
|
315
|
+
let depth = 0;
|
|
316
|
+
let fence = null;
|
|
317
|
+
for (const line of body) {
|
|
318
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
319
|
+
if (fence) {
|
|
320
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
321
|
+
fence = null;
|
|
322
|
+
target.push(line);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (fenceMatch) {
|
|
326
|
+
fence = fenceMatch[1];
|
|
327
|
+
target.push(line);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (/^@loop\s/.test(line)) depth++;
|
|
331
|
+
if (line.trim() === "@endloop") depth--;
|
|
332
|
+
if (depth === 0 && line.trim() === "@empty") {
|
|
333
|
+
empty = [];
|
|
334
|
+
target = empty;
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
target.push(line);
|
|
338
|
+
}
|
|
339
|
+
return { body: rows, empty };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Capture the body of a `:::` container up to its matching close, honoring nesting.
|
|
344
|
+
* @param {string[]} lines
|
|
345
|
+
* @param {number} start Index of the opening line.
|
|
346
|
+
* @param {string} file Source path for errors.
|
|
347
|
+
* @returns {{ body: string[], end: number }}
|
|
348
|
+
*/
|
|
349
|
+
function scanContainer(lines, start, file) {
|
|
350
|
+
/** @type {string[]} */
|
|
351
|
+
const body = [];
|
|
352
|
+
let depth = 0;
|
|
353
|
+
let fence = null;
|
|
354
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
355
|
+
const line = lines[i];
|
|
356
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
357
|
+
if (fence) {
|
|
358
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
359
|
+
fence = null;
|
|
360
|
+
body.push(line);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (fenceMatch) {
|
|
364
|
+
fence = fenceMatch[1];
|
|
365
|
+
body.push(line);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const marker = line.match(/^:::\s*(.*)$/);
|
|
369
|
+
if (marker) {
|
|
370
|
+
if (marker[1].trim()) {
|
|
371
|
+
depth++;
|
|
372
|
+
} else if (depth === 0) {
|
|
373
|
+
return { body, end: i };
|
|
374
|
+
} else {
|
|
375
|
+
depth--;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
body.push(line);
|
|
379
|
+
}
|
|
380
|
+
throw new Error(`Missing closing ::: for "${lines[start]}" in ${at(file, start)}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Split an `:if … :else if … :else … :endif` chain into the first condition's
|
|
385
|
+
* truthy body and a falsy body. A bare `:else` works as before. The first
|
|
386
|
+
* `:else if B` is **desugared** into a nested `:if` that lives in the falsy
|
|
387
|
+
* branch: the falsy body becomes `[":if B", …rest…, ":endif"]`, which re-enters
|
|
388
|
+
* `compileBody`/`handleIf` recursively. A whole `:if/:else if/:else` chain
|
|
389
|
+
* therefore compiles to nested `data-wd-if` regions the runtime already drives —
|
|
390
|
+
* identical behavior for static, reactive, and loop-row conditionals.
|
|
391
|
+
* @param {string[]} lines
|
|
392
|
+
* @param {number} start Index of the `:if` line.
|
|
393
|
+
* @param {string} file Source path for errors.
|
|
394
|
+
* @returns {{ truthy: string[], falsy: string[], end: number }}
|
|
395
|
+
*/
|
|
396
|
+
function scanConditional(lines, start, file) {
|
|
397
|
+
/** @type {string[]} */
|
|
398
|
+
const truthy = [];
|
|
399
|
+
/** @type {string[]} */
|
|
400
|
+
const falsy = [];
|
|
401
|
+
let current = truthy;
|
|
402
|
+
// "truthy": collecting the :if body. "else": a bare :else closed the chain —
|
|
403
|
+
// no further branches allowed. "elseif": the first :else if was desugared into
|
|
404
|
+
// a nested :if inside `falsy`; subsequent depth-0 :else if/:else lines belong to
|
|
405
|
+
// that nested chain and are emitted verbatim for the recursive compile to split.
|
|
406
|
+
let mode = "truthy";
|
|
407
|
+
let depth = 0;
|
|
408
|
+
let fence = null;
|
|
409
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
410
|
+
const line = lines[i];
|
|
411
|
+
const fenceMatch = line.match(/^(```+|~~~+)/);
|
|
412
|
+
if (fence) {
|
|
413
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
|
|
414
|
+
fence = null;
|
|
415
|
+
current.push(line);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
if (fenceMatch) {
|
|
419
|
+
fence = fenceMatch[1];
|
|
420
|
+
current.push(line);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
if (/^:if\s/.test(line)) depth++;
|
|
424
|
+
if (line.trim() === ":endif") {
|
|
425
|
+
if (depth === 0) {
|
|
426
|
+
if (mode === "elseif") falsy.push(":endif"); // close the synthesized nested :if
|
|
427
|
+
return { truthy, falsy, end: i };
|
|
428
|
+
}
|
|
429
|
+
depth--;
|
|
430
|
+
}
|
|
431
|
+
if (depth === 0 && mode !== "elseif") {
|
|
432
|
+
const t = line.trim();
|
|
433
|
+
const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
|
|
434
|
+
if (elseIf) {
|
|
435
|
+
if (mode === "else")
|
|
436
|
+
throw new Error(
|
|
437
|
+
`":else if" after ":else" in ${file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`
|
|
438
|
+
);
|
|
439
|
+
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
440
|
+
current = falsy;
|
|
441
|
+
mode = "elseif";
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (t === ":else") {
|
|
445
|
+
if (mode === "else")
|
|
446
|
+
throw new Error(
|
|
447
|
+
`Duplicate ":else" in ${file}. A conditional may have only one bare ":else".`
|
|
448
|
+
);
|
|
449
|
+
current = falsy;
|
|
450
|
+
mode = "else";
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
current.push(line);
|
|
455
|
+
}
|
|
456
|
+
throw new Error(`Missing :endif for "${lines[start]}" in ${at(file, start)}`);
|
|
457
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared types + tiny helpers threaded through every compiler module.
|
|
3
|
+
//
|
|
4
|
+
// The compiler is split along its natural seams (frontmatter, body parser,
|
|
5
|
+
// directive handlers, loop pipeline, predicates, interpolation, includes,
|
|
6
|
+
// markdown, page shell). This module holds the typedefs and the small helpers
|
|
7
|
+
// that the rest of the modules lean on, so the dependency graph stays a clean
|
|
8
|
+
// DAG with this file at the root (it imports nothing of its own).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {import("../config.js").Paths} Paths
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Frontmatter values are scalars or inline arrays of scalars.
|
|
17
|
+
* @typedef {string | number | boolean | null | Array<string | number | boolean | null>} FrontmatterValue
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parsed page frontmatter, keyed by field name.
|
|
22
|
+
* @typedef {Record<string, FrontmatterValue>} Meta
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Collected output assets for a compiled document.
|
|
27
|
+
* @typedef {object} Assets
|
|
28
|
+
* @property {Set<string>} skins Public hrefs of compiled skin stylesheets.
|
|
29
|
+
* @property {Set<string>} scripts Public hrefs of colocated page scripts.
|
|
30
|
+
* @property {Map<string, string>} files Source path → public href for emitted assets.
|
|
31
|
+
* @property {boolean} runtime Whether the reactive runtime is required.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Per-document compilation accumulator shared across includes/sections.
|
|
36
|
+
* @typedef {object} Compilation
|
|
37
|
+
* @property {Assets} assets
|
|
38
|
+
* @property {Map<string, unknown>} state Declared state keys → initial values.
|
|
39
|
+
* @property {Set<string>} stores Page-global store names (a subset of state keys).
|
|
40
|
+
* @property {string[]} warnings Non-fatal authoring hints.
|
|
41
|
+
* @property {number} sectionCounter Counter for auto-generated section ids.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A lexical scope chain for static interpolation values (include args, loop vars).
|
|
46
|
+
* @typedef {object} Scope
|
|
47
|
+
* @property {Scope | null} parent
|
|
48
|
+
* @property {Record<string, unknown>} vars
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per-file compile context threaded through the directive handlers.
|
|
53
|
+
* @typedef {object} Ctx
|
|
54
|
+
* @property {string} file Absolute path to the file being compiled.
|
|
55
|
+
* @property {Paths} context Resolved project paths.
|
|
56
|
+
* @property {string[]} stack Include stack (real paths) for cycle detection.
|
|
57
|
+
* @property {Scope} scope Static value scope chain.
|
|
58
|
+
* @property {Compilation} comp Shared compilation accumulator.
|
|
59
|
+
* @property {string[]} sections Active section-id scope chain.
|
|
60
|
+
* @property {string | null} loopItem Name of the current reactive loop item, if any.
|
|
61
|
+
* @property {string} [loopKey] State key of the list being looped, if any.
|
|
62
|
+
* @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
|
|
63
|
+
* @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A compiled page document.
|
|
68
|
+
* @typedef {object} CompiledDocument
|
|
69
|
+
* @property {Meta} meta
|
|
70
|
+
* @property {string} html
|
|
71
|
+
* @property {Assets} assets
|
|
72
|
+
* @property {string[]} warnings
|
|
73
|
+
*/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A compiled page: the document plus its full HTML shell.
|
|
77
|
+
* @typedef {object} CompiledPage
|
|
78
|
+
* @property {Meta} meta
|
|
79
|
+
* @property {string} html
|
|
80
|
+
* @property {Assets} assets
|
|
81
|
+
* @property {string[]} warnings
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A compiled `@loop … where` predicate.
|
|
86
|
+
* @typedef {object} Predicate
|
|
87
|
+
* @property {string} body Safe JS boolean expression over `I()`/`S()`/`C()`.
|
|
88
|
+
* @property {boolean} refsState Whether the predicate reads declared state.
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A loop offset/limit argument: a non-negative integer literal or a state key.
|
|
93
|
+
* @typedef {{ kind: "literal", value: number } | { kind: "key", value: string }} NumArg
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Loop clause configuration shared by the static and reactive paths.
|
|
98
|
+
* @typedef {object} LoopOpts
|
|
99
|
+
* @property {Predicate | null} where
|
|
100
|
+
* @property {{ key: string, keyKind: "literal" | "key", dir: string, dirKind: "literal" | "key" } | null} sort A literal item field + asc/desc, or a `:state`/`:store` key for either (reactive, clickable-header sort).
|
|
101
|
+
* @property {boolean} reverse
|
|
102
|
+
* @property {NumArg | null} offset
|
|
103
|
+
* @property {NumArg | null} limit
|
|
104
|
+
* @property {string[] | null} empty Empty-branch body lines, if any.
|
|
105
|
+
* @property {boolean} clauseRefsState Whether offset/limit reference state.
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A parsed `:button` action.
|
|
110
|
+
* @typedef {object} Action
|
|
111
|
+
* @property {string} op Runtime operation (inc/dec/add/sub/append/prepend/set/toggle/member-toggle/remove/remove-value/clear/merge/delete/reset/append-row).
|
|
112
|
+
* @property {string} target State key (possibly dotted) the action mutates.
|
|
113
|
+
* @property {unknown} [value] Literal value for value-carrying ops.
|
|
114
|
+
*/
|
|
115
|
+
|
|
116
|
+
// Page-include extensions are the only two file types `@include` will pull in.
|
|
117
|
+
export const pageIncludeExtensions = [".md", ".wd"];
|
|
118
|
+
|
|
119
|
+
// Reserved per-row meta variables, mapped to their runtime marker token. Shared
|
|
120
|
+
// by the body parser, the `:if` handler, and prose interpolation.
|
|
121
|
+
/** @type {Record<string, string>} */
|
|
122
|
+
export const LOOP_META = {
|
|
123
|
+
$index: "index",
|
|
124
|
+
$number: "number",
|
|
125
|
+
$first: "first",
|
|
126
|
+
$last: "last",
|
|
127
|
+
$count: "count"
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** @returns {Compilation} */
|
|
131
|
+
export function createCompilation() {
|
|
132
|
+
return {
|
|
133
|
+
assets: { skins: new Set(), scripts: new Set(), files: new Map(), runtime: false },
|
|
134
|
+
state: new Map(),
|
|
135
|
+
stores: new Set(),
|
|
136
|
+
warnings: [],
|
|
137
|
+
sectionCounter: 0
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {Scope | null} parent
|
|
143
|
+
* @param {Record<string, unknown>} [vars]
|
|
144
|
+
* @returns {Scope}
|
|
145
|
+
*/
|
|
146
|
+
export function createScope(parent, vars = {}) {
|
|
147
|
+
return { parent, vars: { ...vars } };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Format a source location as `file:line` (1-based) for compile errors. Keeps the
|
|
152
|
+
* file path intact so existing message matchers still pass, while pointing at the
|
|
153
|
+
* directive's (or unclosed opener's) line.
|
|
154
|
+
* @param {string} file
|
|
155
|
+
* @param {number} index 0-based line index.
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
export function at(file, index) {
|
|
159
|
+
return `${file}:${index + 1}`;
|
|
160
|
+
}
|