@zvndev/darkmown 2.9.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.
@@ -11,8 +11,25 @@
11
11
  // while keeping the message text and `Use:` hints intact.
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
- import { at, createScope, LOOP_META, lineOf, nestedCtx, recordSymbol, wdError } from "./context.js";
15
- import { resolveInclude, scopedSkinFor, stampScope } from "./includes.js";
14
+ import path from "node:path";
15
+ import {
16
+ at,
17
+ createScope,
18
+ LOOP_META,
19
+ lineOf,
20
+ nestedCtx,
21
+ normalizeNewlines,
22
+ recordSymbol,
23
+ wdError
24
+ } from "./context.js";
25
+ import { parseFrontmatter } from "./frontmatter.js";
26
+ import {
27
+ CHILDREN_SLOT_LINE,
28
+ INCLUDE_CHILDREN,
29
+ resolveInclude,
30
+ scopedSkinFor,
31
+ stampScope
32
+ } from "./includes.js";
16
33
  import {
17
34
  escapeHtml,
18
35
  getPath,
@@ -146,12 +163,31 @@ export function a11yAttrHtml(attrs) {
146
163
  }
147
164
 
148
165
  /**
166
+ * Compile an `@include` line — inline form (`@include /nav.wd`) or block form
167
+ * (`@include /card.wd … @endinclude`, with `childrenLines` holding the lines
168
+ * between opener and closer, captured by {@link scanIncludeBlock}).
169
+ *
170
+ * Block form is compile-time macro expansion and nothing more: the children
171
+ * lines compile in the CALLER's context (its scope, its `:state`, its loop
172
+ * item when the call sits in a `@loop` body) BEFORE the target compiles, and
173
+ * the resulting HTML rides into the include's scope under
174
+ * {@link INCLUDE_CHILDREN}, where the body parser splices it at each
175
+ * `{ children }` slot line. No runtime marker, no reactivity of its own.
176
+ * Children compile ONCE even when the include places the slot twice, so state
177
+ * a child line declares is declared once in the caller.
178
+ *
179
+ * The include file's own frontmatter may declare `defaults: [name="value" …]`
180
+ * fallback values for `with` arguments (see {@link parseIncludeDefaults}); a
181
+ * caller-provided argument always wins over a default, and an argument with
182
+ * neither keeps the literal-braces behavior.
149
183
  * @param {string} line
150
184
  * @param {Ctx} ctx
151
185
  * @param {number} index 0-based line index for `file:line` errors.
186
+ * @param {string[]} [childrenLines] Lines between `@include` and `@endinclude`
187
+ * for the block form; undefined for the inline form.
152
188
  * @returns {string}
153
189
  */
154
- export function handleInclude(line, ctx, index) {
190
+ export function handleInclude(line, ctx, index, childrenLines) {
155
191
  const match = line.match(/^@include\s+(\S+)(?:\s+with\s+(.+))?$/);
156
192
  if (!match)
157
193
  throw wdError(`Malformed @include in ${at(ctx, index)}: ${line}. ${INCLUDE_USE}`, {
@@ -171,7 +207,39 @@ export function handleInclude(line, ctx, index) {
171
207
  );
172
208
  const args = parseIncludeArgs(match[2] || "", ctx);
173
209
  recordSymbol(ctx, index, { kind: "include", name: match[1], detail: `@include ${match[1]}` });
174
- const childScope = createScope(ctx.scope, args);
210
+ const component = includeComponentInfo(target, ctx);
211
+ let childrenHtml = "";
212
+ if (childrenLines) {
213
+ // One nesting level is the contract: a block include's children (or an
214
+ // include file's own body) may hold one block include, no more. Children
215
+ // depth travels on the ctx (children compile in the caller's context);
216
+ // include-FILE depth is the include stack's length (page = 1, its include
217
+ // = 2, an include's include = 3 — too deep for a block).
218
+ if ((ctx.includeBlockDepth ?? 0) >= 2 || ctx.stack.length > 2) {
219
+ const hint = `${INCLUDE_BLOCK_USE}`;
220
+ throw wdError(
221
+ `Block-form @include nested too deep in ${at(ctx, index)}: ${line}. A children block or an include body may hold ONE level of @include … @endinclude. Use: ${hint}`,
222
+ { code: "WD616", file: ctx.file, line: lineOf(ctx, index), hint, example: INCLUDE_EXAMPLE }
223
+ );
224
+ }
225
+ childrenHtml = ctx.compileBody(childrenLines, {
226
+ ...nestedCtx(ctx, index + 1),
227
+ includeBlockDepth: (ctx.includeBlockDepth ?? 0) + 1
228
+ });
229
+ if (childrenHtml.trim() && !component.usesChildren) {
230
+ ctx.comp.warnings.push(
231
+ `${at(ctx, index)}: the @include block for "${match[1]}" passes children, but ${target} never uses a { children } line, so the content is dropped. Add { children } where the slot content should appear, or drop the block.`
232
+ );
233
+ }
234
+ }
235
+ // Caller args sit over defaults (the caller always wins); the children slot
236
+ // sits alongside them. An inline call still sets the slot (""), so a nested
237
+ // include never sees an OUTER include's children through the scope chain.
238
+ const childScope = createScope(ctx.scope, {
239
+ ...component.defaults,
240
+ ...args,
241
+ [INCLUDE_CHILDREN]: childrenHtml
242
+ });
175
243
  // Thread the reactive-nesting depth (and the enclosing loop opener, so a depth
176
244
  // error names the right opener) into the include, exactly as `loopItem` is —
177
245
  // otherwise a reactive @loop inside the include would start counting from zero
@@ -196,6 +264,206 @@ export function handleInclude(line, ctx, index) {
196
264
  return scopedSkin && scopedSkin.scoped ? stampScope(child.html, scopedSkin.scopeId) : child.html;
197
265
  }
198
266
 
267
+ const INCLUDE_BLOCK_USE = `@include /partial.wd [with key="value"] … @endinclude — e.g. @include /card.wd with title="Pricing"`;
268
+
269
+ /**
270
+ * Scan ahead from an `@include` opener for the `@endinclude` that pairs with
271
+ * it. Pairing is nearest-opener-first: a nested `@include` line pushes, an
272
+ * `@endinclude` line pops the most recent unclosed opener, and the first
273
+ * `@endinclude` found with nothing on the stack belongs to THIS opener — so
274
+ * nested blocks pair correctly, and an inline `@include` earlier in the file
275
+ * than a block (the common nav-include-then-card-block page) is unaffected.
276
+ * Fenced code is skipped, exactly as in the other block scanners.
277
+ *
278
+ * Returns null when no closer pairs — the include is the INLINE form, which
279
+ * stays legal (backward compatible): an opener that never pairs is an inline
280
+ * include followed by ordinary prose, and the two are indistinguishable, so
281
+ * there is deliberately no "unclosed block" error.
282
+ * @param {string[]} lines The current body slice.
283
+ * @param {number} start 0-based index of the `@include` opener line.
284
+ * @returns {{ body: string[], end: number } | null} The children lines and the
285
+ * closer's 0-based index, or null for the inline form.
286
+ */
287
+ export function scanIncludeBlock(lines, start) {
288
+ /** @type {number[]} */
289
+ const stack = [];
290
+ /** @type {string | null} */
291
+ let fence = null;
292
+ for (let i = start + 1; i < lines.length; i++) {
293
+ const line = lines[i];
294
+ const fenceMatch = line.match(/^(```+|~~~+)/);
295
+ if (fence) {
296
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
297
+ fence = null;
298
+ continue;
299
+ }
300
+ if (fenceMatch) {
301
+ fence = fenceMatch[1];
302
+ continue;
303
+ }
304
+ if (/^@include\s/.test(line)) {
305
+ stack.push(i);
306
+ continue;
307
+ }
308
+ if (line.trim() === "@endinclude") {
309
+ if (stack.length === 0) return { body: lines.slice(start + 1, i), end: i };
310
+ stack.pop();
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+
316
+ /**
317
+ * `@endinclude` reached the body dispatcher, meaning no `@include` opener
318
+ * claimed it — the one-closer-too-many case (a typo'd closer, or a nested
319
+ * block attempted inside a children block, where the inner opener already
320
+ * took the closer the author meant for the outer one). Always throws.
321
+ * @param {string} line The offending line, for the message.
322
+ * @param {Ctx} ctx
323
+ * @param {number} index 0-based line index for `file:line`.
324
+ * @returns {never}
325
+ */
326
+ export function strayEndinclude(line, ctx, index) {
327
+ const hint = INCLUDE_BLOCK_USE;
328
+ throw wdError(
329
+ `Stray "${line.trim()}" with no unpaired @include before it in ${at(ctx, index)}. @endinclude pairs with the nearest preceding unpaired @include. Use: ${hint}`,
330
+ { code: "WD614", file: ctx.file, line: lineOf(ctx, index), hint, example: INCLUDE_EXAMPLE }
331
+ );
332
+ }
333
+
334
+ /**
335
+ * The component-facing facts about an include target's source: its declared
336
+ * argument `defaults` (frontmatter) and whether its body ever places the
337
+ * `{ children }` slot. Reads the target once, ahead of the compileFile read.
338
+ * @param {string} target Absolute include path.
339
+ * @param {Ctx} ctx
340
+ * @returns {{ defaults: Record<string, string>, usesChildren: boolean }}
341
+ */
342
+ function includeComponentInfo(target, ctx) {
343
+ const raw = normalizeNewlines(ctx.comp.reader.readText(target));
344
+ const { meta, body } = parseFrontmatter(raw, target);
345
+ return {
346
+ defaults: parseIncludeDefaults(meta, raw, target),
347
+ // A `.md` include never runs the directive/interpolation layer, so a
348
+ // `{ children }` line in one is inert text, not a slot: the drop-hint must
349
+ // treat the file as slotless.
350
+ usesChildren: path.extname(target) !== ".md" && usesChildrenSlot(body)
351
+ };
352
+ }
353
+
354
+ /**
355
+ * Parse an include file's `defaults:` frontmatter: an inline array of
356
+ * `name="value"` entries, e.g. `defaults: [title="Untitled", cta="Learn more"]`.
357
+ * Values are STRINGS (frontmatter is string-typed) and apply only inside that
358
+ * include; a caller-provided `with` argument always wins.
359
+ *
360
+ * The raw `defaults:` line is split quote-aware (so a value may contain a
361
+ * comma) rather than trusting the generic frontmatter array items, whose
362
+ * splitter only honors item-leading quotes. Reached only when
363
+ * `parseFrontmatter` already produced an array for the key, so the source
364
+ * line and its `[…]` shape are guaranteed present.
365
+ * @param {import("./context.js").Meta} meta The include file's frontmatter.
366
+ * @param {string} raw The include file's full (newline-normalized) source.
367
+ * @param {string} target Absolute include path, for error messages.
368
+ * @returns {Record<string, string>}
369
+ */
370
+ function parseIncludeDefaults(meta, raw, target) {
371
+ if (meta.defaults === undefined) return {};
372
+ const use = 'an inline array of name="value" entries — e.g. defaults: [title="Untitled"]';
373
+ if (!Array.isArray(meta.defaults)) {
374
+ throw wdError(`Invalid defaults frontmatter in ${target}: the value must be ${use}.`, {
375
+ code: "WD613",
376
+ file: target,
377
+ hint: use,
378
+ example: 'defaults: [title="Untitled"]'
379
+ });
380
+ }
381
+ const lines = raw.split("\n");
382
+ const lineIndex = lines.findIndex((l) => /^defaults\s*:/.test(l));
383
+ const valueText = lines[lineIndex].replace(/^defaults\s*:\s*/, "").trim();
384
+ const inner = valueText.slice(1, -1); // strip the […] the array shape guarantees
385
+ /** @type {Record<string, string>} */
386
+ const defaults = {};
387
+ for (const entry of splitDefaultEntries(inner)) {
388
+ const match =
389
+ entry.match(/^([A-Za-z0-9_-]+)="([^"]*)"$/) ?? entry.match(/^([A-Za-z0-9_-]+)='([^']*)'$/);
390
+ if (!match) {
391
+ throw wdError(
392
+ `Invalid defaults frontmatter in ${target}:${lineIndex + 1}: entry "${entry}" must look like name="value". Use: ${use}.`,
393
+ {
394
+ code: "WD613",
395
+ file: target,
396
+ line: lineIndex + 1,
397
+ hint: use,
398
+ example: 'defaults: [title="Untitled"]'
399
+ }
400
+ );
401
+ }
402
+ defaults[match[1]] = match[2];
403
+ }
404
+ return defaults;
405
+ }
406
+
407
+ /**
408
+ * Split the inside of a `defaults: […]` array on top-level commas, honoring
409
+ * quotes ANYWHERE in an entry (the value follows `name=`, so the quote is not
410
+ * entry-leading and the generic frontmatter splitter would break on a comma
411
+ * inside it). An unterminated quote leaves its entry malformed, which the
412
+ * caller's entry check rejects.
413
+ * @param {string} inner
414
+ * @returns {string[]} Trimmed, non-empty entries.
415
+ */
416
+ function splitDefaultEntries(inner) {
417
+ /** @type {string[]} */
418
+ const entries = [];
419
+ let buf = "";
420
+ /** @type {string | null} */
421
+ let quote = null;
422
+ for (const ch of inner) {
423
+ if (quote) {
424
+ buf += ch;
425
+ if (ch === quote) quote = null;
426
+ } else if (ch === '"' || ch === "'") {
427
+ quote = ch;
428
+ buf += ch;
429
+ } else if (ch === ",") {
430
+ entries.push(buf);
431
+ buf = "";
432
+ } else {
433
+ buf += ch;
434
+ }
435
+ }
436
+ entries.push(buf);
437
+ return entries.map((entry) => entry.trim()).filter(Boolean);
438
+ }
439
+
440
+ /**
441
+ * Whether an include body ever places the `{ children }` slot: a standalone
442
+ * slot-marker line outside fenced code. Fence-aware so a documentation fence
443
+ * demonstrating `{ children }` does not count (its content is literal text —
444
+ * the slot really is unused there, and the drop-hint should still fire).
445
+ * @param {string} body The include file's post-frontmatter body.
446
+ * @returns {boolean}
447
+ */
448
+ function usesChildrenSlot(body) {
449
+ /** @type {string | null} */
450
+ let fence = null;
451
+ for (const line of body.split("\n")) {
452
+ const fenceMatch = line.match(/^(```+|~~~+)/);
453
+ if (fence) {
454
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length)
455
+ fence = null;
456
+ continue;
457
+ }
458
+ if (fenceMatch) {
459
+ fence = fenceMatch[1];
460
+ continue;
461
+ }
462
+ if (CHILDREN_SLOT_LINE.test(line)) return true;
463
+ }
464
+ return false;
465
+ }
466
+
199
467
  /**
200
468
  * @param {string} raw
201
469
  * @param {Ctx} ctx
package/src/errors.js CHANGED
@@ -1057,6 +1057,30 @@ const ENTRIES = [
1057
1057
  "An `@include` chain reaches a file that is already being compiled, so the compile would never terminate.",
1058
1058
  fix: "Break the loop: remove the `@include` that points back, or move the shared content into a third file both sides include."
1059
1059
  },
1060
+ {
1061
+ code: "WD613",
1062
+ title: "Invalid include `defaults`",
1063
+ cause:
1064
+ 'An include file\'s `defaults:` frontmatter is not an inline array of `name="value"` entries.',
1065
+ fix: 'Write an inline array of quoted entries, e.g. `defaults: [title="Untitled"]`. Values are strings; a caller-provided `with` argument always wins over a default.',
1066
+ example: INCLUDE_EXAMPLE
1067
+ },
1068
+ {
1069
+ code: "WD614",
1070
+ title: "Stray `@endinclude`",
1071
+ cause:
1072
+ "An `@endinclude` has no unpaired `@include` before it — the closer pairs with the nearest preceding unpaired opener, and every opener in range already paired.",
1073
+ fix: "Remove the extra closer, or open a block above it: `@include /partial.wd`, the children lines, then `@endinclude`.",
1074
+ example: INCLUDE_EXAMPLE
1075
+ },
1076
+ {
1077
+ code: "WD616",
1078
+ title: "Include block nested too deep",
1079
+ cause:
1080
+ "A block-form `@include … @endinclude` sits inside another include block's children or inside a nested include's own body, past the one supported level.",
1081
+ fix: "Keep one level: move the inner block into the include's own file, or use the inline form `@include /partial.wd`.",
1082
+ example: INCLUDE_EXAMPLE
1083
+ },
1060
1084
  {
1061
1085
  code: "WD650",
1062
1086
  title: "Attribute is not on the accessibility whitelist",
@@ -0,0 +1,131 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The subset worker for the build-time font post-pass (2.10.0). Never imported
3
+ // by the builder: the build is synchronous and the wasm toolchain is not, so
4
+ // src/font-subset.js spawns this file as a child process with a jobs JSON file
5
+ // and reads back a results JSON file.
6
+ //
7
+ // harfbuzzjs (the hb-subset wasm) and wawoff2 are OPTIONAL dependencies, so
8
+ // both are loaded inside a try/catch: when either cannot load, the worker
9
+ // reports `{ unavailable: true }` and exits cleanly — the parent turns that
10
+ // into one hint, and the build ships exactly what 2.9.0 would have. A failure
11
+ // on ONE font is recorded in `failed` and the rest still subset. Nothing here
12
+ // ever exits non-zero over a font.
13
+ //
14
+ // The harfbuzzjs package ships the subset build as a bare wasm with no JS glue
15
+ // (its exports map only exposes the shaping API), so we instantiate
16
+ // `harfbuzzjs/dist/harfbuzz-subset.wasm` directly — it imports nothing and is
17
+ // initialized with one `_initialize()` call. Default hb-subset behavior keeps
18
+ // the layout features of the retained glyphs (kern/liga/calt via GSUB/GPOS
19
+ // subsetting) and the variation tables (fvar/gvar), so variable axes survive.
20
+ //
21
+ // `WD_HARFBUZZ_WASM` and `WD_WAWOFF2_MODULE` override the two tool specifiers;
22
+ // tests point them at nonexistent paths to exercise the toolchain-absent path
23
+ // without uninstalling anything. They are test seams, not public knobs.
24
+ // ---------------------------------------------------------------------------
25
+
26
+ import fs from "node:fs";
27
+ import { createRequire } from "node:module";
28
+ import path from "node:path";
29
+ import { pathToFileURL } from "node:url";
30
+
31
+ /** hb_memory_mode_writable: the blob may keep referencing the malloc'd bytes. */
32
+ const HB_MEMORY_MODE_WRITABLE = 2;
33
+
34
+ /**
35
+ * Load the optional toolchain, or null when either half is absent. The wasm
36
+ * path resolves through the harfbuzzjs package's exports map, so a consumer's
37
+ * `npm i -D harfbuzzjs` beside the installed framework is found the same way
38
+ * sharp is for the image pre-pass.
39
+ * @returns {Promise<{ hb: any, compress: (input: Uint8Array) => Promise<Uint8Array> } | null>}
40
+ */
41
+ export async function loadFontTools() {
42
+ try {
43
+ const require = createRequire(import.meta.url);
44
+ const wasmPath =
45
+ process.env.WD_HARFBUZZ_WASM ?? require.resolve("harfbuzzjs/dist/harfbuzz-subset.wasm");
46
+ const { instance } = await WebAssembly.instantiate(await fs.promises.readFile(wasmPath));
47
+ const hb = /** @type {any} */ (instance.exports);
48
+ hb._initialize();
49
+ const compress = require(process.env.WD_WAWOFF2_MODULE || "wawoff2").compress;
50
+ return { hb, compress };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Subset one font to `codepoints`, returning the subset as TTF bytes (the
58
+ * caller encodes to woff2). Throws when harfbuzz cannot parse or subset the
59
+ * file. Every wasm handle is released on both paths; the heap view is taken
60
+ * fresh at each use because a malloc can grow (and detach) the memory.
61
+ * @param {any} hb The hb-subset wasm exports.
62
+ * @param {Uint8Array} fontBytes The original font file.
63
+ * @param {number[]} codepoints The charset to keep.
64
+ * @returns {Uint8Array}
65
+ */
66
+ export function subsetFont(hb, fontBytes, codepoints) {
67
+ const input = hb.hb_subset_input_create_or_fail();
68
+ if (!input) throw new Error("harfbuzz could not create a subset input");
69
+ const unicodes = hb.hb_subset_input_unicode_set(input);
70
+ for (const cp of codepoints) hb.hb_set_add(unicodes, cp);
71
+ const fontPtr = hb.malloc(fontBytes.byteLength);
72
+ new Uint8Array(hb.memory.buffer).set(fontBytes, fontPtr);
73
+ const blob = hb.hb_blob_create(fontPtr, fontBytes.byteLength, HB_MEMORY_MODE_WRITABLE, 0, 0);
74
+ const face = hb.hb_face_create(blob, 0);
75
+ hb.hb_blob_destroy(blob);
76
+ const subsetFace = hb.hb_subset_or_fail(face, input);
77
+ if (!subsetFace) {
78
+ hb.hb_face_destroy(face);
79
+ hb.hb_subset_input_destroy(input);
80
+ hb.free(fontPtr);
81
+ throw new Error("harfbuzz could not parse or subset the font");
82
+ }
83
+ const subsetBlob = hb.hb_face_reference_blob(subsetFace);
84
+ const length = hb.hb_blob_get_length(subsetBlob);
85
+ const dataPtr = hb.hb_blob_get_data(subsetBlob, 0);
86
+ const bytes = new Uint8Array(hb.memory.buffer, dataPtr, length).slice();
87
+ hb.hb_blob_destroy(subsetBlob);
88
+ hb.hb_face_destroy(subsetFace);
89
+ hb.hb_face_destroy(face);
90
+ hb.hb_subset_input_destroy(input);
91
+ hb.free(fontPtr);
92
+ return bytes;
93
+ }
94
+
95
+ /**
96
+ * Run every job in the jobs file and write the results file.
97
+ * @param {string} jobsFile JSON `{ charset: number[], jobs: [{ source, out }] }`.
98
+ * @param {string} resultsFile Written with `{ unavailable, failed }`.
99
+ * @returns {Promise<void>}
100
+ */
101
+ export async function runJobsFile(jobsFile, resultsFile) {
102
+ const payload = JSON.parse(fs.readFileSync(jobsFile, "utf8"));
103
+ /** @type {(result: { unavailable: boolean, failed: { file: string, error: string }[] }) => void} */
104
+ const write = (result) => fs.writeFileSync(resultsFile, JSON.stringify(result));
105
+ const tools = await loadFontTools();
106
+ if (!tools) {
107
+ write({ unavailable: true, failed: [] });
108
+ return;
109
+ }
110
+ /** @type {{ file: string, error: string }[]} */
111
+ const failed = [];
112
+ for (const job of payload.jobs) {
113
+ try {
114
+ const subset = subsetFont(tools.hb, fs.readFileSync(job.source), payload.charset);
115
+ const woff2 = await tools.compress(subset);
116
+ fs.mkdirSync(path.dirname(job.out), { recursive: true });
117
+ fs.writeFileSync(job.out, woff2);
118
+ } catch (err) {
119
+ failed.push({
120
+ file: job.source,
121
+ error: err instanceof Error ? err.message : String(err)
122
+ });
123
+ }
124
+ }
125
+ write({ unavailable: false, failed });
126
+ }
127
+
128
+ // Entry-point guard, one line so importing this module in a test registers the
129
+ // line as executed without running main (same pattern as the coverage gate).
130
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
131
+ runJobsFile(process.argv[2], process.argv[3]);