@zvndev/darkmown 2.0.1 → 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.
@@ -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,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} [allowAny] Allow non-page extensions (e.g. JSON for `@loop`).
161
- * @param {string} [loc] Source location (`file:line`) for the error messages, so
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)`; it defaults to `fromFile` (the bare
164
- * path) for any caller without one, preserving the file-only message.
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 = false, loc = fromFile) {
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 (!fs.existsSync(resolved)) continue;
186
+ if (!reader.exists(resolved)) continue;
182
187
  if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
183
188
  return resolved;
184
189
  }
@@ -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 {
@@ -288,9 +288,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
288
288
  source.endsWith(".json")
289
289
  ) {
290
290
  if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
291
- const dataFile = resolveInclude(source, ctx.file, ctx.context, true, at(ctx, index));
291
+ const dataFile = resolveInclude(
292
+ source,
293
+ ctx.file,
294
+ ctx.context,
295
+ true,
296
+ at(ctx, index),
297
+ ctx.comp.reader
298
+ );
292
299
  ctx.comp.deps.add(dataFile);
293
- const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
300
+ const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
294
301
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
295
302
  return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
296
303
  }
@@ -692,7 +699,7 @@ function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
692
699
  */
693
700
  function loopClauseAttrs(c) {
694
701
  return (
695
- (c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
702
+ (c.where ? ` data-wd-loop-where="${escapeHtml(serializeExpr(c.where.body))}"` : "") +
696
703
  (c.sort
697
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)}"`
698
705
  : "") +
@@ -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,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(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
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 = fs.realpathSync(file);
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 = fs.readFileSync(file, "utf8");
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
+ }
@@ -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
  /**
@@ -12,6 +12,7 @@
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
14
  import { at, createScope, LOOP_META, nestedCtx } from "./context.js";
15
+ import { astOf, serializeExpr } from "./expr-ast.js";
15
16
  import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
16
17
  import {
17
18
  escapeHtml,
@@ -44,7 +45,14 @@ const SEMANTIC_CONTAINER_TAGS = new Set(["nav", "main"]);
44
45
  export function handleInclude(line, ctx, index) {
45
46
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
46
47
  if (!match) throw new Error(`Malformed @include in ${at(ctx, index)}: ${line}`);
47
- const target = resolveInclude(match[1], ctx.file, ctx.context, false, at(ctx, index));
48
+ const target = resolveInclude(
49
+ match[1],
50
+ ctx.file,
51
+ ctx.context,
52
+ false,
53
+ at(ctx, index),
54
+ ctx.comp.reader
55
+ );
48
56
  const args = parseIncludeArgs(match[2] || "", ctx);
49
57
  const childScope = createScope(ctx.scope, args);
50
58
  // Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
@@ -67,7 +75,7 @@ export function handleInclude(line, ctx, index) {
67
75
  // anywhere else on the page, and the scope never leaks to the include's
68
76
  // siblings (only this child HTML is stamped). The CSS rewrite (builder) uses
69
77
  // the same path-derived id, so attribute and stylesheet line up.
70
- const scopedSkin = scopedSkinFor(target, ctx.context);
78
+ const scopedSkin = scopedSkinFor(target, ctx.context, ctx.comp.reader);
71
79
  return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
72
80
  }
73
81
 
@@ -110,9 +118,9 @@ export function handleContainer(header, bodyLines, ctx, index) {
110
118
  let tag = "section";
111
119
  /** @type {string[]} Static classes baked into class="". */
112
120
  const extraClass = [];
113
- /** @type {[string, string][]} Reactive loop-item class bindings (data-wd-each-class). */
121
+ /** @type {[string, any[]][]} Reactive loop-item class bindings (data-wd-each-class): [class, exprAST]. */
114
122
  const eachClasses = [];
115
- /** @type {[string, string][]} Global state-driven class bindings (data-wd-class). */
123
+ /** @type {[string, any[]][]} Global state-driven class bindings (data-wd-class): [class, exprAST]. */
116
124
  const stateClasses = [];
117
125
  let id = "";
118
126
  // Leading tag/name token (anything not starting with . or #). "section" keeps
@@ -157,8 +165,8 @@ export function handleContainer(header, bodyLines, ctx, index) {
157
165
  const compiled = compileWhen(whenMatch[1].trim(), ctx);
158
166
  if (compiled.static) {
159
167
  if (compiled.value) extraClass.push(cls);
160
- } else if (compiled.item) eachClasses.push([cls, compiled.body]);
161
- else stateClasses.push([cls, compiled.body]);
168
+ } else if (compiled.item) eachClasses.push([cls, astOf(compiled.body)]);
169
+ else stateClasses.push([cls, astOf(compiled.body)]);
162
170
  } else {
163
171
  extraClass.push(cls);
164
172
  }
@@ -328,7 +336,7 @@ export function handleIf(line, truthyLines, falsyLines, ctx, index, falsyStart =
328
336
  ctx.comp.assets.runtime = true;
329
337
  const truthy = ctx.compileBody(truthyLines, truthyCtx).trim();
330
338
  const falsy = ctx.compileBody(falsyLines, falsyCtx).trim();
331
- const exprAttr = ` data-wd-if-expr="${escapeHtml(compiled.body)}"`;
339
+ const exprAttr = ` data-wd-if-expr="${escapeHtml(serializeExpr(compiled.body))}"`;
332
340
  if (compiled.item) {
333
341
  return `<span data-wd-each-if${exprAttr}><template data-wd-if-true>${truthy}</template><template data-wd-if-false>${falsy}</template><span data-wd-each-if-out></span></span>`;
334
342
  }
package/src/compiler.js CHANGED
@@ -1,7 +1,13 @@
1
1
  // Darkmown's compiler, split into cohesive modules under `src/compiler/`. This
2
- // file is a thin barrel: it re-exports the public API from `src/compiler/index.js`
3
- // so every existing `import … from "./compiler.js"` (builder, tests) keeps
4
- // working unchanged. See `src/compiler/index.js` for the module map.
2
+ // file is the Node-facing barrel: it re-exports the public API from
3
+ // `src/compiler/index.js` so every existing `import … from "./compiler.js"`
4
+ // (builder, tests) keeps working unchanged. It is ALSO the single place the
5
+ // default filesystem reader is injected — `compilePage`/`compileDocument`/
6
+ // `enhanceImages` default their reader to `fsReader()` here, so callers keep
7
+ // their old signatures and the compile stays byte-identical. The fs-free entry
8
+ // point (`compileFromMemory`) needs no default and passes through. Because this
9
+ // module imports `fs-reader.js` (and thus `node:fs`), it is Node-only; the
10
+ // browser bundles `compileFromMemory` from `src/compiler/page.js` directly.
5
11
 
6
12
  // Re-export the public typedefs so existing `import("./compiler.js").Type`
7
13
  // references (e.g. `Assets` in src/builder.js) keep resolving after the split.
@@ -16,12 +22,57 @@
16
22
  * @typedef {import("./compiler/context.js").CompiledPage} CompiledPage
17
23
  */
18
24
 
25
+ import { fsReader } from "./compiler/fs-reader.js";
26
+ import {
27
+ compileDocument as compileDocumentImpl,
28
+ compilePage as compilePageImpl,
29
+ enhanceImages as enhanceImagesImpl
30
+ } from "./compiler/index.js";
31
+
19
32
  export {
20
- compileDocument,
21
- compilePage,
22
- enhanceImages,
33
+ compileFromMemory,
23
34
  escapeHtml,
24
35
  loopKeyOf,
25
36
  parseFrontmatter,
26
37
  stampScope
27
38
  } from "./compiler/index.js";
39
+
40
+ /**
41
+ * Compile a page source file into a full HTML document plus its assets, reading
42
+ * from the real filesystem by default. Pass `options.reader` to compile against
43
+ * a different source (see `compileFromMemory`).
44
+ * @param {string} file Absolute path to the source `.md`/`.wd` file.
45
+ * @param {import("./config.js").Paths} context Resolved project paths.
46
+ * @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
47
+ * @returns {CompiledPage}
48
+ */
49
+ export function compilePage(file, context, options = {}) {
50
+ return compilePageImpl(file, context, { reader: fsReader(), ...options });
51
+ }
52
+
53
+ /**
54
+ * Compile a source file into its body HTML, frontmatter, and assets (no page
55
+ * shell), reading from the real filesystem by default.
56
+ * @param {string} file Absolute path to the source file.
57
+ * @param {import("./config.js").Paths} context Resolved project paths.
58
+ * @param {string[]} [stack] Include stack for cycle detection.
59
+ * @param {Record<string, unknown>} [vars] Initial static scope variables.
60
+ * @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
61
+ * @param {import("./compiler/reader.js").Reader} [reader] Source reader.
62
+ * @returns {CompiledDocument}
63
+ */
64
+ export function compileDocument(file, context, stack, vars, collections, reader = fsReader()) {
65
+ return compileDocumentImpl(file, context, stack, vars, collections, reader);
66
+ }
67
+
68
+ /**
69
+ * Harden every `<img>` in an assembled page body, reading intrinsic dimensions
70
+ * from the real filesystem by default.
71
+ * @param {string} html Assembled page body HTML.
72
+ * @param {import("./config.js").Paths} paths Resolved project paths.
73
+ * @param {import("./compiler/reader.js").Reader} [reader] Source reader for image bytes.
74
+ * @returns {string}
75
+ */
76
+ export function enhanceImages(html, paths, reader = fsReader()) {
77
+ return enhanceImagesImpl(html, paths, reader);
78
+ }