@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liebstoeckel/engine",
3
- "version": "0.3.6",
3
+ "version": "0.3.7",
4
4
  "description": "The compiler, React runtime, and single-file build pipeline at the core of liebstoeckel.",
5
5
  "keywords": [
6
6
  "liebstoeckel",
@@ -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
+ }