@zvndev/darkmown 2.0.0 → 2.1.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 +21 -0
- package/README.md +31 -7
- package/SECURITY.md +4 -4
- package/docs/spec-alignment.md +19 -6
- package/package.json +5 -2
- 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 +18 -2
- package/site/pages/index.js +19 -0
- package/site/pages/index.skin +46 -1
- package/site/pages/index.wd +7 -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 +10 -8
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +22 -4
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +25 -14
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +44 -7
- package/src/compiler/page.js +97 -17
- package/src/compiler/predicates.js +4 -12
- package/src/compiler/reader.js +60 -0
- package/src/compiler/state.js +16 -18
- package/src/compiler/structure.js +40 -10
- package/src/compiler.js +57 -6
- package/src/headers.js +15 -13
- package/src/index.js +1 -0
- package/src/runtime.js +77 -45
- package/src/statics.js +6 -7
- package/src/templates/blog/site/_/nav.wd +6 -9
- package/src/templates/blog/site/pages/index.skin +7 -4
- package/src/templates/blog/site/pages/index.wd +2 -3
- package/src/templates/dashboard/site/_/nav.wd +6 -9
- package/src/templates/dashboard/site/pages/index.skin +7 -4
- package/src/templates/dashboard/site/pages/index.wd +2 -3
- package/src/templates/landing/site/_/nav.wd +6 -9
- package/src/templates/landing/site/pages/index.skin +13 -4
- package/src/templates/landing/site/pages/index.wd +22 -9
- package/src/templates/starter/site/_/nav.wd +7 -10
- package/src/templates/starter/site/pages/about.md +7 -18
- package/src/templates/starter/site/pages/about.skin +0 -25
- package/src/templates/starter/site/pages/index.skin +10 -6
- package/src/templates/starter/site/pages/index.wd +2 -3
- package/src/templates/store/site/_/nav.wd +6 -9
- package/src/templates/store/site/pages/index.skin +7 -4
- package/src/templates/store/site/pages/index.wd +2 -3
- package/types/compiler.d.ts +13 -0
- package/types/index.d.ts +1 -0
|
@@ -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,10 +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}
|
|
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
|
|
165
|
+
* an unresolved/out-of-sandbox include reports the directive's line. Callers
|
|
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`.
|
|
161
170
|
* @returns {string}
|
|
162
171
|
*/
|
|
163
|
-
export function resolveInclude(spec, fromFile, context, allowAny
|
|
172
|
+
export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
|
|
164
173
|
const clean = stripQuotes(spec);
|
|
165
174
|
const candidates = [];
|
|
166
175
|
if (clean.startsWith("/")) {
|
|
@@ -172,13 +181,15 @@ export function resolveInclude(spec, fromFile, context, allowAny = false) {
|
|
|
172
181
|
for (const candidate of candidates) {
|
|
173
182
|
const resolved = path.resolve(candidate);
|
|
174
183
|
if (!isAllowedInclude(resolved, context)) {
|
|
175
|
-
throw new Error(`Include "${spec}" from ${
|
|
184
|
+
throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
|
|
176
185
|
}
|
|
177
|
-
if (!
|
|
186
|
+
if (!reader.exists(resolved)) continue;
|
|
178
187
|
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
179
188
|
return resolved;
|
|
180
189
|
}
|
|
181
|
-
throw new Error(
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Could not resolve include "${spec}" from ${loc}. Looked in site/pages and site/_ — check the path and leading slash.`
|
|
192
|
+
);
|
|
182
193
|
}
|
|
183
194
|
|
|
184
195
|
/**
|
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 fs from "node:fs";
|
|
10
9
|
import { at, createScope, nestedCtx } 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 {
|
|
@@ -219,7 +219,7 @@ function numArgAttr(arg) {
|
|
|
219
219
|
*/
|
|
220
220
|
export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
|
|
221
221
|
const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
|
|
222
|
-
if (!match) throw new Error(`Malformed @loop in ${ctx
|
|
222
|
+
if (!match) throw new Error(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`);
|
|
223
223
|
const source = stripQuotes(match[1].trim());
|
|
224
224
|
const itemName = match[2];
|
|
225
225
|
const clauses = match[3]
|
|
@@ -273,8 +273,13 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
273
273
|
paginate: clauses.paginate
|
|
274
274
|
};
|
|
275
275
|
// The loop body starts on the line after the opener; nested errors in it (and
|
|
276
|
-
// in the empty branch, via `opts.emptyStart`) report the true file line.
|
|
277
|
-
|
|
276
|
+
// in the empty branch, via `opts.emptyStart`) report the true file line. The
|
|
277
|
+
// opener's location rides on the body ctx so a reactive loop nested inside this
|
|
278
|
+
// one can point the depth error back at its own opener line.
|
|
279
|
+
const bodyCtx = {
|
|
280
|
+
...nestedCtx(ctx, index + 1),
|
|
281
|
+
loopOpener: { at: at(ctx, index), line: line.trim() }
|
|
282
|
+
};
|
|
278
283
|
|
|
279
284
|
if (
|
|
280
285
|
source.startsWith("/") ||
|
|
@@ -283,9 +288,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
|
|
|
283
288
|
source.endsWith(".json")
|
|
284
289
|
) {
|
|
285
290
|
if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
|
|
286
|
-
const dataFile = resolveInclude(
|
|
291
|
+
const dataFile = resolveInclude(
|
|
292
|
+
source,
|
|
293
|
+
ctx.file,
|
|
294
|
+
ctx.context,
|
|
295
|
+
true,
|
|
296
|
+
at(ctx, index),
|
|
297
|
+
ctx.comp.reader
|
|
298
|
+
);
|
|
287
299
|
ctx.comp.deps.add(dataFile);
|
|
288
|
-
const rows = JSON.parse(
|
|
300
|
+
const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
|
|
289
301
|
if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
|
|
290
302
|
return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
|
|
291
303
|
}
|
|
@@ -372,6 +384,27 @@ function paginateOnlyCollections(ctx) {
|
|
|
372
384
|
return `@loop paginate requires a collection source (a site/pages/<name>/ subdirectory) in ${ctx.file}. ${LOOP_USAGE}`;
|
|
373
385
|
}
|
|
374
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Reject a reactive `@loop` that would open a THIRD reactive nesting level. The
|
|
389
|
+
* runtime reconciles at most two nested `data-wd-loop` levels (an outer loop and
|
|
390
|
+
* one inner loop); a third level's `data-wd-loop-out` paints empty. This guards
|
|
391
|
+
* every reactive-region entry point ({@link reactiveLoop}, {@link itemRelativeLoop}),
|
|
392
|
+
* so a state-key, item-relative, or state-filtered source is all covered. Static
|
|
393
|
+
* (build-unrolled) loops carry the depth through without incrementing it, so an
|
|
394
|
+
* interleaved static level neither triggers nor masks the limit.
|
|
395
|
+
* `ctx.reactiveDepth` is the number of reactive loops already enclosing this one.
|
|
396
|
+
* @param {Ctx} ctx
|
|
397
|
+
* @returns {void}
|
|
398
|
+
*/
|
|
399
|
+
function assertReactiveDepth(ctx) {
|
|
400
|
+
if ((ctx.reactiveDepth ?? 0) < 2) return;
|
|
401
|
+
const opener = ctx.loopOpener ?? { at: ctx.file, line: "" };
|
|
402
|
+
throw new Error(
|
|
403
|
+
`Reactive @loop nesting is limited to one inner level in ${opener.at}: "${opener.line}". ` +
|
|
404
|
+
`Unroll the outer data at build time (JSON/frontmatter source) or move the innermost list into build-time data.`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
375
408
|
/**
|
|
376
409
|
* Loop over a build-time collection's entry rows. With no `paginate`, this is a
|
|
377
410
|
* plain static unroll (filter/sort/reverse/offset/limit at build time, zero JS).
|
|
@@ -540,6 +573,7 @@ function staticUnroll(rows, itemName, bodyLines, ctx, empty = null, emptyStart =
|
|
|
540
573
|
* @returns {string}
|
|
541
574
|
*/
|
|
542
575
|
function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
576
|
+
assertReactiveDepth(ctx);
|
|
543
577
|
ctx.comp.assets.runtime = true;
|
|
544
578
|
/** @type {Ctx} */
|
|
545
579
|
const templateCtx = {
|
|
@@ -547,6 +581,7 @@ function reactiveLoop(key, itemName, bodyLines, ctx, opts = null) {
|
|
|
547
581
|
loopItem: itemName,
|
|
548
582
|
loopKey: key ?? undefined,
|
|
549
583
|
loopMeta: true,
|
|
584
|
+
reactiveDepth: (ctx.reactiveDepth ?? 0) + 1,
|
|
550
585
|
scope: createScope(ctx.scope)
|
|
551
586
|
};
|
|
552
587
|
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
|
@@ -664,7 +699,7 @@ function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
|
|
|
664
699
|
*/
|
|
665
700
|
function loopClauseAttrs(c) {
|
|
666
701
|
return (
|
|
667
|
-
(c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
|
|
702
|
+
(c.where ? ` data-wd-loop-where="${escapeHtml(serializeExpr(c.where.body))}"` : "") +
|
|
668
703
|
(c.sort
|
|
669
704
|
? ` 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)}"`
|
|
670
705
|
: "") +
|
|
@@ -689,6 +724,7 @@ function loopClauseAttrs(c) {
|
|
|
689
724
|
* @returns {string}
|
|
690
725
|
*/
|
|
691
726
|
function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
727
|
+
assertReactiveDepth(ctx);
|
|
692
728
|
/** @type {Ctx} */
|
|
693
729
|
// loopKey: undefined — an item-relative loop's source is a path off the outer
|
|
694
730
|
// row, not a top-level state key, so a per-row `remove` inside it has no valid
|
|
@@ -698,6 +734,7 @@ function itemRelativeLoop(path, itemName, bodyLines, ctx, opts) {
|
|
|
698
734
|
loopItem: itemName,
|
|
699
735
|
loopKey: undefined,
|
|
700
736
|
loopMeta: true,
|
|
737
|
+
reactiveDepth: (ctx.reactiveDepth ?? 0) + 1,
|
|
701
738
|
scope: createScope(ctx.scope)
|
|
702
739
|
};
|
|
703
740
|
const templateHtml = ctx.compileBody(bodyLines, templateCtx).trim();
|
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,12 +295,34 @@ 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
|
-
const result = compileFile(
|
|
315
|
+
const result = compileFile(
|
|
316
|
+
file,
|
|
317
|
+
context,
|
|
318
|
+
stack,
|
|
319
|
+
createScope(null, vars),
|
|
320
|
+
comp,
|
|
321
|
+
[],
|
|
322
|
+
null,
|
|
323
|
+
0,
|
|
324
|
+
null
|
|
325
|
+
);
|
|
297
326
|
return {
|
|
298
327
|
meta: result.meta,
|
|
299
328
|
html: result.html,
|
|
@@ -314,21 +343,36 @@ export function compileDocument(file, context, stack = [], vars = {}, collection
|
|
|
314
343
|
* @param {Compilation} comp
|
|
315
344
|
* @param {string[]} sections
|
|
316
345
|
* @param {string | null} loopItem
|
|
346
|
+
* @param {number} [reactiveDepth] Enclosing REACTIVE `@loop` levels, threaded in
|
|
347
|
+
* from the including page so an include's body can't bypass the two-level
|
|
348
|
+
* reactive-nesting limit at the `@include` boundary.
|
|
349
|
+
* @param {{ at: string, line: string } | null} [loopOpener] The enclosing `@loop`
|
|
350
|
+
* opener, threaded in so a depth error inside the include names the right opener.
|
|
317
351
|
* @returns {{ meta: Meta, html: string }}
|
|
318
352
|
*/
|
|
319
|
-
export function compileFile(
|
|
320
|
-
|
|
353
|
+
export function compileFile(
|
|
354
|
+
file,
|
|
355
|
+
context,
|
|
356
|
+
stack,
|
|
357
|
+
scope,
|
|
358
|
+
comp,
|
|
359
|
+
sections,
|
|
360
|
+
loopItem,
|
|
361
|
+
reactiveDepth = 0,
|
|
362
|
+
loopOpener = null
|
|
363
|
+
) {
|
|
364
|
+
const real = comp.reader.realpath(file);
|
|
321
365
|
if (stack.includes(real)) {
|
|
322
366
|
throw new Error(
|
|
323
367
|
`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`
|
|
324
368
|
);
|
|
325
369
|
}
|
|
326
370
|
|
|
327
|
-
const raw =
|
|
371
|
+
const raw = comp.reader.readText(file);
|
|
328
372
|
const { meta, body, bodyLine } = parseFrontmatter(raw, file);
|
|
329
373
|
warnLikelyFrontmatter(raw, file, comp);
|
|
330
374
|
comp.deps.add(file);
|
|
331
|
-
collectColocatedAssets(file, context, comp.assets);
|
|
375
|
+
collectColocatedAssets(file, context, comp.assets, comp.reader);
|
|
332
376
|
|
|
333
377
|
if (path.extname(file) === ".md") {
|
|
334
378
|
scanMarkdownHints(body, file, comp);
|
|
@@ -351,6 +395,8 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
351
395
|
comp,
|
|
352
396
|
sections,
|
|
353
397
|
loopItem,
|
|
398
|
+
reactiveDepth,
|
|
399
|
+
...(loopOpener ? { loopOpener } : {}),
|
|
354
400
|
md: selectMd(meta),
|
|
355
401
|
compileBody,
|
|
356
402
|
compileFile
|
|
@@ -359,3 +405,37 @@ export function compileFile(file, context, stack, scope, comp, sections, loopIte
|
|
|
359
405
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
360
406
|
return { meta, html };
|
|
361
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
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// readers — no identifier survives un-mapped, so no raw user content is eval'd.
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
+
import { astOf, evalAst } from "./expr-ast.js";
|
|
8
9
|
import { getPath, lookupVar, resolveStateKey } from "./interpolation.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -93,14 +94,9 @@ function compileOperand(tok, itemName, ctx) {
|
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
|
|
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
97
|
/**
|
|
103
|
-
* Evaluate a compiled predicate against a row at build time
|
|
98
|
+
* Evaluate a compiled predicate against a row at build time by walking its AST —
|
|
99
|
+
* the same closed evaluator the runtime uses, so the fold matches. No eval.
|
|
104
100
|
* @param {string} body
|
|
105
101
|
* @param {unknown} item
|
|
106
102
|
* @param {Ctx} ctx
|
|
@@ -108,11 +104,7 @@ const containsHelper = (a, b) =>
|
|
|
108
104
|
*/
|
|
109
105
|
export function evalPredicate(body, item, ctx) {
|
|
110
106
|
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));
|
|
107
|
+
return Boolean(evalAst(astOf(body), item, ctx.comp));
|
|
116
108
|
} catch {
|
|
117
109
|
console.warn(
|
|
118
110
|
`@loop where predicate "${body}" in ${ctx.file} could not be evaluated at build time; treating the row as excluded. Check the condition.`
|
|
@@ -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
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
|
|
11
11
|
import { at } from "./context.js";
|
|
12
|
-
import {
|
|
13
|
-
import { escapeHtml,
|
|
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
|
/**
|
|
@@ -119,27 +119,25 @@ export function handleComputed(line, ctx, index) {
|
|
|
119
119
|
`Malformed :computed in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
|
|
120
120
|
);
|
|
121
121
|
const expr = compileComputedExpr(match[2].trim(), ctx);
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
// Parse the validated expression to the compact AST the runtime walks. A parse
|
|
123
|
+
// failure means the RHS is syntactically malformed (e.g. `|| <`) — a compile
|
|
124
|
+
// error, not a silently-broken binding.
|
|
125
|
+
/** @type {any[]} */
|
|
126
|
+
let ast;
|
|
124
127
|
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);
|
|
128
|
+
ast = astOf(expr);
|
|
135
129
|
} catch {
|
|
136
|
-
|
|
137
|
-
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Malformed :computed expression in ${at(ctx, index)}: ${line}. Use: :computed total = items.length * 4`
|
|
138
132
|
);
|
|
139
|
-
initial = null;
|
|
140
133
|
}
|
|
134
|
+
// Build-time mirror of the runtime: walk the AST (no eval) so `:computed total =
|
|
135
|
+
// sum(...)` seeds the value the runtime recomputes. The walker is total over a
|
|
136
|
+
// validated AST — state that isn't known at build time resolves to undefined and
|
|
137
|
+
// folds to null, never throws — so parse-failure above is the only error path.
|
|
138
|
+
const initial = evalAst(ast, undefined, ctx.comp);
|
|
141
139
|
const key = declareState(match[1], initial ?? null, ctx);
|
|
142
|
-
return `<span data-wd-computed data-wd-computed-key="${key}" data-wd-computed-expr="${escapeHtml(
|
|
140
|
+
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
141
|
}
|
|
144
142
|
|
|
145
143
|
/**
|