@liebstoeckel/engine 0.3.6 → 0.3.7
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/package.json +1 -1
- package/src/build/buildDeck.ts +6 -0
- package/src/build/font-audit.ts +99 -0
package/package.json
CHANGED
package/src/build/buildDeck.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@liebstoeckel/plugin-sdk/manifest";
|
|
12
12
|
import mdx from "./mdx-plugin";
|
|
13
13
|
import visxEsmInterop from "./visx-esm-plugin";
|
|
14
|
+
import { brandFontWarning } from "./font-audit";
|
|
14
15
|
import {
|
|
15
16
|
createLicenseCollector,
|
|
16
17
|
renderNotices,
|
|
@@ -225,6 +226,11 @@ export async function bundleDeck({
|
|
|
225
226
|
console.log(`✓ embedded source package (${files.length} files, ${(zstd.length / 1024).toFixed(1)}KB)`);
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
// Warn (don't fail) if a brand names a `"… Variable"` webfont that no @font-face
|
|
230
|
+
// bundles, it would silently fall back to a system font in the shipped file.
|
|
231
|
+
const fontWarning = brandFontWarning(html);
|
|
232
|
+
if (fontWarning) console.warn(fontWarning);
|
|
233
|
+
|
|
228
234
|
// Stamp the build's provenance (engine + invoking-tool versions) into the head.
|
|
229
235
|
html = stampGenerator(html, { engine: await engineVersion(), generator });
|
|
230
236
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Catch the silent brand-font fallback at build time.
|
|
2
|
+
//
|
|
3
|
+
// A brand stores its type as a `font-family` *string* (`--brand-font-heading`,
|
|
4
|
+
// `--brand-font-body`, `--brand-font-mono`), but glyphs only ship if a usable
|
|
5
|
+
// `@font-face` is bundled. The build inlines that face's woff2 into the single
|
|
6
|
+
// file. When the bundled face is wrong (or absent), the browser silently falls
|
|
7
|
+
// back to a system font: no error, and the "what you ship is what you see"
|
|
8
|
+
// promise quietly breaks (the failure ADR 0074 describes; a real session shipped
|
|
9
|
+
// Noto Sans this way before a lucky `pdffonts` check caught it).
|
|
10
|
+
//
|
|
11
|
+
// Two failure modes, both detected from the final inlined CSS:
|
|
12
|
+
//
|
|
13
|
+
// 1. Subsetted faces (`subsetted`). Importing a Fontsource package's `index.css`
|
|
14
|
+
// (or the bare package) pulls ~5 `@font-face` rules split by `unicode-range`.
|
|
15
|
+
// Those subset faces do NOT survive the single-file inlining and never
|
|
16
|
+
// register, the exact reported bug. The fix is one latin face with no
|
|
17
|
+
// `unicode-range`, mirroring @liebstoeckel/theme's fonts.css. The house fonts
|
|
18
|
+
// ship that way, so they never carry `unicode-range` and never trip this.
|
|
19
|
+
//
|
|
20
|
+
// 2. Unbundled brand fonts (`unbundled`). A `--brand-font-*` literal-CSS block
|
|
21
|
+
// names a `"… Variable"` webfont (the self-hosted-font convention) that no
|
|
22
|
+
// `@font-face` bundles at all, a typo or a forgotten import. Scoped to the
|
|
23
|
+
// `"… Variable"` convention so a bare `"Inter"` leaning on a system install
|
|
24
|
+
// isn't flagged. (Typed `brandThemes` generate their `--brand-font-*` at
|
|
25
|
+
// runtime, so this arm covers the hand-written-CSS path; the subset arm and
|
|
26
|
+
// the `pdffonts` verification in the skill cover the rest.)
|
|
27
|
+
|
|
28
|
+
const FACE_RE = /@font-face\s*\{([^}]*)\}/gi;
|
|
29
|
+
const FAMILY_DECL_RE = /font-family\s*:\s*([^;}]+)/i;
|
|
30
|
+
const BRAND_FONT_RE = /--brand-font-(?:heading|body|mono)\s*:\s*([^;}]+)/gi;
|
|
31
|
+
|
|
32
|
+
export interface BrandFontAudit {
|
|
33
|
+
/** Families whose `@font-face` is a `unicode-range` subset that won't survive inlining. */
|
|
34
|
+
subsetted: string[];
|
|
35
|
+
/** `"… Variable"` families named by `--brand-font-*` with no `@font-face` at all. */
|
|
36
|
+
unbundled: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** First family in a `font-family` value, unquoted + trimmed (the rest is the
|
|
40
|
+
* fallback stack). `"Nunito Sans Variable", system-ui` → `Nunito Sans Variable`. */
|
|
41
|
+
function primaryFamily(value: string): string {
|
|
42
|
+
const first = value.split(",")[0] ?? "";
|
|
43
|
+
return first.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function dedupe(pairs: Iterable<[string, string]>): string[] {
|
|
47
|
+
const m = new Map<string, string>(); // lowercased key → declared casing
|
|
48
|
+
for (const [key, declared] of pairs) if (!m.has(key)) m.set(key, declared);
|
|
49
|
+
return [...m.values()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Inspect a built deck's inlined CSS for brand fonts that won't render. */
|
|
53
|
+
export function auditBrandFonts(css: string): BrandFontAudit {
|
|
54
|
+
const faces = new Set<string>(); // every @font-face family, lowercased
|
|
55
|
+
const subsetted: [string, string][] = [];
|
|
56
|
+
for (const m of css.matchAll(FACE_RE)) {
|
|
57
|
+
const block = m[1] ?? "";
|
|
58
|
+
const decl = FAMILY_DECL_RE.exec(block);
|
|
59
|
+
if (!decl?.[1]) continue;
|
|
60
|
+
const family = primaryFamily(decl[1]);
|
|
61
|
+
faces.add(family.toLowerCase());
|
|
62
|
+
if (/unicode-range\s*:/i.test(block)) subsetted.push([family.toLowerCase(), family]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const unbundled: [string, string][] = [];
|
|
66
|
+
for (const m of css.matchAll(BRAND_FONT_RE)) {
|
|
67
|
+
const family = primaryFamily(m[1] ?? "");
|
|
68
|
+
const key = family.toLowerCase();
|
|
69
|
+
if (!/\bvariable$/i.test(family)) continue; // only the self-hosted-webfont convention
|
|
70
|
+
if (faces.has(key)) continue; // a face bundles it (subset issues handled above)
|
|
71
|
+
unbundled.push([key, family]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { subsetted: dedupe(subsetted), unbundled: dedupe(unbundled) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Human-facing build warning for brand fonts that won't render, or null if clean.
|
|
78
|
+
* Printed by `bundleDeck`; kept pure so it is unit-tested without a real build. */
|
|
79
|
+
export function brandFontWarning(css: string): string | null {
|
|
80
|
+
const { subsetted, unbundled } = auditBrandFonts(css);
|
|
81
|
+
if (subsetted.length === 0 && unbundled.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
const lines = ["⚠ brand font won't render (text will fall back to a system font):"];
|
|
84
|
+
if (subsetted.length) {
|
|
85
|
+
lines.push(
|
|
86
|
+
` unicode-range subsets that don't survive inlining: ${subsetted.map((f) => `"${f}"`).join(", ")}`,
|
|
87
|
+
` → don't import a Fontsource package / its index.css; bundle one latin face`,
|
|
88
|
+
` (…-latin-wght-normal.woff2), mirroring @liebstoeckel/theme's fonts.css.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (unbundled.length) {
|
|
92
|
+
lines.push(
|
|
93
|
+
` named by the brand but no @font-face bundles them: ${unbundled.map((f) => `"${f}"`).join(", ")}`,
|
|
94
|
+
` → add a latin @font-face for the family, or use a bundled house font.`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
lines.push(` See the skill's brand guide (references/brands.md → Fonts). Verify with pdffonts.`);
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
}
|