Skip to content

Commit 123ab97

Browse files
authored
fix: use server storage for fonts (#151)
1 parent 7dd25b6 commit 123ab97

17 files changed

+100
-54
lines changed

.playground/nuxt.config.ts

+8
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ export default defineNuxtConfig({
7777
},
7878

7979
ogImage: {
80+
fonts: [
81+
{
82+
name: 'optieinstein',
83+
weight: 800,
84+
// path must point to a public font file
85+
path: '/OPTIEinstein-Black.otf',
86+
},
87+
],
8088
// compatibility: {
8189
// runtime: {
8290
// resvg: 'wasm',

src/build/prerender.ts

-2
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import type { ModuleOptions } from '../module'
99
export function setupPrerenderHandler(options: ModuleOptions, resolve: Resolver['resolve'], nuxt: Nuxt = useNuxt()) {
1010
nuxt.hooks.hook('nitro:init', async (nitro) => {
1111
nitro.hooks.hook('prerender:config', async (nitroConfig) => {
12-
nitroConfig.serverAssets = nitroConfig.serverAssets || []
13-
nitroConfig.serverAssets!.push({ baseName: 'nuxt-og-image:fonts', dir: resolve('./runtime/server/assets') })
1412
// bindings
1513
applyNitroPresetCompatibility(nitroConfig, { compatibility: options.compatibility?.prerender, resolve })
1614
// avoid wasm handling while prerendering

src/module.ts

+36-21
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as fs from 'node:fs'
2+
import { readFile, writeFile } from 'node:fs/promises'
23
import {
34
type AddComponentOptions,
45
addComponent,
@@ -18,7 +19,7 @@ import type { SatoriOptions } from 'satori'
1819
import { installNuxtSiteConfig } from 'nuxt-site-config-kit'
1920
import { isDevelopment } from 'std-env'
2021
import { hash } from 'ohash'
21-
import { relative } from 'pathe'
22+
import { basename, join, relative } from 'pathe'
2223
import type { ResvgRenderOptions } from '@resvg/resvg-js'
2324
import type { SharpOptions } from 'sharp'
2425
import { defu } from 'defu'
@@ -43,7 +44,7 @@ import { setupDevHandler } from './build/dev'
4344
import { setupGenerateHandler } from './build/generate'
4445
import { setupPrerenderHandler } from './build/prerender'
4546
import { setupBuildHandler } from './build/build'
46-
import { checkLocalChrome, checkPlaywrightDependency, isUndefinedOrTruthy } from './util'
47+
import { checkLocalChrome, checkPlaywrightDependency, downloadFont, isUndefinedOrTruthy } from './util'
4748
import { normaliseFontInput } from './runtime/utils.pure'
4849

4950
export interface ModuleOptions {
@@ -119,6 +120,12 @@ export interface ModuleOptions {
119120
* Manually modify the compatibility.
120121
*/
121122
compatibility?: CompatibilityFlagEnvOverrides
123+
/**
124+
* Use an alternative host for downloading Google Fonts. This is used to support China where Google Fonts is blocked.
125+
*
126+
* When `true` is set will use `fonts.font.im`, otherwise will use a string as the host.
127+
*/
128+
googleFontMirror?: true | string
122129
}
123130

124131
export interface ModuleHooks {
@@ -259,35 +266,43 @@ export default defineNuxtModule<ModuleOptions>({
259266
if (preset === 'cloudflare' || preset === 'cloudflare-module') {
260267
config.fonts = config.fonts.filter((f) => {
261268
if (typeof f !== 'string' && f.path) {
262-
logger.warn(`The ${f.name}:${f.weight} font was skipped because remote fonts are not available in Cloudflare Workers, please use a Google font.`)
269+
logger.warn(`The ${f.name}:${f.weight} font was skipped because remote fonts are not available in Cloudflare Workers, please use a Google Font.`)
263270
return false
264271
}
265272
return true
266273
})
267274
}
268-
if (preset === 'stackblitz') {
269-
// TODO maybe only for stackblitz, this will increase server bundle size
270-
config.fonts = config.fonts.map((f) => {
271-
if (typeof f === 'string' && f.startsWith('Inter:')) {
272-
const [_, weight] = f.split(':')
273-
return {
274-
name: 'Inter',
275-
weight,
276-
// nuxt server assets
277-
key: `nuxt-og-image:fonts:inter-latin-ext-${weight}-normal.woff`,
275+
const serverFontsDir = resolve('./runtime/nitro/fonts')
276+
config.fonts = await Promise.all(normaliseFontInput(config.fonts)
277+
.map(async (f) => {
278+
if (!f.key && !f.path) {
279+
if (await downloadFont(f, serverFontsDir, config.googleFontMirror)) {
280+
// move file to serverFontsDir
281+
f.key = `nuxt-og-image:fonts:${f.name}-${f.weight}.ttf.base64`
278282
}
279283
}
284+
else if (f.path) {
285+
// move to assets folder as base64 and set key
286+
const fontPath = join(nuxt.options.rootDir, nuxt.options.dir.public, f.path)
287+
const fontData = await readFile(fontPath, 'base64')
288+
f.key = `nuxt-og-image:fonts:${f.name}-${f.weight}.ttf.base64`
289+
await writeFile(resolve(serverFontsDir, `${basename(f.path)}.base64`), fontData)
290+
}
291+
return f
292+
}))
293+
config.fonts = config.fonts.map((f) => {
294+
if (preset === 'stackblitz') {
280295
if (typeof f === 'string' || (!f.path && !f.key)) {
281296
logger.warn(`The ${typeof f === 'string' ? f : `${f.name}:${f.weight}`} font was skipped because remote fonts are not available in StackBlitz, please use a local font.`)
282297
return false
283298
}
284-
return f
285-
}).filter(Boolean) as InputFontConfig[]
286-
nuxt.hooks.hook('nitro:config', (nitroConfig) => {
287-
nitroConfig.serverAssets = nitroConfig.serverAssets || []
288-
nitroConfig.serverAssets!.push({ baseName: 'nuxt-og-image:fonts', dir: resolve('./runtime/server/assets') })
289-
})
290-
}
299+
}
300+
return f
301+
}).filter(Boolean) as InputFontConfig[]
302+
303+
// bundle fonts within nitro runtime
304+
nuxt.options.nitro.serverAssets = nuxt.options.nitro.serverAssets || []
305+
nuxt.options.nitro.serverAssets!.push({ baseName: 'nuxt-og-image:fonts', dir: serverFontsDir })
291306

292307
nuxt.options.experimental.componentIslands = true
293308

@@ -420,7 +435,7 @@ export default defineNuxtModule<ModuleOptions>({
420435
// need to sort by longest first so we don't replace the wrong part of the string
421436
.sort((a, b) => b.length - a.length)
422437
.reduce((name, dir) => {
423-
// only replace from the start of the string
438+
// only replace from the start of the string
424439
return name.replace(new RegExp(`^${dir}`), '')
425440
}, component.pascalName)
426441
return ` '${name}': typeof import('${relativeComponentPath}')['default']`

src/runtime/nitro/fonts/Inter-400.ttf.base64

+1
Large diffs are not rendered by default.

src/runtime/nitro/fonts/Inter-700.ttf.base64

+1
Large diffs are not rendered by default.

src/runtime/nitro/routes/__og-image__/font-[name]-[weight].[extension].ts

+10-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { createError, defineEventHandler, getQuery, proxyRequest, sendRedirect, setHeader } from 'h3'
22
import { parseURL } from 'ufo'
3+
import { prefixStorage } from 'unstorage'
34
import { getExtension, normaliseFontInput, useOgImageRuntimeConfig } from '../../../utils'
45
import type { ResolvedFontConfig } from '../../../types'
5-
import { assets } from '#internal/nitro/virtual/server-assets'
6+
import { useStorage } from '#imports'
7+
8+
const assets = prefixStorage(useStorage(), '/assets')
69

710
// /__og-image__/font/<name>/<weight>.ttf
811
export default defineEventHandler(async (e) => {
@@ -35,12 +38,12 @@ export default defineEventHandler(async (e) => {
3538
})
3639
}
3740

38-
if (import.meta.dev || import.meta.prerender) {
39-
// check cache first, this uses Nuxt server assets
40-
if (font.key && await assets.hasItem(font.key)) {
41-
setHeader(e, 'Content-Type', `font/${getExtension(font.path!)}`)
42-
return assets.getItemRaw<ArrayBuffer>(font.key)
43-
}
41+
// check cache first, this uses Nuxt server assets
42+
if (font.key && await assets.hasItem(font.key)) {
43+
setHeader(e, 'Content-Type', `font/${getExtension(font.path!)}`)
44+
const fontData = await assets.getItemRaw<string>(font.key)
45+
// buf is a string need to convert it to a buffer
46+
return Buffer.from(fontData!, 'base64')
4447
}
4548

4649
// using H3Event $fetch will cause the request headers not to be sent
Binary file not shown.
Binary file not shown.

src/util.ts

+35
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import { writeFile } from 'node:fs/promises'
2+
import { existsSync } from 'node:fs'
13
import { Launcher } from 'chrome-launcher'
24
import { tryResolveModule } from '@nuxt/kit'
35
import { isCI } from 'std-env'
6+
import { $fetch } from 'ofetch'
7+
import { join } from 'pathe'
8+
import type { ResolvedFontConfig } from './runtime/types'
49

510
export const isUndefinedOrTruthy = (v?: any) => typeof v === 'undefined' || v !== false
611

@@ -20,3 +25,33 @@ export function checkLocalChrome() {
2025
export async function checkPlaywrightDependency() {
2126
return !!(await tryResolveModule('playwright'))
2227
}
28+
29+
export async function downloadFont(font: ResolvedFontConfig, outputPath: string, mirror?: true | string) {
30+
const { name, weight } = font
31+
const fontPath = join(outputPath, `${name}-${weight}.ttf.base64`)
32+
if (existsSync(fontPath))
33+
return true
34+
35+
const host = typeof mirror === 'undefined' ? 'fonts.googleapis.com' : mirror === true ? 'fonts.font.im' : mirror
36+
// using H3Event $fetch will cause the request headers not to be sent
37+
const css = await $fetch(`https://${host}/css2?family=${name}:wght@${weight}`, {
38+
headers: {
39+
// Make sure it returns TTF.
40+
'User-Agent':
41+
'Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1',
42+
},
43+
})
44+
if (!css)
45+
return false
46+
47+
const ttfResource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/)
48+
if (ttfResource?.[1]) {
49+
const buf = await $fetch(ttfResource[1], { baseURL: host, responseType: 'arrayBuffer' })
50+
// need to base 64 the buf
51+
const base64Font = Buffer.from(buf).toString('base64')
52+
// output to outputPath
53+
await writeFile(fontPath, base64Font)
54+
return true
55+
}
56+
return false
57+
}

test/integration/endpoints/debug.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,17 @@ describe('debug', () => {
3838
"fonts": [
3939
{
4040
"cacheKey": "Inter:400",
41-
"key": "nuxt-og-image:fonts:inter-latin-ext-400-normal.woff",
41+
"key": "nuxt-og-image:fonts:Inter-400.ttf.base64",
4242
"name": "Inter",
43-
"path": "/__og-image__/font/Inter/400.ttf",
43+
"path": "",
4444
"style": "normal",
4545
"weight": "400",
4646
},
4747
{
4848
"cacheKey": "Inter:700",
49-
"key": "nuxt-og-image:fonts:inter-latin-ext-700-normal.woff",
49+
"key": "nuxt-og-image:fonts:Inter-700.ttf.base64",
5050
"name": "Inter",
51-
"path": "/__og-image__/font/Inter/700.ttf",
51+
"path": "",
5252
"style": "normal",
5353
"weight": "700",
5454
},

test/integration/endpoints/fonts.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ describe('fonts', () => {
1414
Blob {
1515
Symbol(kHandle): Blob {},
1616
Symbol(kLength): 310808,
17-
Symbol(kType): "font/ttf",
17+
Symbol(kType): "",
1818
}
1919
`)
2020
}, 60000)

test/integration/endpoints/satori/html.test.ts

+2-17
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,6 @@ describe('html', () => {
3333
display: inline-block;
3434
}
3535
</style>
36-
<style>
37-
@font-face {
38-
font-family: 'Inter';
39-
font-style: normal;
40-
font-weight: 400;
41-
src: url('/__og-image__/font/Inter/400.ttf') format('truetype');
42-
}
43-
</style>
44-
<style>
45-
@font-face {
46-
font-family: 'Inter';
47-
font-style: normal;
48-
font-weight: 700;
49-
src: url('/__og-image__/font/Inter/700.ttf') format('truetype');
50-
}
51-
</style>
5236
<script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/preset-wind.global.js"></script>
5337
<script>
5438
window.__unocss = {
@@ -59,7 +43,8 @@ describe('html', () => {
5943
}
6044
</script>
6145
<script src="https://cdn.jsdelivr.net/npm/@unocss/runtime/core.global.js"></script>
62-
<link href="https://cdn.jsdelivr.net/npm/gardevoir" rel="stylesheet"></head>
46+
<link href="https://cdn.jsdelivr.net/npm/gardevoir" rel="stylesheet">
47+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"></head>
6348
<body ><div data-v-inspector-ignore="true" style="position: relative; display: flex; margin: 0 auto; width: 1200px; height: 600px; overflow: hidden;"><div class="w-full h-full flex justify-between relative p-[60px] bg-white text-gray-900" data-island-uid><div class="flex absolute top-0 right-[-100%]" style="width:200%;height:200%;background-image:radial-gradient(circle, rgba(0, 220, 130, 0.5) 0%, rgba(255, 255, 255, 0.7) 50%, rgba(255, 255, 255, 0) 70%);"></div><div class="h-full w-full justify-between relative"><div class="flex flex-row justify-between items-start"><div class="flex flex-col w-full max-w-[65%]"><h1 class="m-0 font-bold mb-[30px] text-[75px]">Hello World</h1><!----></div><!----></div><div class="flex flex-row justify-center items-center text-left w-full"><!--[--><svg height="50" width="50" class="mr-3" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><path fill="#00dc82" d="M62.3,-53.9C74.4,-34.5,73.5,-9,67.1,13.8C60.6,36.5,48.7,56.5,30.7,66.1C12.7,75.7,-11.4,74.8,-31.6,65.2C-51.8,55.7,-67.9,37.4,-73.8,15.7C-79.6,-6,-75.1,-31.2,-61.1,-51C-47.1,-70.9,-23.6,-85.4,0.8,-86C25.1,-86.7,50.2,-73.4,62.3,-53.9Z" transform="translate(100 100)"></path></svg><p style="font-size:25px;" class="font-bold">nuxt-og-image</p><!--]--></div></div></div></div></body>
6449
</html>"
6550
`)

0 commit comments

Comments
 (0)