From 0cbf58887a6d578f0634d8df94d35886ff73ee40 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 9 Aug 2026 17:45:14 -0400 Subject: [PATCH 1/2] chore: preload fonts --- .changeset/preload-fonts.md | 5 +++ packages/react/src/html/bundlers/vite.mjs | 14 ++++++++ packages/react/src/html/constants.mjs | 14 ++++++++ packages/react/src/html/template.html | 2 ++ packages/react/src/html/ui/index.css | 31 ++++++++++++++++-- .../html/utils/__tests__/processing.test.mjs | 32 +++++++++++++++++++ packages/react/src/html/utils/processing.mjs | 17 +++++++++- 7 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 .changeset/preload-fonts.md diff --git a/.changeset/preload-fonts.md b/.changeset/preload-fonts.md new file mode 100644 index 00000000..b8224815 --- /dev/null +++ b/.changeset/preload-fonts.md @@ -0,0 +1,5 @@ +--- +'@doc-kit/generator-react': patch +--- + +Preload the theme's fonts diff --git a/packages/react/src/html/bundlers/vite.mjs b/packages/react/src/html/bundlers/vite.mjs index 6bae2aea..c01b6cd3 100644 --- a/packages/react/src/html/bundlers/vite.mjs +++ b/packages/react/src/html/bundlers/vite.mjs @@ -11,6 +11,8 @@ import { mergeConfig, } from 'vite'; +import { FONT_DIRECTORY } from '../constants.mjs'; + const VIRTUAL_PREFIX = 'virtual:doc-kit/'; const RESOLVED_VIRTUAL_PREFIX = '\0doc-kit:'; @@ -252,6 +254,18 @@ export const createViteConfig = ({ output: { ...vite.build?.rolldownOptions?.output, format: 'es', + + /** + * + */ + assetFileNames: asset => + asset.names.some(name => name.endsWith('.woff2')) + ? // We need to know where the fonts are to preload + // them. Using a dynamic hash would make this + // difficult. + `${FONT_DIRECTORY}/[name][extname]` + : 'assets/[name]-[hash][extname]', + ...(server ? { entryFileNames: '[name].mjs', diff --git a/packages/react/src/html/constants.mjs b/packages/react/src/html/constants.mjs index 3864313f..4ca3e783 100644 --- a/packages/react/src/html/constants.mjs +++ b/packages/react/src/html/constants.mjs @@ -71,6 +71,20 @@ export const JSX_IMPORTS = { }, }; +/** + * Where the bundler emits fonts + */ +export const FONT_DIRECTORY = 'assets/fonts'; + +/** + * Fonts to preload + */ +export const FONTS = [ + 'open-sans-latin-wght-normal.woff2', + 'open-sans-latin-wght-italic.woff2', + 'ibm-plex-mono-latin-400-normal.woff2', +]; + /** * Specification rules for resource hints like prerendering and prefetching. * @see https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API diff --git a/packages/react/src/html/template.html b/packages/react/src/html/template.html index b702d8e5..248b3abc 100644 --- a/packages/react/src/html/template.html +++ b/packages/react/src/html/template.html @@ -7,6 +7,8 @@ + ${preloads} + ${head} diff --git a/packages/react/src/html/ui/index.css b/packages/react/src/html/ui/index.css index 14237df8..3af0f65c 100644 --- a/packages/react/src/html/ui/index.css +++ b/packages/react/src/html/ui/index.css @@ -1,9 +1,34 @@ -@import '@fontsource-variable/open-sans/wght.css'; -@import '@fontsource-variable/open-sans/wght-italic.css'; -@import '@fontsource/ibm-plex-mono/400.css'; @import '@node-core/ui-components/styles/index.css'; @import '@node-core/rehype-shiki/index.css'; +/* Fonts (We only load the three fonts that we preload) */ +@font-face { + font-family: 'Open Sans Variable'; + font-style: normal; + font-weight: 300 800; + font-display: swap; + src: url('@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2') + format('woff2-variations'); +} + +@font-face { + font-family: 'Open Sans Variable'; + font-style: italic; + font-weight: 300 800; + font-display: swap; + src: url('@fontsource-variable/open-sans/files/open-sans-latin-wght-italic.woff2') + format('woff2-variations'); +} + +@font-face { + font-family: 'IBM Plex Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2') + format('woff2'); +} + /* Variables */ :root { --font-open-sans: 'Open Sans Variable', sans-serif; diff --git a/packages/react/src/html/utils/__tests__/processing.test.mjs b/packages/react/src/html/utils/__tests__/processing.test.mjs index a032652a..3cab6c0f 100644 --- a/packages/react/src/html/utils/__tests__/processing.test.mjs +++ b/packages/react/src/html/utils/__tests__/processing.test.mjs @@ -6,7 +6,9 @@ import { setConfig, } from '@doc-kit/core/utils/configuration/index.mjs'; +import { FONTS } from '../../constants.mjs'; import { + buildPreloads, buildHead, populateWithEvaluation, resolvePageRoot, @@ -111,6 +113,36 @@ describe('resolvePageRoot', () => { }); }); +describe('buildPreloads', () => { + it('resolves every shipped font against the page root', () => { + const result = buildPreloads('../'); + + // A hint per shipped face, or the unlisted ones load late after all. + assert.strictEqual(result.match(/rel="preload"/g).length, FONTS.length); + + for (const font of FONTS) { + assert.ok(result.includes(`href="../assets/fonts/${font}"`)); + } + }); + + it('keeps an absolute root absolute', () => { + const result = buildPreloads('https://nodejs.org/docs/'); + + assert.ok( + result.includes(`href="https://nodejs.org/docs/assets/fonts/${FONTS[0]}"`) + ); + }); + + it('renders crossorigin valueless, since fonts are fetched in CORS mode', () => { + // Without it the stylesheet re-fetches the font instead of reusing it. + const hints = buildPreloads('./').split('\n'); + + for (const hint of hints) { + assert.match(hint, /as="font" type="font\/woff2" crossorigin \/>$/); + } + }); +}); + describe('buildHead', () => { it('renders meta tags from attribute bags', () => { const result = buildHead({ diff --git a/packages/react/src/html/utils/processing.mjs b/packages/react/src/html/utils/processing.mjs index 17b6dfce..fd579803 100644 --- a/packages/react/src/html/utils/processing.mjs +++ b/packages/react/src/html/utils/processing.mjs @@ -5,7 +5,7 @@ import createConfigSource from './config.mjs'; import createProgramBuilder from './generate.mjs'; import { relativeOrAbsolute } from './relativeOrAbsolute.mjs'; import { resolveBundler } from '../bundlers/index.mjs'; -import { SPECULATION_RULES } from '../constants.mjs'; +import { FONT_DIRECTORY, FONTS, SPECULATION_RULES } from '../constants.mjs'; import { THEME_SCRIPT } from '../ui/theme-script.mjs'; /** @@ -71,6 +71,20 @@ const renderTag = (tag, attrs) => { return `<${tag}${rendered} />`; }; +/** + * Renders the preload hints for a page + */ +export const buildPreloads = root => + FONTS.map(font => + renderTag('link', { + rel: 'preload', + href: `${root}${FONT_DIRECTORY}/${font}`, + as: 'font', + type: 'font/woff2', + crossorigin: true, + }) + ).join('\n '); + /** * Builds the configurable `` markup shared by every page from the * structured `head` config: `` tags, `` tags, and raw HTML. None @@ -183,6 +197,7 @@ export async function processBundles({ entrypoint: bundler.getEntryId(data.api), speculationRules: SPECULATION_RULES, themeScript: THEME_SCRIPT, + preloads: buildPreloads(root), root, metadata: data, config, From 7dace16515ab070b6d307ca8a5d7d5a9697696ac Mon Sep 17 00:00:00 2001 From: Aviv Keller Date: Sun, 9 Aug 2026 17:46:02 -0400 Subject: [PATCH 2/2] Apply suggestions from code review Co-authored-by: Aviv Keller --- packages/react/src/html/bundlers/vite.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/html/bundlers/vite.mjs b/packages/react/src/html/bundlers/vite.mjs index c01b6cd3..4cadefe2 100644 --- a/packages/react/src/html/bundlers/vite.mjs +++ b/packages/react/src/html/bundlers/vite.mjs @@ -256,7 +256,7 @@ export const createViteConfig = ({ format: 'es', /** - * + * Determine the asset names for different files */ assetFileNames: asset => asset.names.some(name => name.endsWith('.woff2'))