@zvndev/darkmown 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +18 -0
  3. package/README.md +61 -6
  4. package/SECURITY.md +4 -4
  5. package/docs/cli.md +8 -0
  6. package/docs/spec-alignment.md +26 -6
  7. package/grammar/wd-directives.gbnf +58 -0
  8. package/package.json +5 -1
  9. package/site/_/footer.wd +1 -1
  10. package/site/_/nav.wd +1 -0
  11. package/site/pages/app.wd +1 -1
  12. package/site/pages/blog/zero-js-by-default.md +1 -1
  13. package/site/pages/docs/index.wd +19 -2
  14. package/site/pages/index.wd +5 -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 +15 -8
  20. package/src/catalog.js +586 -0
  21. package/src/cli.js +8 -0
  22. package/src/compiler/actions.js +45 -12
  23. package/src/compiler/collections.js +16 -11
  24. package/src/compiler/context.js +52 -3
  25. package/src/compiler/expr-ast.js +296 -0
  26. package/src/compiler/fetch.js +12 -4
  27. package/src/compiler/forms.js +78 -24
  28. package/src/compiler/fs-reader.js +25 -0
  29. package/src/compiler/includes.js +20 -15
  30. package/src/compiler/index.js +1 -1
  31. package/src/compiler/loops.js +26 -8
  32. package/src/compiler/media.js +21 -7
  33. package/src/compiler/page.js +68 -15
  34. package/src/compiler/predicates.js +29 -18
  35. package/src/compiler/reader.js +60 -0
  36. package/src/compiler/state.js +81 -32
  37. package/src/compiler/structure.js +48 -17
  38. package/src/compiler.js +57 -6
  39. package/src/grammar.js +131 -0
  40. package/src/headers.js +15 -13
  41. package/src/index.js +2 -0
  42. package/src/runtime.js +77 -45
  43. package/src/statics.js +6 -7
  44. package/types/compiler.d.ts +13 -0
  45. package/types/index.d.ts +1 -0
@@ -9,7 +9,7 @@
9
9
  // while keeping the message text and `Use:` hints intact.
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- import { at, nestedCtx } from "./context.js";
12
+ import { at, lineOf, nestedCtx, wdError } from "./context.js";
13
13
  import { validateFetchUrl } from "./fetch.js";
14
14
  import {
15
15
  escapeHtml,
@@ -25,6 +25,20 @@ import { declareErrorState, declareState } from "./state.js";
25
25
  * @typedef {import("./context.js").Ctx} Ctx
26
26
  */
27
27
 
28
+ // Concrete, compilable one-liners for the form-family hint tails + the catalog.
29
+ // (Field openers like :select/:checkbox expect following `- Label` lines; the
30
+ // example is the opener, which the catalog/grammar pair with option lines.)
31
+ export const FORM_EXAMPLE = ":form into contact";
32
+ export const INPUT_EXAMPLE = ":input email type=email required";
33
+ export const TEXTAREA_EXAMPLE = ':textarea message placeholder="Your message" rows=4';
34
+ export const SELECT_EXAMPLE = ":select topic";
35
+ export const CHECKBOX_EXAMPLE = ":checkbox toppings";
36
+ export const RADIO_EXAMPLE = ":radio size";
37
+ export const SUBMIT_EXAMPLE = ':submit "Send"';
38
+ export const BIND_EXAMPLE = ':bind query placeholder="Search"';
39
+ export const SLIDER_EXAMPLE = ":slider volume = 50 min=0 max=100 step=1";
40
+ const INPUT_USE = `Use: :input name [type=…] [placeholder="…"] [required] — e.g. ${INPUT_EXAMPLE}`;
41
+
28
42
  /**
29
43
  * @param {string} line
30
44
  * @param {string[]} bodyLines
@@ -43,9 +57,13 @@ export function handleForm(line, bodyLines, ctx, index) {
43
57
  .replace(/(?:^|\s)into\s+[A-Za-z_$][\w$]*/, "")
44
58
  .trim();
45
59
  if ((!rawAction && !into) || leftover) {
46
- throw new Error(
47
- `Malformed :form in ${at(ctx, index)}: ${line}. Use ':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state).`
48
- );
60
+ const hint = `':form into name' (client state), ':form action="/url"' (native post), or both (fetch round-trip into state) — e.g. ${FORM_EXAMPLE}`;
61
+ throw wdError(`Malformed :form in ${at(ctx, index)}: ${line}. Use ${hint}`, {
62
+ file: ctx.file,
63
+ line: lineOf(ctx, index),
64
+ hint,
65
+ example: FORM_EXAMPLE
66
+ });
49
67
  }
50
68
  const action = rawAction ? validateFetchUrl(rawAction, ctx, ":form action") : undefined;
51
69
  const inner = ctx.compileBody(bodyLines, nestedCtx(ctx, index + 1)).trim();
@@ -68,7 +86,13 @@ export function handleForm(line, bodyLines, ctx, index) {
68
86
  */
69
87
  export function handleInput(line, ctx, index) {
70
88
  const match = line.match(/^:input\s+([A-Za-z_][\w-]*)\s*(.*)$/);
71
- if (!match) throw new Error(`Malformed :input in ${at(ctx, index)}: ${line}`);
89
+ if (!match)
90
+ throw wdError(`Malformed :input in ${at(ctx, index)}: ${line}. ${INPUT_USE}`, {
91
+ file: ctx.file,
92
+ line: lineOf(ctx, index),
93
+ hint: INPUT_USE.slice("Use: ".length),
94
+ example: INPUT_EXAMPLE
95
+ });
72
96
  const attrs = [`name="${escapeHtml(match[1])}"`];
73
97
  let type = "text";
74
98
  let placeholder;
@@ -127,9 +151,12 @@ export function handleInput(line, ctx, index) {
127
151
  export function handleBind(line, ctx, index) {
128
152
  const match = line.match(/^:bind\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
129
153
  if (!match)
130
- throw new Error(
131
- `Malformed :bind in ${at(ctx, index)}: ${line}. Use: :bind query placeholder="Search"`
132
- );
154
+ throw wdError(`Malformed :bind in ${at(ctx, index)}: ${line}. Use: ${BIND_EXAMPLE}`, {
155
+ file: ctx.file,
156
+ line: lineOf(ctx, index),
157
+ hint: BIND_EXAMPLE,
158
+ example: BIND_EXAMPLE
159
+ });
133
160
  const key = resolveStateKey(match[1], ctx);
134
161
  if (!key) {
135
162
  throw new Error(
@@ -187,9 +214,12 @@ export function handleBind(line, ctx, index) {
187
214
  export function handleSlider(line, ctx, index) {
188
215
  const head = line.match(/^:slider\s+([A-Za-z_$][\w$]*)\s*(.*)$/);
189
216
  if (!head)
190
- throw new Error(
191
- `Malformed :slider in ${at(ctx, index)}: ${line}. Use: :slider volume = 50 min=0 max=100 step=1`
192
- );
217
+ throw wdError(`Malformed :slider in ${at(ctx, index)}: ${line}. Use: ${SLIDER_EXAMPLE}`, {
218
+ file: ctx.file,
219
+ line: lineOf(ctx, index),
220
+ hint: SLIDER_EXAMPLE,
221
+ example: SLIDER_EXAMPLE
222
+ });
193
223
  const name = head[1];
194
224
  let rest = head[2].trim();
195
225
 
@@ -274,7 +304,15 @@ export function handleSlider(line, ctx, index) {
274
304
  export function handleSubmit(line, ctx, index) {
275
305
  const match = line.match(/^:submit\s+"([^"]+)"\s*$/);
276
306
  if (!match)
277
- throw new Error(`Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label"`);
307
+ throw wdError(
308
+ `Malformed :submit in ${at(ctx, index)}: ${line}. Use: :submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
309
+ {
310
+ file: ctx.file,
311
+ line: lineOf(ctx, index),
312
+ hint: `:submit "Label" — e.g. ${SUBMIT_EXAMPLE}`,
313
+ example: SUBMIT_EXAMPLE
314
+ }
315
+ );
278
316
  return `<button type="submit">${escapeHtml(match[1])}</button>`;
279
317
  }
280
318
 
@@ -290,10 +328,15 @@ export function handleSubmit(line, ctx, index) {
290
328
  */
291
329
  export function handleTextarea(line, ctx, index) {
292
330
  const match = line.match(/^:textarea\s+([A-Za-z_][\w-]*)\s*(.*)$/);
293
- if (!match)
294
- throw new Error(
295
- `Malformed :textarea in ${at(ctx, index)}: ${line}. Use: :textarea name [placeholder="…"] [rows=N] [required]`
296
- );
331
+ if (!match) {
332
+ const hint = `:textarea name [placeholder="…"] [rows=N] [required] — e.g. ${TEXTAREA_EXAMPLE}`;
333
+ throw wdError(`Malformed :textarea in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
334
+ file: ctx.file,
335
+ line: lineOf(ctx, index),
336
+ hint,
337
+ example: TEXTAREA_EXAMPLE
338
+ });
339
+ }
297
340
  const attrs = [`name="${escapeHtml(match[1])}"`];
298
341
  let placeholder;
299
342
  let hasAria = false;
@@ -343,10 +386,15 @@ export function handleTextarea(line, ctx, index) {
343
386
  */
344
387
  export function handleSelect(line, optionLines, ctx, index) {
345
388
  const match = line.match(/^:select\s+([A-Za-z_][\w-]*)\s*(.*)$/);
346
- if (!match)
347
- throw new Error(
348
- `Malformed :select in ${at(ctx, index)}: ${line}. Use: :select name [required] then "- Label" lines`
349
- );
389
+ if (!match) {
390
+ const hint = `:select name [required] then "- Label" lines — e.g. ${SELECT_EXAMPLE}`;
391
+ throw wdError(`Malformed :select in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
392
+ file: ctx.file,
393
+ line: lineOf(ctx, index),
394
+ hint,
395
+ example: SELECT_EXAMPLE
396
+ });
397
+ }
350
398
  const attrs = [`name="${escapeHtml(match[1])}"`];
351
399
  let hasAria = false;
352
400
  const re = /([A-Za-z-]+)=("[^"]*"|\S+)|([A-Za-z-]+)/g;
@@ -404,10 +452,16 @@ export function handleChoiceGroup(line, optionLines, ctx, kind, index) {
404
452
  ? /^:checkbox\s+([A-Za-z_][\w-]*)\s*(.*)$/
405
453
  : /^:radio\s+([A-Za-z_][\w-]*)\s*(.*)$/
406
454
  );
407
- if (!match)
408
- throw new Error(
409
- `Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${directive} name [required] then "- Label" lines`
410
- );
455
+ if (!match) {
456
+ const example = kind === "checkbox" ? CHECKBOX_EXAMPLE : RADIO_EXAMPLE;
457
+ const hint = `${directive} name [required] then "- Label" lines — e.g. ${example}`;
458
+ throw wdError(`Malformed ${directive} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
459
+ file: ctx.file,
460
+ line: lineOf(ctx, index),
461
+ hint,
462
+ example
463
+ });
464
+ }
411
465
  const name = match[1];
412
466
  const flags = [];
413
467
  let ariaLabel = null;
@@ -0,0 +1,25 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The default {@link import("./reader.js").Reader}: reads straight from the
3
+ // real filesystem. This is the ONLY compiler module that imports `node:fs`, so
4
+ // it is deliberately isolated — nothing reachable from `compileFromMemory`
5
+ // imports it, keeping the browser/mobile compile path fs-free. The public
6
+ // `compilePage`/`compileDocument`/`enhanceImages` (in `src/compiler.js`) inject
7
+ // this reader by default, so every existing caller keeps working unchanged.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ import fs from "node:fs";
11
+
12
+ /**
13
+ * A {@link import("./reader.js").Reader} backed by `node:fs`. Each method is a
14
+ * thin pass-through to the synchronous fs call the compiler used before the
15
+ * reader abstraction, so an fs-backed compile stays byte-identical.
16
+ * @returns {import("./reader.js").Reader}
17
+ */
18
+ export function fsReader() {
19
+ return {
20
+ readText: (absPath) => fs.readFileSync(absPath, "utf8"),
21
+ readBinary: (absPath) => fs.readFileSync(absPath),
22
+ exists: (absPath) => fs.existsSync(absPath),
23
+ realpath: (absPath) => fs.realpathSync(absPath)
24
+ };
25
+ }
@@ -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
- import { at, createScope, nestedCtx } from "./context.js";
9
+ import { at, createScope, lineOf, nestedCtx, wdError } from "./context.js";
10
+ import { serializeExpr } from "./expr-ast.js";
11
11
  import { applyPipeline, stagesFromAttr } from "./format.js";
12
12
  import { resolveInclude } from "./includes.js";
13
13
  import {
@@ -28,9 +28,14 @@ import { compilePredicate, evalPredicate } from "./predicates.js";
28
28
  * @typedef {import("./context.js").LoopOpts} LoopOpts
29
29
  */
30
30
 
31
+ // One concrete, compilable @loop line, appended to the schematic hint so a small
32
+ // model copies a real line instead of echoing the `[…]` placeholders literally
33
+ // (the on-device finding: bracket-placeholders get pasted into source verbatim).
34
+ // Also the canonical @loop example the directive catalog + grammar draw from.
35
+ export const LOOP_EXAMPLE = "@loop /products.json into p where p.price < 50 sort by p.price asc";
36
+
31
37
  // The fixed corrective suggestion shown for any malformed @loop header.
32
- const LOOP_USAGE =
33
- "Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable]";
38
+ const LOOP_USAGE = `Use: @loop src into item [where …] [sort by …] [reverse] [offset N] [limit N] [paginate N] [sortable] — e.g. ${LOOP_EXAMPLE}`;
34
39
 
35
40
  /**
36
41
  * Parse the optional clause tail of a `@loop` header in FIXED order:
@@ -219,7 +224,13 @@ function numArgAttr(arg) {
219
224
  */
220
225
  export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart = 0) {
221
226
  const match = line.match(/^@loop\s+(.+?)\s+into\s+([A-Za-z_$][\w$]*)(\s+.+?)?\s*$/);
222
- if (!match) throw new Error(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`);
227
+ if (!match)
228
+ throw wdError(`Malformed @loop in ${at(ctx, index)}: ${line}. ${LOOP_USAGE}`, {
229
+ file: ctx.file,
230
+ line: lineOf(ctx, index),
231
+ hint: LOOP_USAGE.slice("Use: ".length),
232
+ example: LOOP_EXAMPLE
233
+ });
223
234
  const source = stripQuotes(match[1].trim());
224
235
  const itemName = match[2];
225
236
  const clauses = match[3]
@@ -288,9 +299,16 @@ export function handleLoop(line, bodyLines, emptyLines, ctx, index, emptyStart =
288
299
  source.endsWith(".json")
289
300
  ) {
290
301
  if (opts.paginate) throw new Error(paginateOnlyCollections(ctx));
291
- const dataFile = resolveInclude(source, ctx.file, ctx.context, true, at(ctx, index));
302
+ const dataFile = resolveInclude(
303
+ source,
304
+ ctx.file,
305
+ ctx.context,
306
+ true,
307
+ at(ctx, index),
308
+ ctx.comp.reader
309
+ );
292
310
  ctx.comp.deps.add(dataFile);
293
- const rows = JSON.parse(fs.readFileSync(dataFile, "utf8"));
311
+ const rows = JSON.parse(ctx.comp.reader.readText(dataFile));
294
312
  if (!Array.isArray(rows)) throw new Error(`@loop data must be a JSON array: ${dataFile}`);
295
313
  return loopOverData(rows, itemName, bodyLines, bodyCtx, opts);
296
314
  }
@@ -692,7 +710,7 @@ function loopEmptyTemplate(emptyLines, ctx, emptyStart = 0) {
692
710
  */
693
711
  function loopClauseAttrs(c) {
694
712
  return (
695
- (c.where ? ` data-wd-loop-where="${escapeHtml(c.where.body)}"` : "") +
713
+ (c.where ? ` data-wd-loop-where="${escapeHtml(serializeExpr(c.where.body))}"` : "") +
696
714
  (c.sort
697
715
  ? ` data-wd-loop-sort="${escapeHtml(c.sort.keyKind === "key" ? `key:${c.sort.key}` : c.sort.key)}" data-wd-loop-sort-dir="${escapeHtml(c.sort.dirKind === "key" ? `key:${c.sort.dir}` : c.sort.dir)}"`
698
716
  : "") +
@@ -8,7 +8,7 @@
8
8
  // while keeping the message text and `Use:` hints intact.
9
9
  // ---------------------------------------------------------------------------
10
10
 
11
- import { at } from "./context.js";
11
+ import { at, lineOf, wdError } from "./context.js";
12
12
  import { validateFetchUrl } from "./fetch.js";
13
13
  import { escapeHtml, stripQuotes } from "./interpolation.js";
14
14
 
@@ -16,6 +16,11 @@ import { escapeHtml, stripQuotes } from "./interpolation.js";
16
16
  * @typedef {import("./context.js").Ctx} Ctx
17
17
  */
18
18
 
19
+ // Concrete, compilable media examples for the hint tails + the directive catalog.
20
+ export const VIDEO_EXAMPLE = ":video /demo.mp4 controls";
21
+ export const AUDIO_EXAMPLE = ":audio /theme.mp3 controls";
22
+ export const EMBED_EXAMPLE = ':embed https://youtu.be/dQw4w9WgXcQ title="Demo"';
23
+
19
24
  /** @type {Record<"video" | "audio", { flags: string[], attrs: string[] }>} */
20
25
  const MEDIA_SPEC = {
21
26
  video: {
@@ -41,9 +46,14 @@ const MEDIA_SPEC = {
41
46
  export function handleMedia(line, kind, ctx, index) {
42
47
  const match = line.match(new RegExp(`^:${kind}\\s+(\\S+)\\s*(.*)$`));
43
48
  if (!match) {
44
- throw new Error(
45
- `Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: :${kind} /clip [controls] [autoplay] [loop] [muted]`
46
- );
49
+ const example = kind === "video" ? VIDEO_EXAMPLE : AUDIO_EXAMPLE;
50
+ const hint = `:${kind} /clip [controls] [autoplay] [loop] [muted] — e.g. ${example}`;
51
+ throw wdError(`Malformed :${kind} in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
52
+ file: ctx.file,
53
+ line: lineOf(ctx, index),
54
+ hint,
55
+ example
56
+ });
47
57
  }
48
58
  const src = validateFetchUrl(stripQuotes(match[1]), ctx, `:${kind}`);
49
59
  const spec = MEDIA_SPEC[kind];
@@ -93,9 +103,13 @@ export function handleMedia(line, kind, ctx, index) {
93
103
  export function handleEmbed(line, ctx, index) {
94
104
  const match = line.match(/^:embed\s+(\S+)\s*(.*)$/);
95
105
  if (!match) {
96
- throw new Error(
97
- `Malformed :embed in ${at(ctx, index)}: ${line}. Use: :embed https://youtu.be/ID [title="…"]`
98
- );
106
+ const hint = `:embed https://youtu.be/ID [title="…"] — e.g. ${EMBED_EXAMPLE}`;
107
+ throw wdError(`Malformed :embed in ${at(ctx, index)}: ${line}. Use: ${hint}`, {
108
+ file: ctx.file,
109
+ line: lineOf(ctx, index),
110
+ hint,
111
+ example: EMBED_EXAMPLE
112
+ });
99
113
  }
100
114
  const raw = stripQuotes(match[1]);
101
115
  let title = "";
@@ -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
+ }