@zvndev/darkmown 2.1.0 → 2.3.1-next.1.g3a4a8dc
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 +22 -0
- package/README.md +65 -0
- package/docs/cli.md +8 -0
- package/docs/spec-alignment.md +18 -0
- package/grammar/wd-directives.gbnf +58 -0
- package/package.json +17 -10
- package/site/pages/docs/index.wd +29 -0
- package/site/pages/playground/index.js +47 -46
- package/src/builder.js +5 -0
- package/src/catalog.js +601 -0
- package/src/cli.js +8 -0
- package/src/compiler/actions.js +87 -34
- package/src/compiler/body.js +36 -14
- package/src/compiler/collections.js +16 -10
- package/src/compiler/context.js +70 -1
- package/src/compiler/fetch.js +51 -19
- package/src/compiler/format.js +5 -2
- package/src/compiler/forms.js +169 -50
- package/src/compiler/frontmatter.js +11 -2
- package/src/compiler/fs-reader.js +2 -1
- package/src/compiler/image-size.js +340 -0
- package/src/compiler/includes.js +8 -4
- package/src/compiler/interpolation.js +15 -7
- package/src/compiler/loops.js +78 -39
- package/src/compiler/markdown.js +4 -3
- package/src/compiler/media.js +38 -10
- package/src/compiler/page.js +8 -6
- package/src/compiler/predicates.js +74 -30
- package/src/compiler/reader.js +12 -2
- package/src/compiler/state.js +83 -18
- package/src/compiler/structure.js +57 -24
- package/src/deploy.js +8 -4
- package/src/errors.js +1156 -0
- package/src/grammar.js +131 -0
- package/src/index.js +1 -0
- package/src/router.js +7 -4
- package/src/scaffold.js +4 -2
- package/src/skin.js +17 -6
- package/types/api-runner.d.ts +94 -0
- package/types/builder.d.ts +141 -10
- package/types/catalog.d.ts +97 -0
- package/types/compiler/actions.d.ts +41 -0
- package/types/compiler/body.d.ts +11 -0
- package/types/compiler/collections.d.ts +76 -0
- package/types/compiler/context.d.ts +647 -0
- package/types/compiler/directives.d.ts +6 -0
- package/types/compiler/expr-ast.d.ts +20 -0
- package/types/compiler/fetch.d.ts +29 -0
- package/types/compiler/format.d.ts +49 -0
- package/types/compiler/forms.d.ts +94 -0
- package/types/compiler/frontmatter.d.ts +33 -0
- package/types/compiler/fs-reader.d.ts +7 -0
- package/types/compiler/image-size.d.ts +14 -0
- package/types/compiler/includes.d.ts +100 -0
- package/types/compiler/index.d.ts +5 -0
- package/types/compiler/interpolation.d.ts +115 -0
- package/types/compiler/loops.d.ts +36 -0
- package/types/compiler/markdown.d.ts +34 -0
- package/types/compiler/media.d.ts +32 -0
- package/types/compiler/page.d.ts +109 -0
- package/types/compiler/predicates.d.ts +50 -0
- package/types/compiler/reader.d.ts +48 -0
- package/types/compiler/state.d.ts +68 -0
- package/types/compiler/structure.d.ts +56 -0
- package/types/compiler.d.ts +35 -223
- package/types/dev.d.ts +47 -2
- package/types/errors.d.ts +100 -0
- package/types/feeds.d.ts +150 -0
- package/types/headers.d.ts +44 -0
- package/types/highlight.d.ts +24 -0
- package/types/router.d.ts +42 -3
- package/types/scaffold.d.ts +15 -3
- package/types/skin.d.ts +28 -1
- package/types/src/api-runner.d.ts +94 -0
- package/types/src/builder.d.ts +170 -0
- package/types/src/catalog.d.ts +97 -0
- package/types/src/compiler/actions.d.ts +41 -0
- package/types/src/compiler/body.d.ts +11 -0
- package/types/src/compiler/collections.d.ts +76 -0
- package/types/src/compiler/context.d.ts +647 -0
- package/types/src/compiler/directives.d.ts +6 -0
- package/types/src/compiler/expr-ast.d.ts +20 -0
- package/types/src/compiler/fetch.d.ts +29 -0
- package/types/src/compiler/format.d.ts +49 -0
- package/types/src/compiler/forms.d.ts +94 -0
- package/types/src/compiler/frontmatter.d.ts +33 -0
- package/types/src/compiler/fs-reader.d.ts +7 -0
- package/types/src/compiler/image-size.d.ts +14 -0
- package/types/src/compiler/includes.d.ts +100 -0
- package/types/src/compiler/index.d.ts +5 -0
- package/types/src/compiler/interpolation.d.ts +115 -0
- package/types/src/compiler/loops.d.ts +36 -0
- package/types/src/compiler/markdown.d.ts +34 -0
- package/types/src/compiler/media.d.ts +32 -0
- package/types/src/compiler/page.d.ts +109 -0
- package/types/src/compiler/predicates.d.ts +50 -0
- package/types/src/compiler/reader.d.ts +48 -0
- package/types/src/compiler/state.d.ts +68 -0
- package/types/src/compiler/structure.d.ts +56 -0
- package/types/src/compiler.d.ts +48 -0
- package/types/src/config.d.ts +45 -0
- package/types/src/dev.d.ts +58 -0
- package/types/src/errors.d.ts +100 -0
- package/types/src/feeds.d.ts +150 -0
- package/types/src/headers.d.ts +44 -0
- package/types/src/highlight.d.ts +24 -0
- package/types/src/router.d.ts +78 -0
- package/types/src/scaffold.d.ts +20 -0
- package/types/src/skin.d.ts +48 -0
- package/types/src/statics.d.ts +24 -0
- package/types/statics.d.ts +2 -1
package/src/compiler/loops.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// string fill used for the initial paint (text binds, per-row `:if`, meta).
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
import { at, createScope, nestedCtx } from "./context.js";
|
|
9
|
+
import { at, createScope, lineOf, nestedCtx, wdError } from "./context.js";
|
|
10
10
|
import { serializeExpr } from "./expr-ast.js";
|
|
11
11
|
import { applyPipeline, stagesFromAttr } from "./format.js";
|
|
12
12
|
import { resolveInclude } from "./includes.js";
|
|
@@ -28,9 +28,14 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
|
|
|
28
28
|
* @typedef {import("./context.js").LoopOpts} LoopOpts
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
// One concrete, compilable @loop line, appended to the schematic hint so a small
|
|
32
|
+
// model copies a real line instead of echoing the `[…]` placeholders literally
|
|
33
|
+
// (the on-device finding: bracket-placeholders get pasted into source verbatim).
|
|
34
|
+
// Also the canonical @loop example the directive catalog + grammar draw from.
|
|
35
|
+
export const LOOP_EXAMPLE = "@loop /products.json into p where p.price < 50 sort by p.price asc";
|
|
36
|
+
|
|
31
37
|
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
32
|
-
const LOOP_USAGE =
|
|
33
|
-
"Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
|
|
38
|
+
const LOOP_USAGE = `Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable] — e.g. ${LOOP_EXAMPLE}`;
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Parse the optional clause tail of a `@loop` header in FIXED order:
|
|
@@ -56,7 +61,10 @@ function parseLoopClauses(tail, itemName, ctx) {
|
|
|
56
61
|
/** @type {number|null} */ let paginate = null;
|
|
57
62
|
/** @returns {never} */
|
|
58
63
|
const bad = () => {
|
|
59
|
-
throw
|
|
64
|
+
throw wdError(`Malformed @loop clause in ${ctx.file}: "${tail.trim()}". ${LOOP_USAGE}`, {
|
|
65
|
+
code: "WD102",
|
|
66
|
+
file: ctx.file
|
|
67
|
+
});
|
|
60
68
|
};
|
|
61
69
|
|
|
62
70
|
// `sortable` is a position-independent flag (drag-reorder); peel it wherever it
|
|
@@ -147,20 +155,25 @@ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
|
|
|
147
155
|
if (fieldState) {
|
|
148
156
|
const resolved = resolveStateKey(fieldState[1], ctx);
|
|
149
157
|
if (!resolved)
|
|
150
|
-
throw
|
|
151
|
-
`@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}
|
|
158
|
+
throw wdError(
|
|
159
|
+
`@loop sort by { ${fieldState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`,
|
|
160
|
+
{ code: "WD103", file: ctx.file }
|
|
152
161
|
);
|
|
153
162
|
key = resolved;
|
|
154
163
|
keyKind = "key";
|
|
155
164
|
} else {
|
|
156
165
|
const segs = fieldTok.split(".");
|
|
157
166
|
if (segs[0] !== itemName)
|
|
158
|
-
throw
|
|
159
|
-
`@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}
|
|
167
|
+
throw wdError(
|
|
168
|
+
`@loop sort key "${fieldTok}" must start with the loop item "${itemName}" in ${ctx.file}. ${LOOP_USAGE}`,
|
|
169
|
+
{ code: "WD104", file: ctx.file }
|
|
160
170
|
);
|
|
161
171
|
const rest = segs.slice(1);
|
|
162
172
|
if (rest.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg)))
|
|
163
|
-
throw
|
|
173
|
+
throw wdError(`Sort key "${fieldTok}" is not allowed in @loop (${ctx.file})`, {
|
|
174
|
+
code: "WD105",
|
|
175
|
+
file: ctx.file
|
|
176
|
+
});
|
|
164
177
|
key = rest.join(".");
|
|
165
178
|
keyKind = "literal";
|
|
166
179
|
}
|
|
@@ -172,8 +185,9 @@ function parseSortClause(fieldTok, dirTok, itemName, ctx) {
|
|
|
172
185
|
if (dirState) {
|
|
173
186
|
const resolved = resolveStateKey(dirState[1], ctx);
|
|
174
187
|
if (!resolved)
|
|
175
|
-
throw
|
|
176
|
-
`@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}
|
|
188
|
+
throw wdError(
|
|
189
|
+
`@loop sort direction { ${dirState[1]} } references unknown :state/:store in ${ctx.file}. ${LOOP_USAGE}`,
|
|
190
|
+
{ code: "WD106", file: ctx.file }
|
|
177
191
|
);
|
|
178
192
|
dir = resolved;
|
|
179
193
|
dirKind = "key";
|
|
@@ -196,8 +210,9 @@ function parseNumArg(tok, ctx) {
|
|
|
196
210
|
if (/^\d+$/.test(tok)) return { kind: "literal", value: Number(tok) };
|
|
197
211
|
const key = resolveStateKey(tok, ctx);
|
|
198
212
|
if (!key)
|
|
199
|
-
throw
|
|
200
|
-
`@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}
|
|
213
|
+
throw wdError(
|
|
214
|
+
`@loop offset/limit "${tok}" in ${ctx.file} is neither a non-negative integer nor a declared :state. ${LOOP_USAGE}`,
|
|
215
|
+
{ code: "WD107", file: ctx.file }
|
|
201
216
|
);
|
|
202
217
|
return { kind: "key", value: key };
|
|
203
218
|
}
|
|
@@ -219,7 +234,14 @@ function numArgAttr(arg) {
|
|
|
219
234
|
*/
|
|
220
235
|
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
221
236
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
222
|
-
if (!match)
|
|
237
|
+
if (!match)
|
|
238
|
+
throw wdError(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`, {
|
|
239
|
+
code: "WD101",
|
|
240
|
+
file: ctx.file,
|
|
241
|
+
line: lineOf(ctx, index),
|
|
242
|
+
hint: LOOP_USAGE.slice("Use: ".length),
|
|
243
|
+
example: LOOP_EXAMPLE
|
|
244
|
+
});
|
|
223
245
|
const source = stripQuotes(match[1].trim());
|
|
224
246
|
const itemName = match[2];
|
|
225
247
|
const clauses = match[3]
|
|
@@ -247,16 +269,18 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
247
269
|
clauses.limit ||
|
|
248
270
|
clauses.paginate)
|
|
249
271
|
) {
|
|
250
|
-
throw
|
|
251
|
-
`@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}
|
|
272
|
+
throw wdError(
|
|
273
|
+
`@loop sortable cannot combine with where/sort/reverse/offset/limit/paginate in ${ctx.file}. ${LOOP_USAGE}`,
|
|
274
|
+
{ code: "WD108", file: ctx.file }
|
|
252
275
|
);
|
|
253
276
|
}
|
|
254
277
|
// `paginate N` OWNS the page slice, so an explicit offset/limit alongside it is
|
|
255
278
|
// a conflict (which one wins?). Reject it with a clear hint rather than silently
|
|
256
279
|
// letting one override the other.
|
|
257
280
|
if (clauses.paginate && (clauses.offset || clauses.limit)) {
|
|
258
|
-
throw
|
|
259
|
-
`@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}
|
|
281
|
+
throw wdError(
|
|
282
|
+
`@loop paginate cannot combine with offset/limit in ${ctx.file} — paginate already slices each page. ${LOOP_USAGE}`,
|
|
283
|
+
{ code: "WD109", file: ctx.file }
|
|
260
284
|
);
|
|
261
285
|
}
|
|
262
286
|
/** @type {LoopOpts} */
|
|
@@ -287,7 +311,7 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
287
311
|
source.startsWith("../") ||
|
|
288
312
|
source.endsWith(".json")
|
|
289
313
|
) {
|
|
290
|
-
if (opts.paginate) throw
|
|
314
|
+
if (opts.paginate) throw paginateOnlyCollections(ctx);
|
|
291
315
|
const dataFile = resolveInclude(
|
|
292
316
|
source,
|
|
293
317
|
ctx.file,
|
|
@@ -298,23 +322,28 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
298
322
|
);
|
|
299
323
|
ctx.comp.deps.add(dataFile);
|
|
300
324
|
const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
|
|
301
|
-
if (!Array.isArray(rows))
|
|
325
|
+
if (!Array.isArray(rows))
|
|
326
|
+
throw wdError(`@loop data must be a JSON array: ${dataFile}`, {
|
|
327
|
+
code: "WD111",
|
|
328
|
+
file: ctx.file
|
|
329
|
+
});
|
|
302
330
|
return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
|
|
303
331
|
}
|
|
304
332
|
|
|
305
333
|
const resolved = lookupPath(source, ctx);
|
|
306
334
|
if (resolved.found) {
|
|
307
|
-
if (opts.paginate) throw
|
|
335
|
+
if (opts.paginate) throw paginateOnlyCollections(ctx);
|
|
308
336
|
// A missing/unset field (e.g. an optional frontmatter key) is an EMPTY
|
|
309
337
|
// list, not an error — the `@empty` branch renders, matching the runtime.
|
|
310
338
|
if (resolved.value === null || resolved.value === undefined) {
|
|
311
339
|
return loopOverData([], itemName, bodyLines, bodyCtx, opts);
|
|
312
340
|
}
|
|
313
341
|
if (!Array.isArray(resolved.value)) {
|
|
314
|
-
throw
|
|
342
|
+
throw wdError(
|
|
315
343
|
`@loop ${source} in ${at(ctx, index)} found an in-scope value, but it is not a list ` +
|
|
316
344
|
`(got ${typeof resolved.value}). Use: a list value (e.g. \`tags: [a, b]\` in frontmatter) — ` +
|
|
317
|
-
`or leave the field out entirely, which loops zero rows and renders the @empty branch
|
|
345
|
+
`or leave the field out entirely, which loops zero rows and renders the @empty branch.`,
|
|
346
|
+
{ code: "WD112", file: ctx.file, line: lineOf(ctx, index) }
|
|
318
347
|
);
|
|
319
348
|
}
|
|
320
349
|
return loopOverData(resolved.value, itemName, bodyLines, bodyCtx, opts);
|
|
@@ -324,11 +353,14 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
324
353
|
const segs = source.split(".");
|
|
325
354
|
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(source)) {
|
|
326
355
|
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
327
|
-
throw
|
|
356
|
+
throw wdError(`@loop source "${source}" is not allowed in ${ctx.file}`, {
|
|
357
|
+
code: "WD113",
|
|
358
|
+
file: ctx.file
|
|
359
|
+
});
|
|
328
360
|
}
|
|
329
361
|
const key = resolveStateKey(segs[0], ctx);
|
|
330
362
|
if (key) {
|
|
331
|
-
if (opts.paginate) throw
|
|
363
|
+
if (opts.paginate) throw paginateOnlyCollections(ctx);
|
|
332
364
|
const fullKey = segs.length > 1 ? `${key}.${segs.slice(1).join(".")}` : key;
|
|
333
365
|
return reactiveLoop(fullKey, itemName, bodyLines, bodyCtx, opts);
|
|
334
366
|
}
|
|
@@ -344,16 +376,17 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
344
376
|
// the enclosing row item — an ITEM-RELATIVE loop the runtime fills per row.
|
|
345
377
|
if (ctx.loopItem && segs[0] === ctx.loopItem && segs.length > 1) {
|
|
346
378
|
if (opts.sortable) {
|
|
347
|
-
throw
|
|
348
|
-
`@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}
|
|
379
|
+
throw wdError(
|
|
380
|
+
`@loop sortable is not supported on a nested (item-relative) loop in ${ctx.file}. ${LOOP_USAGE}`,
|
|
381
|
+
{ code: "WD114", file: ctx.file }
|
|
349
382
|
);
|
|
350
383
|
}
|
|
351
|
-
if (opts.paginate) throw
|
|
384
|
+
if (opts.paginate) throw paginateOnlyCollections(ctx);
|
|
352
385
|
return itemRelativeLoop(segs.slice(1).join("."), itemName, bodyLines, bodyCtx, opts);
|
|
353
386
|
}
|
|
354
387
|
}
|
|
355
388
|
|
|
356
|
-
throw
|
|
389
|
+
throw unresolvedSourceError(source, ctx);
|
|
357
390
|
}
|
|
358
391
|
|
|
359
392
|
/**
|
|
@@ -362,15 +395,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
362
395
|
* collection reference is immediately actionable.
|
|
363
396
|
* @param {string} source
|
|
364
397
|
* @param {Ctx} ctx
|
|
365
|
-
* @returns {
|
|
398
|
+
* @returns {Error}
|
|
366
399
|
*/
|
|
367
400
|
function unresolvedSourceError(source, ctx) {
|
|
368
401
|
const names = [...ctx.comp.collections.keys()].sort();
|
|
369
402
|
const collectionsHint = names.length ? ` Available collections: ${names.join(", ")}.` : "";
|
|
370
|
-
return (
|
|
403
|
+
return wdError(
|
|
371
404
|
`@loop source "${source}" in ${ctx.file} was not found. Loop over a collection ` +
|
|
372
|
-
|
|
373
|
-
|
|
405
|
+
`(a site/pages/<name>/ subdirectory, by its bare name), a JSON file ` +
|
|
406
|
+
`(@loop /data.json into row), an in-scope value, or a :state list.${collectionsHint}`,
|
|
407
|
+
{ code: "WD115", file: ctx.file }
|
|
374
408
|
);
|
|
375
409
|
}
|
|
376
410
|
|
|
@@ -378,10 +412,13 @@ function unresolvedSourceError(source, ctx) {
|
|
|
378
412
|
* The error for `paginate` on a non-collection source: pagination multiplies
|
|
379
413
|
* static routes, which only makes sense for a build-time collection listing.
|
|
380
414
|
* @param {Ctx} ctx
|
|
381
|
-
* @returns {
|
|
415
|
+
* @returns {Error}
|
|
382
416
|
*/
|
|
383
417
|
function paginateOnlyCollections(ctx) {
|
|
384
|
-
return
|
|
418
|
+
return wdError(
|
|
419
|
+
`@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`,
|
|
420
|
+
{ code: "WD110", file: ctx.file }
|
|
421
|
+
);
|
|
385
422
|
}
|
|
386
423
|
|
|
387
424
|
/**
|
|
@@ -399,9 +436,10 @@ function paginateOnlyCollections(ctx) {
|
|
|
399
436
|
function assertReactiveDepth(ctx) {
|
|
400
437
|
if ((ctx.reactiveDepth ?? 0) < 2) return;
|
|
401
438
|
const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
|
|
402
|
-
throw
|
|
439
|
+
throw wdError(
|
|
403
440
|
`Reactive @loop nesting is limited to one inner level in ${opener.at}: "${opener.line}". ` +
|
|
404
|
-
`Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data
|
|
441
|
+
`Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`,
|
|
442
|
+
{ code: "WD116", file: ctx.file }
|
|
405
443
|
);
|
|
406
444
|
}
|
|
407
445
|
|
|
@@ -511,8 +549,9 @@ function pipelineRows(rows, where, opts, ctx) {
|
|
|
511
549
|
*/
|
|
512
550
|
function loopOverData(rows, itemName, bodyLines, ctx, opts) {
|
|
513
551
|
if (opts.sortable) {
|
|
514
|
-
throw
|
|
515
|
-
`@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}
|
|
552
|
+
throw wdError(
|
|
553
|
+
`@loop sortable requires a :state or :store list to reorder, not a JSON file or in-scope value, in ${ctx.file}. ${LOOP_USAGE}`,
|
|
554
|
+
{ code: "WD117", file: ctx.file }
|
|
516
555
|
);
|
|
517
556
|
}
|
|
518
557
|
// Reactive when the where reads state OR an offset/limit references a state key.
|
package/src/compiler/markdown.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import MarkdownIt from "markdown-it";
|
|
9
9
|
import { highlightCode } from "../highlight.js";
|
|
10
|
-
import { LOOP_META } from "./context.js";
|
|
10
|
+
import { LOOP_META, wdError } from "./context.js";
|
|
11
11
|
import { applyPipeline, fmtAttr, validatePipes } from "./format.js";
|
|
12
12
|
import {
|
|
13
13
|
escapeHtml,
|
|
@@ -266,8 +266,9 @@ function resolveBindingHtml(expr, ctx) {
|
|
|
266
266
|
// Per-row meta vars ($index/$number/$first/$last/$count) — only inside a loop.
|
|
267
267
|
if (LOOP_META[head]) {
|
|
268
268
|
if (!ctx.loopMeta)
|
|
269
|
-
throw
|
|
270
|
-
`"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value
|
|
269
|
+
throw wdError(
|
|
270
|
+
`"{ ${expr} }" uses the loop meta variable "${head}" outside a @loop in ${ctx.file}. Move it into a loop body, or rename your value.`,
|
|
271
|
+
{ code: "WD005", file: ctx.file }
|
|
271
272
|
);
|
|
272
273
|
if (ctx.loopItem) return `<span data-wd-each-meta="${LOOP_META[head]}"${fmt}></span>`; // reactive: filled per row
|
|
273
274
|
// static: the value is in scope (injected by staticUnroll); fall through.
|
package/src/compiler/media.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// while keeping the message text and `Use:` hints intact.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
import { at } from "./context.js";
|
|
11
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
12
12
|
import { validateFetchUrl } from "./fetch.js";
|
|
13
13
|
import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
14
14
|
|
|
@@ -16,6 +16,11 @@ import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
|
16
16
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
// Concrete, compilable media examples for the hint tails + the directive catalog.
|
|
20
|
+
export const VIDEO_EXAMPLE = ":video /demo.mp4 controls";
|
|
21
|
+
export const AUDIO_EXAMPLE = ":audio /theme.mp3 controls";
|
|
22
|
+
export const EMBED_EXAMPLE = ':embed https://youtu.be/dQw4w9WgXcQ title="Demo"';
|
|
23
|
+
|
|
19
24
|
/** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
|
|
20
25
|
const MEDIA_SPEC = {
|
|
21
26
|
video: {
|
|
@@ -41,9 +46,15 @@ const MEDIA_SPEC = {
|
|
|
41
46
|
export function handleMedia(line, kind, ctx, index) {
|
|
42
47
|
const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
|
|
43
48
|
if (!match) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
49
|
+
const example = kind === "video" ? VIDEO_EXAMPLE : AUDIO_EXAMPLE;
|
|
50
|
+
const hint = `:${kind} /clip [controls] [autoplay] [loop] [muted] — e.g. ${example}`;
|
|
51
|
+
throw wdError(`Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
52
|
+
code: "WD701",
|
|
53
|
+
file: ctx.file,
|
|
54
|
+
line: lineOf(ctx, index),
|
|
55
|
+
hint,
|
|
56
|
+
example
|
|
57
|
+
});
|
|
47
58
|
}
|
|
48
59
|
const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
|
|
49
60
|
const spec = MEDIA_SPEC[kind];
|
|
@@ -55,11 +66,19 @@ export function handleMedia(line, kind, ctx, index) {
|
|
|
55
66
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
56
67
|
if (token[3]) {
|
|
57
68
|
if (!spec.flags.includes(token[3]))
|
|
58
|
-
throw
|
|
69
|
+
throw wdError(`Unknown :${kind} flag "${token[3]}" in ${at(ctx, index)}`, {
|
|
70
|
+
code: "WD702",
|
|
71
|
+
file: ctx.file,
|
|
72
|
+
line: lineOf(ctx, index)
|
|
73
|
+
});
|
|
59
74
|
flags.add(token[3]);
|
|
60
75
|
} else {
|
|
61
76
|
if (!spec.attrs.includes(token[1]))
|
|
62
|
-
throw
|
|
77
|
+
throw wdError(`Unknown :${kind} attribute "${token[1]}" in ${at(ctx, index)}`, {
|
|
78
|
+
code: "WD703",
|
|
79
|
+
file: ctx.file,
|
|
80
|
+
line: lineOf(ctx, index)
|
|
81
|
+
});
|
|
63
82
|
attrs.set(token[1], stripQuotes(token[2]));
|
|
64
83
|
}
|
|
65
84
|
}
|
|
@@ -93,16 +112,25 @@ export function handleMedia(line, kind, ctx, index) {
|
|
|
93
112
|
export function handleEmbed(line, ctx, index) {
|
|
94
113
|
const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
|
|
95
114
|
if (!match) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
const hint = `:embed https://youtu.be/ID [title="…"] — e.g. ${EMBED_EXAMPLE}`;
|
|
116
|
+
throw wdError(`Malformed :embed in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
117
|
+
code: "WD704",
|
|
118
|
+
file: ctx.file,
|
|
119
|
+
line: lineOf(ctx, index),
|
|
120
|
+
hint,
|
|
121
|
+
example: EMBED_EXAMPLE
|
|
122
|
+
});
|
|
99
123
|
}
|
|
100
124
|
const raw = stripQuotes(match[1]);
|
|
101
125
|
let title = "";
|
|
102
126
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)/g;
|
|
103
127
|
for (const token of (match[2] || "").matchAll(re)) {
|
|
104
128
|
if (token[1] !== "title")
|
|
105
|
-
throw
|
|
129
|
+
throw wdError(`Unknown :embed attribute "${token[1]}" in ${at(ctx, index)}`, {
|
|
130
|
+
code: "WD705",
|
|
131
|
+
file: ctx.file,
|
|
132
|
+
line: lineOf(ctx, index)
|
|
133
|
+
});
|
|
106
134
|
title = stripQuotes(token[2]);
|
|
107
135
|
}
|
|
108
136
|
const { url, label } = resolveEmbed(raw, ctx);
|
package/src/compiler/page.js
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import { imageSize } from "image-size";
|
|
10
9
|
import { createPaths } from "../config.js";
|
|
11
10
|
import { htmlHasHighlight } from "../highlight.js";
|
|
12
11
|
import { compileBody } from "./body.js";
|
|
13
|
-
import { createCompilation, createScope } from "./context.js";
|
|
12
|
+
import { createCompilation, createScope, wdError } from "./context.js";
|
|
14
13
|
import { parseFrontmatter, warnLikelyFrontmatter } from "./frontmatter.js";
|
|
14
|
+
import { imageSize } from "./image-size.js";
|
|
15
15
|
import {
|
|
16
16
|
collectColocatedAssets,
|
|
17
17
|
scanMarkdownHints,
|
|
@@ -279,8 +279,9 @@ function measureImage(src, paths, reader) {
|
|
|
279
279
|
return null; // page-relative: the source directory is lost after assembly
|
|
280
280
|
}
|
|
281
281
|
try {
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
// `imageSize` itself never throws (an unsupported or malformed image is just
|
|
283
|
+
// null); the try/catch is for the read, which does throw on a missing file.
|
|
284
|
+
return imageSize(reader.readBinary(filePath));
|
|
284
285
|
} catch {
|
|
285
286
|
/* missing or unreadable — degrade to no dimensions */
|
|
286
287
|
}
|
|
@@ -363,8 +364,9 @@ export function compileFile(
|
|
|
363
364
|
) {
|
|
364
365
|
const real = comp.reader.realpath(file);
|
|
365
366
|
if (stack.includes(real)) {
|
|
366
|
-
throw
|
|
367
|
-
`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}
|
|
367
|
+
throw wdError(
|
|
368
|
+
`[WD612] Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`,
|
|
369
|
+
{ code: "WD612", file }
|
|
368
370
|
);
|
|
369
371
|
}
|
|
370
372
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// readers — no identifier survives un-mapped, so no raw user content is eval'd.
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
+
import { wdError } from "./context.js";
|
|
8
9
|
import { astOf, evalAst } from "./expr-ast.js";
|
|
9
10
|
import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
10
11
|
|
|
@@ -13,6 +14,20 @@ import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
|
13
14
|
* @typedef {import("./context.js").Predicate} Predicate
|
|
14
15
|
*/
|
|
15
16
|
|
|
17
|
+
// The comparison operators a `where` / `.class when` / `:if` / `:computed`
|
|
18
|
+
// condition may use, longest-match first so `>=` wins over `>`. The single
|
|
19
|
+
// source of truth: the operator regex is built from this list, and the directive
|
|
20
|
+
// catalog + GBNF grammar enumerate the same array (drift-guarded in tests).
|
|
21
|
+
export const PREDICATE_OPS = ["contains", "==", "!=", ">=", "<=", ">", "<"];
|
|
22
|
+
|
|
23
|
+
// The logical joiners between conditions (`and`/`or`), plus a leading `not` the
|
|
24
|
+
// `.class when` / `:if` paths accept. Catalogued alongside the operators.
|
|
25
|
+
export const PREDICATE_JOINERS = ["and", "or", "not"];
|
|
26
|
+
|
|
27
|
+
// `left <op> right` where <op> is one of PREDICATE_OPS. Built from the array so
|
|
28
|
+
// adding an operator in one place updates the parser and the catalog together.
|
|
29
|
+
const CONDITION_RE = new RegExp(`^(.+?)\\s+(${PREDICATE_OPS.join("|")})\\s+(.+)$`, "i");
|
|
30
|
+
|
|
16
31
|
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
17
32
|
// Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
|
|
18
33
|
// paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
|
|
@@ -46,11 +61,16 @@ export function compilePredicate(raw, itemName, ctx) {
|
|
|
46
61
|
* @returns {{ expr: string, usesState: boolean }}
|
|
47
62
|
*/
|
|
48
63
|
function compileCondition(cond, itemName, ctx) {
|
|
49
|
-
const m = cond.match(
|
|
50
|
-
if (!m)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
const m = cond.match(CONDITION_RE);
|
|
65
|
+
if (!m) {
|
|
66
|
+
const hint = `${itemName}.field contains state, or ${itemName}.field <op> value — e.g. ${itemName}.price < 50`;
|
|
67
|
+
throw wdError(`Malformed where-condition "${cond}" in ${ctx.file}. Use: ${hint}`, {
|
|
68
|
+
code: "WD220",
|
|
69
|
+
file: ctx.file,
|
|
70
|
+
hint,
|
|
71
|
+
example: `${itemName}.price < 50`
|
|
72
|
+
});
|
|
73
|
+
}
|
|
54
74
|
const left = compileOperand(m[1].trim(), itemName, ctx);
|
|
55
75
|
const right = compileOperand(m[3].trim(), itemName, ctx);
|
|
56
76
|
const usesState = left.usesState || right.usesState;
|
|
@@ -71,20 +91,25 @@ function compileOperand(tok, itemName, ctx) {
|
|
|
71
91
|
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, usesState: false };
|
|
72
92
|
if (["true", "false", "null"].includes(tok)) return { code: tok, usesState: false };
|
|
73
93
|
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
74
|
-
throw
|
|
75
|
-
`Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string"
|
|
94
|
+
throw wdError(
|
|
95
|
+
`Unsupported operand "${tok}" in @loop where (${ctx.file}). Use ${itemName}.field, a :state name, a number, or a "string".`,
|
|
96
|
+
{ code: "WD221", file: ctx.file }
|
|
76
97
|
);
|
|
77
98
|
}
|
|
78
99
|
const segs = tok.split(".");
|
|
79
100
|
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
80
|
-
throw
|
|
101
|
+
throw wdError(`Path "${tok}" is not allowed in @loop where (${ctx.file})`, {
|
|
102
|
+
code: "WD222",
|
|
103
|
+
file: ctx.file
|
|
104
|
+
});
|
|
81
105
|
}
|
|
82
106
|
if (segs[0] === itemName)
|
|
83
107
|
return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, usesState: false };
|
|
84
108
|
const key = resolveStateKey(segs[0], ctx);
|
|
85
109
|
if (!key) {
|
|
86
|
-
throw
|
|
87
|
-
`@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state
|
|
110
|
+
throw wdError(
|
|
111
|
+
`@loop where references unknown name "${segs[0]}" in ${ctx.file}. Use the loop item (${itemName}.field) or a declared :state.`,
|
|
112
|
+
{ code: "WD223", file: ctx.file }
|
|
88
113
|
);
|
|
89
114
|
}
|
|
90
115
|
const rest = segs.slice(1).join(".");
|
|
@@ -130,13 +155,17 @@ function compileWhenOperand(tok, ctx, what) {
|
|
|
130
155
|
if (/^-?\d+(?:\.\d+)?$/.test(tok)) return { code: tok, state: false, item: false };
|
|
131
156
|
if (["true", "false", "null"].includes(tok)) return { code: tok, state: false, item: false };
|
|
132
157
|
if (!/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(tok)) {
|
|
133
|
-
throw
|
|
134
|
-
`Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string"
|
|
158
|
+
throw wdError(
|
|
159
|
+
`Unsupported operand "${tok}" in ${what} (${ctx.file}). Use item.field, a :state name, a number, or a "string".`,
|
|
160
|
+
{ code: "WD224", file: ctx.file }
|
|
135
161
|
);
|
|
136
162
|
}
|
|
137
163
|
const segs = tok.split(".");
|
|
138
164
|
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
139
|
-
throw
|
|
165
|
+
throw wdError(`Path "${tok}" is not allowed in ${what} (${ctx.file})`, {
|
|
166
|
+
code: "WD225",
|
|
167
|
+
file: ctx.file
|
|
168
|
+
});
|
|
140
169
|
}
|
|
141
170
|
const scoped = lookupVar(ctx.scope, segs[0]);
|
|
142
171
|
if (scoped.found)
|
|
@@ -149,8 +178,9 @@ function compileWhenOperand(tok, ctx, what) {
|
|
|
149
178
|
return { code: `I(${JSON.stringify(segs.slice(1).join("."))})`, state: false, item: true };
|
|
150
179
|
const key = resolveStateKey(segs[0], ctx);
|
|
151
180
|
if (!key)
|
|
152
|
-
throw
|
|
153
|
-
`${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string"
|
|
181
|
+
throw wdError(
|
|
182
|
+
`${what} references unknown name "${segs[0]}" in ${ctx.file}. Use a loop item field, a declared :state, a number, or a "string".`,
|
|
183
|
+
{ code: "WD226", file: ctx.file }
|
|
154
184
|
);
|
|
155
185
|
const rest = segs.slice(1).join(".");
|
|
156
186
|
return {
|
|
@@ -189,7 +219,7 @@ export function compileWhen(raw, ctx, what = '"::: … when"') {
|
|
|
189
219
|
negate = true;
|
|
190
220
|
seg = seg.replace(/^not\s+/i, "").trim();
|
|
191
221
|
}
|
|
192
|
-
const opMatch = seg.match(
|
|
222
|
+
const opMatch = seg.match(CONDITION_RE);
|
|
193
223
|
let expr;
|
|
194
224
|
if (opMatch) {
|
|
195
225
|
const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
|
|
@@ -243,12 +273,16 @@ export function compileComputedExpr(raw, ctx) {
|
|
|
243
273
|
["constructor", "prototype", "__proto__"].includes(s)
|
|
244
274
|
)
|
|
245
275
|
) {
|
|
246
|
-
throw
|
|
276
|
+
throw wdError(`Path "${listPath}" is not allowed in :computed ${name}() (${ctx.file})`, {
|
|
277
|
+
code: "WD227",
|
|
278
|
+
file: ctx.file
|
|
279
|
+
});
|
|
247
280
|
}
|
|
248
281
|
const key = resolveStateKey(segs[0], ctx);
|
|
249
282
|
if (!key) {
|
|
250
|
-
throw
|
|
251
|
-
`:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first
|
|
283
|
+
throw wdError(
|
|
284
|
+
`:computed ${name}() references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`,
|
|
285
|
+
{ code: "WD228", file: ctx.file }
|
|
252
286
|
);
|
|
253
287
|
}
|
|
254
288
|
const rest = segs.slice(1).join(".");
|
|
@@ -260,15 +294,22 @@ export function compileComputedExpr(raw, ctx) {
|
|
|
260
294
|
}
|
|
261
295
|
);
|
|
262
296
|
if (/["'\\`]/.test(expr)) {
|
|
263
|
-
throw
|
|
297
|
+
throw wdError(`Unsupported string syntax in :computed expression "${raw}" (${ctx.file})`, {
|
|
298
|
+
code: "WD229",
|
|
299
|
+
file: ctx.file
|
|
300
|
+
});
|
|
264
301
|
}
|
|
265
302
|
if (!/^[\w$.\s+\-*/%()<>=!&|]*$/.test(expr)) {
|
|
266
|
-
throw
|
|
267
|
-
`Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && ||
|
|
303
|
+
throw wdError(
|
|
304
|
+
`Unsupported syntax in :computed expression "${raw}" (${ctx.file}). Allowed: state names, numbers, strings, + - * / % ( ), comparisons, && || !.`,
|
|
305
|
+
{ code: "WD230", file: ctx.file }
|
|
268
306
|
);
|
|
269
307
|
}
|
|
270
308
|
if (/(^|[^=!<>])=(?!=)/.test(expr)) {
|
|
271
|
-
throw
|
|
309
|
+
throw wdError(`Assignment is not allowed in :computed expressions ("${raw}" in ${ctx.file})`, {
|
|
310
|
+
code: "WD231",
|
|
311
|
+
file: ctx.file
|
|
312
|
+
});
|
|
272
313
|
}
|
|
273
314
|
// Reject function-call syntax: a `(` that directly follows an identifier, a
|
|
274
315
|
// string literal, or a closing `)` (e.g. `x()`, `x.valueOf()`, `(a)(b)`). Only
|
|
@@ -278,8 +319,9 @@ export function compileComputedExpr(raw, ctx) {
|
|
|
278
319
|
// SECURITY.md guarantee that function calls are compile errors. Runs BEFORE the
|
|
279
320
|
// identifier→S() mapping so it never trips on the emitted helper calls.
|
|
280
321
|
if (/[\w$)]\s*\(/.test(expr)) {
|
|
281
|
-
throw
|
|
282
|
-
`Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})
|
|
322
|
+
throw wdError(
|
|
323
|
+
`Function calls are not allowed in :computed expressions ("${raw}" in ${ctx.file})`,
|
|
324
|
+
{ code: "WD232", file: ctx.file }
|
|
283
325
|
);
|
|
284
326
|
}
|
|
285
327
|
expr = expr.replace(/[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*/g, (ref) => {
|
|
@@ -287,14 +329,16 @@ export function compileComputedExpr(raw, ctx) {
|
|
|
287
329
|
if (["true", "false", "null"].includes(ref)) return ref;
|
|
288
330
|
const segs = ref.split(".");
|
|
289
331
|
if (segs.some((seg) => ["constructor", "prototype", "__proto__"].includes(seg))) {
|
|
290
|
-
throw
|
|
291
|
-
|
|
292
|
-
|
|
332
|
+
throw wdError(`Path segment "${ref}" is not allowed in :computed expressions (${ctx.file})`, {
|
|
333
|
+
code: "WD233",
|
|
334
|
+
file: ctx.file
|
|
335
|
+
});
|
|
293
336
|
}
|
|
294
337
|
const key = resolveStateKey(segs[0], ctx);
|
|
295
338
|
if (!key) {
|
|
296
|
-
throw
|
|
297
|
-
`:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first
|
|
339
|
+
throw wdError(
|
|
340
|
+
`:computed references unknown state "${segs[0]}" in ${ctx.file}. Declare it with :state or :fetch first.`,
|
|
341
|
+
{ code: "WD234", file: ctx.file }
|
|
298
342
|
);
|
|
299
343
|
}
|
|
300
344
|
const rest = segs.slice(1).join(".");
|