@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/forms.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// while keeping the message text and `Use:` hints intact.
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import { at, nestedCtx } from "./context.js";
|
|
12
|
+
import { at, lineOf, nestedCtx, wdError } from "./context.js";
|
|
13
13
|
import { validateFetchUrl } from "./fetch.js";
|
|
14
14
|
import {
|
|
15
15
|
escapeHtml,
|
|
@@ -25,6 +25,20 @@ import { declareErrorState, declareState } from "./state.js";
|
|
|
25
25
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
// Concrete, compilable one-liners for the form-family hint tails + the catalog.
|
|
29
|
+
// (Field openers like :select/:checkbox expect following `- Label` lines; the
|
|
30
|
+
// example is the opener, which the catalog/grammar pair with option lines.)
|
|
31
|
+
export const FORM_EXAMPLE = ":form into contact";
|
|
32
|
+
export const INPUT_EXAMPLE = ":input email type=email required";
|
|
33
|
+
export const TEXTAREA_EXAMPLE = ':textarea message placeholder="Your message" rows=4';
|
|
34
|
+
export const SELECT_EXAMPLE = ":select topic";
|
|
35
|
+
export const CHECKBOX_EXAMPLE = ":checkbox toppings";
|
|
36
|
+
export const RADIO_EXAMPLE = ":radio size";
|
|
37
|
+
export const SUBMIT_EXAMPLE = ':submit "Send"';
|
|
38
|
+
export const BIND_EXAMPLE = ':bind query placeholder="Search"';
|
|
39
|
+
export const SLIDER_EXAMPLE = ":slider volume = 50 min=0 max=100 step=1";
|
|
40
|
+
const INPUT_USE = `Use: :input name [type=…] [placeholder="…"] [required] — e.g. ${INPUT_EXAMPLE}`;
|
|
41
|
+
|
|
28
42
|
/**
|
|
29
43
|
* @param {string} line
|
|
30
44
|
* @param {string[]} bodyLines
|
|
@@ -43,9 +57,13 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
43
57
|
.replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
|
|
44
58
|
.trim();
|
|
45
59
|
if ((!rawAction && !into) || leftover) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
60
|
+
const hint = `':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state) — e.g. ${FORM_EXAMPLE}`;
|
|
61
|
+
throw wdError(`Malformed :form in ${at(ctx, index)}: ${line}. Use ${hint}`, {
|
|
62
|
+
file: ctx.file,
|
|
63
|
+
line: lineOf(ctx, index),
|
|
64
|
+
hint,
|
|
65
|
+
example: FORM_EXAMPLE
|
|
66
|
+
});
|
|
49
67
|
}
|
|
50
68
|
const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
|
|
51
69
|
const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1)).trim();
|
|
@@ -68,7 +86,13 @@ export function handleForm(line, bodyLines, ctx, index) {
|
|
|
68
86
|
*/
|
|
69
87
|
export function handleInput(line, ctx, index) {
|
|
70
88
|
const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
71
|
-
if (!match)
|
|
89
|
+
if (!match)
|
|
90
|
+
throw wdError(`Malformed :input in ${at(ctx, index)}: ${line}. ${INPUT_USE}`, {
|
|
91
|
+
file: ctx.file,
|
|
92
|
+
line: lineOf(ctx, index),
|
|
93
|
+
hint: INPUT_USE.slice("Use: ".length),
|
|
94
|
+
example: INPUT_EXAMPLE
|
|
95
|
+
});
|
|
72
96
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
73
97
|
let type = "text";
|
|
74
98
|
let placeholder;
|
|
@@ -127,9 +151,12 @@ export function handleInput(line, ctx, index) {
|
|
|
127
151
|
export function handleBind(line, ctx, index) {
|
|
128
152
|
const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
129
153
|
if (!match)
|
|
130
|
-
throw
|
|
131
|
-
|
|
132
|
-
|
|
154
|
+
throw wdError(`Malformed :bind in ${at(ctx, index)}: ${line}. Use: ${BIND_EXAMPLE}`, {
|
|
155
|
+
file: ctx.file,
|
|
156
|
+
line: lineOf(ctx, index),
|
|
157
|
+
hint: BIND_EXAMPLE,
|
|
158
|
+
example: BIND_EXAMPLE
|
|
159
|
+
});
|
|
133
160
|
const key = resolveStateKey(match[1], ctx);
|
|
134
161
|
if (!key) {
|
|
135
162
|
throw new Error(
|
|
@@ -187,9 +214,12 @@ export function handleBind(line, ctx, index) {
|
|
|
187
214
|
export function handleSlider(line, ctx, index) {
|
|
188
215
|
const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
|
|
189
216
|
if (!head)
|
|
190
|
-
throw
|
|
191
|
-
|
|
192
|
-
|
|
217
|
+
throw wdError(`Malformed :slider in ${at(ctx, index)}: ${line}. Use: ${SLIDER_EXAMPLE}`, {
|
|
218
|
+
file: ctx.file,
|
|
219
|
+
line: lineOf(ctx, index),
|
|
220
|
+
hint: SLIDER_EXAMPLE,
|
|
221
|
+
example: SLIDER_EXAMPLE
|
|
222
|
+
});
|
|
193
223
|
const name = head[1];
|
|
194
224
|
let rest = head[2].trim();
|
|
195
225
|
|
|
@@ -274,7 +304,15 @@ export function handleSlider(line, ctx, index) {
|
|
|
274
304
|
export function handleSubmit(line, ctx, index) {
|
|
275
305
|
const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
|
|
276
306
|
if (!match)
|
|
277
|
-
throw
|
|
307
|
+
throw wdError(
|
|
308
|
+
`Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
|
|
309
|
+
{
|
|
310
|
+
file: ctx.file,
|
|
311
|
+
line: lineOf(ctx, index),
|
|
312
|
+
hint: `:submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
|
|
313
|
+
example: SUBMIT_EXAMPLE
|
|
314
|
+
}
|
|
315
|
+
);
|
|
278
316
|
return `<button type="submit">${escapeHtml(match[1])}</button>`;
|
|
279
317
|
}
|
|
280
318
|
|
|
@@ -290,10 +328,15 @@ export function handleSubmit(line, ctx, index) {
|
|
|
290
328
|
*/
|
|
291
329
|
export function handleTextarea(line, ctx, index) {
|
|
292
330
|
const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
293
|
-
if (!match)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
331
|
+
if (!match) {
|
|
332
|
+
const hint = `:textarea name [placeholder="…"] [rows=N] [required] — e.g. ${TEXTAREA_EXAMPLE}`;
|
|
333
|
+
throw wdError(`Malformed :textarea in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
334
|
+
file: ctx.file,
|
|
335
|
+
line: lineOf(ctx, index),
|
|
336
|
+
hint,
|
|
337
|
+
example: TEXTAREA_EXAMPLE
|
|
338
|
+
});
|
|
339
|
+
}
|
|
297
340
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
298
341
|
let placeholder;
|
|
299
342
|
let hasAria = false;
|
|
@@ -343,10 +386,15 @@ export function handleTextarea(line, ctx, index) {
|
|
|
343
386
|
*/
|
|
344
387
|
export function handleSelect(line, optionLines, ctx, index) {
|
|
345
388
|
const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
|
|
346
|
-
if (!match)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
389
|
+
if (!match) {
|
|
390
|
+
const hint = `:select name [required] then "- Label" lines — e.g. ${SELECT_EXAMPLE}`;
|
|
391
|
+
throw wdError(`Malformed :select in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
392
|
+
file: ctx.file,
|
|
393
|
+
line: lineOf(ctx, index),
|
|
394
|
+
hint,
|
|
395
|
+
example: SELECT_EXAMPLE
|
|
396
|
+
});
|
|
397
|
+
}
|
|
350
398
|
const attrs = [`name="${escapeHtml(match[1])}"`];
|
|
351
399
|
let hasAria = false;
|
|
352
400
|
const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
|
|
@@ -404,10 +452,16 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
|
|
|
404
452
|
? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/
|
|
405
453
|
: /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/
|
|
406
454
|
);
|
|
407
|
-
if (!match)
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
)
|
|
455
|
+
if (!match) {
|
|
456
|
+
const example = kind === "checkbox" ? CHECKBOX_EXAMPLE : RADIO_EXAMPLE;
|
|
457
|
+
const hint = `${directive} name [required] then "- Label" lines — e.g. ${example}`;
|
|
458
|
+
throw wdError(`Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
459
|
+
file: ctx.file,
|
|
460
|
+
line: lineOf(ctx, index),
|
|
461
|
+
hint,
|
|
462
|
+
example
|
|
463
|
+
});
|
|
464
|
+
}
|
|
411
465
|
const name = match[1];
|
|
412
466
|
const flags = [];
|
|
413
467
|
let ariaLabel = null;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The default {@link import("./reader.js").Reader}: reads straight from the
|
|
3
|
+
// real filesystem. This is the ONLY compiler module that imports `node:fs`, so
|
|
4
|
+
// it is deliberately isolated — nothing reachable from `compileFromMemory`
|
|
5
|
+
// imports it, keeping the browser/mobile compile path fs-free. The public
|
|
6
|
+
// `compilePage`/`compileDocument`/`enhanceImages` (in `src/compiler.js`) inject
|
|
7
|
+
// this reader by default, so every existing caller keeps working unchanged.
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A {@link import("./reader.js").Reader} backed by `node:fs`. Each method is a
|
|
14
|
+
* thin pass-through to the synchronous fs call the compiler used before the
|
|
15
|
+
* reader abstraction, so an fs-backed compile stays byte-identical.
|
|
16
|
+
* @returns {import("./reader.js").Reader}
|
|
17
|
+
*/
|
|
18
|
+
export function fsReader() {
|
|
19
|
+
return {
|
|
20
|
+
readText: (absPath) => fs.readFileSync(absPath, "utf8"),
|
|
21
|
+
readBinary: (absPath) => fs.readFileSync(absPath),
|
|
22
|
+
exists: (absPath) => fs.existsSync(absPath),
|
|
23
|
+
realpath: (absPath) => fs.realpathSync(absPath)
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/compiler/includes.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// and warn when a plain `.md` file uses `.wd`-only syntax.
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
-
import fs from "node:fs";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { scopeIdFor } from "../skin.js";
|
|
11
10
|
import { pageIncludeExtensions } from "./context.js";
|
|
@@ -27,12 +26,13 @@ import { stripQuotes } from "./interpolation.js";
|
|
|
27
26
|
* (page/include) always agree on both.
|
|
28
27
|
* @param {string} skinPath Absolute path to the colocated `.skin` file.
|
|
29
28
|
* @param {string} cwd Project root, for the path-based scope id.
|
|
29
|
+
* @param {import("./reader.js").Reader} reader Source reader for the skin file.
|
|
30
30
|
* @returns {{ scoped: boolean, scopeId: string }}
|
|
31
31
|
*/
|
|
32
|
-
export function scopeInfoForSkin(skinPath, cwd) {
|
|
32
|
+
export function scopeInfoForSkin(skinPath, cwd, reader) {
|
|
33
33
|
const rel = path.relative(cwd, skinPath).replaceAll(path.sep, "/");
|
|
34
34
|
const scopeId = scopeIdFor(rel);
|
|
35
|
-
const src =
|
|
35
|
+
const src = reader.readText(skinPath).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
36
36
|
for (const raw of src.split("\n")) {
|
|
37
37
|
const t = raw.trim();
|
|
38
38
|
if (!t || t.startsWith("//") || !/[A-Za-z0-9]/.test(t)) continue;
|
|
@@ -49,13 +49,14 @@ export function scopeInfoForSkin(skinPath, cwd) {
|
|
|
49
49
|
* `.skin` extension).
|
|
50
50
|
* @param {string} file Absolute path to the page/include source file.
|
|
51
51
|
* @param {Paths} context
|
|
52
|
+
* @param {import("./reader.js").Reader} reader Source reader for the skin file.
|
|
52
53
|
* @returns {{ scoped: boolean, scopeId: string } | null}
|
|
53
54
|
*/
|
|
54
|
-
export function scopedSkinFor(file, context) {
|
|
55
|
+
export function scopedSkinFor(file, context, reader) {
|
|
55
56
|
const ext = path.extname(file);
|
|
56
57
|
const candidate = `${file.slice(0, -ext.length)}.skin`;
|
|
57
|
-
if (!
|
|
58
|
-
return scopeInfoForSkin(candidate, context.cwd);
|
|
58
|
+
if (!reader.exists(candidate)) return null;
|
|
59
|
+
return scopeInfoForSkin(candidate, context.cwd, reader);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/**
|
|
@@ -127,9 +128,10 @@ export function stampScope(html, scopeId) {
|
|
|
127
128
|
* @param {string} file
|
|
128
129
|
* @param {Paths} context
|
|
129
130
|
* @param {Assets} assets
|
|
131
|
+
* @param {import("./reader.js").Reader} reader Source reader for sibling probing.
|
|
130
132
|
* @returns {void}
|
|
131
133
|
*/
|
|
132
|
-
export function collectColocatedAssets(file, context, assets) {
|
|
134
|
+
export function collectColocatedAssets(file, context, assets, reader) {
|
|
133
135
|
const ext = path.extname(file);
|
|
134
136
|
const stem = file.slice(0, -ext.length);
|
|
135
137
|
for (const [assetExt, folder] of [
|
|
@@ -137,7 +139,7 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
137
139
|
[".js", "scripts"]
|
|
138
140
|
]) {
|
|
139
141
|
const candidate = `${stem}${assetExt}`;
|
|
140
|
-
if (!
|
|
142
|
+
if (!reader.exists(candidate)) continue;
|
|
141
143
|
const rel = path.relative(context.cwd, candidate).replaceAll(path.sep, "/");
|
|
142
144
|
const outputExt = assetExt === ".skin" ? ".css" : ".js";
|
|
143
145
|
const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
|
|
@@ -146,7 +148,8 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
146
148
|
assets.skins.add(publicPath);
|
|
147
149
|
// Surface "this skin is scoped" so the warning pass (builder) and the HTML
|
|
148
150
|
// stamp can find the scoped skins among all colocated ones by source path.
|
|
149
|
-
if (scopeInfoForSkin(candidate, context.cwd).scoped)
|
|
151
|
+
if (scopeInfoForSkin(candidate, context.cwd, reader).scoped)
|
|
152
|
+
assets.scopedSkins.add(candidate);
|
|
150
153
|
}
|
|
151
154
|
if (assetExt === ".js") assets.scripts.add(publicPath);
|
|
152
155
|
}
|
|
@@ -157,14 +160,16 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
157
160
|
* @param {string} spec Include target (may be quoted).
|
|
158
161
|
* @param {string} fromFile File requesting the include.
|
|
159
162
|
* @param {Paths} context
|
|
160
|
-
* @param {boolean}
|
|
161
|
-
* @param {string}
|
|
163
|
+
* @param {boolean} allowAny Allow non-page extensions (e.g. JSON for `@loop`).
|
|
164
|
+
* @param {string} loc Source location (`file:line`) for the error messages, so
|
|
162
165
|
* an unresolved/out-of-sandbox include reports the directive's line. Callers
|
|
163
|
-
* with a line index pass `at(ctx, index)`;
|
|
164
|
-
*
|
|
166
|
+
* with a line index pass `at(ctx, index)`; pass `fromFile` for the file-only
|
|
167
|
+
* message.
|
|
168
|
+
* @param {import("./reader.js").Reader} reader Source reader for existence
|
|
169
|
+
* probing, threaded from `ctx.comp.reader`.
|
|
165
170
|
* @returns {string}
|
|
166
171
|
*/
|
|
167
|
-
export function resolveInclude(spec, fromFile, context, allowAny
|
|
172
|
+
export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
|
|
168
173
|
const clean = stripQuotes(spec);
|
|
169
174
|
const candidates = [];
|
|
170
175
|
if (clean.startsWith("/")) {
|
|
@@ -178,7 +183,7 @@ export function resolveInclude(spec, fromFile, context, allowAny = false, loc =
|
|
|
178
183
|
if (!isAllowedInclude(resolved, context)) {
|
|
179
184
|
throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
|
|
180
185
|
}
|
|
181
|
-
if (!
|
|
186
|
+
if (!reader.exists(resolved)) continue;
|
|
182
187
|
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
183
188
|
return resolved;
|
|
184
189
|
}
|
package/src/compiler/index.js
CHANGED
|
@@ -32,4 +32,4 @@ export { parseFrontmatter } from "./frontmatter.js";
|
|
|
32
32
|
export { stampScope } from "./includes.js";
|
|
33
33
|
export { escapeHtml } from "./interpolation.js";
|
|
34
34
|
export { loopKeyOf } from "./loops.js";
|
|
35
|
-
export { compileDocument, compilePage, enhanceImages } from "./page.js";
|
|
35
|
+
export { compileDocument, compileFromMemory, compilePage, enhanceImages } from "./page.js";
|
package/src/compiler/loops.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// string fill used for the initial paint (text binds, per-row `:if`, meta).
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
9
|
+
import { at, createScope, lineOf, nestedCtx, wdError } from "./context.js";
|
|
10
|
+
import { serializeExpr } from "./expr-ast.js";
|
|
11
11
|
import { applyPipeline, stagesFromAttr } from "./format.js";
|
|
12
12
|
import { resolveInclude } from "./includes.js";
|
|
13
13
|
import {
|
|
@@ -28,9 +28,14 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
|
|
|
28
28
|
* @typedef {import("./context.js").LoopOpts} LoopOpts
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
|
+
// One concrete, compilable @loop line, appended to the schematic hint so a small
|
|
32
|
+
// model copies a real line instead of echoing the `[…]` placeholders literally
|
|
33
|
+
// (the on-device finding: bracket-placeholders get pasted into source verbatim).
|
|
34
|
+
// Also the canonical @loop example the directive catalog + grammar draw from.
|
|
35
|
+
export const LOOP_EXAMPLE = "@loop /products.json into p where p.price < 50 sort by p.price asc";
|
|
36
|
+
|
|
31
37
|
// The fixed corrective suggestion shown for any malformed @loop header.
|
|
32
|
-
const LOOP_USAGE =
|
|
33
|
-
"Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
|
|
38
|
+
const LOOP_USAGE = `Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable] — e.g. ${LOOP_EXAMPLE}`;
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Parse the optional clause tail of a `@loop` header in FIXED order:
|
|
@@ -219,7 +224,13 @@ function numArgAttr(arg) {
|
|
|
219
224
|
*/
|
|
220
225
|
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
221
226
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
222
|
-
if (!match)
|
|
227
|
+
if (!match)
|
|
228
|
+
throw wdError(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`, {
|
|
229
|
+
file: ctx.file,
|
|
230
|
+
line: lineOf(ctx, index),
|
|
231
|
+
hint: LOOP_USAGE.slice("Use: ".length),
|
|
232
|
+
example: LOOP_EXAMPLE
|
|
233
|
+
});
|
|
223
234
|
const source = stripQuotes(match[1].trim());
|
|
224
235
|
const itemName = match[2];
|
|
225
236
|
const clauses = match[3]
|
|
@@ -288,9 +299,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
288
299
|
source.endsWith(".json")
|
|
289
300
|
) {
|
|
290
301
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
291
|
-
const dataFile = resolveInclude(
|
|
302
|
+
const dataFile = resolveInclude(
|
|
303
|
+
source,
|
|
304
|
+
ctx.file,
|
|
305
|
+
ctx.context,
|
|
306
|
+
true,
|
|
307
|
+
at(ctx, index),
|
|
308
|
+
ctx.comp.reader
|
|
309
|
+
);
|
|
292
310
|
ctx.comp.deps.add(dataFile);
|
|
293
|
-
const rows = JSON.parse(
|
|
311
|
+
const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
|
|
294
312
|
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
295
313
|
return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
|
|
296
314
|
}
|
|
@@ -692,7 +710,7 @@ function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
|
|
|
692
710
|
*/
|
|
693
711
|
function loopClauseAttrs(c) {
|
|
694
712
|
return (
|
|
695
|
-
(c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
|
|
713
|
+
(c.where ? ` data-wd-loop-where="${escapeHtml(serializeExpr(c.where.body))}"` : "") +
|
|
696
714
|
(c.sort
|
|
697
715
|
? ` data-wd-loop-sort="${escapeHtml(c.sort.keyKind === "key" ? `key:${c.sort.key}` : c.sort.key)}" data-wd-loop-sort-dir="${escapeHtml(c.sort.dirKind === "key" ? `key:${c.sort.dir}` : c.sort.dir)}"`
|
|
698
716
|
: "") +
|
package/src/compiler/media.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// while keeping the message text and `Use:` hints intact.
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
|
-
import { at } from "./context.js";
|
|
11
|
+
import { at, lineOf, wdError } from "./context.js";
|
|
12
12
|
import { validateFetchUrl } from "./fetch.js";
|
|
13
13
|
import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
14
14
|
|
|
@@ -16,6 +16,11 @@ import { escapeHtml, stripQuotes } from "./interpolation.js";
|
|
|
16
16
|
* @typedef {import("./context.js").Ctx} Ctx
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
// Concrete, compilable media examples for the hint tails + the directive catalog.
|
|
20
|
+
export const VIDEO_EXAMPLE = ":video /demo.mp4 controls";
|
|
21
|
+
export const AUDIO_EXAMPLE = ":audio /theme.mp3 controls";
|
|
22
|
+
export const EMBED_EXAMPLE = ':embed https://youtu.be/dQw4w9WgXcQ title="Demo"';
|
|
23
|
+
|
|
19
24
|
/** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
|
|
20
25
|
const MEDIA_SPEC = {
|
|
21
26
|
video: {
|
|
@@ -41,9 +46,14 @@ const MEDIA_SPEC = {
|
|
|
41
46
|
export function handleMedia(line, kind, ctx, index) {
|
|
42
47
|
const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
|
|
43
48
|
if (!match) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
)
|
|
49
|
+
const example = kind === "video" ? VIDEO_EXAMPLE : AUDIO_EXAMPLE;
|
|
50
|
+
const hint = `:${kind} /clip [controls] [autoplay] [loop] [muted] — e.g. ${example}`;
|
|
51
|
+
throw wdError(`Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
52
|
+
file: ctx.file,
|
|
53
|
+
line: lineOf(ctx, index),
|
|
54
|
+
hint,
|
|
55
|
+
example
|
|
56
|
+
});
|
|
47
57
|
}
|
|
48
58
|
const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
|
|
49
59
|
const spec = MEDIA_SPEC[kind];
|
|
@@ -93,9 +103,13 @@ export function handleMedia(line, kind, ctx, index) {
|
|
|
93
103
|
export function handleEmbed(line, ctx, index) {
|
|
94
104
|
const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
|
|
95
105
|
if (!match) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
const hint = `:embed https://youtu.be/ID [title="…"] — e.g. ${EMBED_EXAMPLE}`;
|
|
107
|
+
throw wdError(`Malformed :embed in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
|
|
108
|
+
file: ctx.file,
|
|
109
|
+
line: lineOf(ctx, index),
|
|
110
|
+
hint,
|
|
111
|
+
example: EMBED_EXAMPLE
|
|
112
|
+
});
|
|
99
113
|
}
|
|
100
114
|
const raw = stripQuotes(match[1]);
|
|
101
115
|
let title = "";
|
package/src/compiler/page.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// per-file compile (frontmatter + colocated assets + body / `.md` passthrough).
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
-
import fs from "node:fs";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { imageSize } from "image-size";
|
|
10
|
+
import { createPaths } from "../config.js";
|
|
11
11
|
import { htmlHasHighlight } from "../highlight.js";
|
|
12
12
|
import { compileBody } from "./body.js";
|
|
13
13
|
import { createCompilation, createScope } from "./context.js";
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./includes.js";
|
|
21
21
|
import { escapeHtml } from "./interpolation.js";
|
|
22
22
|
import { selectMd } from "./markdown.js";
|
|
23
|
+
import { memoryReader } from "./reader.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* @typedef {import("./context.js").Paths} Paths
|
|
@@ -42,16 +43,20 @@ const SKIP_LINK_STYLE =
|
|
|
42
43
|
* Compile a page source file into a full HTML document plus its assets.
|
|
43
44
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
44
45
|
* @param {Paths} context Resolved project paths.
|
|
45
|
-
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown
|
|
46
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader }} [options]
|
|
46
47
|
* When a site-wide RSS feed is emitted (the home page set `site_url` and the
|
|
47
48
|
* site has dated posts), `feed` carries its absolute href + title so every page
|
|
48
49
|
* links it. `collections` is the build-time collection index a bare-name
|
|
49
50
|
* `@loop` resolves against; `vars` seeds the document scope (the pager `page`
|
|
50
|
-
* object on a paginated route).
|
|
51
|
+
* object on a paginated route). `reader` is the source reader (fs-backed by
|
|
52
|
+
* default via `src/compiler.js`; in-memory for `compileFromMemory`).
|
|
51
53
|
* @returns {CompiledPage}
|
|
52
54
|
*/
|
|
53
55
|
export function compilePage(file, context, options = {}) {
|
|
54
|
-
|
|
56
|
+
// The raw entry point requires a reader — the Node barrel (`src/compiler.js`)
|
|
57
|
+
// injects `fsReader()`, and `compileFromMemory` injects a `memoryReader`.
|
|
58
|
+
const reader = /** @type {import("./reader.js").Reader} */ (options.reader);
|
|
59
|
+
const compiled = compileDocument(file, context, [], options.vars, options.collections, reader);
|
|
55
60
|
const title = compiled.meta.title || "Darkmown";
|
|
56
61
|
const description = compiled.meta.description || "";
|
|
57
62
|
// Per-page document language for the `<html lang>` attribute (`lang: fr` in
|
|
@@ -155,10 +160,10 @@ export function compilePage(file, context, options = {}) {
|
|
|
155
160
|
// body so its scoped selectors match. Pure compile-time string post-pass (like
|
|
156
161
|
// enhanceImages) — no runtime, no `data-wd-*` marker, so a static page stays
|
|
157
162
|
// static. Include-scoped skins are stamped earlier, in handleInclude.
|
|
158
|
-
const scopedSkin = scopedSkinFor(file, context);
|
|
163
|
+
const scopedSkin = scopedSkinFor(file, context, reader);
|
|
159
164
|
const stamped =
|
|
160
165
|
scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
|
|
161
|
-
const body = enhanceImages(stamped, context);
|
|
166
|
+
const body = enhanceImages(stamped, context, reader);
|
|
162
167
|
const { content, target } = ensureMainLandmark(body);
|
|
163
168
|
|
|
164
169
|
return {
|
|
@@ -216,9 +221,10 @@ function ensureMainLandmark(html) {
|
|
|
216
221
|
* lazy-load. Author-set attributes are never overwritten.
|
|
217
222
|
* @param {string} html Assembled page body HTML.
|
|
218
223
|
* @param {Paths} paths Resolved project paths.
|
|
224
|
+
* @param {import("./reader.js").Reader} reader Source reader for the image bytes.
|
|
219
225
|
* @returns {string}
|
|
220
226
|
*/
|
|
221
|
-
export function enhanceImages(html, paths) {
|
|
227
|
+
export function enhanceImages(html, paths, reader) {
|
|
222
228
|
let index = 0;
|
|
223
229
|
return html.replace(/<img\b[^>]*?\/?>/g, (tag) => {
|
|
224
230
|
const i = index++;
|
|
@@ -228,7 +234,7 @@ export function enhanceImages(html, paths) {
|
|
|
228
234
|
// Dimensions only when the author hasn't sized it — adding one axis to a
|
|
229
235
|
// manually-sized image would distort its aspect ratio.
|
|
230
236
|
if (!has("width") && !has("height")) {
|
|
231
|
-
const dim = measureImage(srcOf(tag), paths);
|
|
237
|
+
const dim = measureImage(srcOf(tag), paths, reader);
|
|
232
238
|
if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
|
|
233
239
|
}
|
|
234
240
|
if (!has("decoding")) add.push(`decoding="async"`);
|
|
@@ -259,9 +265,10 @@ function srcOf(tag) {
|
|
|
259
265
|
* comes from the shelf (`site/_`), other absolute paths from `site/pages`.
|
|
260
266
|
* @param {string} src
|
|
261
267
|
* @param {Paths} paths
|
|
268
|
+
* @param {import("./reader.js").Reader} reader
|
|
262
269
|
* @returns {{ width: number, height: number } | null}
|
|
263
270
|
*/
|
|
264
|
-
function measureImage(src, paths) {
|
|
271
|
+
function measureImage(src, paths, reader) {
|
|
265
272
|
if (!src || /^(https?:)?\/\//i.test(src) || src.startsWith("data:")) return null;
|
|
266
273
|
let filePath;
|
|
267
274
|
if (src.startsWith("/__wd/media/")) {
|
|
@@ -272,7 +279,7 @@ function measureImage(src, paths) {
|
|
|
272
279
|
return null; // page-relative: the source directory is lost after assembly
|
|
273
280
|
}
|
|
274
281
|
try {
|
|
275
|
-
const { width, height } = imageSize(
|
|
282
|
+
const { width, height } = imageSize(reader.readBinary(filePath));
|
|
276
283
|
if (typeof width === "number" && typeof height === "number") return { width, height };
|
|
277
284
|
} catch {
|
|
278
285
|
/* missing or unreadable — degrade to no dimensions */
|
|
@@ -288,10 +295,22 @@ function measureImage(src, paths) {
|
|
|
288
295
|
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
289
296
|
* @param {Map<string, import("./collections.js").CollectionRow[]>} [collections]
|
|
290
297
|
* Build-time collection index a bare-name `@loop` resolves against.
|
|
298
|
+
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed when
|
|
299
|
+
* omitted via `src/compiler.js`; in-memory for `compileFromMemory`).
|
|
291
300
|
* @returns {CompiledDocument}
|
|
292
301
|
*/
|
|
293
|
-
export function compileDocument(
|
|
294
|
-
|
|
302
|
+
export function compileDocument(
|
|
303
|
+
file,
|
|
304
|
+
context,
|
|
305
|
+
stack = [],
|
|
306
|
+
vars = {},
|
|
307
|
+
collections = new Map(),
|
|
308
|
+
reader
|
|
309
|
+
) {
|
|
310
|
+
// reader is injected by every caller (the Node barrel's `fsReader()` or
|
|
311
|
+
// `compileFromMemory`'s `memoryReader`); it is only trailing-optional here so
|
|
312
|
+
// the defaulted `vars`/`collections` params can precede it.
|
|
313
|
+
const comp = createCompilation(/** @type {import("./reader.js").Reader} */ (reader));
|
|
295
314
|
comp.collections = collections;
|
|
296
315
|
const result = compileFile(
|
|
297
316
|
file,
|
|
@@ -342,18 +361,18 @@ export function compileFile(
|
|
|
342
361
|
reactiveDepth = 0,
|
|
343
362
|
loopOpener = null
|
|
344
363
|
) {
|
|
345
|
-
const real =
|
|
364
|
+
const real = comp.reader.realpath(file);
|
|
346
365
|
if (stack.includes(real)) {
|
|
347
366
|
throw new Error(
|
|
348
367
|
`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`
|
|
349
368
|
);
|
|
350
369
|
}
|
|
351
370
|
|
|
352
|
-
const raw =
|
|
371
|
+
const raw = comp.reader.readText(file);
|
|
353
372
|
const { meta, body, bodyLine } = parseFrontmatter(raw, file);
|
|
354
373
|
warnLikelyFrontmatter(raw, file, comp);
|
|
355
374
|
comp.deps.add(file);
|
|
356
|
-
collectColocatedAssets(file, context, comp.assets);
|
|
375
|
+
collectColocatedAssets(file, context, comp.assets, comp.reader);
|
|
357
376
|
|
|
358
377
|
if (path.extname(file) === ".md") {
|
|
359
378
|
scanMarkdownHints(body, file, comp);
|
|
@@ -386,3 +405,37 @@ export function compileFile(
|
|
|
386
405
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
387
406
|
return { meta, html };
|
|
388
407
|
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Compile a page entirely from an in-memory file map — no filesystem, no
|
|
411
|
+
* `node:fs`. `files` maps PROJECT-RELATIVE POSIX paths (`site/pages/index.wd`,
|
|
412
|
+
* `site/_/nav.wd`) to their source strings; `entryPath` is the project-relative
|
|
413
|
+
* path of the page to compile. Includes, colocated `.skin`/`.js` detection, and
|
|
414
|
+
* `@loop` JSON-data reads all resolve against the same map (anything not in it
|
|
415
|
+
* is simply "absent"), so `@include`s work as long as their targets are present.
|
|
416
|
+
*
|
|
417
|
+
* This is the strategic fs-free entry point the browser playground and a planned
|
|
418
|
+
* mobile host use: the entire module graph reachable from here is free of
|
|
419
|
+
* `node:fs`, so it bundles for the browser. It returns the same
|
|
420
|
+
* {@link CompiledPage} shape as {@link compilePage} and throws the same
|
|
421
|
+
* `file:line` compile errors, so the error DX is identical in-browser.
|
|
422
|
+
* @param {Record<string, string> | Map<string, string>} files Project-relative
|
|
423
|
+
* path → source content.
|
|
424
|
+
* @param {string} entryPath Project-relative path of the page to compile.
|
|
425
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, cwd?: string }} [options]
|
|
426
|
+
* Same shell options as {@link compilePage}; `cwd` overrides the virtual
|
|
427
|
+
* project root the relative keys resolve against (defaults to `/`).
|
|
428
|
+
* @returns {CompiledPage}
|
|
429
|
+
*/
|
|
430
|
+
export function compileFromMemory(files, entryPath, options = {}) {
|
|
431
|
+
const cwd = options.cwd ?? "/";
|
|
432
|
+
const reader = memoryReader(files, cwd);
|
|
433
|
+
const context = createPaths(cwd);
|
|
434
|
+
const abs = path.resolve(cwd, entryPath);
|
|
435
|
+
return compilePage(abs, context, {
|
|
436
|
+
feed: options.feed,
|
|
437
|
+
collections: options.collections,
|
|
438
|
+
vars: options.vars,
|
|
439
|
+
reader
|
|
440
|
+
});
|
|
441
|
+
}
|