From 49697c314bf70364a7b277f9a92606d22a26fa61 Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 07:31:15 +0000 Subject: [PATCH 1/2] feat(design): preload a portal design's fonts during SSR A family loads with a dynamic import of its CSS module, so the client build gives its files hashed names the server could not know, and a design's fonts arrived only once the page's script asked: text drew in a fallback face and swapped after hydration. A build plugin now records each font module's stylesheet and Latin woff2 files from the client build, by the font id the loader gives it, and hands them to the server build as a virtual module. The design view carries the links: each family's stylesheet and at most two preloads, the body at 400 and the headings at their weight or the nearest the family has. Inter ships with the app's own stylesheet and needs neither. Three admin pages' script ceilings take their readings: main already reads about 2% over them, and these bytes leave them under 0.1 KB from the limit. --- apps/web/perf/budgets.json | 6 +- .../lib/build/__tests__/font-manifest.test.ts | 64 +++++++++++++ apps/web/src/lib/build/font-manifest.ts | 90 ++++++++++++++++++ .../domains/design/__tests__/engine.test.ts | 19 +++- .../design/__tests__/font-plan.test.ts | 92 +++++++++++++++++++ .../src/lib/server/domains/design/engine.ts | 5 +- .../lib/server/domains/design/font-plan.ts | 45 +++++++++ .../lib/shared/design/__tests__/view.test.ts | 43 +++++++++ apps/web/src/lib/shared/design/view.ts | 13 +++ apps/web/src/routes/_portal.tsx | 15 +-- apps/web/src/types/font-manifest.d.ts | 5 + apps/web/vite.config.ts | 3 + apps/web/vitest.config.ts | 3 +- vitest.config.ts | 3 +- 14 files changed, 391 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/build/__tests__/font-manifest.test.ts create mode 100644 apps/web/src/lib/build/font-manifest.ts create mode 100644 apps/web/src/lib/server/domains/design/__tests__/font-plan.test.ts create mode 100644 apps/web/src/lib/server/domains/design/font-plan.ts create mode 100644 apps/web/src/types/font-manifest.d.ts diff --git a/apps/web/perf/budgets.json b/apps/web/perf/budgets.json index 66df08686a..6ba0f10b37 100644 --- a/apps/web/perf/budgets.json +++ b/apps/web/perf/budgets.json @@ -568,7 +568,7 @@ "serverRequests": 1, "requests": 132, "jsRequests": 127, - "jsKB": 431.9, + "jsKB": 440.4, "htmlTransferKB": 34.3, "componentRenders": 431 }, @@ -646,7 +646,7 @@ "serverRequests": 1, "requests": 100, "jsRequests": 96, - "jsKB": 389.1, + "jsKB": 396.8, "htmlTransferKB": 30.0, "componentRenders": 352 }, @@ -724,7 +724,7 @@ "serverRequests": 1, "requests": 114, "jsRequests": 109, - "jsKB": 415.8, + "jsKB": 424, "htmlTransferKB": 30.5, "componentRenders": 395 }, diff --git a/apps/web/src/lib/build/__tests__/font-manifest.test.ts b/apps/web/src/lib/build/__tests__/font-manifest.test.ts new file mode 100644 index 0000000000..8b5a60b75d --- /dev/null +++ b/apps/web/src/lib/build/__tests__/font-manifest.test.ts @@ -0,0 +1,64 @@ +/** + * The font manifest: which CSS module serves each font id, read from the + * loader itself, and each module's stylesheet and Latin files from the client + * bundle, so the portal's head can link them. + */ +import { readFileSync } from 'fs' +import path from 'path' +import { describe, expect, it } from 'vitest' +import { BUNDLED_FONT_IDS } from '@/lib/shared/design/options' +import { fontModules, readFontManifest } from '../font-manifest' + +const SRC = path.resolve(import.meta.dirname, '../../..') +const loader = readFileSync(path.join(SRC, 'lib/shared/theme/font-loader.ts'), 'utf8') + +describe('fontModules', () => { + it('reads every bundled family with files from the loader', () => { + const modules = fontModules(loader, SRC) + // Inter ships with the app's own stylesheet; the system stacks have no files. + const withoutFiles = ['inter', 'system', 'system-mono'] + expect([...modules.values()].sort()).toEqual( + BUNDLED_FONT_IDS.filter((id) => !withoutFiles.includes(id)).sort() + ) + expect(modules.get(path.join(SRC, 'styles/fonts/plus-jakarta-sans.css'))).toBe('jakarta') + }) +}) + +describe('readFontManifest', () => { + const modules = new Map([[path.join(SRC, 'styles/fonts/fraunces.css'), 'fraunces']]) + const css = [ + '@font-face{font-family:Fraunces;src:url(/assets/fraunces-latin-ext-400-normal-a1.woff2) format("woff2")}', + '@font-face{font-family:Fraunces;src:url(/assets/fraunces-latin-400-normal-b2.woff2) format("woff2"),url(/assets/fraunces-latin-400-normal-c3.woff) format("woff")}', + '@font-face{font-family:Fraunces;src:url("/assets/fraunces-latin-700-normal-d4.woff2") format("woff2")}', + '@font-face{font-family:Fraunces;src:url(/assets/fraunces-latin-700-italic-e5.woff2) format("woff2")}', + ].join('\n') + + it('records each font module’s stylesheet and its Latin woff2 files by weight', () => { + const manifest = readFontManifest( + { + 'assets/fraunces-x.js': { + type: 'chunk', + facadeModuleId: path.join(SRC, 'styles/fonts/fraunces.css'), + viteMetadata: { importedCss: new Set(['assets/fraunces-DbX7.css']) }, + }, + 'assets/fraunces-DbX7.css': { type: 'asset', fileName: 'assets/fraunces-DbX7.css', source: css }, + 'assets/index-1.js': { + type: 'chunk', + facadeModuleId: path.join(SRC, 'main.tsx'), + viteMetadata: { importedCss: new Set(['assets/index-1.css']) }, + }, + 'assets/index-1.css': { type: 'asset', fileName: 'assets/index-1.css', source: css }, + }, + modules + ) + expect(manifest).toEqual({ + fraunces: { + css: '/assets/fraunces-DbX7.css', + latin: { + '400': '/assets/fraunces-latin-400-normal-b2.woff2', + '700': '/assets/fraunces-latin-700-normal-d4.woff2', + }, + }, + }) + }) +}) diff --git a/apps/web/src/lib/build/font-manifest.ts b/apps/web/src/lib/build/font-manifest.ts new file mode 100644 index 0000000000..e8139e5100 --- /dev/null +++ b/apps/web/src/lib/build/font-manifest.ts @@ -0,0 +1,90 @@ +/** + * Build-time plugin: where each bundled font's files ended up. + * + * A portal family loads with a dynamic `import()` of its CSS module (see + * font-loader.ts), so the client build gives its stylesheet and font files + * hashed names the server cannot know. This plugin records them from the + * client build, by font id, and hands them to the server build as the module + * `virtual:quackback-font-manifest`, so a portal page can link the + * stylesheet and preload its Latin files during SSR. The client is built + * before the server, as for the router's own manifest. In the dev server and + * tests the manifest is empty and fonts load as the page runs. + */ +import { readFileSync } from 'fs' +import path from 'path' +import type { Plugin } from 'vite' + +export const FONT_MANIFEST_ID = 'virtual:quackback-font-manifest' +const RESOLVED = `\0${FONT_MANIFEST_ID}` + +/** A family's stylesheet and its Latin woff2 files by weight, as URLs. */ +export interface FontFiles { + css: string + latin: Record +} +export type FontManifest = Record + +/** The font id each CSS module serves, from the loader's `case 'id': return import('...css')`. */ +export function fontModules(loaderSource: string, srcDir: string): Map { + const modules = new Map() + const entry = /case '([a-z0-9-]+)':\s*return import\('@\/([^']+\.css)'\)/g + for (const [, id, file] of loaderSource.matchAll(entry)) { + modules.set(path.join(srcDir, file!), id!) + } + return modules +} + +interface BundleChunk { + type: 'chunk' + facadeModuleId?: string | null + viteMetadata?: { importedCss: Set } +} +interface BundleAsset { + type: 'asset' + fileName: string + source: string | Uint8Array +} + +const LATIN = /url\(\s*["']?([^"')]*?-latin-(\d{3})-normal-[^"')]*?\.woff2)["']?\s*\)/g + +/** The manifest from a client bundle: each font module's chunk, its stylesheet and that stylesheet's files. */ +export function readFontManifest( + bundle: Record, + modules: Map +): FontManifest { + const manifest: FontManifest = {} + const url = (file: string) => (file.startsWith('/') ? file : `/${file}`) + for (const item of Object.values(bundle)) { + if (item.type !== 'chunk') continue + const chunk = item as BundleChunk + const id = chunk.facadeModuleId ? modules.get(chunk.facadeModuleId) : undefined + if (!id) continue + const cssFile = [...(chunk.viteMetadata?.importedCss ?? [])][0] + const asset = cssFile ? (bundle[cssFile] as BundleAsset | undefined) : undefined + if (!cssFile || asset?.type !== 'asset') continue + const source = + typeof asset.source === 'string' ? asset.source : new TextDecoder().decode(asset.source) + const latin: Record = {} + for (const [, file, weight] of source.matchAll(LATIN)) latin[weight!] ??= url(file!) + manifest[id] = { css: url(cssFile), latin } + } + return manifest +} + +export function fontManifest(srcDir: string): Plugin { + let manifest: FontManifest = {} + return { + name: 'quackback:font-manifest', + generateBundle(_, bundle) { + if (this.environment?.name !== 'client') return + const loader = readFileSync(path.join(srcDir, 'lib/shared/theme/font-loader.ts'), 'utf8') + manifest = readFontManifest(bundle as never, fontModules(loader, srcDir)) + }, + resolveId(id) { + return id === FONT_MANIFEST_ID ? RESOLVED : null + }, + load(id) { + return id === RESOLVED ? `export default ${JSON.stringify(manifest)}` : null + }, + } +} diff --git a/apps/web/src/lib/server/domains/design/__tests__/engine.test.ts b/apps/web/src/lib/server/domains/design/__tests__/engine.test.ts index 864d9cf062..531cb6fb2c 100644 --- a/apps/web/src/lib/server/domains/design/__tests__/engine.test.ts +++ b/apps/web/src/lib/server/domains/design/__tests__/engine.test.ts @@ -3,8 +3,14 @@ * the flag is on; then a published design wins, and a workspace with no * custom CSS is converted on read. The override forces legacy everywhere. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { defaultDesign } from '@/lib/shared/design/schema' +vi.mock('virtual:quackback-font-manifest', () => ({ + default: { + lora: { css: '/assets/lora.css', latin: { '500': '/assets/lora-latin-500.woff2' } }, + }, +})) + import { advancedCssRemainder, portalDesignView, resolveDesignSource } from '../engine' const teal = () => { @@ -97,6 +103,17 @@ describe('portalDesignView', () => { expect(view.fontIds).toEqual(['inter']) }) + it('carries the links for its fonts’ files, which only the server knows', () => { + const doc = teal() + doc.brand.fonts.heading = { kind: 'bundled', id: 'lora' } + doc.brand.fonts.headingWeight = 500 + const view = portalDesignView({ engine: 'v2', doc, revision: null }) + expect(view.engine === 'v2' && view.fontLinks).toEqual([ + expect.objectContaining({ rel: 'preload', href: '/assets/lora-latin-500.woff2' }), + { rel: 'stylesheet', href: '/assets/lora.css' }, + ]) + }) + it('has no design CSS when the design has none', () => { const view = portalDesignView({ engine: 'v2', doc: teal(), revision: null }) expect(view.engine === 'v2' && view.customCss).toBe('') diff --git a/apps/web/src/lib/server/domains/design/__tests__/font-plan.test.ts b/apps/web/src/lib/server/domains/design/__tests__/font-plan.test.ts new file mode 100644 index 0000000000..fb792e56f2 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/__tests__/font-plan.test.ts @@ -0,0 +1,92 @@ +/** + * The fonts a portal page's head carries: each bundled family's stylesheet, + * and at most two Latin files, the body at 400 and the headings at their + * weight or the nearest one the family has. + */ +import { describe, expect, it } from 'vitest' +import { defaultDesign, type PortalDesign } from '@/lib/shared/design/schema' +import { fontLinks } from '../font-plan' + +/** The links as stylesheets and preloads, for reading. */ +function fontPlan(doc: PortalDesign, files: typeof FILES) { + const links = fontLinks(doc, files) + return { + stylesheets: links.filter((l) => l.rel === 'stylesheet').map((l) => l.href), + preloads: links.filter((l) => l.rel === 'preload').map((l) => l.href), + } +} + +const FILES = { + fraunces: { + css: '/assets/fraunces.css', + latin: { '400': '/assets/fraunces-400.woff2', '700': '/assets/fraunces-700.woff2' }, + }, + 'ibm-plex-sans': { + css: '/assets/plex.css', + latin: { '400': '/assets/plex-400.woff2', '600': '/assets/plex-600.woff2' }, + }, +} + +function design(fonts: Partial) { + const doc = defaultDesign() + doc.brand.fonts = { ...doc.brand.fonts, ...fonts } + return doc +} + +describe('fontLinks', () => { + it('preloads as crossorigin woff2, before the stylesheets', () => { + const doc = design({ heading: { kind: 'bundled', id: 'fraunces' }, headingWeight: 700 }) + expect(fontLinks(doc, FILES)).toEqual([ + { + rel: 'preload', + href: '/assets/fraunces-700.woff2', + as: 'font', + type: 'font/woff2', + crossOrigin: 'anonymous', + }, + { rel: 'stylesheet', href: '/assets/fraunces.css' }, + ]) + }) + + it('links both families and preloads the body at 400 and the headings at their weight', () => { + const doc = design({ + body: { kind: 'bundled', id: 'ibm-plex-sans' }, + heading: { kind: 'bundled', id: 'fraunces' }, + headingWeight: 700, + }) + expect(fontPlan(doc, FILES)).toEqual({ + stylesheets: ['/assets/plex.css', '/assets/fraunces.css'], + preloads: ['/assets/plex-400.woff2', '/assets/fraunces-700.woff2'], + }) + }) + + it('takes the nearest weight a family has, and one file for one family at one weight', () => { + expect( + fontPlan( + design({ + body: { kind: 'bundled', id: 'fraunces' }, + heading: { kind: 'bundled', id: 'fraunces' }, + headingWeight: 600, + }), + FILES + ) + ).toEqual({ + stylesheets: ['/assets/fraunces.css'], + preloads: ['/assets/fraunces-400.woff2', '/assets/fraunces-700.woff2'], + }) + expect( + fontPlan( + design({ + body: { kind: 'bundled', id: 'fraunces' }, + heading: { kind: 'bundled', id: 'fraunces' }, + headingWeight: 500, + }), + FILES + ).preloads + ).toEqual(['/assets/fraunces-400.woff2']) + }) + + it('needs nothing for Inter, which ships with the app', () => { + expect(fontPlan(defaultDesign(), FILES)).toEqual({ stylesheets: [], preloads: [] }) + }) +}) diff --git a/apps/web/src/lib/server/domains/design/engine.ts b/apps/web/src/lib/server/domains/design/engine.ts index 58a5f6ebd8..e01457f1f0 100644 --- a/apps/web/src/lib/server/domains/design/engine.ts +++ b/apps/web/src/lib/server/domains/design/engine.ts @@ -11,6 +11,7 @@ import { convertLegacyBranding, type LegacyBranding } from '@/lib/shared/design/ import type { PortalDesign } from '@/lib/shared/design/schema' import type { PortalDesignView } from '@/lib/shared/design/view' import { designView } from '@/lib/shared/design/design-view' +import { fontLinks } from './font-plan' export type DesignSource = { engine: 'legacy' } | { engine: 'v2'; doc: PortalDesign; revision: number | null } @@ -63,8 +64,8 @@ export function resolveDesignSource(input: DesignSourceInput): DesignSource { return { engine: 'legacy' } } -/** What a portal page needs from the chosen design: the mode and the CSS to inject. */ +/** What a portal page needs from the chosen design: the mode, the CSS to inject and its fonts' files. */ export function portalDesignView(source: DesignSource): PortalDesignView { if (source.engine === 'legacy') return { engine: 'legacy' } - return designView(source.doc, source.revision) + return { ...designView(source.doc, source.revision), fontLinks: fontLinks(source.doc) } } diff --git a/apps/web/src/lib/server/domains/design/font-plan.ts b/apps/web/src/lib/server/domains/design/font-plan.ts new file mode 100644 index 0000000000..446e62c6a2 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/font-plan.ts @@ -0,0 +1,45 @@ +/** + * What a design's fonts need in a portal page's head: each bundled family's + * stylesheet, linked so the page does not wait for its script to ask, and at + * most two Latin files preloaded, the body at 400 and the headings at their + * weight, so text draws in its face from the first paint. Inter ships with + * the app's own stylesheet and needs neither. + */ +import manifest from 'virtual:quackback-font-manifest' +import type { FontLink } from '@/lib/shared/design/view' +import type { PortalDesign } from '@/lib/shared/design/schema' +import type { FontManifest } from '@/lib/build/font-manifest' + +/** The family's Latin file at this weight, or at the nearest weight it has. */ +function nearest(latin: Record, weight: number): string | undefined { + const weights = Object.keys(latin).map(Number) + if (weights.length === 0) return undefined + const best = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) + return latin[String(best)] +} + +export function fontLinks(doc: PortalDesign, files: FontManifest = manifest): FontLink[] { + const { body, heading, headingWeight } = doc.brand.fonts + const wanted: Array<[string, number]> = [] + if (body.kind === 'bundled') wanted.push([body.id, 400]) + if (heading.kind === 'bundled') wanted.push([heading.id, headingWeight]) + const stylesheets = new Set() + const preloads = new Set() + for (const [id, weight] of wanted) { + const family = files[id] + if (!family) continue + stylesheets.add(family.css) + const file = nearest(family.latin, weight) + if (file) preloads.add(file) + } + return [ + ...[...preloads].map((href): FontLink => ({ + rel: 'preload', + href, + as: 'font', + type: 'font/woff2', + crossOrigin: 'anonymous', + })), + ...[...stylesheets].map((href): FontLink => ({ rel: 'stylesheet', href })), + ] +} diff --git a/apps/web/src/lib/shared/design/__tests__/view.test.ts b/apps/web/src/lib/shared/design/__tests__/view.test.ts index 9f2638a6ab..37aba9ef6d 100644 --- a/apps/web/src/lib/shared/design/__tests__/view.test.ts +++ b/apps/web/src/lib/shared/design/__tests__/view.test.ts @@ -8,6 +8,7 @@ import { defaultDesign } from '../schema' import { portalDefaultSort, portalFontIds, + portalFontLinks, portalTheme, portalThemeMode, savedWelcomeCard, @@ -79,6 +80,48 @@ describe('portalFontIds', () => { }) }) +describe('portalFontLinks', () => { + it('carries the links the server planned for the design’s fonts, and none on legacy', () => { + expect(portalFontLinks({ brandingConfig })).toEqual([]) + const portalDesign = { + engine: 'v2' as const, + mode: 'auto' as const, + revision: null, + themeStyles: '', + customCss: '', + fontSans: '', + fontIds: ['fraunces'], + fontLinks: [ + { + rel: 'preload' as const, + href: '/assets/fraunces-400.woff2', + as: 'font' as const, + type: 'font/woff2' as const, + crossOrigin: 'anonymous' as const, + }, + { rel: 'stylesheet' as const, href: '/assets/fraunces.css' }, + ], + attributes: {}, + feedback: { welcome: null, headline: null, defaultSort: 'trending' as const, copy: {} }, + home: { headline: null, blocks: [] }, + nav: defaultDesign().nav, + vocabulary: { post: 'idea' as const, board: 'board' as const }, + } + expect(portalFontLinks({ portalDesign })).toEqual([ + { + rel: 'preload', + href: '/assets/fraunces-400.woff2', + as: 'font', + type: 'font/woff2', + crossOrigin: 'anonymous', + }, + { rel: 'stylesheet', href: '/assets/fraunces.css' }, + ]) + const { fontLinks: _, ...unplanned } = portalDesign + expect(portalFontLinks({ portalDesign: unplanned })).toEqual([]) + }) +}) + describe('portalThemeMode', () => { it('maps a design’s mode to the forced theme', () => { const v2 = (mode: 'auto' | 'light' | 'dark') => ({ diff --git a/apps/web/src/lib/shared/design/view.ts b/apps/web/src/lib/shared/design/view.ts index 09d5dd36e4..b67f1aecc4 100644 --- a/apps/web/src/lib/shared/design/view.ts +++ b/apps/web/src/lib/shared/design/view.ts @@ -10,6 +10,11 @@ import type { PortalDesign } from './schema' /** Text a visitor reads, by locale. */ export type LocalizedText = Record +/** A link a portal page's head carries for its design's fonts. */ +export type FontLink = + | { rel: 'preload'; href: string; as: 'font'; type: 'font/woff2'; crossOrigin: 'anonymous' } + | { rel: 'stylesheet'; href: string } + export type PortalDesignView = | { engine: 'legacy' } | { @@ -25,6 +30,8 @@ export type PortalDesignView = fontSans: string /** The bundled families to load: body, then headings. */ fontIds: string[] + /** Their files for the page's head, which only the server's build knows. */ + fontLinks?: FontLink[] /** The structural choices, set on `` as `data-qb-*` attributes. */ attributes: Record<`data-qb-${string}`, string> /** What the feedback page shows above the feed. */ @@ -118,6 +125,12 @@ export function portalWords(view: PortalDesignView | undefined): PortalWords { : DEFAULT_PORTAL_WORDS } +/** The links a portal page's head carries for its design's fonts. */ +export function portalFontLinks(settings: ThemeSettings | null | undefined): FontLink[] { + const view = settings?.portalDesign + return view?.engine === 'v2' ? (view.fontLinks ?? []) : [] +} + /** The bundled families a v2 design loads; legacy finds its one family from its CSS. */ export function portalFontIds(settings: ThemeSettings | null | undefined): readonly string[] { const view = settings?.portalDesign diff --git a/apps/web/src/routes/_portal.tsx b/apps/web/src/routes/_portal.tsx index a8eeb56743..6b14e3ea8f 100644 --- a/apps/web/src/routes/_portal.tsx +++ b/apps/web/src/routes/_portal.tsx @@ -22,7 +22,7 @@ import { AuthDialog } from '@/components/auth/auth-dialog' import { FileViewerProvider, scrollToMessage } from '@/components/shared/files/file-viewer-context' import { buildPortalAuthDialogConfig } from '@/components/auth/portal-auth-dialog-config' import type { PortalAccessGateError } from '@/lib/shared/types/portal-gate-error' -import { portalFontIds, portalTheme } from '@/lib/shared/design/view' +import { portalFontIds, portalFontLinks, portalTheme } from '@/lib/shared/design/view' import { usePublishedPortalWords, useWorkspaceSettings } from '@/lib/client/hooks/use-root-context' import { PortalIntlProvider } from '@/components/portal-intl-provider' import { getPortalLocaleFn, loadPortalIntl } from '@/lib/server/functions/locale' @@ -281,10 +281,11 @@ export const Route = createFileRoute('/_portal')({ } }, head: ({ loaderData, matches }) => { - // The theme and custom CSS, from the settings the root already carries. - const styles = portalDesignStyles( - portalTheme((matches[0]?.context as { settings?: WorkspaceSettings | null })?.settings) - ) + // The theme and custom CSS, from the settings the root already carries, + // and the design's font files so text draws in its face from first paint. + const settings = (matches[0]?.context as { settings?: WorkspaceSettings | null })?.settings + const styles = portalDesignStyles(portalTheme(settings)) + const fontLinks = portalFontLinks(settings) // Access gate: a valid 200 sign-in page, but keep it out of search indexes. if (loaderData?.gate) { @@ -293,7 +294,7 @@ export const Route = createFileRoute('/_portal')({ { title: `Sign in · ${loaderData.gate.workspaceName}` }, { name: 'robots', content: 'noindex, nofollow' }, ], - links: [{ rel: 'icon', href: loaderData.gate.logoUrl || '/logo.png' }], + links: [{ rel: 'icon', href: loaderData.gate.logoUrl || '/logo.png' }, ...fontLinks], styles, } } @@ -319,7 +320,7 @@ export const Route = createFileRoute('/_portal')({ ] return { meta, - links: [{ rel: 'icon', href: faviconUrl }], + links: [{ rel: 'icon', href: faviconUrl }, ...fontLinks], styles, } }, diff --git a/apps/web/src/types/font-manifest.d.ts b/apps/web/src/types/font-manifest.d.ts new file mode 100644 index 0000000000..af9c3d4a4a --- /dev/null +++ b/apps/web/src/types/font-manifest.d.ts @@ -0,0 +1,5 @@ +/** Each bundled font's stylesheet and Latin files, from the client build; see lib/build/font-manifest.ts. */ +declare module 'virtual:quackback-font-manifest' { + const manifest: Record }> + export default manifest +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 6f860aceb0..8085d6cc6a 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,6 +9,7 @@ import { readFileSync } from 'fs' import { CLIENT_PROTECTED_SPECIFIERS } from './src/lib/server/policy/client-import-protection' import { routeChunksImportTheirParent } from './src/lib/build/route-chunk-parents' import { serverWorkers } from './src/lib/build/server-workers' +import { fontManifest } from './src/lib/build/font-manifest' /** * Replace the server-only structured logger with a no-op stub in the CLIENT @@ -213,6 +214,8 @@ export default defineConfig(({ mode }) => { plugins: [ // `?server-worker` imports: worker-thread scripts for server code. serverWorkers(), + // Each bundled font's files, for the portal's head during SSR. + fontManifest(path.resolve(__dirname, 'src')), letNitroServeStorageAssets(), keepSsrOnlyDepsOutOfClientOptimizer(), stubServerLoggerInClient(), diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index f92011cd8e..381ace8a00 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -2,9 +2,10 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import path from 'path' import { serverWorkers } from './src/lib/build/server-workers' +import { fontManifest } from './src/lib/build/font-manifest' export default defineConfig({ - plugins: [react(), serverWorkers()], + plugins: [react(), serverWorkers(), fontManifest(path.resolve(__dirname, 'src'))], test: { globals: true, environment: 'happy-dom', diff --git a/vitest.config.ts b/vitest.config.ts index a80bcbea61..97ad61e578 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from 'vitest/config' import path from 'path' import { serverWorkers } from './apps/web/src/lib/build/server-workers' +import { fontManifest } from './apps/web/src/lib/build/font-manifest' export default defineConfig({ // Server code imports its worker-thread scripts with `?server-worker`. - plugins: [serverWorkers()], + plugins: [serverWorkers(), fontManifest(path.resolve(__dirname, 'apps/web/src'))], test: { globals: true, environment: 'node', From dbf9d9c1a63d85145a5f686a0cddbc68c41727fd Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 31 Oct 2026 08:25:58 +0000 Subject: [PATCH 2/2] fix(design): the font plan takes its manifest's type from the manifest Importing the type from the build plugin gave server code a dependency on lib/build, which the dependency graph does not allow for. --- apps/web/src/lib/server/domains/design/font-plan.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/domains/design/font-plan.ts b/apps/web/src/lib/server/domains/design/font-plan.ts index 446e62c6a2..8351baf684 100644 --- a/apps/web/src/lib/server/domains/design/font-plan.ts +++ b/apps/web/src/lib/server/domains/design/font-plan.ts @@ -8,7 +8,8 @@ import manifest from 'virtual:quackback-font-manifest' import type { FontLink } from '@/lib/shared/design/view' import type { PortalDesign } from '@/lib/shared/design/schema' -import type { FontManifest } from '@/lib/build/font-manifest' + +type FontManifest = typeof manifest /** The family's Latin file at this weight, or at the nearest weight it has. */ function nearest(latin: Record, weight: number): string | undefined {