@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.
Files changed (55) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +31 -7
  4. package/SECURITY.md +4 -4
  5. package/docs/spec-alignment.md +19 -6
  6. package/package.json +5 -2
  7. package/site/_/footer.wd +1 -1
  8. package/site/_/nav.wd +1 -0
  9. package/site/pages/app.wd +1 -1
  10. package/site/pages/blog/zero-js-by-default.md +1 -1
  11. package/site/pages/docs/index.wd +18 -2
  12. package/site/pages/index.js +19 -0
  13. package/site/pages/index.skin +46 -1
  14. package/site/pages/index.wd +7 -3
  15. package/site/pages/playground/index.js +158 -0
  16. package/site/pages/playground/index.skin +128 -0
  17. package/site/pages/playground/index.wd +51 -0
  18. package/site/pages/showcase/index.wd +1 -1
  19. package/src/builder.js +10 -8
  20. package/src/compiler/collections.js +16 -11
  21. package/src/compiler/context.js +22 -4
  22. package/src/compiler/expr-ast.js +296 -0
  23. package/src/compiler/fs-reader.js +25 -0
  24. package/src/compiler/includes.js +25 -14
  25. package/src/compiler/index.js +1 -1
  26. package/src/compiler/loops.js +44 -7
  27. package/src/compiler/page.js +97 -17
  28. package/src/compiler/predicates.js +4 -12
  29. package/src/compiler/reader.js +60 -0
  30. package/src/compiler/state.js +16 -18
  31. package/src/compiler/structure.js +40 -10
  32. package/src/compiler.js +57 -6
  33. package/src/headers.js +15 -13
  34. package/src/index.js +1 -0
  35. package/src/runtime.js +77 -45
  36. package/src/statics.js +6 -7
  37. package/src/templates/blog/site/_/nav.wd +6 -9
  38. package/src/templates/blog/site/pages/index.skin +7 -4
  39. package/src/templates/blog/site/pages/index.wd +2 -3
  40. package/src/templates/dashboard/site/_/nav.wd +6 -9
  41. package/src/templates/dashboard/site/pages/index.skin +7 -4
  42. package/src/templates/dashboard/site/pages/index.wd +2 -3
  43. package/src/templates/landing/site/_/nav.wd +6 -9
  44. package/src/templates/landing/site/pages/index.skin +13 -4
  45. package/src/templates/landing/site/pages/index.wd +22 -9
  46. package/src/templates/starter/site/_/nav.wd +7 -10
  47. package/src/templates/starter/site/pages/about.md +7 -18
  48. package/src/templates/starter/site/pages/about.skin +0 -25
  49. package/src/templates/starter/site/pages/index.skin +10 -6
  50. package/src/templates/starter/site/pages/index.wd +2 -3
  51. package/src/templates/store/site/_/nav.wd +6 -9
  52. package/src/templates/store/site/pages/index.skin +7 -4
  53. package/src/templates/store/site/pages/index.wd +2 -3
  54. package/types/compiler.d.ts +13 -0
  55. 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
+ }
@@ -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 = fs.readFileSync(skinPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
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 (!fs.existsSync(candidate)) return null;
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 (!fs.existsSync(candidate)) continue;
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) assets.scopedSkins.add(candidate);
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} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
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 = false) {
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 ${fromFile} resolves outside site/pages or site/_`);
184
+ throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
176
185
  }
177
- if (!fs.existsSync(resolved)) continue;
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(`Could not resolve include "${spec}" from ${fromFile}`);
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
  /**
@@ -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";
@@ -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.file}: ${line}. ${LOOP_USAGE}`);
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
- const bodyCtx = nestedCtx(ctx, index + 1);
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(source, ctx.file, ctx.context, true);
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(fs.readFileSync(dataFile, "utf8"));
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();
@@ -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> }} [options]
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
- const compiled = compileDocument(file, context, [], options.vars, options.collections);
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(fs.readFileSync(filePath));
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(file, context, stack = [], vars = {}, collections = new Map()) {
294
- const comp = createCompilation();
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(file, context, stack, createScope(null, vars), comp, [], null);
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(file, context, stack, scope, comp, sections, loopItem) {
320
- const real = fs.realpathSync(file);
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 = fs.readFileSync(file, "utf8");
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
- /** @param {string} [p] */
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
+ }
@@ -9,8 +9,8 @@
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
11
  import { at } from "./context.js";
12
- import { FORMATTERS } from "./format.js";
13
- import { escapeHtml, getPath, parseStateValue, safeScriptJson } from "./interpolation.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
  /**
@@ -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
- /** @type {unknown} */
123
- let initial;
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
- /** @param {string} key @param {string} [path] */
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
- console.warn(
137
- `:computed "${match[2].trim()}" in ${ctx.file} could not be evaluated at build time; falling back to null. Check the expression.`
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(expr)}"></span><script type="application/json" data-wd-state>${safeScriptJson({ [key]: initial ?? null })}</script>`;
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
  /**