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..8351baf684 --- /dev/null +++ b/apps/web/src/lib/server/domains/design/font-plan.ts @@ -0,0 +1,46 @@ +/** + * 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' + +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 { + 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',