Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/web/perf/budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,7 @@
"serverRequests": 1,
"requests": 132,
"jsRequests": 127,
"jsKB": 431.9,
"jsKB": 440.4,
"htmlTransferKB": 34.3,
"componentRenders": 431
},
Expand Down Expand Up @@ -646,7 +646,7 @@
"serverRequests": 1,
"requests": 100,
"jsRequests": 96,
"jsKB": 389.1,
"jsKB": 396.8,
"htmlTransferKB": 30.0,
"componentRenders": 352
},
Expand Down Expand Up @@ -724,7 +724,7 @@
"serverRequests": 1,
"requests": 114,
"jsRequests": 109,
"jsKB": 415.8,
"jsKB": 424,
"htmlTransferKB": 30.5,
"componentRenders": 395
},
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/lib/build/__tests__/font-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
})
})
})
90 changes: 90 additions & 0 deletions apps/web/src/lib/build/font-manifest.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}
export type FontManifest = Record<string, FontFiles>

/** The font id each CSS module serves, from the loader's `case 'id': return import('...css')`. */
export function fontModules(loaderSource: string, srcDir: string): Map<string, string> {
const modules = new Map<string, string>()
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<string> }
}
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<string, BundleChunk | BundleAsset | { type: string }>,
modules: Map<string, string>
): 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<string, string> = {}
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
},
}
}
19 changes: 18 additions & 1 deletion apps/web/src/lib/server/domains/design/__tests__/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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('')
Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/lib/server/domains/design/__tests__/font-plan.test.ts
Original file line number Diff line number Diff line change
@@ -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<PortalDesign['brand']['fonts']>) {
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: [] })
})
})
5 changes: 3 additions & 2 deletions apps/web/src/lib/server/domains/design/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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) }
}
46 changes: 46 additions & 0 deletions apps/web/src/lib/server/domains/design/font-plan.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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<string>()
const preloads = new Set<string>()
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 })),
]
}
Loading
Loading