@zvndev/darkmown 2.8.0 → 2.10.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/src/cli.js CHANGED
@@ -604,7 +604,7 @@ Authoring:
604
604
  * An incremental (dev `--changed`) build reports what it actually rebuilt —
605
605
  * `Rebuilt 2 of 14 routes (/blog/, /blog/hello/) into dist` — naming the routes
606
606
  * when there are few enough to read.
607
- * @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number } }} result
607
+ * @param {{ routes: unknown[], feeds: { sitemap: number | null, rss: number | null }, incremental?: { rebuilt: string[], total: number }, images?: { generated: number, cached: number, unavailable: boolean }, fonts?: { subsetted: number, cached: number, unavailable: boolean } }} result
608
608
  * @param {string} relativeRoot `dist` relative to cwd, for the message tail.
609
609
  * @returns {string}
610
610
  */
@@ -617,6 +617,16 @@ export function buildSummary(result, relativeRoot) {
617
617
  const parts = [`Built ${result.routes.length} routes`];
618
618
  if (result.feeds.sitemap !== null) parts.push(`sitemap (${result.feeds.sitemap} urls)`);
619
619
  if (result.feeds.rss !== null) parts.push(`rss (${result.feeds.rss} posts)`);
620
+ // Image pre-pass (2.9.0): worth reporting only when variants materialized.
621
+ if (result.images && result.images.generated + result.images.cached > 0) {
622
+ parts.push(
623
+ `image variants (${result.images.generated} generated, ${result.images.cached} cached)`
624
+ );
625
+ }
626
+ // Font post-pass (2.10.0): same rule — report only when subsets materialized.
627
+ if (result.fonts && result.fonts.subsetted + result.fonts.cached > 0) {
628
+ parts.push(`fonts (${result.fonts.subsetted} subsetted, ${result.fonts.cached} cached)`);
629
+ }
620
630
  return `${parts.join(", ")} into ${relativeRoot}`;
621
631
  }
622
632
 
@@ -33,8 +33,10 @@ import {
33
33
  handleTheme,
34
34
  renderDemoDirective
35
35
  } from "./directives.js";
36
+ import { CHILDREN_SLOT_LINE, childrenSlot } from "./includes.js";
36
37
  import { handleLoop, spliceTables } from "./loops.js";
37
38
  import { renderProse } from "./markdown.js";
39
+ import { scanIncludeBlock, strayEndinclude } from "./structure.js";
38
40
 
39
41
  /**
40
42
  * @typedef {import("./context.js").Ctx} Ctx
@@ -148,6 +150,21 @@ export function compileBody(lines, ctx) {
148
150
  }
149
151
  }
150
152
 
153
+ // A standalone `{ children }` line is an include's slot marker: when the
154
+ // file being compiled was called as a block-form @include, the compiled
155
+ // children HTML (built in the caller's context by handleInclude) splices
156
+ // in here. With no slot in scope — a top-level page — the line falls
157
+ // through to prose and stays the literal text `{ children }`; an include
158
+ // called without a block carries an empty slot and splices nothing.
159
+ if (!inRawBlock && CHILDREN_SLOT_LINE.test(line)) {
160
+ const slot = childrenSlot(ctx);
161
+ if (slot !== null) {
162
+ flush();
163
+ if (slot) push(slot);
164
+ continue;
165
+ }
166
+ }
167
+
151
168
  // A directive name ALONE on a line is a mistake with one obvious fix: it is
152
169
  // missing its arguments. Every dispatcher below requires a space after the
153
170
  // keyword, so such a line used to fall through to prose — and because
@@ -164,7 +181,15 @@ export function compileBody(lines, ctx) {
164
181
 
165
182
  if (/^@include\s/.test(line)) {
166
183
  flush();
167
- push(handleInclude(line, ctx, i));
184
+ const block = scanIncludeBlock(lines, i);
185
+ if (block) {
186
+ const includeAt = ctx.comp.symbols.length;
187
+ push(handleInclude(line, ctx, i, block.body));
188
+ stampBlockEnd(ctx, includeAt, block.end);
189
+ i = block.end;
190
+ } else {
191
+ push(handleInclude(line, ctx, i));
192
+ }
168
193
  continue;
169
194
  }
170
195
  if (/^@loop\s/.test(line)) {
@@ -340,6 +365,10 @@ export function compileBody(lines, ctx) {
340
365
  { code: "WD009", file: ctx.file, hint: "@loop items into item ... @endloop" }
341
366
  );
342
367
  }
368
+ // `@endinclude` that reached the dispatcher: every opener that could pair
369
+ // with it already did (scanIncludeBlock consumes paired closers), so this
370
+ // one has no opener of its own — one obvious fix, its own WD6xx code.
371
+ if (/^@endinclude(?:\s|$)/.test(line)) strayEndinclude(line, ctx, i);
343
372
  // `:endcarousel` joins the family: a closer with no opener is a mistake with
344
373
  // one obvious fix, and the vague "matches none" warning it used to get named
345
374
  // the wrong problem ("check the spelling") for a token spelled correctly.
@@ -39,6 +39,12 @@
39
39
  * that opted into scoping (`scoped` first line). The builder emits these with
40
40
  * the scope attribute and runs the unused-selector warning against the stamped
41
41
  * subtree; the HTML stamp uses them to scope the matching page/include subtree.
42
+ * @property {Set<string>} embedHosts Frame sources (origins like
43
+ * `https://app.example.com`, or `'self'` for a same-origin embed) of every
44
+ * `:embed` on the page, collected AFTER the YouTube/Vimeo rewrite. The builder
45
+ * records them on the route manifest entry so the per-route CSP's `frame-src`
46
+ * authorizes exactly the hosts this page frames (see `routeCsp` in
47
+ * src/headers.js).
42
48
  */
43
49
 
44
50
  /**
@@ -81,6 +87,11 @@
81
87
  * read threads through — the real filesystem by default, or an in-memory map
82
88
  * for `compileFromMemory`. Set once by `createCompilation` and reached via
83
89
  * `ctx.comp.reader` in the directive handlers (includes, loop data).
90
+ * @property {Map<string, number[]> | null} imageVariants Build-time-GENERATED
91
+ * responsive variants (2.9.0): the original image's absolute source path →
92
+ * the sorted `@<width>` sizes the builder's pre-pass resized into dist. Null
93
+ * outside a builder-driven compile (e.g. `compileFromMemory`), where the
94
+ * srcset wiring probes author-provided variants only.
84
95
  * @property {Symbol[]} symbols Every declared thing this compile saw, in source
85
96
  * order, recorded by {@link recordSymbol} from the handler that parsed it.
86
97
  * Surfaced on {@link CompiledPage} so a tool layer can answer "what is in this
@@ -142,6 +153,11 @@
142
153
  * opener's `file:line` + raw text, set when descending into a loop body so the
143
154
  * reactive-nesting-depth error can point at the offending opener.
144
155
  * @property {boolean} [loopMeta] Inside a loop body, so `$index`/`$first`/… are valid.
156
+ * @property {number} [includeBlockDepth] Enclosing block-form `@include`
157
+ * CHILDREN levels (0 at page level, absent on a fresh file compile). Set
158
+ * while compiling a block's children so a block include nested past one
159
+ * level inside children is a compile error (WD616, together with the
160
+ * include-stack depth check for include bodies).
145
161
  * @property {import("markdown-it").default} [md] Markdown-it instance selected for this file.
146
162
  * @property {(lines: string[], ctx: Ctx) => string} compileBody The body
147
163
  * dispatcher, threaded through the context so block handlers (`:if`, `:::`,
@@ -290,6 +306,7 @@ export const LOOP_META = Object.assign(Object.create(null), {
290
306
  export function createCompilation(reader) {
291
307
  return {
292
308
  reader,
309
+ imageVariants: null,
293
310
  assets: {
294
311
  skins: new Set(),
295
312
  scripts: new Set(),
@@ -297,7 +314,8 @@ export function createCompilation(reader) {
297
314
  runtime: false,
298
315
  behaviors: new Set(),
299
316
  hasCode: false,
300
- scopedSkins: new Set()
317
+ scopedSkins: new Set(),
318
+ embedHosts: new Set()
301
319
  },
302
320
  state: new Map(),
303
321
  stores: new Set(),
@@ -8,7 +8,7 @@
8
8
  import path from "node:path";
9
9
  import { scopeIdFor } from "../skin.js";
10
10
  import { pageIncludeExtensions, wdError } from "./context.js";
11
- import { stripQuotes } from "./interpolation.js";
11
+ import { lookupVar, stripQuotes } from "./interpolation.js";
12
12
 
13
13
  /**
14
14
  * @typedef {import("./context.js").Paths} Paths
@@ -244,6 +244,49 @@ export function isAllowedInclude(file, context, reader) {
244
244
  return contains(canonical(path.resolve(file)), roots.map(canonical));
245
245
  }
246
246
 
247
+ // ---------------------------------------------------------------------------
248
+ // The `{ children }` slot (block-form @include).
249
+ //
250
+ // `@include /card.wd … @endinclude` compiles the lines between opener and
251
+ // closer in the CALLER's context and hands the resulting HTML to the include
252
+ // file, which places it with a standalone `{ children }` line. The hand-off
253
+ // travels in the static scope chain under a key no author syntax can produce
254
+ // (interpolation, `with` args, and `:state` names are all `[A-Za-z0-9_$-]`
255
+ // shaped), so a `{ children }` interpolation in prose never resolves it — only
256
+ // the body parser's slot-marker intercept reads it. Pure macro expansion: the
257
+ // children compile once, at build time, and the include stays zero-JS unless
258
+ // the children themselves bind state.
259
+ // ---------------------------------------------------------------------------
260
+
261
+ /**
262
+ * Scope key carrying the compiled children HTML for the include currently
263
+ * being compiled. Control-character-prefixed so no author-visible name can
264
+ * collide with it or read it through `{ }` interpolation.
265
+ */
266
+ export const INCLUDE_CHILDREN = "\0children";
267
+
268
+ /**
269
+ * A line that is exactly the slot marker (`{ children }`, whitespace-tolerant),
270
+ * matching the spelling freedom of `{ name }` interpolation. Shared by the
271
+ * body parser (which splices the slot) and the include handler (which scans an
272
+ * include's body for whether the slot is ever used).
273
+ * @type {RegExp}
274
+ */
275
+ export const CHILDREN_SLOT_LINE = /^\s*\{\s*children\s*\}\s*$/;
276
+
277
+ /**
278
+ * The compiled children HTML for the include currently compiling, or null when
279
+ * the file is not being compiled as an include at all (a top-level page) — in
280
+ * which case a `{ children }` line stays literal text. An include called
281
+ * WITHOUT a block carries "" (the slot renders empty).
282
+ * @param {import("./context.js").Ctx} ctx
283
+ * @returns {string | null}
284
+ */
285
+ export function childrenSlot(ctx) {
286
+ const found = lookupVar(ctx.scope, INCLUDE_CHILDREN);
287
+ return found.found ? /** @type {string} */ (found.value) : null;
288
+ }
289
+
247
290
  // ---------------------------------------------------------------------------
248
291
  // Plain .md hints
249
292
  // ---------------------------------------------------------------------------
@@ -363,25 +363,36 @@ function attrBindPlugin(mdInstance) {
363
363
  }
364
364
 
365
365
  // ---------------------------------------------------------------------------
366
- // RESPONSIVE IMAGE VARIANTS (the `@width` convention, 2.8.0 / A2).
366
+ // RESPONSIVE IMAGE VARIANTS (the `@width` convention, 2.8.0 / A2; generated
367
+ // variants, 2.9.0).
367
368
  //
368
- // Darkmown has no image pipeline and never will (no resize dependency), so the
369
- // convention wires variants the AUTHOR (or their CDN tooling) already made:
369
+ // Variants come from two places, and this wiring treats them identically:
370
370
  // next to `/doctors/dr-x.webp` sit `dr-x@640.webp`, `dr-x@1280.webp` — same
371
371
  // directory, same extension, `@<width>` before it. When at least one exists,
372
372
  // the markdown image gains a `srcset` (variants ascending, then the original
373
373
  // at its intrinsic width) so the browser downloads the size it needs instead
374
374
  // of the full-resolution file. The original stays the `src` fallback.
375
375
  //
376
- // Discovery is an existence PROBE over {@link VARIANT_WIDTHS} through the
377
- // injected reader: the Reader contract has no directory listing, so an off-list
378
- // width (say `@500`) is simply not wired — a deliberate, documented miss, never
379
- // an error. Each variant's descriptor width is its REAL intrinsic width from
380
- // the image header (a `@640` file that actually holds 600px says 600w); when
381
- // the bytes cannot be read or parsed (the in-memory reader has no binaries), the
382
- // filename width is the fallback. The original's intrinsic width follows the
383
- // same rule, except it has no filename fallback: unreadable means it is left
384
- // out of the srcset and stays the plain `src` default.
376
+ // * AUTHOR-PROVIDED (2.8.0): files the author (or their CDN tooling) placed
377
+ // beside the source. Discovery is an existence PROBE over
378
+ // {@link VARIANT_WIDTHS} through the injected reader: the Reader contract
379
+ // has no directory listing, so an off-list width (say `@500`) is simply not
380
+ // wired — a deliberate, documented miss, never an error. Each variant's
381
+ // descriptor width is its REAL intrinsic width from the image header (a
382
+ // `@640` file that actually holds 600px says 600w); when the bytes cannot
383
+ // be read or parsed (the in-memory reader has no binaries), the filename
384
+ // width is the fallback.
385
+ // * BUILD-TIME GENERATED (2.9.0): the builder's pre-pass (src/image-variants.js)
386
+ // resized the source with the optional sharp dependency and wrote the
387
+ // variants into dist. It hands this compile a map (original's absolute
388
+ // source path → widths) via `comp.imageVariants`; the widths ARE the
389
+ // descriptor widths because the generator made them so. An author-provided
390
+ // file at the same width always wins — the generator skips those widths,
391
+ // and the href dedupe below keeps it that way even against a stale map.
392
+ //
393
+ // The original's intrinsic width follows the same measure-with-fallback rule,
394
+ // except it has no filename fallback: unreadable means it is left out of the
395
+ // srcset and stays the plain `src` default.
385
396
  //
386
397
  // A bound src (`![alt]({ photo })`) is repainted by the runtime, so a srcset
387
398
  // computed from the seed would go stale on the first render — those images are
@@ -483,6 +494,18 @@ function buildSrcset(src, ctx) {
483
494
  if (!reader.exists(abs)) continue;
484
495
  candidates.push({ href: `${base}@${width}${ext}`, width: measureWidth(abs, reader) ?? width });
485
496
  }
497
+ // Builder-generated variants (2.9.0): resized into dist by the pre-pass, so
498
+ // the reader cannot probe them — they arrive through the compile instead.
499
+ // An author-provided sibling at the same width wins (the generator skips
500
+ // those widths; the href check keeps precedence even against a stale map).
501
+ const generated = ctx.comp.imageVariants?.get(path.join(root, `${relBase}${ext}`));
502
+ if (generated) {
503
+ for (const width of generated) {
504
+ const href = `${base}@${width}${ext}`;
505
+ if (candidates.some((c) => c.href === href)) continue;
506
+ candidates.push({ href, width });
507
+ }
508
+ }
486
509
  if (!candidates.length) return null;
487
510
  const intrinsic = measureWidth(path.join(root, `${relBase}${ext}`), reader);
488
511
  if (intrinsic) candidates.push({ href: src, width: intrinsic });
@@ -175,6 +175,7 @@ export function handleEmbed(line, ctx, index) {
175
175
  const sizing = embedSizing(attrs, ctx, index);
176
176
  const allow = embedAllow(attrs, ctx, index);
177
177
  const { url, label } = resolveEmbed(raw, ctx);
178
+ recordEmbedHost(ctx, url);
178
179
  const title = attrs.get("title") || "";
179
180
  const iframe =
180
181
  `<iframe src="${escapeHtml(url)}" title="${escapeHtml(title || label)}" loading="lazy" ` +
@@ -237,6 +238,30 @@ function embedAllow(attrs, ctx, index) {
237
238
  return tokens.join("; ");
238
239
  }
239
240
 
241
+ /**
242
+ * Record the frame source the emitted iframe actually loads, so the builder can
243
+ * union it into this route's CSP `frame-src`. An absolute http(s) URL contributes
244
+ * its origin; a relative URL frames the page's own origin, so it contributes
245
+ * `'self'`. A `{ state }`-driven URL is unknowable at compile time and records
246
+ * nothing (widening that policy is the app's deploy-config decision, exactly
247
+ * like a remote `:fetch` target widening `connect-src`).
248
+ * @param {Ctx} ctx
249
+ * @param {string} url The FINAL iframe src (post YouTube/Vimeo rewrite).
250
+ * @returns {void}
251
+ */
252
+ function recordEmbedHost(ctx, url) {
253
+ if (url.startsWith("{")) return;
254
+ if (!/^https?:\/\//i.test(url)) {
255
+ ctx.comp.assets.embedHosts.add("'self'");
256
+ return;
257
+ }
258
+ try {
259
+ ctx.comp.assets.embedHosts.add(new URL(url).origin);
260
+ } catch {
261
+ /* a malformed-but-scheme-valid URL paints as-is; nothing to authorize */
262
+ }
263
+ }
264
+
240
265
  /**
241
266
  * Map a watch/share URL to its embeddable form. YouTube → no-cookie embed, Vimeo →
242
267
  * player; anything else is validated as a generic http(s) iframe source.
@@ -14,6 +14,7 @@ import { parseFrontmatter, resolveSharedSkins, warnLikelyFrontmatter } from "./f
14
14
  import { imageSize } from "./image-size.js";
15
15
  import {
16
16
  collectColocatedAssets,
17
+ isAllowedInclude,
17
18
  scanMarkdownHints,
18
19
  scopedSkinFor,
19
20
  scopeInfoForSkin,
@@ -45,7 +46,7 @@ const SKIP_LINK_STYLE =
45
46
  * Compile a page source file into a full HTML document plus its assets.
46
47
  * @param {string} file Absolute path to the source `.md`/`.wd` file.
47
48
  * @param {Paths} context Resolved project paths.
48
- * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader }} [options]
49
+ * @param {{ feed?: { href: string, title: string }, site?: SiteContext, collections?: Map<string, import("./collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
49
50
  * When a site-wide RSS feed is emitted (the home page set `site_url` and the
50
51
  * site has dated posts), `feed` carries its absolute href + title so every page
51
52
  * links it. `site` carries the origin + this page's own route (and its resolved
@@ -53,14 +54,23 @@ const SKIP_LINK_STYLE =
53
54
  * build-time collection index a bare-name `@loop` resolves against; `vars` seeds
54
55
  * the document scope (the pager `page` object on a paginated route). `reader` is
55
56
  * the source reader (fs-backed by default via `src/compiler.js`; in-memory for
56
- * `compileFromMemory`).
57
+ * `compileFromMemory`). `imageVariants` is the builder's generated-variant map
58
+ * (2.9.0): original absolute source path → `@<width>` sizes resized into dist.
57
59
  * @returns {CompiledPage}
58
60
  */
59
61
  export function compilePage(file, context, options = {}) {
60
62
  // The raw entry point requires a reader — the Node barrel (`src/compiler.js`)
61
63
  // injects `fsReader()`, and `compileFromMemory` injects a `memoryReader`.
62
64
  const reader = /** @type {import("./reader.js").Reader} */ (options.reader);
63
- const compiled = compileDocument(file, context, [], options.vars, options.collections, reader);
65
+ const compiled = compileDocument(
66
+ file,
67
+ context,
68
+ [],
69
+ options.vars,
70
+ options.collections,
71
+ reader,
72
+ options.imageVariants ?? null
73
+ );
64
74
  const title = String(compiled.meta.title || "Darkmown");
65
75
  const description = String(compiled.meta.description || "");
66
76
  // Per-page document language for the `<html lang>` attribute (`lang: fr` in
@@ -399,6 +409,10 @@ function srcOf(tag) {
399
409
  * Read an image's intrinsic dimensions from disk, or null when the src is
400
410
  * remote/relative/unreadable. Resolution mirrors the asset emit: `/__wd/media/x`
401
411
  * comes from the shelf (`site/_`), other absolute paths from `site/pages`.
412
+ * Containment mirrors the `@include` sandbox ({@link isAllowedInclude}): a src
413
+ * that resolves OUTSIDE those roots — `![x](/../../anywhere.png)`, or a symlink
414
+ * pointing out — is treated exactly like a missing image (no dimensions), never
415
+ * read.
402
416
  * @param {string} src
403
417
  * @param {Paths} paths
404
418
  * @param {import("./reader.js").Reader} reader
@@ -414,6 +428,7 @@ function measureImage(src, paths, reader) {
414
428
  } else {
415
429
  return null; // page-relative: the source directory is lost after assembly
416
430
  }
431
+ if (!isAllowedInclude(filePath, paths, reader)) return null;
417
432
  try {
418
433
  // `imageSize` itself never throws (an unsupported or malformed image is just
419
434
  // null); the try/catch is for the read, which does throw on a missing file.
@@ -434,6 +449,10 @@ function measureImage(src, paths, reader) {
434
449
  * Build-time collection index a bare-name `@loop` resolves against.
435
450
  * @param {import("./reader.js").Reader} [reader] Source reader (fs-backed when
436
451
  * omitted via `src/compiler.js`; in-memory for `compileFromMemory`).
452
+ * @param {Map<string, number[]> | null} [imageVariants] Build-time-generated
453
+ * responsive variants (original's absolute source path → widths), threaded by
454
+ * the builder so the srcset wiring includes them alongside author-provided
455
+ * `@<width>` siblings.
437
456
  * @returns {CompiledDocument}
438
457
  */
439
458
  export function compileDocument(
@@ -442,13 +461,15 @@ export function compileDocument(
442
461
  stack = [],
443
462
  vars = {},
444
463
  collections = new Map(),
445
- reader
464
+ reader,
465
+ imageVariants = null
446
466
  ) {
447
467
  // reader is injected by every caller (the Node barrel's `fsReader()` or
448
468
  // `compileFromMemory`'s `memoryReader`); it is only trailing-optional here so
449
469
  // the defaulted `vars`/`collections` params can precede it.
450
470
  const comp = createCompilation(/** @type {import("./reader.js").Reader} */ (reader));
451
471
  comp.collections = collections;
472
+ comp.imageVariants = imageVariants;
452
473
  const result = compileFile(
453
474
  file,
454
475
  context,