@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/AGENTS.md +21 -10
- package/CHANGELOG.md +17 -0
- package/README.md +117 -16
- package/docs/spec-alignment.md +10 -0
- package/grammar/wd-directives.gbnf +1 -1
- package/package.json +11 -3
- package/site/pages/benchmarks/index.skin +67 -0
- package/site/pages/benchmarks/index.wd +126 -0
- package/site/pages/docs/index.wd +48 -3
- package/site/pages/index.wd +1 -0
- package/src/builder.js +140 -20
- package/src/catalog.js +13 -4
- package/src/cli.js +11 -1
- package/src/compiler/body.js +30 -1
- package/src/compiler/context.js +19 -1
- package/src/compiler/includes.js +44 -1
- package/src/compiler/markdown.js +35 -12
- package/src/compiler/media.js +25 -0
- package/src/compiler/page.js +25 -4
- package/src/compiler/structure.js +272 -4
- package/src/compiler.js +13 -3
- package/src/errors.js +24 -0
- package/src/font-subset-worker.js +131 -0
- package/src/font-subset.js +457 -0
- package/src/grammar.js +3 -2
- package/src/headers.js +28 -1
- package/src/image-variant-resize.js +71 -0
- package/src/image-variants.js +294 -0
- package/src/statics.js +22 -14
- package/types/builder.d.ts +34 -1
- package/types/catalog.d.ts +3 -2
- package/types/compiler/context.d.ts +41 -0
- package/types/compiler/includes.d.ts +23 -0
- package/types/compiler/page.d.ts +9 -3
- package/types/compiler/structure.d.ts +53 -1
- package/types/compiler.d.ts +5 -2
- package/types/font-subset.d.ts +140 -0
- package/types/headers.d.ts +14 -0
- package/types/image-variants.d.ts +131 -0
|
@@ -11,8 +11,25 @@
|
|
|
11
11
|
// while keeping the message text and `Use:` hints intact.
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
13
13
|
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
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
|
|
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/compiler.js
CHANGED
|
@@ -43,7 +43,7 @@ export {
|
|
|
43
43
|
* a different source (see `compileFromMemory`).
|
|
44
44
|
* @param {string} file Absolute path to the source `.md`/`.wd` file.
|
|
45
45
|
* @param {import("./config.js").Paths} context Resolved project paths.
|
|
46
|
-
* @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader }} [options]
|
|
46
|
+
* @param {{ feed?: { href: string, title: string }, site?: import("./compiler/page.js").SiteContext, collections?: Map<string, import("./compiler/collections.js").CollectionRow[]>, vars?: Record<string, unknown>, reader?: import("./compiler/reader.js").Reader, imageVariants?: Map<string, number[]> | null }} [options]
|
|
47
47
|
* @returns {CompiledPage}
|
|
48
48
|
*/
|
|
49
49
|
export function compilePage(file, context, options = {}) {
|
|
@@ -59,10 +59,20 @@ export function compilePage(file, context, options = {}) {
|
|
|
59
59
|
* @param {Record<string, unknown>} [vars] Initial static scope variables.
|
|
60
60
|
* @param {Map<string, import("./compiler/collections.js").CollectionRow[]>} [collections]
|
|
61
61
|
* @param {import("./compiler/reader.js").Reader} [reader] Source reader.
|
|
62
|
+
* @param {Map<string, number[]> | null} [imageVariants] Builder-generated
|
|
63
|
+
* responsive variants (original's absolute source path → widths).
|
|
62
64
|
* @returns {CompiledDocument}
|
|
63
65
|
*/
|
|
64
|
-
export function compileDocument(
|
|
65
|
-
|
|
66
|
+
export function compileDocument(
|
|
67
|
+
file,
|
|
68
|
+
context,
|
|
69
|
+
stack,
|
|
70
|
+
vars,
|
|
71
|
+
collections,
|
|
72
|
+
reader = fsReader(),
|
|
73
|
+
imageVariants = null
|
|
74
|
+
) {
|
|
75
|
+
return compileDocumentImpl(file, context, stack, vars, collections, reader, imageVariants);
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
/**
|
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]);
|