@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +9 -0
- package/README.md +28 -6
- package/SECURITY.md +4 -4
- package/docs/spec-alignment.md +18 -6
- package/package.json +3 -1
- package/site/_/footer.wd +1 -1
- package/site/_/nav.wd +1 -0
- package/site/pages/app.wd +1 -1
- package/site/pages/blog/zero-js-by-default.md +1 -1
- package/site/pages/docs/index.wd +4 -2
- package/site/pages/index.wd +5 -3
- package/site/pages/playground/index.js +158 -0
- package/site/pages/playground/index.skin +128 -0
- package/site/pages/playground/index.wd +51 -0
- package/site/pages/showcase/index.wd +1 -1
- package/src/builder.js +10 -8
- package/src/compiler/collections.js +16 -11
- package/src/compiler/context.js +11 -2
- package/src/compiler/expr-ast.js +296 -0
- package/src/compiler/fs-reader.js +25 -0
- package/src/compiler/includes.js +20 -15
- package/src/compiler/index.js +1 -1
- package/src/compiler/loops.js +11 -4
- package/src/compiler/page.js +68 -15
- 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 +15 -7
- 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/types/compiler.d.ts +13 -0
- package/types/index.d.ts +1 -0
package/src/compiler/includes.js
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// and warn when a plain `.md` file uses `.wd`-only syntax.
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
-
import fs from "node:fs";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { scopeIdFor } from "../skin.js";
|
|
11
10
|
import { pageIncludeExtensions } from "./context.js";
|
|
@@ -27,12 +26,13 @@ import { stripQuotes } from "./interpolation.js";
|
|
|
27
26
|
* (page/include) always agree on both.
|
|
28
27
|
* @param {string} skinPath Absolute path to the colocated `.skin` file.
|
|
29
28
|
* @param {string} cwd Project root, for the path-based scope id.
|
|
29
|
+
* @param {import("./reader.js").Reader} reader Source reader for the skin file.
|
|
30
30
|
* @returns {{ scoped: boolean, scopeId: string }}
|
|
31
31
|
*/
|
|
32
|
-
export function scopeInfoForSkin(skinPath, cwd) {
|
|
32
|
+
export function scopeInfoForSkin(skinPath, cwd, reader) {
|
|
33
33
|
const rel = path.relative(cwd, skinPath).replaceAll(path.sep, "/");
|
|
34
34
|
const scopeId = scopeIdFor(rel);
|
|
35
|
-
const src =
|
|
35
|
+
const src = reader.readText(skinPath).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
36
36
|
for (const raw of src.split("\n")) {
|
|
37
37
|
const t = raw.trim();
|
|
38
38
|
if (!t || t.startsWith("//") || !/[A-Za-z0-9]/.test(t)) continue;
|
|
@@ -49,13 +49,14 @@ export function scopeInfoForSkin(skinPath, cwd) {
|
|
|
49
49
|
* `.skin` extension).
|
|
50
50
|
* @param {string} file Absolute path to the page/include source file.
|
|
51
51
|
* @param {Paths} context
|
|
52
|
+
* @param {import("./reader.js").Reader} reader Source reader for the skin file.
|
|
52
53
|
* @returns {{ scoped: boolean, scopeId: string } | null}
|
|
53
54
|
*/
|
|
54
|
-
export function scopedSkinFor(file, context) {
|
|
55
|
+
export function scopedSkinFor(file, context, reader) {
|
|
55
56
|
const ext = path.extname(file);
|
|
56
57
|
const candidate = `${file.slice(0, -ext.length)}.skin`;
|
|
57
|
-
if (!
|
|
58
|
-
return scopeInfoForSkin(candidate, context.cwd);
|
|
58
|
+
if (!reader.exists(candidate)) return null;
|
|
59
|
+
return scopeInfoForSkin(candidate, context.cwd, reader);
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
/**
|
|
@@ -127,9 +128,10 @@ export function stampScope(html, scopeId) {
|
|
|
127
128
|
* @param {string} file
|
|
128
129
|
* @param {Paths} context
|
|
129
130
|
* @param {Assets} assets
|
|
131
|
+
* @param {import("./reader.js").Reader} reader Source reader for sibling probing.
|
|
130
132
|
* @returns {void}
|
|
131
133
|
*/
|
|
132
|
-
export function collectColocatedAssets(file, context, assets) {
|
|
134
|
+
export function collectColocatedAssets(file, context, assets, reader) {
|
|
133
135
|
const ext = path.extname(file);
|
|
134
136
|
const stem = file.slice(0, -ext.length);
|
|
135
137
|
for (const [assetExt, folder] of [
|
|
@@ -137,7 +139,7 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
137
139
|
[".js", "scripts"]
|
|
138
140
|
]) {
|
|
139
141
|
const candidate = `${stem}${assetExt}`;
|
|
140
|
-
if (!
|
|
142
|
+
if (!reader.exists(candidate)) continue;
|
|
141
143
|
const rel = path.relative(context.cwd, candidate).replaceAll(path.sep, "/");
|
|
142
144
|
const outputExt = assetExt === ".skin" ? ".css" : ".js";
|
|
143
145
|
const publicPath = `/__wd/${folder}/${rel.slice(0, -assetExt.length).replace(/[/.]/g, "_")}${outputExt}`;
|
|
@@ -146,7 +148,8 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
146
148
|
assets.skins.add(publicPath);
|
|
147
149
|
// Surface "this skin is scoped" so the warning pass (builder) and the HTML
|
|
148
150
|
// stamp can find the scoped skins among all colocated ones by source path.
|
|
149
|
-
if (scopeInfoForSkin(candidate, context.cwd).scoped)
|
|
151
|
+
if (scopeInfoForSkin(candidate, context.cwd, reader).scoped)
|
|
152
|
+
assets.scopedSkins.add(candidate);
|
|
150
153
|
}
|
|
151
154
|
if (assetExt === ".js") assets.scripts.add(publicPath);
|
|
152
155
|
}
|
|
@@ -157,14 +160,16 @@ export function collectColocatedAssets(file, context, assets) {
|
|
|
157
160
|
* @param {string} spec Include target (may be quoted).
|
|
158
161
|
* @param {string} fromFile File requesting the include.
|
|
159
162
|
* @param {Paths} context
|
|
160
|
-
* @param {boolean}
|
|
161
|
-
* @param {string}
|
|
163
|
+
* @param {boolean} allowAny Allow non-page extensions (e.g. JSON for `@loop`).
|
|
164
|
+
* @param {string} loc Source location (`file:line`) for the error messages, so
|
|
162
165
|
* an unresolved/out-of-sandbox include reports the directive's line. Callers
|
|
163
|
-
* with a line index pass `at(ctx, index)`;
|
|
164
|
-
*
|
|
166
|
+
* with a line index pass `at(ctx, index)`; pass `fromFile` for the file-only
|
|
167
|
+
* message.
|
|
168
|
+
* @param {import("./reader.js").Reader} reader Source reader for existence
|
|
169
|
+
* probing, threaded from `ctx.comp.reader`.
|
|
165
170
|
* @returns {string}
|
|
166
171
|
*/
|
|
167
|
-
export function resolveInclude(spec, fromFile, context, allowAny
|
|
172
|
+
export function resolveInclude(spec, fromFile, context, allowAny, loc, reader) {
|
|
168
173
|
const clean = stripQuotes(spec);
|
|
169
174
|
const candidates = [];
|
|
170
175
|
if (clean.startsWith("/")) {
|
|
@@ -178,7 +183,7 @@ export function resolveInclude(spec, fromFile, context, allowAny = false, loc =
|
|
|
178
183
|
if (!isAllowedInclude(resolved, context)) {
|
|
179
184
|
throw new Error(`Include "${spec}" from ${loc} resolves outside site/pages or site/_`);
|
|
180
185
|
}
|
|
181
|
-
if (!
|
|
186
|
+
if (!reader.exists(resolved)) continue;
|
|
182
187
|
if (!allowAny && !pageIncludeExtensions.includes(path.extname(resolved))) continue;
|
|
183
188
|
return resolved;
|
|
184
189
|
}
|
package/src/compiler/index.js
CHANGED
|
@@ -32,4 +32,4 @@ export { parseFrontmatter } from "./frontmatter.js";
|
|
|
32
32
|
export { stampScope } from "./includes.js";
|
|
33
33
|
export { escapeHtml } from "./interpolation.js";
|
|
34
34
|
export { loopKeyOf } from "./loops.js";
|
|
35
|
-
export { compileDocument, compilePage, enhanceImages } from "./page.js";
|
|
35
|
+
export { compileDocument, compileFromMemory, compilePage, enhanceImages } from "./page.js";
|
package/src/compiler/loops.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// string fill used for the initial paint (text binds, per-row `:if`, meta).
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
|
|
9
|
-
import 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(
|
|
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(
|
|
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
|
: "") +
|
package/src/compiler/page.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
// per-file compile (frontmatter + colocated assets + body / `.md` passthrough).
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
|
|
8
|
-
import fs from "node:fs";
|
|
9
8
|
import path from "node:path";
|
|
10
9
|
import { imageSize } from "image-size";
|
|
10
|
+
import { createPaths } from "../config.js";
|
|
11
11
|
import { htmlHasHighlight } from "../highlight.js";
|
|
12
12
|
import { compileBody } from "./body.js";
|
|
13
13
|
import { createCompilation, createScope } from "./context.js";
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
} from "./includes.js";
|
|
21
21
|
import { escapeHtml } from "./interpolation.js";
|
|
22
22
|
import { selectMd } from "./markdown.js";
|
|
23
|
+
import { memoryReader } from "./reader.js";
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* @typedef {import("./context.js").Paths} Paths
|
|
@@ -42,16 +43,20 @@ const SKIP_LINK_STYLE =
|
|
|
42
43
|
* Compile a page source file into a full HTML document plus its assets.
|
|
43
44
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
44
45
|
* @param {Paths} context Resolved project paths.
|
|
45
|
-
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown
|
|
46
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader }} [options]
|
|
46
47
|
* When a site-wide RSS feed is emitted (the home page set `site_url` and the
|
|
47
48
|
* site has dated posts), `feed` carries its absolute href + title so every page
|
|
48
49
|
* links it. `collections` is the build-time collection index a bare-name
|
|
49
50
|
* `@loop` resolves against; `vars` seeds the document scope (the pager `page`
|
|
50
|
-
* object on a paginated route).
|
|
51
|
+
* object on a paginated route). `reader` is the source reader (fs-backed by
|
|
52
|
+
* default via `src/compiler.js`; in-memory for `compileFromMemory`).
|
|
51
53
|
* @returns {CompiledPage}
|
|
52
54
|
*/
|
|
53
55
|
export function compilePage(file, context, options = {}) {
|
|
54
|
-
|
|
56
|
+
// The raw entry point requires a reader — the Node barrel (`src/compiler.js`)
|
|
57
|
+
// injects `fsReader()`, and `compileFromMemory` injects a `memoryReader`.
|
|
58
|
+
const reader = /** @type {import("./reader.js").Reader} */ (options.reader);
|
|
59
|
+
const compiled = compileDocument(file, context, [], options.vars, options.collections, reader);
|
|
55
60
|
const title = compiled.meta.title || "Darkmown";
|
|
56
61
|
const description = compiled.meta.description || "";
|
|
57
62
|
// Per-page document language for the `<html lang>` attribute (`lang: fr` in
|
|
@@ -155,10 +160,10 @@ export function compilePage(file, context, options = {}) {
|
|
|
155
160
|
// body so its scoped selectors match. Pure compile-time string post-pass (like
|
|
156
161
|
// enhanceImages) — no runtime, no `data-wd-*` marker, so a static page stays
|
|
157
162
|
// static. Include-scoped skins are stamped earlier, in handleInclude.
|
|
158
|
-
const scopedSkin = scopedSkinFor(file, context);
|
|
163
|
+
const scopedSkin = scopedSkinFor(file, context, reader);
|
|
159
164
|
const stamped =
|
|
160
165
|
scopedSkin && scopedSkin.scoped ? stampScope(compiled.html, scopedSkin.scopeId) : compiled.html;
|
|
161
|
-
const body = enhanceImages(stamped, context);
|
|
166
|
+
const body = enhanceImages(stamped, context, reader);
|
|
162
167
|
const { content, target } = ensureMainLandmark(body);
|
|
163
168
|
|
|
164
169
|
return {
|
|
@@ -216,9 +221,10 @@ function ensureMainLandmark(html) {
|
|
|
216
221
|
* lazy-load. Author-set attributes are never overwritten.
|
|
217
222
|
* @param {string} html Assembled page body HTML.
|
|
218
223
|
* @param {Paths} paths Resolved project paths.
|
|
224
|
+
* @param {import("./reader.js").Reader} reader Source reader for the image bytes.
|
|
219
225
|
* @returns {string}
|
|
220
226
|
*/
|
|
221
|
-
export function enhanceImages(html, paths) {
|
|
227
|
+
export function enhanceImages(html, paths, reader) {
|
|
222
228
|
let index = 0;
|
|
223
229
|
return html.replace(/<img\b[^>]*?\/?>/g, (tag) => {
|
|
224
230
|
const i = index++;
|
|
@@ -228,7 +234,7 @@ export function enhanceImages(html, paths) {
|
|
|
228
234
|
// Dimensions only when the author hasn't sized it — adding one axis to a
|
|
229
235
|
// manually-sized image would distort its aspect ratio.
|
|
230
236
|
if (!has("width") && !has("height")) {
|
|
231
|
-
const dim = measureImage(srcOf(tag), paths);
|
|
237
|
+
const dim = measureImage(srcOf(tag), paths, reader);
|
|
232
238
|
if (dim) add.push(`width="${dim.width}"`, `height="${dim.height}"`);
|
|
233
239
|
}
|
|
234
240
|
if (!has("decoding")) add.push(`decoding="async"`);
|
|
@@ -259,9 +265,10 @@ function srcOf(tag) {
|
|
|
259
265
|
* comes from the shelf (`site/_`), other absolute paths from `site/pages`.
|
|
260
266
|
* @param {string} src
|
|
261
267
|
* @param {Paths} paths
|
|
268
|
+
* @param {import("./reader.js").Reader} reader
|
|
262
269
|
* @returns {{ width: number, height: number } | null}
|
|
263
270
|
*/
|
|
264
|
-
function measureImage(src, paths) {
|
|
271
|
+
function measureImage(src, paths, reader) {
|
|
265
272
|
if (!src || /^(https?:)?\/\//i.test(src) || src.startsWith("data:")) return null;
|
|
266
273
|
let filePath;
|
|
267
274
|
if (src.startsWith("/__wd/media/")) {
|
|
@@ -272,7 +279,7 @@ function measureImage(src, paths) {
|
|
|
272
279
|
return null; // page-relative: the source directory is lost after assembly
|
|
273
280
|
}
|
|
274
281
|
try {
|
|
275
|
-
const { width, height } = imageSize(
|
|
282
|
+
const { width, height } = imageSize(reader.readBinary(filePath));
|
|
276
283
|
if (typeof width === "number" && typeof height === "number") return { width, height };
|
|
277
284
|
} catch {
|
|
278
285
|
/* missing or unreadable — degrade to no dimensions */
|
|
@@ -288,10 +295,22 @@ function measureImage(src, paths) {
|
|
|
288
295
|
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
289
296
|
* @param {Map<string, import("./collections.js").CollectionRow[]>} [collections]
|
|
290
297
|
* Build-time collection index a bare-name `@loop` resolves against.
|
|
298
|
+
* @param {import("./reader.js").Reader} [reader] Source reader (fs-backed when
|
|
299
|
+
* omitted via `src/compiler.js`; in-memory for `compileFromMemory`).
|
|
291
300
|
* @returns {CompiledDocument}
|
|
292
301
|
*/
|
|
293
|
-
export function compileDocument(
|
|
294
|
-
|
|
302
|
+
export function compileDocument(
|
|
303
|
+
file,
|
|
304
|
+
context,
|
|
305
|
+
stack = [],
|
|
306
|
+
vars = {},
|
|
307
|
+
collections = new Map(),
|
|
308
|
+
reader
|
|
309
|
+
) {
|
|
310
|
+
// reader is injected by every caller (the Node barrel's `fsReader()` or
|
|
311
|
+
// `compileFromMemory`'s `memoryReader`); it is only trailing-optional here so
|
|
312
|
+
// the defaulted `vars`/`collections` params can precede it.
|
|
313
|
+
const comp = createCompilation(/** @type {import("./reader.js").Reader} */ (reader));
|
|
295
314
|
comp.collections = collections;
|
|
296
315
|
const result = compileFile(
|
|
297
316
|
file,
|
|
@@ -342,18 +361,18 @@ export function compileFile(
|
|
|
342
361
|
reactiveDepth = 0,
|
|
343
362
|
loopOpener = null
|
|
344
363
|
) {
|
|
345
|
-
const real =
|
|
364
|
+
const real = comp.reader.realpath(file);
|
|
346
365
|
if (stack.includes(real)) {
|
|
347
366
|
throw new Error(
|
|
348
367
|
`Include cycle detected: ${[...stack, real].map((p) => path.basename(p)).join(" -> ")}`
|
|
349
368
|
);
|
|
350
369
|
}
|
|
351
370
|
|
|
352
|
-
const raw =
|
|
371
|
+
const raw = comp.reader.readText(file);
|
|
353
372
|
const { meta, body, bodyLine } = parseFrontmatter(raw, file);
|
|
354
373
|
warnLikelyFrontmatter(raw, file, comp);
|
|
355
374
|
comp.deps.add(file);
|
|
356
|
-
collectColocatedAssets(file, context, comp.assets);
|
|
375
|
+
collectColocatedAssets(file, context, comp.assets, comp.reader);
|
|
357
376
|
|
|
358
377
|
if (path.extname(file) === ".md") {
|
|
359
378
|
scanMarkdownHints(body, file, comp);
|
|
@@ -386,3 +405,37 @@ export function compileFile(
|
|
|
386
405
|
if (htmlHasHighlight(html)) comp.assets.hasCode = true;
|
|
387
406
|
return { meta, html };
|
|
388
407
|
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Compile a page entirely from an in-memory file map — no filesystem, no
|
|
411
|
+
* `node:fs`. `files` maps PROJECT-RELATIVE POSIX paths (`site/pages/index.wd`,
|
|
412
|
+
* `site/_/nav.wd`) to their source strings; `entryPath` is the project-relative
|
|
413
|
+
* path of the page to compile. Includes, colocated `.skin`/`.js` detection, and
|
|
414
|
+
* `@loop` JSON-data reads all resolve against the same map (anything not in it
|
|
415
|
+
* is simply "absent"), so `@include`s work as long as their targets are present.
|
|
416
|
+
*
|
|
417
|
+
* This is the strategic fs-free entry point the browser playground and a planned
|
|
418
|
+
* mobile host use: the entire module graph reachable from here is free of
|
|
419
|
+
* `node:fs`, so it bundles for the browser. It returns the same
|
|
420
|
+
* {@link CompiledPage} shape as {@link compilePage} and throws the same
|
|
421
|
+
* `file:line` compile errors, so the error DX is identical in-browser.
|
|
422
|
+
* @param {Record<string, string> | Map<string, string>} files Project-relative
|
|
423
|
+
* path → source content.
|
|
424
|
+
* @param {string} entryPath Project-relative path of the page to compile.
|
|
425
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, cwd?: string }} [options]
|
|
426
|
+
* Same shell options as {@link compilePage}; `cwd` overrides the virtual
|
|
427
|
+
* project root the relative keys resolve against (defaults to `/`).
|
|
428
|
+
* @returns {CompiledPage}
|
|
429
|
+
*/
|
|
430
|
+
export function compileFromMemory(files, entryPath, options = {}) {
|
|
431
|
+
const cwd = options.cwd ?? "/";
|
|
432
|
+
const reader = memoryReader(files, cwd);
|
|
433
|
+
const context = createPaths(cwd);
|
|
434
|
+
const abs = path.resolve(cwd, entryPath);
|
|
435
|
+
return compilePage(abs, context, {
|
|
436
|
+
feed: options.feed,
|
|
437
|
+
collections: options.collections,
|
|
438
|
+
vars: options.vars,
|
|
439
|
+
reader
|
|
440
|
+
});
|
|
441
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -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(
|
|
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,
|
|
121
|
+
/** @type {[string, any[]][]} Reactive loop-item class bindings (data-wd-each-class): [class, exprAST]. */
|
|
114
122
|
const eachClasses = [];
|
|
115
|
-
/** @type {[string,
|
|
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
|
|
3
|
-
// so every existing `import … from "./compiler.js"`
|
|
4
|
-
// working unchanged.
|
|
2
|
+
// file is the Node-facing barrel: it re-exports the public API from
|
|
3
|
+
// `src/compiler/index.js` so every existing `import … from "./compiler.js"`
|
|
4
|
+
// (builder, tests) keeps working unchanged. It is ALSO the single place the
|
|
5
|
+
// default filesystem reader is injected — `compilePage`/`compileDocument`/
|
|
6
|
+
// `enhanceImages` default their reader to `fsReader()` here, so callers keep
|
|
7
|
+
// their old signatures and the compile stays byte-identical. The fs-free entry
|
|
8
|
+
// point (`compileFromMemory`) needs no default and passes through. Because this
|
|
9
|
+
// module imports `fs-reader.js` (and thus `node:fs`), it is Node-only; the
|
|
10
|
+
// browser bundles `compileFromMemory` from `src/compiler/page.js` directly.
|
|
5
11
|
|
|
6
12
|
// Re-export the public typedefs so existing `import("./compiler.js").Type`
|
|
7
13
|
// references (e.g. `Assets` in src/builder.js) keep resolving after the split.
|
|
@@ -16,12 +22,57 @@
|
|
|
16
22
|
* @typedef {import("./compiler/context.js").CompiledPage} CompiledPage
|
|
17
23
|
*/
|
|
18
24
|
|
|
25
|
+
import { fsReader } from "./compiler/fs-reader.js";
|
|
26
|
+
import {
|
|
27
|
+
compileDocument as compileDocumentImpl,
|
|
28
|
+
compilePage as compilePageImpl,
|
|
29
|
+
enhanceImages as enhanceImagesImpl
|
|
30
|
+
} from "./compiler/index.js";
|
|
31
|
+
|
|
19
32
|
export {
|
|
20
|
-
|
|
21
|
-
compilePage,
|
|
22
|
-
enhanceImages,
|
|
33
|
+
compileFromMemory,
|
|
23
34
|
escapeHtml,
|
|
24
35
|
loopKeyOf,
|
|
25
36
|
parseFrontmatter,
|
|
26
37
|
stampScope
|
|
27
38
|
} from "./compiler/index.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compile a page source file into a full HTML document plus its assets, reading
|
|
42
|
+
* from the real filesystem by default. Pass `options.reader` to compile against
|
|
43
|
+
* a different source (see `compileFromMemory`).
|
|
44
|
+
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
45
|
+
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
46
|
+
* @param {{ feed?: { href: string, title: string }, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
|
|
47
|
+
* @returns {CompiledPage}
|
|
48
|
+
*/
|
|
49
|
+
export function compilePage(file, context, options = {}) {
|
|
50
|
+
return compilePageImpl(file, context, { reader: fsReader(), ...options });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Compile a source file into its body HTML, frontmatter, and assets (no page
|
|
55
|
+
* shell), reading from the real filesystem by default.
|
|
56
|
+
* @param {string} file Absolute path to the source file.
|
|
57
|
+
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
58
|
+
* @param {string[]} [stack] Include stack for cycle detection.
|
|
59
|
+
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
60
|
+
* @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
|
|
61
|
+
* @param {import("./compiler/reader.js").Reader} [reader] Source reader.
|
|
62
|
+
* @returns {CompiledDocument}
|
|
63
|
+
*/
|
|
64
|
+
export function compileDocument(file, context, stack, vars, collections, reader = fsReader()) {
|
|
65
|
+
return compileDocumentImpl(file, context, stack, vars, collections, reader);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Harden every `<img>` in an assembled page body, reading intrinsic dimensions
|
|
70
|
+
* from the real filesystem by default.
|
|
71
|
+
* @param {string} html Assembled page body HTML.
|
|
72
|
+
* @param {import("./config.js").Paths} paths Resolved project paths.
|
|
73
|
+
* @param {import("./compiler/reader.js").Reader} [reader] Source reader for image bytes.
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function enhanceImages(html, paths, reader = fsReader()) {
|
|
77
|
+
return enhanceImagesImpl(html, paths, reader);
|
|
78
|
+
}
|