@bycrux/editor 1.2.0 → 1.2.2

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.
@@ -1,5 +1,6 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { ensureGoogleFontsLoaded } from '../google-fonts'
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { ensureGoogleFontsLoaded, setFontsBaseUrl, vendoredFamiliesDigest } from '../google-fonts'
3
+ import { fontFamilyKey, familiesDigest, partitionFontSpecs, vendoredFaceIndex, vendoredKeySet } from '../font-families'
3
4
 
4
5
  // Each test injects fonts that have not been requested by any prior test so the
5
6
  // module-level dedupe Set never short-circuits the <link> append we assert on.
@@ -11,7 +12,21 @@ function injectedHrefs(): string[] {
11
12
 
12
13
  beforeEach(() => {
13
14
  document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
15
+ // The setters log a line per call; individual tests that assert on logging
16
+ // install their own spies.
17
+ vi.spyOn(console, 'info').mockImplementation(() => {})
18
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
14
19
  })
20
+ afterEach(() => {
21
+ setFontsBaseUrl(undefined)
22
+ vi.restoreAllMocks()
23
+ })
24
+
25
+ // A base per test. The injected-URL Set is module state that outlives a single
26
+ // test (only document.head is reset in beforeEach), so two tests producing the
27
+ // same URL would see the second one suppressed — and it would pass for the
28
+ // wrong reason. Keep every base, and every unvendored family, unique.
29
+ const B = (name: string) => `https://example.com/fonts/${name}`
15
30
 
16
31
  describe('ensureGoogleFontsLoaded', () => {
17
32
  it('accepts a proper string[] and appends one <link> with each family', () => {
@@ -54,3 +69,305 @@ describe('ensureGoogleFontsLoaded', () => {
54
69
  expect(hrefs[0]).toContain('family=Inter')
55
70
  })
56
71
  })
72
+
73
+ // The loader is FULLY SYNCHRONOUS and must stay that way. It is called from
74
+ // effects and has to decide the partition before it can act, so the vendored
75
+ // family list is passed into the setter by the host rather than fetched here:
76
+ // a pending fetch would leave the loader with no good move — it cannot block,
77
+ // guessing is silently wrong, and re-injecting once the answer lands leaves
78
+ // two competing stylesheets with the stale one never pruned.
79
+ describe('setFontsBaseUrl', () => {
80
+ it('unset produces exactly the default per-family googleapis URL', () => {
81
+ ensureGoogleFontsLoaded(['Syne:wght@800'])
82
+ expect(injectedHrefs()).toEqual(['https://fonts.googleapis.com/css2?family=Syne:wght@800&display=swap'])
83
+ })
84
+
85
+ it('never fetches — there is no network call anywhere in this loader', () => {
86
+ const fetchSpy = vi.fn()
87
+ vi.stubGlobal('fetch', fetchSpy)
88
+ try {
89
+ setFontsBaseUrl(B('no-fetch'), ['Karla'])
90
+ ensureGoogleFontsLoaded(['Karla:wght@800'])
91
+ expect(fetchSpy).not.toHaveBeenCalled()
92
+ } finally {
93
+ vi.unstubAllGlobals()
94
+ }
95
+ })
96
+
97
+ it('a vendored family produces <base>/fonts.css ONLY — no googleapis link, no preconnect, no egress', () => {
98
+ const base = B('all-vendored')
99
+ setFontsBaseUrl(base, ['Syne', 'Inter'])
100
+ ensureGoogleFontsLoaded(['Syne:wght@800'])
101
+ expect(injectedHrefs()).toEqual([`${base}/fonts.css`])
102
+ })
103
+
104
+ it('setting a base after a prior injection still injects — a stale guard must not suppress it', () => {
105
+ ensureGoogleFontsLoaded(['Zilla:wght@800'])
106
+ expect(injectedHrefs()).toHaveLength(1)
107
+
108
+ const base = B('another-base')
109
+ setFontsBaseUrl(base, ['Zilla'])
110
+ ensureGoogleFontsLoaded(['Zilla:wght@800'])
111
+
112
+ const hrefs = injectedHrefs()
113
+ expect(hrefs).toHaveLength(2)
114
+ expect(hrefs).toContain(`${base}/fonts.css`)
115
+ })
116
+
117
+ it('logs the vendored set digest once per setter call', () => {
118
+ const info = vi.spyOn(console, 'info').mockImplementation(() => {})
119
+ const base = B('digest-logged')
120
+ setFontsBaseUrl(base, ['Inter', 'Baloo 2'])
121
+ expect(info).toHaveBeenCalledOnce()
122
+ expect(info.mock.calls[0][0]).toContain(vendoredFamiliesDigest())
123
+ expect(info.mock.calls[0][0]).toContain('2 families')
124
+ })
125
+
126
+ it('vendoredFamiliesDigest is empty with no base, and stable across equivalent spellings', () => {
127
+ expect(vendoredFamiliesDigest()).toBe('')
128
+ setFontsBaseUrl(B('digest-a'), ['Baloo 2', 'Inter'])
129
+ const a = vendoredFamiliesDigest()
130
+ // Same set, different order and spelling — the digest sorts and normalises.
131
+ setFontsBaseUrl(B('digest-b'), ['inter', 'Baloo+2'])
132
+ expect(vendoredFamiliesDigest()).toBe(a)
133
+ expect(a).not.toBe('')
134
+ })
135
+ })
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // The partition (L1 fall-through)
139
+ // ---------------------------------------------------------------------------
140
+ //
141
+ // The vendored stylesheet declares the editor's twenty picker families and
142
+ // nothing else, but `googleFonts` comes out of project data and
143
+ // skills/write-overlay documents arbitrary Google families as first-class —
144
+ // its own worked example is `["Anton", "Playfair+Display:ital@1"]`, and Anton
145
+ // is not a picker family. Linking the vendored sheet and dropping the
146
+ // requested entries renders those in a fallback face with nothing on screen
147
+ // to say so, which is what this partition exists to stop.
148
+ //
149
+ // Every assertion here has a twin on the render side
150
+ // (`montaj_assets/render/test/fonts-fallthrough.test.mjs`). The two sides MUST
151
+ // partition identically: a family loaded locally while editing and from
152
+ // Google at export time — or vice versa — is the caption-shifts-between-
153
+ // preview-and-export bug the Syne case study documents.
154
+ describe('ensureGoogleFontsLoaded: unvendored families fall through to Google', () => {
155
+ it('an unvendored family gets a googleapis link alongside the vendored stylesheet', () => {
156
+ const base = B('mix-basic')
157
+ setFontsBaseUrl(base, ['Inter', 'Baloo 2'])
158
+ ensureGoogleFontsLoaded(['Inter:wght@400', 'Anton', 'Syne:wght@800'])
159
+ expect(injectedHrefs()).toEqual([
160
+ `${base}/fonts.css`,
161
+ 'https://fonts.googleapis.com/css2?family=Anton&family=Syne:wght@800&display=swap',
162
+ ])
163
+ })
164
+
165
+ it('a MIX splits: vendored families stay local, unvendored ones go to Google, and neither list leaks into the other', () => {
166
+ const base = B('mixed')
167
+ setFontsBaseUrl(base, ['Baloo 2', 'Playfair Display', 'Inter'])
168
+ ensureGoogleFontsLoaded(['Baloo+2:wght@400;500', 'Bitter', 'Playfair+Display:ital@1'])
169
+
170
+ const hrefs = injectedHrefs()
171
+ expect(hrefs).toHaveLength(2)
172
+ expect(hrefs[0]).toBe(`${base}/fonts.css`)
173
+ // Only Bitter crosses to Google. Baloo 2 and Playfair Display are vendored,
174
+ // so their specs must NOT appear in the googleapis URL — a regression that
175
+ // fell everything through would still look right and would silently
176
+ // restore the egress this feature removes.
177
+ expect(hrefs[1]).toBe('https://fonts.googleapis.com/css2?family=Bitter&display=swap')
178
+ expect(hrefs[1]).not.toContain('Baloo')
179
+ expect(hrefs[1]).not.toContain('Playfair')
180
+ })
181
+
182
+ it('all-unvendored does not link the vendored stylesheet at all — it would serve nothing', () => {
183
+ const base = B('all-unvendored')
184
+ setFontsBaseUrl(base, ['Inter', 'Baloo 2'])
185
+ ensureGoogleFontsLoaded(['Amiri', 'Cabin:wght@400'])
186
+ expect(injectedHrefs()).toEqual([
187
+ 'https://fonts.googleapis.com/css2?family=Amiri&family=Cabin:wght@400&display=swap',
188
+ ])
189
+ })
190
+
191
+ it('logs which families were fetched from Google, and not the ones that were not', () => {
192
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
193
+ const base = B('logged')
194
+ setFontsBaseUrl(base, ['Inter'])
195
+ ensureGoogleFontsLoaded(['Inter:wght@400', 'Cantata+One'])
196
+ expect(warn).toHaveBeenCalledOnce()
197
+ expect(warn.mock.calls[0][0]).toContain('Cantata+One')
198
+ expect(warn.mock.calls[0][0]).toContain('fonts.googleapis.com')
199
+ expect(warn.mock.calls[0][0]).not.toContain('Inter')
200
+ })
201
+
202
+ it('does not re-log per overlay', () => {
203
+ const base = B('repeat')
204
+ setFontsBaseUrl(base, ['Inter'])
205
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
206
+ ensureGoogleFontsLoaded(['Eczar'])
207
+ ensureGoogleFontsLoaded(['Eczar'])
208
+ expect(warn).toHaveBeenCalledOnce()
209
+ expect(injectedHrefs()).toHaveLength(1)
210
+ })
211
+
212
+ it('the defensive string coercion still holds with a base set', () => {
213
+ const base = B('coercion')
214
+ setFontsBaseUrl(base, ['Inter'])
215
+ // A bare string used to throw "n.map is not a function" and surface as
216
+ // "overlay error: <file>.jsx", taking the whole overlay layer with it.
217
+ expect(() => ensureGoogleFontsLoaded('Dosis,Inter:wght@400' as unknown as string[])).not.toThrow()
218
+ expect(injectedHrefs()).toEqual([
219
+ `${base}/fonts.css`,
220
+ 'https://fonts.googleapis.com/css2?family=Dosis&display=swap',
221
+ ])
222
+ })
223
+ })
224
+
225
+ // THE FAILURE MODE, and it is deliberate in this direction.
226
+ //
227
+ // A base with no family list means NOTHING is vendored: no vendored <link>,
228
+ // every family from Google. The tempting opposite — assume the sheet covers
229
+ // what was asked for — is the SILENT-wrong option: an unvendored family would
230
+ // get no stylesheet at all and preview as a system fallback, while the render
231
+ // (reading its manifest off local disk, where it cannot fail independently)
232
+ // fetched that same family from Google and got it right. Preview and export
233
+ // would disagree with nothing on screen to say so — the Syne bug exactly.
234
+ // This way is loud-wrong: glyphs correct, both sides agreeing, cost is egress,
235
+ // and egress is the one failure the logging already detects.
236
+ describe('setFontsBaseUrl: a base with no family list', () => {
237
+ for (const [label, families] of [
238
+ ['omitted', undefined],
239
+ ['empty', []],
240
+ ['not an array', 'Inter'],
241
+ ['all non-strings', [7, null]],
242
+ ] as [string, unknown][]) {
243
+ it(`${label}: nothing is vendored, the stylesheet is not linked, everything goes to Google`, () => {
244
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
245
+ const base = B(`nolist-${label.replace(/\s/g, '-')}`)
246
+ setFontsBaseUrl(base, families as string[] | undefined)
247
+ ensureGoogleFontsLoaded([`Q${label.replace(/\s/g, '')}`, 'Inter:wght@400'])
248
+
249
+ const hrefs = injectedHrefs()
250
+ expect(hrefs).toHaveLength(1)
251
+ expect(hrefs[0]).toContain('fonts.googleapis.com')
252
+ expect(hrefs[0]).toContain('family=Inter:wght@400')
253
+ expect(hrefs[0]).not.toContain('fonts.css')
254
+ expect(warn.mock.calls[0][0]).toContain('treating NOTHING as vendored')
255
+ })
256
+ }
257
+ })
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // The shared partition primitives
261
+ // ---------------------------------------------------------------------------
262
+ describe('fontFamilyKey: a spec normalises to the family name fonts.css declares', () => {
263
+ for (const [spec, key] of [
264
+ ['Baloo+2:wght@400;500', 'baloo 2'],
265
+ ['Playfair+Display:ital@1', 'playfair display'],
266
+ ['Anton', 'anton'],
267
+ ['Source+Serif+4:wght@400;700', 'source serif 4'],
268
+ ['Inter:wght@400;600;700;800', 'inter'],
269
+ ['Playfair Display', 'playfair display'], // a literal space, not '+'
270
+ [' Anton ', 'anton'],
271
+ ['DM+SANS', 'dm sans'], // CSS family matching is case-insensitive
272
+ ]) {
273
+ it(`${JSON.stringify(spec)} → ${JSON.stringify(key)}`, () => {
274
+ expect(fontFamilyKey(spec)).toBe(key)
275
+ })
276
+ }
277
+ })
278
+
279
+ describe('partitionFontSpecs', () => {
280
+ it('preserves the caller order within each side', () => {
281
+ const keys = vendoredKeySet(['Inter', 'Baloo 2'])
282
+ expect(partitionFontSpecs(['Anton', 'Inter:wght@400', 'Syne', 'Baloo+2'], keys)).toEqual({
283
+ vendored: ['Inter:wght@400', 'Baloo+2'],
284
+ fellThrough: ['Anton', 'Syne'],
285
+ })
286
+ })
287
+
288
+ it('an empty vendored set falls everything through', () => {
289
+ expect(partitionFontSpecs(['Anton', 'Inter'], new Set())).toEqual({
290
+ vendored: [],
291
+ fellThrough: ['Anton', 'Inter'],
292
+ })
293
+ })
294
+
295
+ it('drops non-string list entries rather than coercing them into families', () => {
296
+ // String(7) would otherwise become a "family" named "7".
297
+ expect(vendoredKeySet(['Inter', 7, null, 'Baloo 2'] as unknown[])).toEqual(
298
+ new Set(['inter', 'baloo 2']),
299
+ )
300
+ })
301
+ })
302
+
303
+ // THE CROSS-LANGUAGE GATE. The renderers carry this same algorithm in plain
304
+ // JS; a textual parity test cannot span TypeScript and JavaScript, so both
305
+ // suites pin the SAME literal digest for the SAME family list instead. If this
306
+ // value and the one in `montaj_assets/render/test/fonts-fallthrough.test.mjs`
307
+ // ever disagree, the editor and the renderers are fingerprinting differently
308
+ // and the digest stops being able to prove they agree — which is the only job
309
+ // it has. Change one, change the other, and only ever deliberately.
310
+ describe('familiesDigest', () => {
311
+ const PRODUCTION_20 = [
312
+ 'Baloo 2', 'Bebas Neue', 'DM Sans', 'Fredoka', 'Inter', 'JetBrains Mono', 'Lato',
313
+ 'Merriweather', 'Montserrat', 'Nunito', 'Open Sans', 'Oswald', 'Playfair Display',
314
+ 'Poppins', 'Raleway', 'Roboto', 'Rubik', 'Sniglet', 'Source Serif 4', 'Work Sans',
315
+ ]
316
+
317
+ // The faces those twenty actually resolve to, as `families.json` records
318
+ // them. Bebas Neue is the one family whose `faces` and `requested` differ:
319
+ // it publishes no 700, so we asked for one and received only the 400.
320
+ const W: Record<string, number[]> = {
321
+ 'Baloo 2': [400, 500, 600, 700, 800], Fredoka: [300, 400, 500, 600, 700], Sniglet: [400, 800],
322
+ }
323
+ const faces: Record<string, { normal: number[] }> = {}
324
+ const requested: Record<string, { normal: number[] }> = {}
325
+ for (const f of PRODUCTION_20) {
326
+ requested[f] = { normal: W[f] ?? [400, 700] }
327
+ faces[f] = { normal: f === 'Bebas Neue' ? [400] : (W[f] ?? [400, 700]) }
328
+ }
329
+ const INDEX = vendoredFaceIndex(faces, requested)
330
+
331
+ it('the shipped manifest has the digest the render suite also pins', () => {
332
+ expect(familiesDigest(vendoredKeySet(PRODUCTION_20), INDEX)).toBe('1fcf41c1')
333
+ })
334
+
335
+ // Unchanged from before faces existed, which is the point: a manifest
336
+ // carrying no face information still digests exactly as an older renderer
337
+ // would, so the two stay comparable.
338
+ it('a manifest with no face information keeps its original digest', () => {
339
+ expect(familiesDigest(vendoredKeySet(PRODUCTION_20))).toBe('63d7e733')
340
+ })
341
+
342
+ // The reason the digest had to grow: same twenty family NAMES, one weight
343
+ // fewer. A families-only fingerprint reports a match here, which is exactly
344
+ // the silent drift it exists to make loud.
345
+ it('dropping a single WEIGHT moves the digest, though every family name is unchanged', () => {
346
+ const thinner = vendoredFaceIndex(
347
+ { ...faces, Inter: { normal: [400] } },
348
+ { ...requested, Inter: { normal: [400] } },
349
+ )
350
+ expect(familiesDigest(vendoredKeySet(PRODUCTION_20), thinner)).toBe('7b42f0b4')
351
+ expect(familiesDigest(vendoredKeySet(PRODUCTION_20), thinner))
352
+ .not.toBe(familiesDigest(vendoredKeySet(PRODUCTION_20), INDEX))
353
+ })
354
+
355
+ it('gaining an ITALIC face moves the digest — the style axis is in the input too', () => {
356
+ const italic = vendoredFaceIndex(
357
+ { ...faces, 'Playfair Display': { normal: [400, 700], italic: [400] } },
358
+ requested,
359
+ )
360
+ expect(familiesDigest(vendoredKeySet(PRODUCTION_20), italic))
361
+ .not.toBe(familiesDigest(vendoredKeySet(PRODUCTION_20), INDEX))
362
+ })
363
+
364
+ it('is order- and spelling-independent, and changes when the set changes', () => {
365
+ const a = familiesDigest(vendoredKeySet(['Inter', 'Baloo 2']))
366
+ expect(familiesDigest(vendoredKeySet(['Baloo+2', 'inter']))).toBe(a)
367
+ expect(familiesDigest(vendoredKeySet(['Inter']))).not.toBe(a)
368
+ })
369
+
370
+ it('is eight hex characters', () => {
371
+ expect(familiesDigest(vendoredKeySet(PRODUCTION_20))).toMatch(/^[0-9a-f]{8}$/)
372
+ })
373
+ })
@@ -0,0 +1,286 @@
1
+ // The vendored-font partition, shared by the package's two Google Fonts
2
+ // loaders (`lib/google-fonts.ts` and `text/FontPicker.tsx`).
3
+ //
4
+ // The two loaders stay independent — separate setters, separate injection
5
+ // state, neither imports the other — but they must reach the SAME answer about
6
+ // which families a vendored stylesheet covers. That is not a style preference:
7
+ // a family served locally by one and from Google by the other is a caption
8
+ // laid out in one face while editing and a different one at export, which is
9
+ // the bug `skills/write-overlay/SKILL.md`'s Syne case study exists to document.
10
+ //
11
+ // The two renderers (`montaj_assets/render/bundle.js`,
12
+ // `render-carousel.js`) keep hand-copied versions of these same three
13
+ // functions, textually pinned against each other by `shim-bake.test.mjs`. They
14
+ // have to copy: they are separate CLI entry points that share no module and
15
+ // cannot import this package. These two loaders CAN import, so they do —
16
+ // a copy that cannot drift beats a copy pinned not to.
17
+
18
+ /** A `googleFonts` entry is a SPEC, not a family name: "Baloo+2:wght@400;500",
19
+ * "Playfair+Display:ital@1", "Anton". Everything from the first ':' is the
20
+ * axis list, and '+' is how Google's API encodes the space in a family name —
21
+ * strip the one, undo the other, and what is left is the family exactly as
22
+ * `fonts.css` spells it in its `font-family` declarations, which is what the
23
+ * vendored family list holds.
24
+ *
25
+ * Case-folded because CSS font-family matching is case-insensitive: a spec
26
+ * that differs from the list only in case names a family the vendored
27
+ * stylesheet genuinely serves, and treating it as unvendored would buy
28
+ * nothing but a fetch from Google. */
29
+ export function fontFamilyKey(spec: string): string {
30
+ return String(spec).split(':')[0].replace(/\+/g, ' ').trim().toLowerCase()
31
+ }
32
+
33
+ /** Normalise a host-supplied family list into the comparison set.
34
+ *
35
+ * List entries go through `fontFamilyKey` too, not just the requested specs.
36
+ * Deliberate leniency: a host that passes Google's '+'-encoded spelling
37
+ * ("Open+Sans") rather than the `fonts.css` one still matches, and the
38
+ * failure it avoids is silent egress for a family sitting right there on
39
+ * disk. Non-strings are dropped rather than coerced — a number in the list
40
+ * is a host bug, and `String(7)` would silently become a "family". */
41
+ export function vendoredKeySet(families: readonly unknown[] | undefined): Set<string> {
42
+ if (!Array.isArray(families)) return new Set()
43
+ return new Set(families.filter((f): f is string => typeof f === 'string').map(fontFamilyKey))
44
+ }
45
+
46
+ /** Split requested specs into the ones the vendored stylesheet covers and the
47
+ * ones that must still come from Google. Order within each list is the
48
+ * caller's original order, so the googleapis URL reads the way the project
49
+ * declared it. */
50
+ export function partitionFontSpecs(
51
+ specs: readonly string[],
52
+ vendoredKeys: Set<string>,
53
+ faceIndex?: FaceIndex,
54
+ ): { vendored: string[]; fellThrough: string[] } {
55
+ const vendored: string[] = []
56
+ const fellThrough: string[] = []
57
+ for (const spec of specs) {
58
+ const covered =
59
+ vendoredKeys.has(fontFamilyKey(spec)) && (!faceIndex || specFacesAvailable(spec, faceIndex))
60
+ ;(covered ? vendored : fellThrough).push(spec)
61
+ }
62
+ return { vendored, fellThrough }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Faces: the family list is not precise enough on its own
67
+ // ---------------------------------------------------------------------------
68
+ //
69
+ // The vendored set is family + STYLE + WEIGHT, not family. `fonts.css` carries
70
+ // only the faces the vendoring pass actually received — every face is
71
+ // `font-style: normal`, and the weights are only the ones it asked for. So a
72
+ // family-level partition gets `Playfair+Display:ital@1` wrong: the family
73
+ // matches, the spec is treated as vendored, the real italic is never fetched,
74
+ // and the browser synthesises an oblique from the upright. `Inter:wght@300` is
75
+ // the same shape one axis over. Both are silent — no failed request, nothing
76
+ // on screen — and both change what the user sees, which is the entire defect
77
+ // this refinement removes.
78
+ //
79
+ // The face index REFINES the family partition and can only ever move a spec
80
+ // from "vendored" to "fell through", never the reverse. Passing no index means
81
+ // no refinement, which is the family-level behaviour that predates this.
82
+
83
+ export type FaceList = { normal?: readonly number[]; italic?: readonly number[] }
84
+ export type FaceMap = Readonly<Record<string, FaceList>>
85
+ export type FaceIndex = Map<string, { normal: Set<number>; italic: Set<number> }>
86
+
87
+ /** Resolve a `googleFonts` SPEC to the concrete faces it asks Google for, or
88
+ * `null` for "I cannot parse this confidently".
89
+ *
90
+ * `null` MUST be treated as a fall-through by the caller. That is the safe
91
+ * direction and it is this feature's established philosophy: fetching a font
92
+ * we happen to have costs one request, while silently dropping one we lack
93
+ * costs the author a wrong face in a finished export with no visible cause.
94
+ *
95
+ * A spec is `Family[:axes@tuples]`, where the axes are named in one
96
+ * comma-separated list and their values in another, POSITIONALLY:
97
+ *
98
+ * Anton → normal 400 (Google's default)
99
+ * Inter:wght@400;700 → normal 400, normal 700
100
+ * Playfair+Display:ital@1 → italic 400
101
+ * Playfair+Display:ital,wght@1,700 → italic 700
102
+ * Playfair+Display:ital,wght@0,400;1,700 → normal 400, italic 700
103
+ *
104
+ * `ital@0` is normal and `ital@1` is italic. Any other axis (`opsz`, `slnt`,
105
+ * a custom one like `GRAD`), any variable RANGE (`wght@100..900`), a
106
+ * duplicated axis, or a tuple whose arity does not match the axis list all
107
+ * return `null` rather than a guess. */
108
+ export function requiredFaces(spec: string): { style: 'normal' | 'italic'; weight: number }[] | null {
109
+ const s = String(spec)
110
+ const colon = s.indexOf(':')
111
+ // No axis list: Google serves the family's default face, which is normal 400.
112
+ if (colon === -1) return [{ style: 'normal', weight: 400 }]
113
+
114
+ const axisPart = s.slice(colon + 1)
115
+ const at = axisPart.indexOf('@')
116
+ // `Family:` with no '@' at all, or more than one — not a shape we model.
117
+ if (at === -1 || axisPart.indexOf('@', at + 1) !== -1) return null
118
+
119
+ const axes = axisPart.slice(0, at).split(',')
120
+ const tuples = axisPart.slice(at + 1).split(';')
121
+ const iItal = axes.indexOf('ital')
122
+ const iWght = axes.indexOf('wght')
123
+ // Every axis must be one we model. An unmodelled, duplicated or empty axis
124
+ // name makes the face set unknowable, and a guess here is the silent-wrong
125
+ // answer this whole refinement exists to delete.
126
+ for (let i = 0; i < axes.length; i++) if (i !== iItal && i !== iWght) return null
127
+
128
+ const faces: { style: 'normal' | 'italic'; weight: number }[] = []
129
+ for (const tuple of tuples) {
130
+ const values = tuple.split(',')
131
+ if (values.length !== axes.length) return null
132
+ let style: 'normal' | 'italic' = 'normal'
133
+ let weight = 400
134
+ if (iItal !== -1) {
135
+ const v = values[iItal]
136
+ if (v === '0') style = 'normal'
137
+ else if (v === '1') style = 'italic'
138
+ else return null // an `ital` range (0..1), or junk
139
+ }
140
+ if (iWght !== -1) {
141
+ const v = values[iWght]
142
+ if (!/^\d{1,4}$/.test(v)) return null // a `wght` range (100..900), or junk
143
+ weight = Number(v)
144
+ if (weight < 1 || weight > 1000) return null
145
+ }
146
+ faces.push({ style, weight })
147
+ }
148
+ return faces
149
+ }
150
+
151
+ /** Build the face index from the manifest's `faces` and `requested` maps.
152
+ *
153
+ * A face counts as AVAILABLE if it is in `faces` (we have the file) or in
154
+ * `requested` (we asked Google for it and were refused). The second half is
155
+ * not a special case: the gap between the two maps is exactly "weights Google
156
+ * does not publish", and falling through for one of those fetches a
157
+ * stylesheet that declines identically — a guaranteed-useless request rather
158
+ * than a probably-useless one. `Bebas+Neue:wght@400;700` is the only spec
159
+ * that exercises it today; Bebas Neue ships no 700 face at all.
160
+ *
161
+ * Family keys go through `fontFamilyKey`, matching `vendoredKeySet`'s
162
+ * leniency, so a manifest written with Google's '+'-encoded spelling still
163
+ * matches. Malformed entries are skipped rather than thrown on: a manifest
164
+ * this code cannot read must degrade to "no information", never to a crash
165
+ * inside a fire-and-forget font load. */
166
+ export function vendoredFaceIndex(faces?: FaceMap, requested?: FaceMap): FaceIndex | undefined {
167
+ const usable = [faces, requested].filter((m) => m && typeof m === 'object' && !Array.isArray(m))
168
+ // NO face information at all is different from face information that covers
169
+ // nothing. The first leaves the partition at family level (the behaviour
170
+ // that predates this refinement, for a manifest or a host that predates it
171
+ // too); the second says every requested face is genuinely absent. Returning
172
+ // an empty Map for both would silently turn an old manifest into "nothing is
173
+ // vendored", which is safe but wrong to do without saying so.
174
+ if (!usable.length) return undefined
175
+
176
+ const index: FaceIndex = new Map()
177
+ for (const source of usable as FaceMap[]) {
178
+ for (const [family, styles] of Object.entries(source)) {
179
+ if (!styles || typeof styles !== 'object') continue
180
+ const key = fontFamilyKey(family)
181
+ let entry = index.get(key)
182
+ if (!entry) index.set(key, (entry = { normal: new Set(), italic: new Set() }))
183
+ for (const style of ['normal', 'italic'] as const) {
184
+ const weights = styles[style]
185
+ if (!Array.isArray(weights)) continue
186
+ for (const w of weights) if (Number.isInteger(w)) entry[style].add(w as number)
187
+ }
188
+ }
189
+ }
190
+ return index
191
+ }
192
+
193
+ /** Whether every face `spec` requires is available locally.
194
+ *
195
+ * A family with no entry in the index is NOT covered — the index is built
196
+ * from the same manifest as the family list, so a family present in one and
197
+ * absent from the other means the two disagree, and the safe reading of a
198
+ * disagreement is "fall through".
199
+ *
200
+ * A PARTIALLY vendored spec falls through WHOLE. `Inter:wght@400;300` goes to
201
+ * Google as one spec rather than being split into a vendored half and a
202
+ * fetched half. Splitting would mean synthesising a new spec string, and a
203
+ * spec is the author's — ours to honour or to pass on untouched, never to
204
+ * rewrite. */
205
+ export function specFacesAvailable(spec: string, index: FaceIndex): boolean {
206
+ const entry = index.get(fontFamilyKey(spec))
207
+ if (!entry) return false
208
+ const required = requiredFaces(spec)
209
+ if (!required) return false
210
+ return required.every((f) => entry[f.style].has(f.weight))
211
+ }
212
+
213
+ /** A short, stable fingerprint of a vendored set, logged once by each loader
214
+ * and once per render by each renderer. It turns an invisible divergence into
215
+ * two visibly different strings: if the editor's digest and the render's
216
+ * digest disagree, the two sides are partitioning against different vendored
217
+ * sets and captions WILL differ between editing and export. QA compares two
218
+ * hashes instead of trying to observe a partition.
219
+ *
220
+ * **It fingerprints the FACES, not just the families, when face information
221
+ * is available.** A families-only digest would report a match across a set
222
+ * that materially changed — re-vendor at a different weight, or drop one, and
223
+ * every family name is still identical while what the stylesheet can actually
224
+ * resolve is not. That is precisely the silent drift this exists to make
225
+ * loud, so the weights and styles go into the input too.
226
+ *
227
+ * With NO face index the input is byte-for-byte what it was before faces
228
+ * existed, so a family-only manifest keeps producing its old digest and stays
229
+ * comparable against an older renderer. A face index therefore changes the
230
+ * value exactly when there is new information to report, never incidentally.
231
+ *
232
+ * FNV-1a over the sorted lines, not a crypto hash, and that is deliberate: it
233
+ * must be computable synchronously in a browser (`crypto.subtle` is async),
234
+ * and it is a comparison token, never a security primitive.
235
+ *
236
+ * The renderers carry the same algorithm in plain JS. The gate that keeps the
237
+ * two languages honest is a literal digest pinned in BOTH test suites for the
238
+ * same manifest — this TS↔JS seam is the one place a textual comparison
239
+ * cannot reach, which is why the literal is the pin. */
240
+ export function familiesDigest(keys: Iterable<string>, faceIndex?: FaceIndex): string {
241
+ const lines = [...keys].sort().map((key) => {
242
+ const entry = faceIndex ? faceIndex.get(key) : undefined
243
+ if (!entry) return key
244
+ const axis = (style: 'normal' | 'italic') =>
245
+ `${style}:${[...entry[style]].sort((a, b) => a - b).join(',')}`
246
+ return `${key}\t${axis('normal')}\t${axis('italic')}`
247
+ })
248
+ let h = 0x811c9dc5
249
+ for (const ch of lines.join('\n')) {
250
+ h = Math.imul(h ^ (ch.codePointAt(0) as number), 0x01000193) >>> 0
251
+ }
252
+ return h.toString(16).padStart(8, '0')
253
+ }
254
+
255
+ /** One line naming the vendored set a loader is about to partition against,
256
+ * or — when a base was set without one — the loud warning that nothing will
257
+ * be treated as vendored.
258
+ *
259
+ * "No list" means every requested family goes to Google and the vendored
260
+ * stylesheet is not linked at all. The tempting opposite, assuming the sheet
261
+ * covers what was asked for, is the SILENT-wrong option: an unvendored family
262
+ * would get no stylesheet at all and preview as a system fallback, while the
263
+ * render — reading its own manifest off local disk, where it cannot fail
264
+ * independently — would fetch that same family from Google and get it right.
265
+ * Preview and export would disagree with nothing on screen to say so. This
266
+ * way is loud-wrong instead: every glyph is correct, both sides agree, and
267
+ * the only cost is egress, which is the one failure this logging detects. */
268
+ export function reportVendoredSet(base: string, vendoredKeys: Set<string>, faceIndex?: FaceIndex): void {
269
+ if (!vendoredKeys.size) {
270
+ console.warn(
271
+ `[montaj] fonts: a base was set (${base}) with no vendored family list — treating NOTHING as vendored, `
272
+ + 'so the vendored stylesheet is not linked and every requested family is fetched from fonts.googleapis.com',
273
+ )
274
+ return
275
+ }
276
+ console.info(`[montaj] fonts: vendored set ${familiesDigest(vendoredKeys, faceIndex)} (${vendoredKeys.size} families) at ${base}`)
277
+ }
278
+
279
+ /** Name the families that crossed to Google. An author who names a family the
280
+ * vendored set does not carry should learn it while editing, not by noticing
281
+ * the wrong face in a finished export. Mirrors the renderers' line of the
282
+ * same text so a log from either side reads identically. */
283
+ export function reportUnvendoredFonts(fellThrough: readonly string[]): void {
284
+ if (!fellThrough.length) return
285
+ console.warn(`[montaj] fonts: not in the vendored set, fetching from fonts.googleapis.com: ${fellThrough.join(', ')}`)
286
+ }