@zvndev/darkmown 2.0.1 → 2.2.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 +18 -0
- package/README.md +61 -6
- package/SECURITY.md +4 -4
- package/docs/cli.md +8 -0
- package/docs/spec-alignment.md +26 -6
- package/grammar/wd-directives.gbnf +58 -0
- package/package.json +5 -1
- package/site/_/footer.wd +1 -1
- package/site/_/nav.wd +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/docs/index.wd +19 -2
- package/site/pages/index.wd +5 -3
- package/site/pages/playground/index.js +158 -0
- package/site/pages/playground/index.skin +128 -0
- package/site/pages/playground/index.wd +51 -0
- package/site/pages/showcase/index.wd +1 -1
- package/src/builder.js +15 -8
- package/src/catalog.js +586 -0
- package/src/cli.js +8 -0
- package/src/compiler/actions.js +45 -12
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +52 -3
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fetch.js +12 -4
- package/src/compiler/forms.js +78 -24
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +20 -15
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +26 -8
- package/src/compiler/media.js +21 -7
- package/src/compiler/page.js +68 -15
- package/src/compiler/predicates.js +29 -18
- package/src/compiler/reader.js +60 -0
- package/src/compiler/state.js +81 -32
- package/src/compiler/structure.js +48 -17
- package/src/compiler.js +57 -6
- package/src/grammar.js +131 -0
- package/src/headers.js +15 -13
- package/src/index.js +2 -0
- package/src/runtime.js +77 -45
- package/src/statics.js +6 -7
- package/types/compiler.d.ts +13 -0
- package/types/index.d.ts +1 -0
package/src/compiler/actions.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while keeping the message text and `Use:` hints intact.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import { at } from "./context.js";
|
|
12
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
13
13
|
import {
|
|
14
14
|
escapeHtml,
|
|
15
15
|
resolveStateKey,
|
|
@@ -23,6 +23,10 @@ import {
|
|
|
23
23
|
* @typedef {import("./context.js").Action} Action
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
// A concrete, compilable :button line for the malformed hint + directive catalog.
|
|
27
|
+
export const BUTTON_EXAMPLE = ':button "Add one" -> count++';
|
|
28
|
+
const BUTTON_USE = `Use: :button "Label" -> action — e.g. ${BUTTON_EXAMPLE}`;
|
|
29
|
+
|
|
26
30
|
/**
|
|
27
31
|
* @param {string} line
|
|
28
32
|
* @param {Ctx} ctx
|
|
@@ -31,7 +35,13 @@ import {
|
|
|
31
35
|
*/
|
|
32
36
|
export function handleButton(line, ctx, index) {
|
|
33
37
|
const match = line.match(/^:button\s+"([^"]+)"\s*->\s*(.+)$/);
|
|
34
|
-
if (!match)
|
|
38
|
+
if (!match)
|
|
39
|
+
throw wdError(`Malformed :button in ${at(ctx, index)}: ${line}. ${BUTTON_USE}`, {
|
|
40
|
+
file: ctx.file,
|
|
41
|
+
line: lineOf(ctx, index),
|
|
42
|
+
hint: BUTTON_USE.slice("Use: ".length),
|
|
43
|
+
example: BUTTON_EXAMPLE
|
|
44
|
+
});
|
|
35
45
|
ctx.comp.assets.runtime = true;
|
|
36
46
|
const action = parseAction(match[2], ctx);
|
|
37
47
|
if (Array.isArray(action)) {
|
|
@@ -44,8 +54,11 @@ export function handleButton(line, ctx, index) {
|
|
|
44
54
|
return `<button type="button" data-wd-action="${action.op}" data-wd-target="${action.target}"${valueAttr}>${escapeHtml(match[1])}</button>`;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
// Concrete, compilable examples for the effect / every hint tails + catalog.
|
|
58
|
+
// (Effects run the :button action vocabulary — mutations, not expressions; use
|
|
59
|
+
// :computed for derived values. `searches++` is the canonical watch-and-act.)
|
|
60
|
+
export const EFFECT_EXAMPLE = ":effect query -> searches++";
|
|
61
|
+
const EFFECT_USE = `Use: :effect watchedState -> action[; action…] (actions use the :button vocabulary) — e.g. ${EFFECT_EXAMPLE}`;
|
|
49
62
|
|
|
50
63
|
/**
|
|
51
64
|
* Parse `:effect <watched> -> <actions>` into a zero-output marker the runtime
|
|
@@ -59,7 +72,13 @@ const EFFECT_USE =
|
|
|
59
72
|
*/
|
|
60
73
|
export function handleEffect(line, ctx, index) {
|
|
61
74
|
const match = line.match(/^:effect\s+([A-Za-z_$][\w$.]*)\s*->\s*(.+)$/);
|
|
62
|
-
if (!match)
|
|
75
|
+
if (!match)
|
|
76
|
+
throw wdError(`Malformed :effect in ${at(ctx, index)}: ${line}. ${EFFECT_USE}`, {
|
|
77
|
+
file: ctx.file,
|
|
78
|
+
line: lineOf(ctx, index),
|
|
79
|
+
hint: EFFECT_USE.slice("Use: ".length),
|
|
80
|
+
example: EFFECT_EXAMPLE
|
|
81
|
+
});
|
|
63
82
|
const segs = validatePath(match[1], ctx, EFFECT_USE);
|
|
64
83
|
const key = resolveStateKey(segs[0], ctx);
|
|
65
84
|
if (!key)
|
|
@@ -73,8 +92,8 @@ export function handleEffect(line, ctx, index) {
|
|
|
73
92
|
return `<script type="application/json" data-wd-effect>${safeScriptJson({ watch, actions })}</script>`;
|
|
74
93
|
}
|
|
75
94
|
|
|
76
|
-
const
|
|
77
|
-
|
|
95
|
+
export const EVERY_EXAMPLE = ":every 5s -> seconds++";
|
|
96
|
+
const EVERY_USE = `Use: :every <duration> -> action[; action…] — duration like 5s, 500ms, or 2m (actions use the :button vocabulary) — e.g. ${EVERY_EXAMPLE}`;
|
|
78
97
|
|
|
79
98
|
/**
|
|
80
99
|
* Parse a duration token into milliseconds: `<int>[ms|s|m]`, defaulting to ms.
|
|
@@ -101,7 +120,13 @@ function parseDuration(raw) {
|
|
|
101
120
|
*/
|
|
102
121
|
export function handleEvery(line, ctx, index) {
|
|
103
122
|
const match = line.match(/^:every\s+(\S+)\s*->\s*(.+)$/);
|
|
104
|
-
if (!match)
|
|
123
|
+
if (!match)
|
|
124
|
+
throw wdError(`Malformed :every in ${at(ctx, index)}: ${line}. ${EVERY_USE}`, {
|
|
125
|
+
file: ctx.file,
|
|
126
|
+
line: lineOf(ctx, index),
|
|
127
|
+
hint: EVERY_USE.slice("Use: ".length),
|
|
128
|
+
example: EVERY_EXAMPLE
|
|
129
|
+
});
|
|
105
130
|
const ms = parseDuration(match[1]);
|
|
106
131
|
if (ms == null || ms <= 0) {
|
|
107
132
|
throw new Error(
|
|
@@ -114,8 +139,11 @@ export function handleEvery(line, ctx, index) {
|
|
|
114
139
|
return `<script type="application/json" data-wd-every>${safeScriptJson({ ms, actions })}</script>`;
|
|
115
140
|
}
|
|
116
141
|
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
// One concrete action expression, appended so a model copies `count++` rather
|
|
143
|
+
// than the schematic `name++`. The action vocabulary the directive catalog draws
|
|
144
|
+
// from lives here (the parser branches below), mirrored compactly in ACTION_USE.
|
|
145
|
+
export const ACTION_EXAMPLE = "count++";
|
|
146
|
+
export const ACTION_USE = `Use: name++, name--, n += k, n -= k, name = v, flag toggle, list append/prepend v, list toggle v, list remove v, x clear, obj merge other, obj delete key, name reset — chain with ";" — e.g. ${ACTION_EXAMPLE}`;
|
|
119
147
|
|
|
120
148
|
/**
|
|
121
149
|
* Parse a `:button` action expression into a validated `{ op, target, value }`,
|
|
@@ -271,8 +299,13 @@ function parseMergeOperand(raw, ctx) {
|
|
|
271
299
|
}
|
|
272
300
|
const parsed = parseActionLiteral(value, ctx);
|
|
273
301
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
274
|
-
throw
|
|
275
|
-
`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object)
|
|
302
|
+
throw wdError(
|
|
303
|
+
`Unsupported merge operand "${raw}" in ${ctx.file}. Use: obj merge other (a state key or an inline {…} object) — e.g. settings merge patch`,
|
|
304
|
+
{
|
|
305
|
+
file: ctx.file,
|
|
306
|
+
hint: "obj merge other (a state key or an inline {…} object) — e.g. settings merge patch",
|
|
307
|
+
example: "settings merge patch"
|
|
308
|
+
}
|
|
276
309
|
);
|
|
277
310
|
}
|
|
278
311
|
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
// `scoped`/`draft`).
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
-
import fs from "node:fs";
|
|
19
18
|
import path from "node:path";
|
|
20
19
|
import { firstParagraph } from "../feeds.js";
|
|
21
20
|
import { parseFrontmatter } from "./frontmatter.js";
|
|
21
|
+
import { fsReader } from "./fs-reader.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* @typedef {import("./context.js").Paths} Paths
|
|
@@ -47,20 +47,22 @@ const SCHEMA_TYPES = new Set(["string", "number", "boolean", "date", "string[]"]
|
|
|
47
47
|
*
|
|
48
48
|
* @param {Route[]} routes Post-draft-filter routes from `discoverRoutes`.
|
|
49
49
|
* @param {Paths} paths Resolved project paths (`routesRoot` for `_schema.wd`).
|
|
50
|
+
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed by
|
|
51
|
+
* default; pass an in-memory reader to build collections without a filesystem).
|
|
50
52
|
* @returns {Map<string, CollectionRow[]>} Collection name → entry rows.
|
|
51
53
|
*/
|
|
52
|
-
export function buildCollections(routes, paths) {
|
|
54
|
+
export function buildCollections(routes, paths, reader = fsReader()) {
|
|
53
55
|
/** @type {Map<string, CollectionRow[]>} */
|
|
54
56
|
const collections = new Map();
|
|
55
57
|
for (const route of routes) {
|
|
56
58
|
const name = collectionNameOf(route.route);
|
|
57
59
|
if (!name) continue;
|
|
58
60
|
const rows = collections.get(name) || [];
|
|
59
|
-
rows.push(rowFor(route));
|
|
61
|
+
rows.push(rowFor(route, reader));
|
|
60
62
|
collections.set(name, rows);
|
|
61
63
|
}
|
|
62
64
|
for (const [name, rows] of collections) {
|
|
63
|
-
const schema = readSchema(name, paths);
|
|
65
|
+
const schema = readSchema(name, paths, reader);
|
|
64
66
|
if (schema) validateRows(rows, schema, name);
|
|
65
67
|
}
|
|
66
68
|
return collections;
|
|
@@ -87,9 +89,10 @@ function collectionNameOf(route) {
|
|
|
87
89
|
* are added AFTER the spread so a stray frontmatter `url:`/`slug:` can't shadow
|
|
88
90
|
* the real route data the loop relies on.
|
|
89
91
|
* @param {Route} route
|
|
92
|
+
* @param {import("./reader.js").Reader} reader Source reader for the excerpt body.
|
|
90
93
|
* @returns {CollectionRow}
|
|
91
94
|
*/
|
|
92
|
-
function rowFor(route) {
|
|
95
|
+
function rowFor(route, reader) {
|
|
93
96
|
const slug = path.basename(route.file, path.extname(route.file));
|
|
94
97
|
/** @type {Record<string, unknown>} */
|
|
95
98
|
const fields = {};
|
|
@@ -98,7 +101,7 @@ function rowFor(route) {
|
|
|
98
101
|
...fields,
|
|
99
102
|
url: route.route,
|
|
100
103
|
slug: slug === "index" ? path.basename(path.dirname(route.file)) : slug,
|
|
101
|
-
excerpt: excerptFor(route)
|
|
104
|
+
excerpt: excerptFor(route, reader)
|
|
102
105
|
};
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -126,13 +129,14 @@ function coerceScalar(value) {
|
|
|
126
129
|
* (plain `.md` only) the first paragraph of its body, else "". Mirrors the RSS
|
|
127
130
|
* description fallback so a collection card and the feed read the same snippet.
|
|
128
131
|
* @param {Route} route
|
|
132
|
+
* @param {import("./reader.js").Reader} reader Source reader for the `.md` body.
|
|
129
133
|
* @returns {string}
|
|
130
134
|
*/
|
|
131
|
-
function excerptFor(route) {
|
|
135
|
+
function excerptFor(route, reader) {
|
|
132
136
|
const fromMeta = route.meta.excerpt;
|
|
133
137
|
if (typeof fromMeta === "string" && fromMeta.trim()) return fromMeta.trim();
|
|
134
138
|
if (path.extname(route.file) !== ".md") return "";
|
|
135
|
-
const { body } = parseFrontmatter(
|
|
139
|
+
const { body } = parseFrontmatter(reader.readText(route.file), route.file);
|
|
136
140
|
return firstParagraph(body);
|
|
137
141
|
}
|
|
138
142
|
|
|
@@ -153,12 +157,13 @@ function excerptFor(route) {
|
|
|
153
157
|
* schema file itself, pointing at the offending line.
|
|
154
158
|
* @param {string} name Collection name.
|
|
155
159
|
* @param {Paths} paths
|
|
160
|
+
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed by default).
|
|
156
161
|
* @returns {SchemaField[] | null}
|
|
157
162
|
*/
|
|
158
|
-
export function readSchema(name, paths) {
|
|
163
|
+
export function readSchema(name, paths, reader = fsReader()) {
|
|
159
164
|
const file = path.join(paths.routesRoot, name, "_schema.wd");
|
|
160
|
-
if (!
|
|
161
|
-
const raw =
|
|
165
|
+
if (!reader.exists(file)) return null;
|
|
166
|
+
const raw = reader.readText(file);
|
|
162
167
|
return parseSchema(raw, file);
|
|
163
168
|
}
|
|
164
169
|
|
package/src/compiler/context.js
CHANGED
|
@@ -77,6 +77,10 @@
|
|
|
77
77
|
* dependency map that drives incremental rebuilds.
|
|
78
78
|
* @property {Set<string>} collectionsUsed Names of the collections this compile
|
|
79
79
|
* looped, so a change to any entry of a collection rebuilds its consumers.
|
|
80
|
+
* @property {import("./reader.js").Reader} reader The file reader every source
|
|
81
|
+
* read threads through — the real filesystem by default, or an in-memory map
|
|
82
|
+
* for `compileFromMemory`. Set once by `createCompilation` and reached via
|
|
83
|
+
* `ctx.comp.reader` in the directive handlers (includes, loop data).
|
|
80
84
|
*/
|
|
81
85
|
|
|
82
86
|
/**
|
|
@@ -148,6 +152,20 @@
|
|
|
148
152
|
* @property {Set<string>} collectionsUsed Collection names this compile looped.
|
|
149
153
|
*/
|
|
150
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Structured, machine-readable companion to a compile error's string message.
|
|
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 file/line, the
|
|
159
|
+
* corrective `Use:` template, and one concrete compilable example line.
|
|
160
|
+
* @typedef {object} WdErrorInfo
|
|
161
|
+
* @property {string} file Absolute path of the source file the error is in.
|
|
162
|
+
* @property {number} [line] 1-based file line the error points at, when known
|
|
163
|
+
* (some errors — e.g. a bad `where` operand — are file-scoped only, matching
|
|
164
|
+
* the string message, which likewise omits the line there).
|
|
165
|
+
* @property {string} [hint] The corrective `Use:` template (without the `Use: ` prefix).
|
|
166
|
+
* @property {string} [example] One concrete, compilable directive line.
|
|
167
|
+
*/
|
|
168
|
+
|
|
151
169
|
/**
|
|
152
170
|
* A compiled `@loop … where` predicate.
|
|
153
171
|
* @typedef {object} Predicate
|
|
@@ -202,9 +220,14 @@ export const LOOP_META = {
|
|
|
202
220
|
$count: "count"
|
|
203
221
|
};
|
|
204
222
|
|
|
205
|
-
/**
|
|
206
|
-
|
|
223
|
+
/**
|
|
224
|
+
* @param {import("./reader.js").Reader} reader The source reader for this
|
|
225
|
+
* compile (fs-backed by default; in-memory for `compileFromMemory`).
|
|
226
|
+
* @returns {Compilation}
|
|
227
|
+
*/
|
|
228
|
+
export function createCompilation(reader) {
|
|
207
229
|
return {
|
|
230
|
+
reader,
|
|
208
231
|
assets: {
|
|
209
232
|
skins: new Set(),
|
|
210
233
|
scripts: new Set(),
|
|
@@ -248,7 +271,33 @@ export function createScope(parent, vars = {}) {
|
|
|
248
271
|
* @returns {string}
|
|
249
272
|
*/
|
|
250
273
|
export function at(ctx, index) {
|
|
251
|
-
return `${ctx.file}:${
|
|
274
|
+
return `${ctx.file}:${lineOf(ctx, index)}`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The 1-based true file line for a body-slice `index` — the numeric half of
|
|
279
|
+
* {@link at}, exposed so {@link wdError} can populate the structured `wd.line`.
|
|
280
|
+
* @param {Pick<Ctx, "bodyLine" | "lineOffset">} ctx
|
|
281
|
+
* @param {number} index 0-based line index into the current body slice.
|
|
282
|
+
* @returns {number}
|
|
283
|
+
*/
|
|
284
|
+
export function lineOf(ctx, index) {
|
|
285
|
+
return index + 1 + (ctx.bodyLine ?? 0) + (ctx.lineOffset ?? 0);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build a compile `Error` whose string `message` is unchanged but which also
|
|
290
|
+
* carries a structured {@link WdErrorInfo} on `err.wd` — so an AI edit-loop (or
|
|
291
|
+
* an editor) gets file/line/hint/example without re-parsing the prose. The
|
|
292
|
+
* string contract is the source of truth; `wd` is a strictly additive mirror.
|
|
293
|
+
* @param {string} message The full user-facing error string (unchanged contract).
|
|
294
|
+
* @param {WdErrorInfo} wd The structured mirror.
|
|
295
|
+
* @returns {Error & { wd: WdErrorInfo }}
|
|
296
|
+
*/
|
|
297
|
+
export function wdError(message, wd) {
|
|
298
|
+
const err = /** @type {Error & { wd: WdErrorInfo }} */ (new Error(message));
|
|
299
|
+
err.wd = wd;
|
|
300
|
+
return err;
|
|
252
301
|
}
|
|
253
302
|
|
|
254
303
|
/**
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Compiled-expression → compact AST.
|
|
3
|
+
//
|
|
4
|
+
// The predicate/computed compilers in `predicates.js` still do ALL the security
|
|
5
|
+
// validation — they map every operand through a fixed grammar to safe JS over the
|
|
6
|
+
// `I()`/`S()`/`C()`/`A()` readers, and reject anything outside the whitelist. This
|
|
7
|
+
// module takes that ALREADY-VALIDATED JS fragment and re-parses it into a compact
|
|
8
|
+
// serialized AST (nested arrays), so the runtime can WALK it with a tiny closed
|
|
9
|
+
// evaluator instead of constructing a `new Function`. That is the whole point:
|
|
10
|
+
// reactive pages no longer need `script-src 'unsafe-eval'`.
|
|
11
|
+
//
|
|
12
|
+
// The parsed sub-language is closed and small: the reader calls `S(k[,r])`,
|
|
13
|
+
// `I(p)`, `C(a,b)`, `A(name,list[,field])`; number / JSON-string / boolean / null
|
|
14
|
+
// / object / array literals; the operators `! - * / % + - < > <= >= == != && ||`;
|
|
15
|
+
// and grouping parens. Precedence and associativity match JavaScript exactly, so
|
|
16
|
+
// the walked AST evaluates identically to the prior `new Function` path. Anything
|
|
17
|
+
// unexpected is a hard parse error — never a silent fallthrough.
|
|
18
|
+
//
|
|
19
|
+
// Node shapes (array with a tag at [0]):
|
|
20
|
+
// ["L", value] literal (number | string | boolean | null | obj | arr)
|
|
21
|
+
// ["S", key] state read → getPath(state[key])
|
|
22
|
+
// ["S", key, rest] dotted state read → getPath(state[key], rest)
|
|
23
|
+
// ["I", path] loop-item read → getPath(item, path)
|
|
24
|
+
// ["C", a, b] contains(a, b)
|
|
25
|
+
// ["A", name, list] aggregate → AGG(name, list)
|
|
26
|
+
// ["A", name, list, fld] aggregate w/ field → AGG(name, list, fld)
|
|
27
|
+
// ["!", a] | ["u-", a] unary not / negate
|
|
28
|
+
// [op, a, b] binary op (op ∈ the operator set above)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
import { FORMATTERS } from "./format.js";
|
|
32
|
+
import { getPath } from "./interpolation.js";
|
|
33
|
+
|
|
34
|
+
// Binary-operator precedence (higher binds tighter), matching JS.
|
|
35
|
+
/** @type {Record<string, number>} */
|
|
36
|
+
const PREC = {
|
|
37
|
+
"||": 1,
|
|
38
|
+
"&&": 2,
|
|
39
|
+
"==": 3,
|
|
40
|
+
"!=": 3,
|
|
41
|
+
"<": 4,
|
|
42
|
+
">": 4,
|
|
43
|
+
"<=": 4,
|
|
44
|
+
">=": 4,
|
|
45
|
+
"+": 5,
|
|
46
|
+
"-": 5,
|
|
47
|
+
"*": 6,
|
|
48
|
+
"/": 6,
|
|
49
|
+
"%": 6
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Re-parse a validated compiled expression (JS fragment over I()/S()/C()/A())
|
|
54
|
+
* into a compact AST. Throws on anything outside the closed grammar.
|
|
55
|
+
* @param {string} code
|
|
56
|
+
* @returns {any[]}
|
|
57
|
+
*/
|
|
58
|
+
export function astOf(code) {
|
|
59
|
+
let i = 0;
|
|
60
|
+
const n = code.length;
|
|
61
|
+
const ws = () => {
|
|
62
|
+
while (i < n && /\s/.test(code[i])) i++;
|
|
63
|
+
};
|
|
64
|
+
/** Peek a binary operator at the cursor without consuming; null if none. */
|
|
65
|
+
const peekOp = () => {
|
|
66
|
+
ws();
|
|
67
|
+
const two = code.slice(i, i + 2);
|
|
68
|
+
if (
|
|
69
|
+
two === "&&" ||
|
|
70
|
+
two === "||" ||
|
|
71
|
+
two === "==" ||
|
|
72
|
+
two === "!=" ||
|
|
73
|
+
two === "<=" ||
|
|
74
|
+
two === ">="
|
|
75
|
+
)
|
|
76
|
+
return two;
|
|
77
|
+
const one = code[i];
|
|
78
|
+
if (
|
|
79
|
+
one === "<" ||
|
|
80
|
+
one === ">" ||
|
|
81
|
+
one === "+" ||
|
|
82
|
+
one === "-" ||
|
|
83
|
+
one === "*" ||
|
|
84
|
+
one === "/" ||
|
|
85
|
+
one === "%"
|
|
86
|
+
)
|
|
87
|
+
return one;
|
|
88
|
+
return null;
|
|
89
|
+
};
|
|
90
|
+
/** @param {number} minPrec @returns {any[]} */
|
|
91
|
+
const parseExpr = (minPrec) => {
|
|
92
|
+
/** @type {any[]} */
|
|
93
|
+
let left = parseUnary();
|
|
94
|
+
for (;;) {
|
|
95
|
+
const op = peekOp();
|
|
96
|
+
if (op === null) break;
|
|
97
|
+
const prec = PREC[op];
|
|
98
|
+
if (prec < minPrec) break;
|
|
99
|
+
i += op.length;
|
|
100
|
+
const right = parseExpr(prec + 1);
|
|
101
|
+
left = [op, left, right];
|
|
102
|
+
}
|
|
103
|
+
return left;
|
|
104
|
+
};
|
|
105
|
+
/** @returns {any[]} */
|
|
106
|
+
const parseUnary = () => {
|
|
107
|
+
ws();
|
|
108
|
+
if (code[i] === "!" && code[i + 1] !== "=") {
|
|
109
|
+
i++;
|
|
110
|
+
return ["!", parseUnary()];
|
|
111
|
+
}
|
|
112
|
+
if (code[i] === "-") {
|
|
113
|
+
i++;
|
|
114
|
+
return ["u-", parseUnary()];
|
|
115
|
+
}
|
|
116
|
+
return parsePrimary();
|
|
117
|
+
};
|
|
118
|
+
/** @returns {any[]} */
|
|
119
|
+
const parsePrimary = () => {
|
|
120
|
+
ws();
|
|
121
|
+
const c = code[i];
|
|
122
|
+
if (c === "(") {
|
|
123
|
+
i++;
|
|
124
|
+
/** @type {any[]} */
|
|
125
|
+
const e = parseExpr(1);
|
|
126
|
+
ws();
|
|
127
|
+
if (code[i] !== ")") throw new Error(`expr-ast: expected ) at ${i} in ${code}`);
|
|
128
|
+
i++;
|
|
129
|
+
return e;
|
|
130
|
+
}
|
|
131
|
+
if (c === '"') return ["L", readString()];
|
|
132
|
+
if (c === "{" || c === "[") return ["L", readJson()];
|
|
133
|
+
if (c === "." || (c >= "0" && c <= "9")) return ["L", readNumber()];
|
|
134
|
+
if (/[A-Za-z_$]/.test(c)) return readIdent();
|
|
135
|
+
throw new Error(`expr-ast: unexpected "${c ?? "<end>"}" at ${i} in ${code}`);
|
|
136
|
+
};
|
|
137
|
+
const readString = () => {
|
|
138
|
+
let j = i + 1;
|
|
139
|
+
while (j < n) {
|
|
140
|
+
if (code[j] === "\\") j += 2;
|
|
141
|
+
else if (code[j] === '"') break;
|
|
142
|
+
else j++;
|
|
143
|
+
}
|
|
144
|
+
const lit = code.slice(i, j + 1);
|
|
145
|
+
i = j + 1;
|
|
146
|
+
return JSON.parse(lit);
|
|
147
|
+
};
|
|
148
|
+
const readJson = () => {
|
|
149
|
+
let depth = 0;
|
|
150
|
+
let j = i;
|
|
151
|
+
for (; j < n; j++) {
|
|
152
|
+
const ch = code[j];
|
|
153
|
+
if (ch === '"') {
|
|
154
|
+
j++;
|
|
155
|
+
while (j < n && code[j] !== '"') j += code[j] === "\\" ? 2 : 1;
|
|
156
|
+
} else if (ch === "{" || ch === "[") depth++;
|
|
157
|
+
else if (ch === "}" || ch === "]") {
|
|
158
|
+
depth--;
|
|
159
|
+
if (depth === 0) {
|
|
160
|
+
j++;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const lit = code.slice(i, j);
|
|
166
|
+
i = j;
|
|
167
|
+
return JSON.parse(lit);
|
|
168
|
+
};
|
|
169
|
+
const readNumber = () => {
|
|
170
|
+
let j = i;
|
|
171
|
+
while (j < n && /[0-9.]/.test(code[j])) j++;
|
|
172
|
+
const num = Number(code.slice(i, j));
|
|
173
|
+
i = j;
|
|
174
|
+
return num;
|
|
175
|
+
};
|
|
176
|
+
/** @returns {any[]} */
|
|
177
|
+
const readIdent = () => {
|
|
178
|
+
let j = i;
|
|
179
|
+
while (j < n && /[\w$]/.test(code[j])) j++;
|
|
180
|
+
const word = code.slice(i, j);
|
|
181
|
+
i = j;
|
|
182
|
+
if (word === "true") return ["L", true];
|
|
183
|
+
if (word === "false") return ["L", false];
|
|
184
|
+
if (word === "null") return ["L", null];
|
|
185
|
+
if (code[i] !== "(") throw new Error(`expr-ast: unexpected identifier "${word}" in ${code}`);
|
|
186
|
+
const args = parseArgs();
|
|
187
|
+
if (word === "S") return args.length > 1 ? ["S", args[0][1], args[1][1]] : ["S", args[0][1]];
|
|
188
|
+
if (word === "I") return ["I", args[0][1]];
|
|
189
|
+
if (word === "C") return ["C", args[0], args[1]];
|
|
190
|
+
if (word === "A")
|
|
191
|
+
return args.length > 2 ? ["A", args[0][1], args[1], args[2][1]] : ["A", args[0][1], args[1]];
|
|
192
|
+
throw new Error(`expr-ast: unknown call "${word}" in ${code}`);
|
|
193
|
+
};
|
|
194
|
+
/** @returns {any[][]} */
|
|
195
|
+
const parseArgs = () => {
|
|
196
|
+
i++; // consume "("
|
|
197
|
+
/** @type {any[][]} */
|
|
198
|
+
const args = [];
|
|
199
|
+
ws();
|
|
200
|
+
if (code[i] !== ")") {
|
|
201
|
+
args.push(parseExpr(1));
|
|
202
|
+
ws();
|
|
203
|
+
while (code[i] === ",") {
|
|
204
|
+
i++;
|
|
205
|
+
args.push(parseExpr(1));
|
|
206
|
+
ws();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
ws();
|
|
210
|
+
if (code[i] !== ")") throw new Error(`expr-ast: expected ) closing call in ${code}`);
|
|
211
|
+
i++;
|
|
212
|
+
return args;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const ast = parseExpr(1);
|
|
216
|
+
ws();
|
|
217
|
+
if (i !== n) throw new Error(`expr-ast: trailing input at ${i} in ${code}`);
|
|
218
|
+
return ast;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* `JSON.stringify(astOf(code))` — the attribute payload the runtime reads.
|
|
223
|
+
* @param {string} code
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
export const serializeExpr = (code) => JSON.stringify(astOf(code));
|
|
227
|
+
|
|
228
|
+
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
229
|
+
const contains = (a, b) =>
|
|
230
|
+
String(a ?? "")
|
|
231
|
+
.toLowerCase()
|
|
232
|
+
.includes(String(b ?? "").toLowerCase());
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build-time mirror of the runtime AST walker — same operators, so a `:computed`
|
|
236
|
+
* or `where`/`when` predicate folds to the SAME value the runtime will recompute.
|
|
237
|
+
* `state` reads come from the compilation's declared-state map. No eval.
|
|
238
|
+
* @param {any[]} node
|
|
239
|
+
* @param {unknown} item Loop row for I(); undefined otherwise.
|
|
240
|
+
* @param {{ state: Map<string, unknown> }} comp
|
|
241
|
+
* @returns {any}
|
|
242
|
+
*/
|
|
243
|
+
export function evalAst(node, item, comp) {
|
|
244
|
+
/** @param {any[]} n @returns {any} */
|
|
245
|
+
const ev = (n) => {
|
|
246
|
+
const t = n[0];
|
|
247
|
+
switch (t) {
|
|
248
|
+
case "L":
|
|
249
|
+
return n[1];
|
|
250
|
+
case "S":
|
|
251
|
+
return getPath(comp.state.get(n[1]), n[2] ? n[2].split(".") : []);
|
|
252
|
+
case "I":
|
|
253
|
+
return getPath(item, n[1] ? n[1].split(".") : []);
|
|
254
|
+
case "C":
|
|
255
|
+
return contains(ev(n[1]), ev(n[2]));
|
|
256
|
+
case "A": {
|
|
257
|
+
const f = FORMATTERS[n[1]];
|
|
258
|
+
return f ? f(ev(n[2]), n[3] == null ? [] : [n[3]]) : undefined;
|
|
259
|
+
}
|
|
260
|
+
case "!":
|
|
261
|
+
return !ev(n[1]);
|
|
262
|
+
case "u-":
|
|
263
|
+
return -ev(n[1]);
|
|
264
|
+
case "&&":
|
|
265
|
+
return ev(n[1]) && ev(n[2]);
|
|
266
|
+
case "||":
|
|
267
|
+
return ev(n[1]) || ev(n[2]);
|
|
268
|
+
case "==":
|
|
269
|
+
// biome-ignore lint/suspicious/noDoubleEquals: loose equality preserves prior `new Function` semantics exactly.
|
|
270
|
+
return ev(n[1]) == ev(n[2]);
|
|
271
|
+
case "!=":
|
|
272
|
+
// biome-ignore lint/suspicious/noDoubleEquals: loose inequality preserves prior `new Function` semantics exactly.
|
|
273
|
+
return ev(n[1]) != ev(n[2]);
|
|
274
|
+
case "<":
|
|
275
|
+
return ev(n[1]) < ev(n[2]);
|
|
276
|
+
case ">":
|
|
277
|
+
return ev(n[1]) > ev(n[2]);
|
|
278
|
+
case "<=":
|
|
279
|
+
return ev(n[1]) <= ev(n[2]);
|
|
280
|
+
case ">=":
|
|
281
|
+
return ev(n[1]) >= ev(n[2]);
|
|
282
|
+
case "+":
|
|
283
|
+
return ev(n[1]) + ev(n[2]);
|
|
284
|
+
case "-":
|
|
285
|
+
return ev(n[1]) - ev(n[2]);
|
|
286
|
+
case "*":
|
|
287
|
+
return ev(n[1]) * ev(n[2]);
|
|
288
|
+
case "/":
|
|
289
|
+
return ev(n[1]) / ev(n[2]);
|
|
290
|
+
case "%":
|
|
291
|
+
return ev(n[1]) % ev(n[2]);
|
|
292
|
+
}
|
|
293
|
+
throw new Error(`expr-ast: unknown op "${t}"`);
|
|
294
|
+
};
|
|
295
|
+
return ev(node);
|
|
296
|
+
}
|
package/src/compiler/fetch.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// while keeping the message text and `Use:` hints intact.
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
import { at } from "./context.js";
|
|
10
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
11
11
|
import {
|
|
12
12
|
escapeHtml,
|
|
13
13
|
resolveStateKey,
|
|
@@ -21,8 +21,10 @@ import { declareState } from "./state.js";
|
|
|
21
21
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// A concrete, compilable :fetch line for the hint tail + the directive catalog.
|
|
25
|
+
export const FETCH_EXAMPLE = ':fetch todos from "/api/todos.json" when=visible';
|
|
26
|
+
|
|
27
|
+
const FETCH_USE = `Use: :fetch name from "url" [method=…] [timeout=ms] [retry=N] [when=visible] [headers=key] [body=key] [refresh=url] — e.g. ${FETCH_EXAMPLE}`;
|
|
26
28
|
|
|
27
29
|
/**
|
|
28
30
|
* Parse a keyword-arg `:fetch` directive into a lifecycle-aware marker.
|
|
@@ -36,7 +38,13 @@ const FETCH_USE =
|
|
|
36
38
|
*/
|
|
37
39
|
export function handleFetch(line, ctx, index) {
|
|
38
40
|
const head = line.match(/^:fetch\s+([A-Za-z_$][\w$]*)\s+from\s+("[^"]+"|\S+)\s*(.*)$/);
|
|
39
|
-
if (!head)
|
|
41
|
+
if (!head)
|
|
42
|
+
throw wdError(`Malformed :fetch in ${at(ctx, index)}: ${line}. ${FETCH_USE}`, {
|
|
43
|
+
file: ctx.file,
|
|
44
|
+
line: lineOf(ctx, index),
|
|
45
|
+
hint: FETCH_USE.slice("Use: ".length),
|
|
46
|
+
example: FETCH_EXAMPLE
|
|
47
|
+
});
|
|
40
48
|
const name = head[1];
|
|
41
49
|
const url = validateFetchUrl(stripQuotes(head[2]), ctx);
|
|
42
50
|
/** @type {Record<string, string>} */
|