diff --git a/README.md b/README.md index c810a28..80a7aab 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Plug-and-play custom web font optimization and configuration for Nuxt apps. ## Features - ✨ zero-configuration required -- 🔡 built-in providers (`google`, `bunny`, `fontshare`, `adobe`, `local` - more welcome!) +- 🔡 built-in providers (`google`, `bunny`, `fontshare`, `fontsource`, `adobe`, `local` - more welcome!) - 💪 custom providers for full control - ⏬ local download support (until `nuxt/assets` lands) - ⚡️ automatic font metric optimisation powered by [**fontaine**](https://github.com/unjs/fontaine) and [**capsize**](https://github.com/seek-oss/capsize) @@ -187,6 +187,10 @@ Then, when you use a `font-family` in your CSS, we check to see whether it match You should read [their terms in full](https://www.fontshare.com/licenses/itf-ffl) before using a font through `fontshare`. +### `fontsource` + +[Fontsource](https://fontsource.org/docs/getting-started/introduction) is a collection of open-source fonts that are designed for self-hosting in web applications. + ### `adobe` [Adobe Fonts](https://fonts.adobe.com/) is a font service for both personal and commercial use included with Creative Cloud subscriptions. diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index d650bba..abf5208 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -17,7 +17,8 @@ export default defineNuxtConfig({ { name: 'CustomGlobal', global: true, src: '/font-global.woff2' }, { name: 'Oswald', fallbacks: ['Times New Roman'] }, { name: 'Aleo', provider: 'adobe'}, - { name: 'Barlow Semi Condensed', provider: 'adobe' } + { name: 'Barlow Semi Condensed', provider: 'adobe' }, + { name: 'Roboto Mono', provider: 'fontsource' }, ], adobe: { id: ['sij5ufr', 'grx7wdj'], diff --git a/playground/pages/providers/fontsource.vue b/playground/pages/providers/fontsource.vue new file mode 100644 index 0000000..0eac40a --- /dev/null +++ b/playground/pages/providers/fontsource.vue @@ -0,0 +1,11 @@ + + + diff --git a/src/module.ts b/src/module.ts index a5d755c..17a33eb 100644 --- a/src/module.ts +++ b/src/module.ts @@ -8,6 +8,7 @@ import google from './providers/google' import bunny from './providers/bunny' import fontshare from './providers/fontshare' import adobe from './providers/adobe' +import fontsource from './providers/fontsource' import { FontFamilyInjectionPlugin, type FontFaceResolution } from './plugins/transform' import { generateFontFace } from './css/render' @@ -95,6 +96,7 @@ export default defineNuxtModule({ google, bunny, fontshare, + fontsource, }, }, async setup (options, nuxt) { diff --git a/src/providers/fontsource.ts b/src/providers/fontsource.ts new file mode 100644 index 0000000..80ba6dc --- /dev/null +++ b/src/providers/fontsource.ts @@ -0,0 +1,127 @@ +import { $fetch } from 'ofetch' +import { hash } from 'ohash' + +import type { FontProvider, NormalizedFontFaceData, ResolveFontFacesOptions } from '../types' +import { addLocalFallbacks } from '../css/parse' +import { cachedData } from '../cache' +import { logger } from '../logger' + +export default { + async setup () { + await initialiseFontMeta() + }, + async resolveFontFaces (fontFamily, defaults) { + if (!isFontsourceFont(fontFamily)) { return } + + return { + fonts: await cachedData(`fontsource:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults), { + onError (err) { + logger.error(`Could not fetch metadata for \`${fontFamily}\` from \`fontsource\`.`, err) + return [] + } + }) + } + }, +} satisfies FontProvider + +/** internal */ + +const fontAPI = $fetch.create({ + baseURL: 'https://api.fontsource.org/v1' +}) + +export interface FontsourceFontMeta { + [key: string]: { + id: string + family: string + subsets: string[] + weights: number[] + styles: string[] + defSubset: string + variable: boolean + lastModified: string + category: string + version: string + type: string + } +} + +interface FontsourceFontFile { + url: { + woff2?: string + woff?: string + ttf?: string + } +} + +interface FontsourceFontVariant { + [key: string]: { + [key: string]: { + [key: string]: FontsourceFontFile + } + } +} + +interface FontsourceFontDetail { + id: string + family: string + subsets: string[] + weights: number[] + styles: string[] + unicodeRange: Record + defSubset: string + variable: boolean + lastModified: string + category: string + version: string + type: string + variants: FontsourceFontVariant +} + +let fonts: FontsourceFontMeta +const familyMap = new Map() + +async function initialiseFontMeta () { + fonts = await cachedData('fontsource:meta.json', () => fontAPI('/fonts', { responseType: 'json' }), { + onError () { + logger.error('Could not download `fontsource` font metadata. `@nuxt/fonts` will not be able to inject `@font-face` rules for fontsource.') + return {} + } + }) + for (const id in fonts) { + familyMap.set(fonts[id]!.family!, id) + } +} + +function isFontsourceFont (family: string) { + return familyMap.has(family) +} + + +async function getFontDetails (family: string, variants: ResolveFontFacesOptions) { + const id = familyMap.get(family) as keyof typeof fonts + const font = fonts[id]! + const weights = variants.weights.filter(weight => font.weights.includes(Number(weight))) + const styles = variants.styles.filter(style => font.styles.includes(style)) + if (weights.length === 0 || styles.length === 0) return [] + + const fontDetail = await fontAPI(`/fonts/${font.id}`, { responseType: 'json' }) + const fontFaceData: NormalizedFontFaceData[] = [] + + // TODO: support subsets apart from default + const defaultSubset = fontDetail.defSubset + + for (const weight of weights) { + for (const style of styles) { + const variantUrl = fontDetail.variants[weight]![style]![defaultSubset]!.url + fontFaceData.push({ + style, + weight, + src: Object.entries(variantUrl).map(([format, url]) => ({ url, format })), + unicodeRange: fontDetail.unicodeRange[defaultSubset]?.split(',') + }) + } + } + + return addLocalFallbacks(family, fontFaceData) +} diff --git a/test/basic.test.ts b/test/basic.test.ts index e52f374..cd32695 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -34,6 +34,16 @@ describe('providers', async () => { `) }) + it('generates inlined font face rules for `fontsource` provider', async () => { + const html = await $fetch('/providers/fontsource') + expect(extractFontFaces('Roboto Mono', html)).toMatchInlineSnapshot(` + [ + "@font-face{font-family:Roboto Mono;src:local("Roboto Mono"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.ttf) format(ttf);font-display:swap;unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;font-weight:400;font-style:italic}", + "@font-face{font-family:Roboto Mono;src:local("Roboto Mono"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.ttf) format(ttf);font-display:swap;unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;font-weight:400;font-style:normal}", + ] + `) + }) + it('generates inlined font face rules for `google` provider', async () => { const html = await $fetch('/providers/google') const poppins = extractFontFaces('Poppins', html)