@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
|
@@ -5,6 +5,8 @@
|
|
|
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";
|
|
9
|
+
import { astOf, evalAst } from "./expr-ast.js";
|
|
8
10
|
import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -12,6 +14,20 @@ import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
|
12
14
|
* @typedef {import("./context.js").Predicate} Predicate
|
|
13
15
|
*/
|
|
14
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
|
+
|
|
15
31
|
// Compile a `where` predicate to a safe JS boolean expression over I()/S()/C().
|
|
16
32
|
// Conditions (operand <op> operand) join with `and`/`or`; operands are loop-item
|
|
17
33
|
// paths, declared :state, numbers, or strings. No identifiers survive un-mapped.
|
|
@@ -45,11 +61,15 @@ export function compilePredicate(raw, itemName, ctx) {
|
|
|
45
61
|
* @returns {{ expr: string, usesState: boolean }}
|
|
46
62
|
*/
|
|
47
63
|
function compileCondition(cond, itemName, ctx) {
|
|
48
|
-
const m = cond.match(
|
|
49
|
-
if (!m)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
file: ctx.file,
|
|
69
|
+
hint,
|
|
70
|
+
example: `${itemName}.price < 50`
|
|
71
|
+
});
|
|
72
|
+
}
|
|
53
73
|
const left = compileOperand(m[1].trim(), itemName, ctx);
|
|
54
74
|
const right = compileOperand(m[3].trim(), itemName, ctx);
|
|
55
75
|
const usesState = left.usesState || right.usesState;
|
|
@@ -93,14 +113,9 @@ function compileOperand(tok, itemName, ctx) {
|
|
|
93
113
|
};
|
|
94
114
|
}
|
|
95
115
|
|
|
96
|
-
/** @param {unknown} a @param {unknown} b @returns {boolean} */
|
|
97
|
-
const containsHelper = (a, b) =>
|
|
98
|
-
String(a ?? "")
|
|
99
|
-
.toLowerCase()
|
|
100
|
-
.includes(String(b ?? "").toLowerCase());
|
|
101
|
-
|
|
102
116
|
/**
|
|
103
|
-
* Evaluate a compiled predicate against a row at build time
|
|
117
|
+
* Evaluate a compiled predicate against a row at build time by walking its AST —
|
|
118
|
+
* the same closed evaluator the runtime uses, so the fold matches. No eval.
|
|
104
119
|
* @param {string} body
|
|
105
120
|
* @param {unknown} item
|
|
106
121
|
* @param {Ctx} ctx
|
|
@@ -108,11 +123,7 @@ const containsHelper = (a, b) =>
|
|
|
108
123
|
*/
|
|
109
124
|
export function evalPredicate(body, item, ctx) {
|
|
110
125
|
try {
|
|
111
|
-
|
|
112
|
-
const I = (p) => getPath(item, p ? p.split(".") : []);
|
|
113
|
-
/** @param {string} k @param {string} [r] */
|
|
114
|
-
const S = (k, r) => getPath(ctx.comp.state.get(k), r ? r.split(".") : []);
|
|
115
|
-
return Boolean(new Function("I", "S", "C", `return (${body});`)(I, S, containsHelper));
|
|
126
|
+
return Boolean(evalAst(astOf(body), item, ctx.comp));
|
|
116
127
|
} catch {
|
|
117
128
|
console.warn(
|
|
118
129
|
`@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`
|
|
@@ -197,7 +208,7 @@ export function compileWhen(raw, ctx, what = '"::: … when"') {
|
|
|
197
208
|
negate = true;
|
|
198
209
|
seg = seg.replace(/^not\s+/i, "").trim();
|
|
199
210
|
}
|
|
200
|
-
const opMatch = seg.match(
|
|
211
|
+
const opMatch = seg.match(CONDITION_RE);
|
|
201
212
|
let expr;
|
|
202
213
|
if (opMatch) {
|
|
203
214
|
const left = compileWhenOperand(opMatch[1].trim(), ctx, what);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The file-reader abstraction the compiler reads every source through. The
|
|
3
|
+
// compiler never touches `node:fs` directly; instead a `Reader` is threaded
|
|
4
|
+
// through the `Compilation` (and the page-shell helpers), so the exact same
|
|
5
|
+
// compile can run against the real filesystem (`fsReader`, in `fs-reader.js`)
|
|
6
|
+
// or against an in-memory map of project-relative path → content
|
|
7
|
+
// (`memoryReader`, here). The memory path is what `compileFromMemory` — and a
|
|
8
|
+
// planned mobile/browser host — needs: a fully fs-free compile.
|
|
9
|
+
//
|
|
10
|
+
// This module imports NOTHING from `node:fs`, so it (and everything reachable
|
|
11
|
+
// from `compileFromMemory`) bundles cleanly for the browser. The Node reader
|
|
12
|
+
// lives in its own `fs-reader.js` that no browser-reachable module imports.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The narrow filesystem surface the compiler needs. Every method takes an
|
|
19
|
+
* ABSOLUTE path (the compiler resolves includes/assets to absolute paths before
|
|
20
|
+
* reading), so a reader only has to map absolute paths to bytes/existence.
|
|
21
|
+
* @typedef {object} Reader
|
|
22
|
+
* @property {(absPath: string) => string} readText Read a UTF-8 text file.
|
|
23
|
+
* @property {(absPath: string) => Uint8Array} readBinary Read raw bytes (images).
|
|
24
|
+
* @property {(absPath: string) => boolean} exists Whether the path exists.
|
|
25
|
+
* @property {(absPath: string) => string} realpath Canonical path (symlinks
|
|
26
|
+
* resolved on a real fs; a no-op normalize in memory) — used for cycle keys.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A reader backed by an in-memory map of PROJECT-RELATIVE path → content. Keys
|
|
31
|
+
* are POSIX, project-relative (e.g. `site/pages/index.wd`, `site/_/nav.wd`);
|
|
32
|
+
* lookups translate an absolute compile path back to its key via `cwd`. There
|
|
33
|
+
* are no symlinks in memory, so `realpath` just normalizes. Binary reads are
|
|
34
|
+
* unsupported (the playground has no image files, and image measuring degrades
|
|
35
|
+
* to "no dimensions" on a throw), so `readBinary` throws — callers already
|
|
36
|
+
* try/catch it.
|
|
37
|
+
* @param {Record<string, string> | Map<string, string>} files
|
|
38
|
+
* @param {string} [cwd] Virtual project root the keys are relative to.
|
|
39
|
+
* @returns {Reader}
|
|
40
|
+
*/
|
|
41
|
+
export function memoryReader(files, cwd = "/") {
|
|
42
|
+
const map = files instanceof Map ? files : new Map(Object.entries(files));
|
|
43
|
+
const keyOf = (/** @type {string} */ absPath) =>
|
|
44
|
+
path.relative(cwd, path.resolve(absPath)).split(path.sep).join("/");
|
|
45
|
+
return {
|
|
46
|
+
readText: (absPath) => {
|
|
47
|
+
const key = keyOf(absPath);
|
|
48
|
+
const value = map.get(key);
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
throw new Error(`ENOENT: no in-memory file "${key}" (from ${absPath})`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
},
|
|
54
|
+
readBinary: () => {
|
|
55
|
+
throw new Error("memoryReader: binary reads are unsupported");
|
|
56
|
+
},
|
|
57
|
+
exists: (absPath) => map.has(keyOf(absPath)),
|
|
58
|
+
realpath: (absPath) => path.resolve(absPath)
|
|
59
|
+
};
|
|
60
|
+
}
|
package/src/compiler/state.js
CHANGED
|
@@ -8,15 +8,22 @@
|
|
|
8
8
|
// while keeping the message text and `Use:` hints intact.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
import { at } from "./context.js";
|
|
12
|
-
import {
|
|
13
|
-
import { escapeHtml,
|
|
11
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
12
|
+
import { astOf, evalAst } from "./expr-ast.js";
|
|
13
|
+
import { escapeHtml, parseStateValue, safeScriptJson } from "./interpolation.js";
|
|
14
14
|
import { compileComputedExpr } from "./predicates.js";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
// Concrete, compilable one-liners for the state-family hint tails + the catalog.
|
|
21
|
+
export const STATE_EXAMPLE = ":state count = 0";
|
|
22
|
+
export const STORE_EXAMPLE = ":store cart = []";
|
|
23
|
+
export const COMPUTED_EXAMPLE = ":computed total = items.length * 4";
|
|
24
|
+
export const THEME_EXAMPLE = ":theme";
|
|
25
|
+
const STATE_USE = `Use: :state name = value [persist] — e.g. ${STATE_EXAMPLE}`;
|
|
26
|
+
|
|
20
27
|
/**
|
|
21
28
|
* @param {string} line
|
|
22
29
|
* @param {Ctx} ctx
|
|
@@ -25,7 +32,13 @@ import { compileComputedExpr } from "./predicates.js";
|
|
|
25
32
|
*/
|
|
26
33
|
export function handleState(line, ctx, index) {
|
|
27
34
|
const match = line.match(/^:state\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+persist)?$/);
|
|
28
|
-
if (!match)
|
|
35
|
+
if (!match)
|
|
36
|
+
throw wdError(`Malformed :state in ${at(ctx, index)}: ${line}. ${STATE_USE}`, {
|
|
37
|
+
file: ctx.file,
|
|
38
|
+
line: lineOf(ctx, index),
|
|
39
|
+
hint: STATE_USE.slice("Use: ".length),
|
|
40
|
+
example: STATE_EXAMPLE
|
|
41
|
+
});
|
|
29
42
|
const value = parseStateValue(match[2], at(ctx, index));
|
|
30
43
|
const key = declareState(match[1], value, ctx);
|
|
31
44
|
const persistAttr = match[3] ? ` data-wd-persist="${key}"` : "";
|
|
@@ -43,8 +56,13 @@ export function declareState(name, value, ctx) {
|
|
|
43
56
|
if (ctx.loopItem)
|
|
44
57
|
throw new Error(`State cannot be declared inside a reactive @loop body (${ctx.file})`);
|
|
45
58
|
if (ctx.comp.stores.has(name))
|
|
46
|
-
throw
|
|
47
|
-
`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one
|
|
59
|
+
throw wdError(
|
|
60
|
+
`State "${name}" collides with a :store of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
|
|
61
|
+
{
|
|
62
|
+
file: ctx.file,
|
|
63
|
+
hint: `:store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
|
|
64
|
+
example: STORE_EXAMPLE
|
|
65
|
+
}
|
|
48
66
|
);
|
|
49
67
|
const key = ctx.sections.length ? `${ctx.sections.at(-1)}:${name}` : name;
|
|
50
68
|
if (ctx.comp.state.has(key))
|
|
@@ -63,8 +81,14 @@ export function declareState(name, value, ctx) {
|
|
|
63
81
|
export function handleStore(line, ctx, index) {
|
|
64
82
|
const match = line.match(/^:store\s+([A-Za-z_$][\w$]*)\s*=\s*(.+?)(\s+ephemeral)?$/);
|
|
65
83
|
if (!match)
|
|
66
|
-
throw
|
|
67
|
-
`Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral]
|
|
84
|
+
throw wdError(
|
|
85
|
+
`Malformed :store in ${at(ctx, index)}: ${line}. Use: :store name = value [ephemeral] — e.g. ${STORE_EXAMPLE}`,
|
|
86
|
+
{
|
|
87
|
+
file: ctx.file,
|
|
88
|
+
line: lineOf(ctx, index),
|
|
89
|
+
hint: `:store name = value [ephemeral] — e.g. ${STORE_EXAMPLE}`,
|
|
90
|
+
example: STORE_EXAMPLE
|
|
91
|
+
}
|
|
68
92
|
);
|
|
69
93
|
const value = parseStateValue(match[2], at(ctx, index));
|
|
70
94
|
const name = declareStore(match[1], value, ctx);
|
|
@@ -83,10 +107,22 @@ export function handleStore(line, ctx, index) {
|
|
|
83
107
|
*/
|
|
84
108
|
export function declareStore(name, value, ctx) {
|
|
85
109
|
if (ctx.comp.stores.has(name))
|
|
86
|
-
throw
|
|
110
|
+
throw wdError(
|
|
111
|
+
`Store "${name}" is declared twice in ${ctx.file}. Use: :store name = value — e.g. ${STORE_EXAMPLE}`,
|
|
112
|
+
{
|
|
113
|
+
file: ctx.file,
|
|
114
|
+
hint: `:store name = value — e.g. ${STORE_EXAMPLE}`,
|
|
115
|
+
example: STORE_EXAMPLE
|
|
116
|
+
}
|
|
117
|
+
);
|
|
87
118
|
if (ctx.comp.state.has(name))
|
|
88
|
-
throw
|
|
89
|
-
`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one
|
|
119
|
+
throw wdError(
|
|
120
|
+
`Store "${name}" collides with a :state of the same name in ${ctx.file}. Use: :store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
|
|
121
|
+
{
|
|
122
|
+
file: ctx.file,
|
|
123
|
+
hint: `:store name = value for the global, or rename one — e.g. ${STORE_EXAMPLE}`,
|
|
124
|
+
example: STORE_EXAMPLE
|
|
125
|
+
}
|
|
90
126
|
);
|
|
91
127
|
ctx.comp.stores.add(name);
|
|
92
128
|
ctx.comp.state.set(name, value);
|
|
@@ -115,31 +151,38 @@ export function declareErrorState(key, ctx) {
|
|
|
115
151
|
export function handleComputed(line, ctx, index) {
|
|
116
152
|
const match = line.match(/^:computed\s+([A-Za-z_$][\w$]*)\s*=\s*(.+)$/);
|
|
117
153
|
if (!match)
|
|
118
|
-
throw
|
|
119
|
-
|
|
120
|
-
|
|
154
|
+
throw wdError(`Malformed :computed in ${at(ctx, index)}: ${line}. Use: ${COMPUTED_EXAMPLE}`, {
|
|
155
|
+
file: ctx.file,
|
|
156
|
+
line: lineOf(ctx, index),
|
|
157
|
+
hint: COMPUTED_EXAMPLE,
|
|
158
|
+
example: COMPUTED_EXAMPLE
|
|
159
|
+
});
|
|
121
160
|
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
122
|
-
|
|
123
|
-
|
|
161
|
+
// Parse the validated expression to the compact AST the runtime walks. A parse
|
|
162
|
+
// failure means the RHS is syntactically malformed (e.g. `|| <`) — a compile
|
|
163
|
+
// error, not a silently-broken binding.
|
|
164
|
+
/** @type {any[]} */
|
|
165
|
+
let ast;
|
|
124
166
|
try {
|
|
125
|
-
|
|
126
|
-
const read = (key, path) => getPath(ctx.comp.state.get(key), path ? path.split(".") : []);
|
|
127
|
-
// Build-time mirror of the runtime's AGG helper, so a `:computed total = sum(...)`
|
|
128
|
-
// has the same initial value the runtime will recompute to.
|
|
129
|
-
const agg = (
|
|
130
|
-
/** @type {string} */ name,
|
|
131
|
-
/** @type {any} */ list,
|
|
132
|
-
/** @type {string} */ field
|
|
133
|
-
) => (FORMATTERS[name] ? FORMATTERS[name](list, field == null ? [] : [field]) : undefined);
|
|
134
|
-
initial = new Function("S", "A", `return (${expr});`)(read, agg);
|
|
167
|
+
ast = astOf(expr);
|
|
135
168
|
} catch {
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
throw wdError(
|
|
170
|
+
`Malformed :computed expression in ${at(ctx, index)}: ${line}. Use: ${COMPUTED_EXAMPLE}`,
|
|
171
|
+
{
|
|
172
|
+
file: ctx.file,
|
|
173
|
+
line: lineOf(ctx, index),
|
|
174
|
+
hint: COMPUTED_EXAMPLE,
|
|
175
|
+
example: COMPUTED_EXAMPLE
|
|
176
|
+
}
|
|
138
177
|
);
|
|
139
|
-
initial = null;
|
|
140
178
|
}
|
|
179
|
+
// Build-time mirror of the runtime: walk the AST (no eval) so `:computed total =
|
|
180
|
+
// sum(...)` seeds the value the runtime recomputes. The walker is total over a
|
|
181
|
+
// validated AST — state that isn't known at build time resolves to undefined and
|
|
182
|
+
// folds to null, never throws — so parse-failure above is the only error path.
|
|
183
|
+
const initial = evalAst(ast, undefined, ctx.comp);
|
|
141
184
|
const key = declareState(match[1], initial ?? null, ctx);
|
|
142
|
-
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(
|
|
185
|
+
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(JSON.stringify(ast))}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
|
|
143
186
|
}
|
|
144
187
|
|
|
145
188
|
/**
|
|
@@ -157,8 +200,14 @@ export function handleComputed(line, ctx, index) {
|
|
|
157
200
|
export function handleTheme(line, ctx, index) {
|
|
158
201
|
const match = line.match(/^:theme(?:\s+([A-Za-z_$][\w$]*))?(?:\s*=\s*(.+))?$/);
|
|
159
202
|
if (!match) {
|
|
160
|
-
throw
|
|
161
|
-
`Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto")
|
|
203
|
+
throw wdError(
|
|
204
|
+
`Malformed :theme in ${at(ctx, index)}: ${line}. Use: :theme (or :theme name = "auto") — e.g. ${THEME_EXAMPLE}`,
|
|
205
|
+
{
|
|
206
|
+
file: ctx.file,
|
|
207
|
+
line: lineOf(ctx, index),
|
|
208
|
+
hint: `:theme (or :theme name = "auto") — e.g. ${THEME_EXAMPLE}`,
|
|
209
|
+
example: THEME_EXAMPLE
|
|
210
|
+
}
|
|
162
211
|
);
|
|
163
212
|
}
|
|
164
213
|
const name = match[1] || "theme";
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// while keeping the message text and `Use:` hints intact.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
import { at, createScope, LOOP_META, nestedCtx } from "./context.js";
|
|
14
|
+
import { at, createScope, LOOP_META, lineOf, nestedCtx, wdError } from "./context.js";
|
|
15
|
+
import { astOf, serializeExpr } from "./expr-ast.js";
|
|
15
16
|
import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
|
|
16
17
|
import {
|
|
17
18
|
escapeHtml,
|
|
@@ -27,6 +28,13 @@ import { compileWhen, evalPredicate } from "./predicates.js";
|
|
|
27
28
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
28
29
|
*/
|
|
29
30
|
|
|
31
|
+
// Concrete, compilable structure examples for the hint tails + the catalog.
|
|
32
|
+
export const INCLUDE_EXAMPLE = "@include /header.wd";
|
|
33
|
+
export const CONTAINER_EXAMPLE = "::: card .featured";
|
|
34
|
+
export const IF_EXAMPLE = ":if count > 0";
|
|
35
|
+
export const CAROUSEL_EXAMPLE = ":carousel autoplay=3000";
|
|
36
|
+
const INCLUDE_USE = `Use: @include /partial.wd [with key="value"] — e.g. ${INCLUDE_EXAMPLE}`;
|
|
37
|
+
|
|
30
38
|
// Container names that emit their own semantic landmark element instead of a
|
|
31
39
|
// `<div>`, so scaffolds get real `<nav>`/`<main>` landmarks (accessibility: the
|
|
32
40
|
// skip link targets a genuine main-content region and navigation chrome sits in
|
|
@@ -43,8 +51,21 @@ const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
|
|
|
43
51
|
*/
|
|
44
52
|
export function handleInclude(line, ctx, index) {
|
|
45
53
|
const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
|
|
46
|
-
if (!match)
|
|
47
|
-
|
|
54
|
+
if (!match)
|
|
55
|
+
throw wdError(`Malformed @include in ${at(ctx, index)}: ${line}. ${INCLUDE_USE}`, {
|
|
56
|
+
file: ctx.file,
|
|
57
|
+
line: lineOf(ctx, index),
|
|
58
|
+
hint: INCLUDE_USE.slice("Use: ".length),
|
|
59
|
+
example: INCLUDE_EXAMPLE
|
|
60
|
+
});
|
|
61
|
+
const target = resolveInclude(
|
|
62
|
+
match[1],
|
|
63
|
+
ctx.file,
|
|
64
|
+
ctx.context,
|
|
65
|
+
false,
|
|
66
|
+
at(ctx, index),
|
|
67
|
+
ctx.comp.reader
|
|
68
|
+
);
|
|
48
69
|
const args = parseIncludeArgs(match[2] || "", ctx);
|
|
49
70
|
const childScope = createScope(ctx.scope, args);
|
|
50
71
|
// Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
|
|
@@ -67,7 +88,7 @@ export function handleInclude(line, ctx, index) {
|
|
|
67
88
|
// anywhere else on the page, and the scope never leaks to the include's
|
|
68
89
|
// siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
|
|
69
90
|
// the same path-derived id, so attribute and stylesheet line up.
|
|
70
|
-
const scopedSkin = scopedSkinFor(target, ctx.context);
|
|
91
|
+
const scopedSkin = scopedSkinFor(target, ctx.context, ctx.comp.reader);
|
|
71
92
|
return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
|
|
72
93
|
}
|
|
73
94
|
|
|
@@ -110,9 +131,9 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
110
131
|
let tag = "section";
|
|
111
132
|
/** @type {string[]} Static classes baked into class="". */
|
|
112
133
|
const extraClass = [];
|
|
113
|
-
/** @type {[string,
|
|
134
|
+
/** @type {[string, any[]][]} Reactive loop-item class bindings (data-wd-each-class): [class, exprAST]. */
|
|
114
135
|
const eachClasses = [];
|
|
115
|
-
/** @type {[string,
|
|
136
|
+
/** @type {[string, any[]][]} Global state-driven class bindings (data-wd-class): [class, exprAST]. */
|
|
116
137
|
const stateClasses = [];
|
|
117
138
|
let id = "";
|
|
118
139
|
// Leading tag/name token (anything not starting with . or #). "section" keeps
|
|
@@ -157,8 +178,8 @@ export function handleContainer(header, bodyLines, ctx, index) {
|
|
|
157
178
|
const compiled = compileWhen(whenMatch[1].trim(), ctx);
|
|
158
179
|
if (compiled.static) {
|
|
159
180
|
if (compiled.value) extraClass.push(cls);
|
|
160
|
-
} else if (compiled.item) eachClasses.push([cls, compiled.body]);
|
|
161
|
-
else stateClasses.push([cls, compiled.body]);
|
|
181
|
+
} else if (compiled.item) eachClasses.push([cls, astOf(compiled.body)]);
|
|
182
|
+
else stateClasses.push([cls, astOf(compiled.body)]);
|
|
162
183
|
} else {
|
|
163
184
|
extraClass.push(cls);
|
|
164
185
|
}
|
|
@@ -207,10 +228,15 @@ export function handleCarousel(line, bodyLines, ctx, index) {
|
|
|
207
228
|
let autoplayAttr = "";
|
|
208
229
|
if (rest) {
|
|
209
230
|
const auto = rest.match(/^autoplay=(\d+)$/);
|
|
210
|
-
if (!auto)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
if (!auto) {
|
|
232
|
+
const hint = `:carousel [autoplay=3000] … :endcarousel — e.g. ${CAROUSEL_EXAMPLE}`;
|
|
233
|
+
throw wdError(`Malformed :carousel in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
234
|
+
file: ctx.file,
|
|
235
|
+
line: lineOf(ctx, index),
|
|
236
|
+
hint,
|
|
237
|
+
example: CAROUSEL_EXAMPLE
|
|
238
|
+
});
|
|
239
|
+
}
|
|
214
240
|
autoplayAttr = ` data-wd-carousel-autoplay="${auto[1]}"`;
|
|
215
241
|
}
|
|
216
242
|
ctx.comp.assets.behaviors.add("carousel");
|
|
@@ -315,10 +341,15 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
315
341
|
// whitelist as `@loop … where` / `.class when` (with `not`), so it folds at
|
|
316
342
|
// build when static, drives a per-row each-if when it reads the loop item, and
|
|
317
343
|
// a global if-region (evaluated each render) when it reads state. No raw eval.
|
|
318
|
-
if (!condition)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
if (!condition) {
|
|
345
|
+
const hint = `":if name" or ":if a <op> b [and|or|not …]" — e.g. ${IF_EXAMPLE}`;
|
|
346
|
+
throw wdError(`Malformed :if in ${at(ctx, index)}: ${line}. Use ${hint}`, {
|
|
347
|
+
file: ctx.file,
|
|
348
|
+
line: lineOf(ctx, index),
|
|
349
|
+
hint,
|
|
350
|
+
example: IF_EXAMPLE
|
|
351
|
+
});
|
|
352
|
+
}
|
|
322
353
|
const compiled = compileWhen(condition, ctx, '":if"');
|
|
323
354
|
if (compiled.static)
|
|
324
355
|
return ctx.compileBody(
|
|
@@ -328,7 +359,7 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
|
|
|
328
359
|
ctx.comp.assets.runtime = true;
|
|
329
360
|
const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
|
|
330
361
|
const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
|
|
331
|
-
const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
|
|
362
|
+
const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExpr(compiled.body))}"`;
|
|
332
363
|
if (compiled.item) {
|
|
333
364
|
return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
|
|
334
365
|
}
|
package/src/compiler.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Darkmown's compiler, split into cohesive modules under `src/compiler/`. This
|
|
2
|
-
// file is
|
|
3
|
-
// so every existing `import … from "./compiler.js"`
|
|
4
|
-
// working unchanged.
|
|
2
|
+
// file is the Node-facing barrel: it re-exports the public API from
|
|
3
|
+
// `src/compiler/index.js` so every existing `import … from "./compiler.js"`
|
|
4
|
+
// (builder, tests) keeps working unchanged. It is ALSO the single place the
|
|
5
|
+
// default filesystem reader is injected — `compilePage`/`compileDocument`/
|
|
6
|
+
// `enhanceImages` default their reader to `fsReader()` here, so callers keep
|
|
7
|
+
// their old signatures and the compile stays byte-identical. The fs-free entry
|
|
8
|
+
// point (`compileFromMemory`) needs no default and passes through. Because this
|
|
9
|
+
// module imports `fs-reader.js` (and thus `node:fs`), it is Node-only; the
|
|
10
|
+
// browser bundles `compileFromMemory` from `src/compiler/page.js` directly.
|
|
5
11
|
|
|
6
12
|
// Re-export the public typedefs so existing `import("./compiler.js").Type`
|
|
7
13
|
// references (e.g. `Assets` in src/builder.js) keep resolving after the split.
|
|
@@ -16,12 +22,57 @@
|
|
|
16
22
|
* @typedef {import("./compiler/context.js").CompiledPage} CompiledPage
|
|
17
23
|
*/
|
|
18
24
|
|
|
25
|
+
import { fsReader } from "./compiler/fs-reader.js";
|
|
26
|
+
import {
|
|
27
|
+
compileDocument as compileDocumentImpl,
|
|
28
|
+
compilePage as compilePageImpl,
|
|
29
|
+
enhanceImages as enhanceImagesImpl
|
|
30
|
+
} from "./compiler/index.js";
|
|
31
|
+
|
|
19
32
|
export {
|
|
20
|
-
|
|
21
|
-
compilePage,
|
|
22
|
-
enhanceImages,
|
|
33
|
+
compileFromMemory,
|
|
23
34
|
escapeHtml,
|
|
24
35
|
loopKeyOf,
|
|
25
36
|
parseFrontmatter,
|
|
26
37
|
stampScope
|
|
27
38
|
} from "./compiler/index.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compile a page source file into a full HTML document plus its assets, reading
|
|
42
|
+
* from the real filesystem by default. Pass `options.reader` to compile against
|
|
43
|
+
* a different source (see `compileFromMemory`).
|
|
44
|
+
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
45
|
+
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
46
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
|
|
47
|
+
* @returns {CompiledPage}
|
|
48
|
+
*/
|
|
49
|
+
export function compilePage(file, context, options = {}) {
|
|
50
|
+
return compilePageImpl(file, context, { reader: fsReader(), ...options });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compile a source file into its body HTML, frontmatter, and assets (no page
|
|
55
|
+
* shell), reading from the real filesystem by default.
|
|
56
|
+
* @param {string} file Absolute path to the source file.
|
|
57
|
+
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
58
|
+
* @param {string[]} [stack] Include stack for cycle detection.
|
|
59
|
+
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
60
|
+
* @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
|
|
61
|
+
* @param {import("./compiler/reader.js").Reader} [reader] Source reader.
|
|
62
|
+
* @returns {CompiledDocument}
|
|
63
|
+
*/
|
|
64
|
+
export function compileDocument(file, context, stack, vars, collections, reader = fsReader()) {
|
|
65
|
+
return compileDocumentImpl(file, context, stack, vars, collections, reader);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Harden every `<img>` in an assembled page body, reading intrinsic dimensions
|
|
70
|
+
* from the real filesystem by default.
|
|
71
|
+
* @param {string} html Assembled page body HTML.
|
|
72
|
+
* @param {import("./config.js").Paths} paths Resolved project paths.
|
|
73
|
+
* @param {import("./compiler/reader.js").Reader} [reader] Source reader for image bytes.
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function enhanceImages(html, paths, reader = fsReader()) {
|
|
77
|
+
return enhanceImagesImpl(html, paths, reader);
|
|
78
|
+
}
|
package/src/grammar.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// GBNF grammar generator for `.wd` DIRECTIVE LINES.
|
|
3
|
+
//
|
|
4
|
+
// `generateGrammar()` emits a llama.cpp-compatible GBNF grammar that constrains
|
|
5
|
+
// a small model's decoding to well-formed Darkmown directive lines (plus the
|
|
6
|
+
// `{ path | pipe }` interpolation token). It is generated from the SAME catalog
|
|
7
|
+
// the cheatsheet uses, so the keyword set, `@loop` clauses, comparison operators,
|
|
8
|
+
// and format-pipe names can never drift from what compiles.
|
|
9
|
+
//
|
|
10
|
+
// Scope (stated deliberately): this grammar constrains the SHAPE of a directive
|
|
11
|
+
// line — the leading keyword must be a real directive, `@loop` must be
|
|
12
|
+
// `@loop <src> into <item> …clauses`, a `where`/`:if` condition must be
|
|
13
|
+
// `operand <op> operand`, a `:button` must be `"…" -> <action-chain>`, and an
|
|
14
|
+
// interpolation must pipe through a known formatter. It does NOT try to grammar-
|
|
15
|
+
// check free-form values (arbitrary JSON seeds, URLs, prose) — those are `text`.
|
|
16
|
+
// That is enough to delete the on-device failure class (JS/HTML muscle-memory
|
|
17
|
+
// openers like `::: card class=` or `cart.filter(...)` become unrepresentable)
|
|
18
|
+
// while staying small and maintainable.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
import { directiveCatalog } from "./catalog.js";
|
|
22
|
+
|
|
23
|
+
/** Slugify a directive token into a GBNF rule name (`@loop` → `dir-loop`). */
|
|
24
|
+
/** @type {Record<string, string>} */
|
|
25
|
+
const SLUG = {
|
|
26
|
+
"@include": "dir-include",
|
|
27
|
+
"@loop": "dir-loop",
|
|
28
|
+
":::": "dir-container",
|
|
29
|
+
":if": "dir-if",
|
|
30
|
+
":state": "dir-state",
|
|
31
|
+
":store": "dir-store",
|
|
32
|
+
":computed": "dir-computed",
|
|
33
|
+
":fetch": "dir-fetch",
|
|
34
|
+
":effect": "dir-effect",
|
|
35
|
+
":every": "dir-every",
|
|
36
|
+
":theme": "dir-theme",
|
|
37
|
+
":button": "dir-button",
|
|
38
|
+
":form": "dir-form",
|
|
39
|
+
":input": "dir-input",
|
|
40
|
+
":textarea": "dir-textarea",
|
|
41
|
+
":select": "dir-select",
|
|
42
|
+
":checkbox": "dir-checkbox",
|
|
43
|
+
":radio": "dir-radio",
|
|
44
|
+
":submit": "dir-submit",
|
|
45
|
+
":bind": "dir-bind",
|
|
46
|
+
":slider": "dir-slider",
|
|
47
|
+
":video": "dir-video",
|
|
48
|
+
":audio": "dir-audio",
|
|
49
|
+
":embed": "dir-embed",
|
|
50
|
+
":carousel": "dir-carousel"
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The directives given a structured RHS below; every other directive gets the
|
|
54
|
+
// generic `"<keyword>" restofline` rule (keyword pinned, argument tail free).
|
|
55
|
+
const STRUCTURED = new Set(["@loop", ":if", ":button", ":::"]);
|
|
56
|
+
|
|
57
|
+
/** A GBNF alternation literal list, e.g. `"a" | "b"`. */
|
|
58
|
+
const alt = (/** @type {string[]} */ items) => items.map((s) => `"${s}"`).join(" | ");
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Generate the GBNF grammar for `.wd` directive lines from the directive catalog.
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function generateGrammar() {
|
|
65
|
+
const cat = directiveCatalog();
|
|
66
|
+
const dirRules = cat.directives.map((d) => SLUG[d.name]).filter(Boolean);
|
|
67
|
+
|
|
68
|
+
/** @type {string[]} */
|
|
69
|
+
const lines = [];
|
|
70
|
+
lines.push("# Darkmown .wd directive-line grammar — generated by src/grammar.js");
|
|
71
|
+
lines.push(
|
|
72
|
+
"# Constrains a directive line (or a { path | pipe } interpolation). Do not edit by hand."
|
|
73
|
+
);
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push("root ::= directive | binding");
|
|
76
|
+
lines.push(`directive ::= ${dirRules.join(" | ")}`);
|
|
77
|
+
lines.push("");
|
|
78
|
+
|
|
79
|
+
// Structured directives.
|
|
80
|
+
lines.push('dir-loop ::= "@loop" sp operand sp "into" sp ident loop-clauses');
|
|
81
|
+
lines.push('dir-if ::= ":if" sp ( condition (sp joiner sp condition)* | path )');
|
|
82
|
+
lines.push('dir-button ::= ":button" sp dqstring sp "->" sp action-chain');
|
|
83
|
+
lines.push('dir-container ::= ":::" restofline');
|
|
84
|
+
lines.push("");
|
|
85
|
+
|
|
86
|
+
// Generic directives: keyword pinned, argument tail free to end of line.
|
|
87
|
+
for (const d of cat.directives) {
|
|
88
|
+
if (STRUCTURED.has(d.name)) continue;
|
|
89
|
+
lines.push(`${SLUG[d.name]} ::= "${d.name}" restofline`);
|
|
90
|
+
}
|
|
91
|
+
lines.push("");
|
|
92
|
+
|
|
93
|
+
// @loop clause vocabulary (mirrors the fixed clause order in the catalog).
|
|
94
|
+
lines.push("loop-clauses ::= ( sp loop-clause )*");
|
|
95
|
+
lines.push(
|
|
96
|
+
'loop-clause ::= where-clause | "sort by" sp path ( sp ( "asc" | "desc" ) )? | "reverse" | "offset" sp number | "limit" sp number | "paginate" sp number | "sortable"'
|
|
97
|
+
);
|
|
98
|
+
lines.push('where-clause ::= "where" sp condition ( sp joiner sp condition )*');
|
|
99
|
+
lines.push("condition ::= operand sp op sp operand");
|
|
100
|
+
lines.push(`op ::= ${alt(cat.predicateOps.map((o) => o.name))}`);
|
|
101
|
+
lines.push(`joiner ::= ${alt(cat.predicateJoiners.filter((j) => j !== "not"))}`);
|
|
102
|
+
lines.push("");
|
|
103
|
+
|
|
104
|
+
// Button-action vocabulary. Each operator token below is asserted present
|
|
105
|
+
// against the catalog's action tokens in tests/grammar.test.js.
|
|
106
|
+
lines.push('action-chain ::= action ( wsopt ";" wsopt action )*');
|
|
107
|
+
lines.push("action ::= ident action-op");
|
|
108
|
+
lines.push(
|
|
109
|
+
'action-op ::= "++" | "--" | sp "+=" sp value | sp "-=" sp value | sp "=" sp value | sp "toggle" ( sp value )? | sp "append" sp value | sp "prepend" sp value | sp "remove" sp value | sp "clear" | sp "merge" sp ident | sp "delete" sp value | sp "reset" | sp "refetch"'
|
|
110
|
+
);
|
|
111
|
+
lines.push("");
|
|
112
|
+
|
|
113
|
+
// Interpolation binding with format pipes.
|
|
114
|
+
lines.push('binding ::= "{" wsopt path pipe-chain wsopt "}"');
|
|
115
|
+
lines.push('pipe-chain ::= ( wsopt "|" wsopt pipe ( ":" value )* )*');
|
|
116
|
+
lines.push(`pipe ::= ${alt(cat.formatPipes.map((p) => p.name))}`);
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
// Terminals.
|
|
120
|
+
lines.push("operand ::= path | dqstring | number");
|
|
121
|
+
lines.push("value ::= dqstring | number | path");
|
|
122
|
+
lines.push("ident ::= [a-zA-Z_$] [a-zA-Z0-9_$]*");
|
|
123
|
+
lines.push("path ::= [a-zA-Z0-9_$/.~-]+");
|
|
124
|
+
lines.push("number ::= [0-9]+");
|
|
125
|
+
lines.push('dqstring ::= "\\"" [^"]* "\\""');
|
|
126
|
+
lines.push("restofline ::= [^\\n]*");
|
|
127
|
+
lines.push('sp ::= " "+');
|
|
128
|
+
lines.push('wsopt ::= " "*');
|
|
129
|
+
lines.push("");
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|