@zvndev/darkmown 2.2.0 → 2.3.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 +13 -0
- package/README.md +34 -2
- package/docs/spec-alignment.md +10 -0
- package/package.json +16 -11
- package/site/pages/docs/index.wd +14 -0
- package/site/pages/playground/index.js +47 -46
- package/src/catalog.js +16 -1
- package/src/compiler/actions.js +42 -22
- package/src/compiler/body.js +36 -14
- package/src/compiler/collections.js +16 -10
- package/src/compiler/context.js +39 -10
- package/src/compiler/fetch.js +39 -15
- package/src/compiler/format.js +5 -2
- package/src/compiler/forms.js +91 -26
- 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 +63 -35
- package/src/compiler/markdown.js +4 -3
- package/src/compiler/media.js +17 -3
- package/src/compiler/page.js +8 -6
- package/src/compiler/predicates.js +49 -24
- package/src/compiler/reader.js +12 -2
- package/src/compiler/state.js +16 -2
- package/src/compiler/structure.js +24 -14
- package/src/deploy.js +8 -4
- package/src/errors.js +1156 -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 +661 -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 +56 -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/statics.d.ts +2 -1
package/src/catalog.js
CHANGED
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
// hints use, and the operator/formatter lists are read from the compiler's own
|
|
14
14
|
// tables (FORMATTER_NAMES, PREDICATE_OPS, LOOP_META) — so the catalog cannot
|
|
15
15
|
// drift from what actually compiles (drift-guarded in tests/catalog.test.js).
|
|
16
|
+
//
|
|
17
|
+
// The catalog also carries the stable compile-error codes (`errors`, from
|
|
18
|
+
// src/errors.js): an AI edit-loop that hits `[WD201] …` can look the code up in
|
|
19
|
+
// the same artifact it learned the syntax from, without parsing the prose.
|
|
16
20
|
// ---------------------------------------------------------------------------
|
|
17
21
|
|
|
18
22
|
import fs from "node:fs";
|
|
@@ -41,6 +45,7 @@ import {
|
|
|
41
45
|
IF_EXAMPLE,
|
|
42
46
|
INCLUDE_EXAMPLE
|
|
43
47
|
} from "./compiler/structure.js";
|
|
48
|
+
import { ERROR_AREAS, errorCatalog } from "./errors.js";
|
|
44
49
|
|
|
45
50
|
/**
|
|
46
51
|
* @typedef {"static" | "reactive" | "either"} Reactivity Whether the directive
|
|
@@ -77,6 +82,10 @@ import {
|
|
|
77
82
|
* @property {CatalogEntry[]} formatPipes The `{ value | pipe }` formatter whitelist.
|
|
78
83
|
* @property {CatalogEntry[]} predicateOps Comparison operators for `where`/`:if`/`when`.
|
|
79
84
|
* @property {string[]} predicateJoiners Logical joiners (`and`/`or`/`not`).
|
|
85
|
+
* @property {import("./errors.js").ErrorArea[]} errorAreas The `WDxxx` code blocks.
|
|
86
|
+
* @property {import("./errors.js").ErrorEntry[]} errors Every stable compile-error
|
|
87
|
+
* code, with its cause and fix. A thrown error's message starts with its code
|
|
88
|
+
* (`[WD201] …`) and mirrors it on `err.wd.code`.
|
|
80
89
|
*/
|
|
81
90
|
|
|
82
91
|
/** The directive surface. `example` is drawn from the compiler's own constants. */
|
|
@@ -512,7 +521,9 @@ export function directiveCatalog() {
|
|
|
512
521
|
description: OP_DESC[op],
|
|
513
522
|
example: op === "contains" ? 'p.tags contains "sale"' : `p.price ${op} 50`
|
|
514
523
|
})),
|
|
515
|
-
predicateJoiners: [...PREDICATE_JOINERS]
|
|
524
|
+
predicateJoiners: [...PREDICATE_JOINERS],
|
|
525
|
+
errorAreas: ERROR_AREAS.map((area) => ({ ...area })),
|
|
526
|
+
errors: errorCatalog()
|
|
516
527
|
};
|
|
517
528
|
}
|
|
518
529
|
|
|
@@ -581,6 +592,10 @@ export function llmsText() {
|
|
|
581
592
|
out.push("- One loop syntax (`@loop … into … @endloop`), one interpolation syntax (`{ name }`).");
|
|
582
593
|
out.push("- Actions and predicates are a fixed whitelist — no JavaScript, no `x.filter(...)`.");
|
|
583
594
|
out.push("- Close every block: `@endloop`, `:endif`, `:endform`, `:endcarousel`, `:::`.");
|
|
595
|
+
out.push(
|
|
596
|
+
"- Compile errors start with a stable code (`[WD201] …`) and always name the file," +
|
|
597
|
+
" the line, and a fix. Read the message; it tells you exactly what to write."
|
|
598
|
+
);
|
|
584
599
|
out.push("");
|
|
585
600
|
return out.join("\n");
|
|
586
601
|
}
|
package/src/compiler/actions.js
CHANGED
|
@@ -37,6 +37,7 @@ export function handleButton(line, ctx, index) {
|
|
|
37
37
|
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
38
38
|
if (!match)
|
|
39
39
|
throw wdError(`Malformed :button in ${at(ctx, index)}: ${line}. ${BUTTON_USE}`, {
|
|
40
|
+
code: "WD301",
|
|
40
41
|
file: ctx.file,
|
|
41
42
|
line: lineOf(ctx, index),
|
|
42
43
|
hint: BUTTON_USE.slice("Use: ".length),
|
|
@@ -74,6 +75,7 @@ export function handleEffect(line, ctx, index) {
|
|
|
74
75
|
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
75
76
|
if (!match)
|
|
76
77
|
throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
|
|
78
|
+
code: "WD302",
|
|
77
79
|
file: ctx.file,
|
|
78
80
|
line: lineOf(ctx, index),
|
|
79
81
|
hint: EFFECT_USE.slice("Use: ".length),
|
|
@@ -82,8 +84,9 @@ export function handleEffect(line, ctx, index) {
|
|
|
82
84
|
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
83
85
|
const key = resolveStateKey(segs[0], ctx);
|
|
84
86
|
if (!key)
|
|
85
|
-
throw
|
|
86
|
-
`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} =
|
|
87
|
+
throw wdError(
|
|
88
|
+
`:effect watches unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`,
|
|
89
|
+
{ code: "WD303", file: ctx.file }
|
|
87
90
|
);
|
|
88
91
|
ctx.comp.assets.runtime = true;
|
|
89
92
|
const watch = [key, ...segs.slice(1)].join(".");
|
|
@@ -122,6 +125,7 @@ export function handleEvery(line, ctx, index) {
|
|
|
122
125
|
const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
|
|
123
126
|
if (!match)
|
|
124
127
|
throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
|
|
128
|
+
code: "WD304",
|
|
125
129
|
file: ctx.file,
|
|
126
130
|
line: lineOf(ctx, index),
|
|
127
131
|
hint: EVERY_USE.slice("Use: ".length),
|
|
@@ -129,9 +133,11 @@ export function handleEvery(line, ctx, index) {
|
|
|
129
133
|
});
|
|
130
134
|
const ms = parseDuration(match[1]);
|
|
131
135
|
if (ms == null || ms <= 0) {
|
|
132
|
-
throw
|
|
133
|
-
|
|
134
|
-
|
|
136
|
+
throw wdError(`:every duration "${match[1]}" is not valid in ${at(ctx, index)}. ${EVERY_USE}`, {
|
|
137
|
+
code: "WD305",
|
|
138
|
+
file: ctx.file,
|
|
139
|
+
line: lineOf(ctx, index)
|
|
140
|
+
});
|
|
135
141
|
}
|
|
136
142
|
ctx.comp.assets.runtime = true;
|
|
137
143
|
const action = parseAction(match[2], ctx);
|
|
@@ -176,8 +182,9 @@ function parseSingleAction(expression, raw, ctx) {
|
|
|
176
182
|
const segs = validatePath(name, ctx, ACTION_USE);
|
|
177
183
|
const key = resolveStateKey(segs[0], ctx);
|
|
178
184
|
if (!key) {
|
|
179
|
-
throw
|
|
180
|
-
`Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} =
|
|
185
|
+
throw wdError(
|
|
186
|
+
`Button action targets unknown state "${segs[0]}" in ${ctx.file}. Declare it first with :state ${segs[0]} = ...`,
|
|
187
|
+
{ code: "WD306", file: ctx.file }
|
|
181
188
|
);
|
|
182
189
|
}
|
|
183
190
|
return [key, ...segs.slice(1)].join(".");
|
|
@@ -198,8 +205,9 @@ function parseSingleAction(expression, raw, ctx) {
|
|
|
198
205
|
if (ctx.loopItem && rhs === ctx.loopItem) {
|
|
199
206
|
const target = resolveTarget(add[1]);
|
|
200
207
|
if (!Array.isArray(ctx.comp.state.get(target))) {
|
|
201
|
-
throw
|
|
202
|
-
`Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}
|
|
208
|
+
throw wdError(
|
|
209
|
+
`Button action "${raw}" needs ${add[1]} to be a :state list (declare it "${add[1]} = []") in ${ctx.file}.`,
|
|
210
|
+
{ code: "WD307", file: ctx.file }
|
|
203
211
|
);
|
|
204
212
|
}
|
|
205
213
|
return { op: "append-row", target };
|
|
@@ -208,8 +216,9 @@ function parseSingleAction(expression, raw, ctx) {
|
|
|
208
216
|
const value = parseActionLiteral(rhs, ctx);
|
|
209
217
|
if (Array.isArray(ctx.comp.state.get(target))) return { op: "append", target, value };
|
|
210
218
|
if (typeof value === "number") return { op: "add", target, value };
|
|
211
|
-
throw
|
|
212
|
-
`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []"
|
|
219
|
+
throw wdError(
|
|
220
|
+
`Unsupported button action "${raw}" in ${ctx.file}. += with non-number values requires a list state target — declare it "${add[1]} = []".`,
|
|
221
|
+
{ code: "WD308", file: ctx.file }
|
|
213
222
|
);
|
|
214
223
|
}
|
|
215
224
|
// `flag toggle` (no operand) → boolean flip; `list toggle v` → member-toggle.
|
|
@@ -235,13 +244,15 @@ function parseSingleAction(expression, raw, ctx) {
|
|
|
235
244
|
// its source is a path off the outer row, which the runtime's row-remove
|
|
236
245
|
// can't target. Fail loud with the honest workaround.
|
|
237
246
|
if (!ctx.loopKey) {
|
|
238
|
-
throw
|
|
239
|
-
`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there
|
|
247
|
+
throw wdError(
|
|
248
|
+
`Button action "${raw}" can't delete a row of a nested (item-relative) loop in ${ctx.file}. Per-row "remove" needs a top-level :state/:store list; carry the row into one (cart += ${ctx.loopItem}) and remove it there.`,
|
|
249
|
+
{ code: "WD309", file: ctx.file }
|
|
240
250
|
);
|
|
241
251
|
}
|
|
242
252
|
if (resolveStateKey(remove[1], ctx) !== ctx.loopKey) {
|
|
243
|
-
throw
|
|
244
|
-
`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}
|
|
253
|
+
throw wdError(
|
|
254
|
+
`Button action "${raw}" must target the :state list being looped (@loop ${remove[1]} into ${ctx.loopItem}) in ${ctx.file}.`,
|
|
255
|
+
{ code: "WD310", file: ctx.file }
|
|
245
256
|
);
|
|
246
257
|
}
|
|
247
258
|
return { op: "remove", target: ctx.loopKey };
|
|
@@ -277,7 +288,12 @@ function parseSingleAction(expression, raw, ctx) {
|
|
|
277
288
|
target: resolveTarget(assign[1]),
|
|
278
289
|
value: parseActionLiteral(assign[2], ctx)
|
|
279
290
|
};
|
|
280
|
-
throw
|
|
291
|
+
throw wdError(`Unsupported button action "${raw}" in ${ctx.file}. ${ACTION_USE}`, {
|
|
292
|
+
code: "WD311",
|
|
293
|
+
file: ctx.file,
|
|
294
|
+
hint: ACTION_USE.slice("Use: ".length),
|
|
295
|
+
example: ACTION_EXAMPLE
|
|
296
|
+
});
|
|
281
297
|
}
|
|
282
298
|
|
|
283
299
|
/**
|
|
@@ -292,8 +308,9 @@ function parseMergeOperand(raw, ctx) {
|
|
|
292
308
|
if (/^[A-Za-z_$][\w$]*$/.test(value)) {
|
|
293
309
|
const key = resolveStateKey(value, ctx);
|
|
294
310
|
if (!key)
|
|
295
|
-
throw
|
|
296
|
-
`Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} =
|
|
311
|
+
throw wdError(
|
|
312
|
+
`Button action merge targets unknown state "${value}" in ${ctx.file}. Declare it first with :state ${value} = ...`,
|
|
313
|
+
{ code: "WD312", file: ctx.file }
|
|
297
314
|
);
|
|
298
315
|
return key;
|
|
299
316
|
}
|
|
@@ -302,6 +319,7 @@ function parseMergeOperand(raw, ctx) {
|
|
|
302
319
|
throw wdError(
|
|
303
320
|
`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object) — e.g. settings merge patch`,
|
|
304
321
|
{
|
|
322
|
+
code: "WD313",
|
|
305
323
|
file: ctx.file,
|
|
306
324
|
hint: "obj merge other (a state key or an inline {…} object) — e.g. settings merge patch",
|
|
307
325
|
example: "settings merge patch"
|
|
@@ -325,12 +343,14 @@ function parseActionLiteral(raw, ctx) {
|
|
|
325
343
|
try {
|
|
326
344
|
return JSON.parse(value);
|
|
327
345
|
} catch {
|
|
328
|
-
throw
|
|
329
|
-
`Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON
|
|
346
|
+
throw wdError(
|
|
347
|
+
`Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`,
|
|
348
|
+
{ code: "WD314", file: ctx.file }
|
|
330
349
|
);
|
|
331
350
|
}
|
|
332
351
|
}
|
|
333
|
-
throw
|
|
334
|
-
`Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON
|
|
352
|
+
throw wdError(
|
|
353
|
+
`Unsupported action literal "${raw}" in ${ctx.file}. Use: a quoted string, number, boolean, null, or valid JSON.`,
|
|
354
|
+
{ code: "WD314", file: ctx.file }
|
|
335
355
|
);
|
|
336
356
|
}
|
package/src/compiler/body.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// multi-line directive bodies with nesting + fence awareness.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
import { at } from "./context.js";
|
|
10
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
11
11
|
import {
|
|
12
12
|
handleBind,
|
|
13
13
|
handleButton,
|
|
@@ -92,7 +92,10 @@ export function compileBody(lines, ctx) {
|
|
|
92
92
|
if (container) {
|
|
93
93
|
flush();
|
|
94
94
|
if (!container[1].trim())
|
|
95
|
-
throw
|
|
95
|
+
throw wdError(`Stray ::: close with no open container in ${ctx.file}`, {
|
|
96
|
+
code: "WD007",
|
|
97
|
+
file: ctx.file
|
|
98
|
+
});
|
|
96
99
|
const block = scanContainer(lines, i, ctx);
|
|
97
100
|
out.push(handleContainer(container[1].trim(), block.body, ctx, i));
|
|
98
101
|
i = block.end;
|
|
@@ -235,17 +238,22 @@ export function compileBody(lines, ctx) {
|
|
|
235
238
|
continue;
|
|
236
239
|
}
|
|
237
240
|
if (/^@repeat\b/.test(line)) {
|
|
238
|
-
throw
|
|
239
|
-
`@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop
|
|
241
|
+
throw wdError(
|
|
242
|
+
`@repeat was replaced by @loop in ${ctx.file}. Use: @loop /data.json into item ... @endloop`,
|
|
243
|
+
{ code: "WD008", file: ctx.file, hint: "@loop /data.json into item ... @endloop" }
|
|
240
244
|
);
|
|
241
245
|
}
|
|
242
246
|
if (/^:for\b/.test(line)) {
|
|
243
|
-
throw
|
|
244
|
-
`:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop
|
|
247
|
+
throw wdError(
|
|
248
|
+
`:for was replaced by @loop in ${ctx.file}. Use: @loop items into item ... @endloop`,
|
|
249
|
+
{ code: "WD009", file: ctx.file, hint: "@loop items into item ... @endloop" }
|
|
245
250
|
);
|
|
246
251
|
}
|
|
247
252
|
if (/^(@endloop|:endif|:endfor|:endform|:else)\s*$/.test(line)) {
|
|
248
|
-
throw
|
|
253
|
+
throw wdError(`Stray "${line.trim()}" with no matching opener in ${ctx.file}`, {
|
|
254
|
+
code: "WD010",
|
|
255
|
+
file: ctx.file
|
|
256
|
+
});
|
|
249
257
|
}
|
|
250
258
|
warnUnknownDirective(line, ctx);
|
|
251
259
|
prose.push(line);
|
|
@@ -371,7 +379,11 @@ function scanBlock(lines, start, openRe, endToken, ctx) {
|
|
|
371
379
|
}
|
|
372
380
|
body.push(line);
|
|
373
381
|
}
|
|
374
|
-
throw
|
|
382
|
+
throw wdError(`Missing ${endToken} for "${lines[start]}" in ${at(ctx, start)}`, {
|
|
383
|
+
code: "WD011",
|
|
384
|
+
file: ctx.file,
|
|
385
|
+
line: lineOf(ctx, start)
|
|
386
|
+
});
|
|
375
387
|
}
|
|
376
388
|
|
|
377
389
|
/**
|
|
@@ -457,7 +469,11 @@ function scanContainer(lines, start, ctx) {
|
|
|
457
469
|
}
|
|
458
470
|
body.push(line);
|
|
459
471
|
}
|
|
460
|
-
throw
|
|
472
|
+
throw wdError(`Missing closing ::: for "${lines[start]}" in ${at(ctx, start)}`, {
|
|
473
|
+
code: "WD012",
|
|
474
|
+
file: ctx.file,
|
|
475
|
+
line: lineOf(ctx, start)
|
|
476
|
+
});
|
|
461
477
|
}
|
|
462
478
|
|
|
463
479
|
/**
|
|
@@ -518,8 +534,9 @@ function scanConditional(lines, start, ctx) {
|
|
|
518
534
|
const elseIf = t.match(/^:else if\s+(.+?)\s*$/);
|
|
519
535
|
if (elseIf) {
|
|
520
536
|
if (mode === "else")
|
|
521
|
-
throw
|
|
522
|
-
`":else if" after ":else" in ${ctx.file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else"
|
|
537
|
+
throw wdError(
|
|
538
|
+
`":else if" after ":else" in ${ctx.file}. ":else" must be the last branch — order the "else if" conditions before the bare ":else".`,
|
|
539
|
+
{ code: "WD013", file: ctx.file }
|
|
523
540
|
);
|
|
524
541
|
falsy.push(`:if ${elseIf[1]}`); // desugar the chain tail into a nested :if
|
|
525
542
|
falsyStart = i;
|
|
@@ -529,8 +546,9 @@ function scanConditional(lines, start, ctx) {
|
|
|
529
546
|
}
|
|
530
547
|
if (t === ":else") {
|
|
531
548
|
if (mode === "else")
|
|
532
|
-
throw
|
|
533
|
-
`Duplicate ":else" in ${ctx.file}. A conditional may have only one bare ":else"
|
|
549
|
+
throw wdError(
|
|
550
|
+
`Duplicate ":else" in ${ctx.file}. A conditional may have only one bare ":else".`,
|
|
551
|
+
{ code: "WD014", file: ctx.file }
|
|
534
552
|
);
|
|
535
553
|
falsyStart = i + 1;
|
|
536
554
|
current = falsy;
|
|
@@ -540,5 +558,9 @@ function scanConditional(lines, start, ctx) {
|
|
|
540
558
|
}
|
|
541
559
|
current.push(line);
|
|
542
560
|
}
|
|
543
|
-
throw
|
|
561
|
+
throw wdError(`Missing :endif for "${lines[start]}" in ${at(ctx, start)}`, {
|
|
562
|
+
code: "WD015",
|
|
563
|
+
file: ctx.file,
|
|
564
|
+
line: lineOf(ctx, start)
|
|
565
|
+
});
|
|
544
566
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { firstParagraph } from "../feeds.js";
|
|
20
|
+
import { wdError } from "./context.js";
|
|
20
21
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
21
22
|
import { fsReader } from "./fs-reader.js";
|
|
22
23
|
|
|
@@ -197,8 +198,9 @@ export function parseSchema(raw, file) {
|
|
|
197
198
|
if (!trimmed || trimmed.startsWith("#")) continue; // blank line or comment
|
|
198
199
|
const match = trimmed.match(/^([A-Za-z0-9_-]+):\s*(.+)$/);
|
|
199
200
|
if (!match) {
|
|
200
|
-
throw
|
|
201
|
-
`Malformed schema line in ${file}:${i + 1}: "${trimmed}". Use: field: type (e.g. \`title: string\`, \`tags: string[]\`, \`featured: boolean?\`)
|
|
201
|
+
throw wdError(
|
|
202
|
+
`Malformed schema line in ${file}:${i + 1}: "${trimmed}". Use: field: type (e.g. \`title: string\`, \`tags: string[]\`, \`featured: boolean?\`).`,
|
|
203
|
+
{ code: "WD120", file, line: i + 1 }
|
|
202
204
|
);
|
|
203
205
|
}
|
|
204
206
|
fields.push(parseSchemaField(match[1], match[2].trim(), file, i + 1));
|
|
@@ -219,8 +221,9 @@ function parseSchemaField(name, rawType, file, lineNo) {
|
|
|
219
221
|
const optional = rawType.endsWith("?");
|
|
220
222
|
const type = optional ? rawType.slice(0, -1).trim() : rawType;
|
|
221
223
|
if (!SCHEMA_TYPES.has(type)) {
|
|
222
|
-
throw
|
|
223
|
-
`Unknown schema type "${rawType}" for field "${name}" in ${file}:${lineNo}. Use one of: ${[...SCHEMA_TYPES].join(", ")} (append "?" to make a field optional)
|
|
224
|
+
throw wdError(
|
|
225
|
+
`Unknown schema type "${rawType}" for field "${name}" in ${file}:${lineNo}. Use one of: ${[...SCHEMA_TYPES].join(", ")} (append "?" to make a field optional).`,
|
|
226
|
+
{ code: "WD121", file, line: lineNo }
|
|
224
227
|
);
|
|
225
228
|
}
|
|
226
229
|
return { name, type, optional };
|
|
@@ -249,8 +252,9 @@ function validateRows(rows, schema, name) {
|
|
|
249
252
|
for (const field of schema) validateField(row, field, where);
|
|
250
253
|
for (const key of Object.keys(row)) {
|
|
251
254
|
if (allowed.has(key)) continue;
|
|
252
|
-
throw
|
|
253
|
-
`Unknown frontmatter field "${key}" in ${where}: it is not declared in _schema.wd. Add \`${key}: <type>\` to the schema, or remove the field
|
|
255
|
+
throw wdError(
|
|
256
|
+
`Unknown frontmatter field "${key}" in ${where}: it is not declared in _schema.wd. Add \`${key}: <type>\` to the schema, or remove the field.`,
|
|
257
|
+
{ code: "WD122" }
|
|
254
258
|
);
|
|
255
259
|
}
|
|
256
260
|
}
|
|
@@ -267,14 +271,16 @@ function validateField(row, field, where) {
|
|
|
267
271
|
const present = field.name in row && row[field.name] !== null && row[field.name] !== "";
|
|
268
272
|
if (!present) {
|
|
269
273
|
if (field.optional) return;
|
|
270
|
-
throw
|
|
271
|
-
`Missing required field "${field.name}" (${field.type}) in ${where}. Add \`${field.name}: …\` to the entry's frontmatter, or mark it optional with "${field.name}: ${field.type}?" in _schema.wd
|
|
274
|
+
throw wdError(
|
|
275
|
+
`Missing required field "${field.name}" (${field.type}) in ${where}. Add \`${field.name}: …\` to the entry's frontmatter, or mark it optional with "${field.name}: ${field.type}?" in _schema.wd.`,
|
|
276
|
+
{ code: "WD123" }
|
|
272
277
|
);
|
|
273
278
|
}
|
|
274
279
|
const value = row[field.name];
|
|
275
280
|
if (!matchesType(value, field.type)) {
|
|
276
|
-
throw
|
|
277
|
-
`Field "${field.name}" in ${where} should be ${field.type} but is "${describe(value)}". Fix the entry's frontmatter or the schema
|
|
281
|
+
throw wdError(
|
|
282
|
+
`Field "${field.name}" in ${where} should be ${field.type} but is "${describe(value)}". Fix the entry's frontmatter or the schema.`,
|
|
283
|
+
{ code: "WD124" }
|
|
278
284
|
);
|
|
279
285
|
}
|
|
280
286
|
}
|
package/src/compiler/context.js
CHANGED
|
@@ -155,10 +155,17 @@
|
|
|
155
155
|
/**
|
|
156
156
|
* Structured, machine-readable companion to a compile error's string message.
|
|
157
157
|
* The string `message` contract is unchanged; `wd` gives an AI edit-loop (or an
|
|
158
|
-
* editor) the same facts without re-parsing the prose: which
|
|
159
|
-
* corrective `Use:` template, and one concrete compilable
|
|
158
|
+
* editor) the same facts without re-parsing the prose: which code, which
|
|
159
|
+
* file/line, the corrective `Use:` template, and one concrete compilable
|
|
160
|
+
* example line.
|
|
160
161
|
* @typedef {object} WdErrorInfo
|
|
161
|
-
* @property {string}
|
|
162
|
+
* @property {string} code Stable error code (`WD` + three digits, e.g. `WD201`).
|
|
163
|
+
* Every code is registered in `src/errors.js` with a title, cause, and fix,
|
|
164
|
+
* and documented in `docs/errors.md`. {@link wdError} prefixes the string
|
|
165
|
+
* message with `[<code>] `, so the code is greppable from the message too.
|
|
166
|
+
* @property {string} [file] Absolute path of the source file the error is in,
|
|
167
|
+
* when the throwing layer knows one (`compileSkin` is a pure string→string
|
|
168
|
+
* pass and a collection-row check names the entry route instead).
|
|
162
169
|
* @property {number} [line] 1-based file line the error points at, when known
|
|
163
170
|
* (some errors — e.g. a bad `where` operand — are file-scoped only, matching
|
|
164
171
|
* the string message, which likewise omits the line there).
|
|
@@ -207,6 +214,23 @@
|
|
|
207
214
|
*/
|
|
208
215
|
|
|
209
216
|
// Page-include extensions are the only two file types `@include` will pull in.
|
|
217
|
+
/**
|
|
218
|
+
* Normalize CRLF/CR line endings to LF.
|
|
219
|
+
*
|
|
220
|
+
* Every line-structured parser in the project (frontmatter delimiters, the `.wd`
|
|
221
|
+
* directive dispatcher, `.skin` indentation) tests LF-shaped text. Source files
|
|
222
|
+
* authored on Windows — or checked out anywhere with git's `core.autocrlf`, the
|
|
223
|
+
* default on Windows — arrive CRLF-terminated, and an un-normalized `\r` turns
|
|
224
|
+
* `---` into `---\r` and silently defeats those tests. Applied at the reader
|
|
225
|
+
* boundary and again at the two entry points that accept raw strings from
|
|
226
|
+
* callers that bypass the reader; it is idempotent, so doubling up is free.
|
|
227
|
+
* @param {string} text
|
|
228
|
+
* @returns {string}
|
|
229
|
+
*/
|
|
230
|
+
export function normalizeNewlines(text) {
|
|
231
|
+
return text.includes("\r") ? text.replace(/\r\n?/g, "\n") : text;
|
|
232
|
+
}
|
|
233
|
+
|
|
210
234
|
export const pageIncludeExtensions = [".md", ".wd"];
|
|
211
235
|
|
|
212
236
|
// Reserved per-row meta variables, mapped to their runtime marker token. Shared
|
|
@@ -286,16 +310,21 @@ export function lineOf(ctx, index) {
|
|
|
286
310
|
}
|
|
287
311
|
|
|
288
312
|
/**
|
|
289
|
-
* Build a compile `Error`
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
313
|
+
* Build a compile `Error` carrying a structured {@link WdErrorInfo} on `err.wd`
|
|
314
|
+
* — so an AI edit-loop (or an editor) gets code/file/line/hint/example without
|
|
315
|
+
* re-parsing the prose. The string message is the caller's text prefixed with
|
|
316
|
+
* the stable `[WDxxx]` code, so a user can search the code straight out of the
|
|
317
|
+
* terminal; everything after the prefix (the `file:line`, the corrective `Use:`
|
|
318
|
+
* template, the concrete `e.g.` example) is unchanged.
|
|
319
|
+
*
|
|
320
|
+
* Every author-facing compile error is built here, so no error can ship without
|
|
321
|
+
* a code. See `src/errors.js` for the registry and the numbering scheme.
|
|
322
|
+
* @param {string} message The user-facing error text, without the code prefix.
|
|
323
|
+
* @param {WdErrorInfo} wd The structured mirror (its `code` becomes the prefix).
|
|
295
324
|
* @returns {Error & { wd: WdErrorInfo }}
|
|
296
325
|
*/
|
|
297
326
|
export function wdError(message, wd) {
|
|
298
|
-
const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(message));
|
|
327
|
+
const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(`[${wd.code}] ${message}`));
|
|
299
328
|
err.wd = wd;
|
|
300
329
|
return err;
|
|
301
330
|
}
|
package/src/compiler/fetch.js
CHANGED
|
@@ -40,6 +40,7 @@ export function handleFetch(line, ctx, index) {
|
|
|
40
40
|
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
41
41
|
if (!head)
|
|
42
42
|
throw wdError(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`, {
|
|
43
|
+
code: "WD501",
|
|
43
44
|
file: ctx.file,
|
|
44
45
|
line: lineOf(ctx, index),
|
|
45
46
|
hint: FETCH_USE.slice("Use: ".length),
|
|
@@ -51,10 +52,19 @@ export function handleFetch(line, ctx, index) {
|
|
|
51
52
|
const opts = {};
|
|
52
53
|
for (const part of head[3].trim().split(/\s+/).filter(Boolean)) {
|
|
53
54
|
const kv = part.match(/^([A-Za-z]+)=(.+)$/);
|
|
54
|
-
if (!kv)
|
|
55
|
+
if (!kv)
|
|
56
|
+
throw wdError(`Unknown :fetch option "${part}" in ${at(ctx, index)}. ${FETCH_USE}`, {
|
|
57
|
+
code: "WD502",
|
|
58
|
+
file: ctx.file,
|
|
59
|
+
line: lineOf(ctx, index)
|
|
60
|
+
});
|
|
55
61
|
const optName = kv[1];
|
|
56
62
|
if (!["method", "when", "timeout", "retry", "headers", "body", "refresh"].includes(optName)) {
|
|
57
|
-
throw
|
|
63
|
+
throw wdError(`Unknown :fetch option "${optName}" in ${at(ctx, index)}. ${FETCH_USE}`, {
|
|
64
|
+
code: "WD502",
|
|
65
|
+
file: ctx.file,
|
|
66
|
+
line: lineOf(ctx, index)
|
|
67
|
+
});
|
|
58
68
|
}
|
|
59
69
|
opts[optName] = stripQuotes(kv[2]);
|
|
60
70
|
}
|
|
@@ -63,17 +73,27 @@ export function handleFetch(line, ctx, index) {
|
|
|
63
73
|
opts.method &&
|
|
64
74
|
!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(opts.method.toUpperCase())
|
|
65
75
|
) {
|
|
66
|
-
throw
|
|
67
|
-
`:fetch method "${opts.method}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}
|
|
76
|
+
throw wdError(
|
|
77
|
+
`:fetch method "${opts.method}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`,
|
|
78
|
+
{
|
|
79
|
+
code: "WD503",
|
|
80
|
+
file: ctx.file,
|
|
81
|
+
line: lineOf(ctx, index)
|
|
82
|
+
}
|
|
68
83
|
);
|
|
69
84
|
}
|
|
70
85
|
if (opts.when && !["load", "visible"].includes(opts.when)) {
|
|
71
|
-
throw
|
|
86
|
+
throw wdError(`:fetch when "${opts.when}" is not allowed in ${at(ctx, index)}. ${FETCH_USE}`, {
|
|
87
|
+
code: "WD504",
|
|
88
|
+
file: ctx.file,
|
|
89
|
+
line: lineOf(ctx, index)
|
|
90
|
+
});
|
|
72
91
|
}
|
|
73
92
|
for (const n of ["timeout", "retry"]) {
|
|
74
93
|
if (opts[n] !== undefined && !/^\d+$/.test(opts[n])) {
|
|
75
|
-
throw
|
|
76
|
-
`:fetch ${n} must be a non-negative integer in ${at(ctx, index)}. ${FETCH_USE}
|
|
94
|
+
throw wdError(
|
|
95
|
+
`:fetch ${n} must be a non-negative integer in ${at(ctx, index)}. ${FETCH_USE}`,
|
|
96
|
+
{ code: "WD505", file: ctx.file, line: lineOf(ctx, index) }
|
|
77
97
|
);
|
|
78
98
|
}
|
|
79
99
|
}
|
|
@@ -81,8 +101,9 @@ export function handleFetch(line, ctx, index) {
|
|
|
81
101
|
// Layer 2: a 401 triggers a token-refresh POST to this URL, then one retry.
|
|
82
102
|
// The new token is written back into the `headers=` state, so it is required.
|
|
83
103
|
if (!opts.headers) {
|
|
84
|
-
throw
|
|
85
|
-
`:fetch refresh= needs headers= (the state key holding the token to renew) in ${at(ctx, index)}. ${FETCH_USE}
|
|
104
|
+
throw wdError(
|
|
105
|
+
`:fetch refresh= needs headers= (the state key holding the token to renew) in ${at(ctx, index)}. ${FETCH_USE}`,
|
|
106
|
+
{ code: "WD506", file: ctx.file, line: lineOf(ctx, index) }
|
|
86
107
|
);
|
|
87
108
|
}
|
|
88
109
|
opts.refresh = validateFetchUrl(opts.refresh, ctx, ":fetch refresh");
|
|
@@ -147,20 +168,23 @@ export function validateFetchUrl(url, ctx, what = ":fetch") {
|
|
|
147
168
|
const value = url.trim();
|
|
148
169
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional — rejects control characters in URLs/hrefs.
|
|
149
170
|
if (value !== url || value === "" || /[\u0000-\u001F\u007F]/.test(value)) {
|
|
150
|
-
throw
|
|
151
|
-
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation
|
|
171
|
+
throw wdError(
|
|
172
|
+
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Use a relative path (/, ./, ../), an http(s):// URL, or a { state } interpolation.`,
|
|
173
|
+
{ code: "WD507", file: ctx.file }
|
|
152
174
|
);
|
|
153
175
|
}
|
|
154
176
|
if (value.startsWith("//")) {
|
|
155
|
-
throw
|
|
156
|
-
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly
|
|
177
|
+
throw wdError(
|
|
178
|
+
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. Protocol-relative URLs are not allowed; use http:// or https:// explicitly.`,
|
|
179
|
+
{ code: "WD508", file: ctx.file }
|
|
157
180
|
);
|
|
158
181
|
}
|
|
159
182
|
if (value.startsWith("{")) return value; // interpolation-first; the runtime percent-encodes state values
|
|
160
183
|
const scheme = value.match(/^([A-Za-z][A-Za-z0-9+.-]*):/);
|
|
161
184
|
if (scheme && !["http", "https"].includes(scheme[1].toLowerCase())) {
|
|
162
|
-
throw
|
|
163
|
-
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path
|
|
185
|
+
throw wdError(
|
|
186
|
+
`Unsafe ${what} URL "${escapeHtml(url)}" in ${ctx.file}. The "${scheme[1]}:" scheme is not allowed; use http://, https://, or a relative path.`,
|
|
187
|
+
{ code: "WD509", file: ctx.file }
|
|
164
188
|
);
|
|
165
189
|
}
|
|
166
190
|
return value;
|
package/src/compiler/format.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// reads :state/:store emits `data-wd-fmt` and re-formats in the runtime.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
+
import { wdError } from "./context.js";
|
|
15
|
+
|
|
14
16
|
/**
|
|
15
17
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
16
18
|
* @typedef {{ name: string, args: Array<string | number | boolean | null> }} Stage
|
|
@@ -221,8 +223,9 @@ export function validatePipes(inner, ctx) {
|
|
|
221
223
|
const parsed = parsePipes(inner);
|
|
222
224
|
for (const stage of parsed.stages) {
|
|
223
225
|
if (!Object.hasOwn(FORMATTERS, stage.name)) {
|
|
224
|
-
throw
|
|
225
|
-
`Unknown formatter "${stage.name}" in { ${inner} } (${ctx.file}). Available: ${FORMATTER_NAMES.join(", ")}
|
|
226
|
+
throw wdError(
|
|
227
|
+
`Unknown formatter "${stage.name}" in { ${inner} } (${ctx.file}). Available: ${FORMATTER_NAMES.join(", ")}.`,
|
|
228
|
+
{ code: "WD006", file: ctx.file }
|
|
226
229
|
);
|
|
227
230
|
}
|
|
228
231
|
}
|