From de2f16c63353d600ede55002550ff9b07b97108d Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Oct 2024 18:42:57 +0200 Subject: [PATCH 1/6] refactor: migrate to `unifont` --- package.json | 1 + pnpm-lock.yaml | 14 +++ src/assets.ts | 6 +- src/cache.ts | 24 ----- src/css/parse.ts | 139 +------------------------ src/css/render.ts | 11 +- src/devtools.ts | 6 +- src/module.ts | 109 ++++++++++---------- src/plugins/transform.ts | 6 +- src/providers/adobe.ts | 126 ----------------------- src/providers/bunny.ts | 88 ---------------- src/providers/fontshare.ts | 119 ---------------------- src/providers/fontsource.ts | 155 ---------------------------- src/providers/google.ts | 108 -------------------- src/providers/googleicons.ts | 98 ------------------ src/providers/local.ts | 192 ++++++++++++++++++----------------- src/types.ts | 59 +++++++---- src/utils.ts | 21 ++++ test/extract.test.ts | 61 ----------- test/providers/local.test.ts | 96 ++++++++++++------ 20 files changed, 314 insertions(+), 1125 deletions(-) delete mode 100644 src/providers/adobe.ts delete mode 100644 src/providers/bunny.ts delete mode 100644 src/providers/fontshare.ts delete mode 100644 src/providers/fontsource.ts delete mode 100644 src/providers/google.ts delete mode 100644 src/providers/googleicons.ts delete mode 100644 test/extract.test.ts diff --git a/package.json b/package.json index 478c88b8..c484e890 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "sirv": "^2.0.4", "tinyglobby": "^0.2.9", "ufo": "^1.5.4", + "unifont": "^0.1.0", "unplugin": "^1.14.1", "unstorage": "^1.12.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61235f23..1b291c7b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: ufo: specifier: ^1.5.4 version: 1.5.4 + unifont: + specifier: ^0.1.0 + version: 0.1.0 unplugin: specifier: ^1.14.1 version: 1.14.1(webpack-sources@3.2.3) @@ -6024,6 +6027,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unifont@0.1.0: + resolution: {integrity: sha512-A9fX7w4mFhKBiP1ecDTN43QssU4vd8IQuoaxzz6PXu6bvNDJC4pV+FNWrJm/P0n8lk/3oXQBJxHs5GHVCBPBpQ==} + unimport@3.12.0: resolution: {integrity: sha512-5y8dSvNvyevsnw4TBQkIQR1Rjdbb+XjVSwQwxltpnVZrStBvvPkMPcZrh1kg5kY77kpx6+D4Ztd3W6FOBH/y2Q==} @@ -14333,6 +14339,14 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unifont@0.1.0: + dependencies: + css-tree: 3.0.0 + defu: 6.1.4 + node-fetch-native: 1.6.4 + ohash: 1.1.4 + ufo: 1.5.4 + unimport@3.12.0(rollup@3.29.5)(webpack-sources@3.2.3): dependencies: '@rollup/pluginutils': 5.1.0(rollup@3.29.5) diff --git a/src/assets.ts b/src/assets.ts index 2855b985..ecf0d0d2 100644 --- a/src/assets.ts +++ b/src/assets.ts @@ -14,7 +14,7 @@ import { hash } from 'ohash' import { storage } from './cache' import { logger } from './logger' import { formatToExtension, parseFont } from './css/render' -import type { FontFaceData, ModuleOptions, NormalizedFontFaceData } from './types' +import type { ModuleOptions, FontFaceData, RawFontFaceData } from './types' // TODO: replace this with nuxt/assets when it is released export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) { @@ -22,8 +22,8 @@ export function setupPublicAssetStrategy(options: ModuleOptions['assets'] = {}) const nuxt = useNuxt() const renderedFontURLs = new Map() - function normalizeFontData(faces: FontFaceData | FontFaceData[]): NormalizedFontFaceData[] { - const data: NormalizedFontFaceData[] = [] + function normalizeFontData(faces: RawFontFaceData | FontFaceData[]): FontFaceData[] { + const data: FontFaceData[] = [] for (const face of Array.isArray(faces) ? faces : [faces]) { data.push({ ...face, diff --git a/src/cache.ts b/src/cache.ts index 3b35522a..078beffc 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,33 +1,9 @@ import { createStorage } from 'unstorage' import fsDriver from 'unstorage/drivers/fs' -import { version } from '../package.json' -import type { Awaitable } from './types' - export const cacheBase = 'node_modules/.cache/nuxt/fonts/meta' // TODO: refactor to use nitro storage when possible export const storage = createStorage({ driver: fsDriver({ base: cacheBase }), }) - -export async function cachedData(key: string, fetcher: () => Awaitable, options?: { - onError?: (err: unknown) => Awaitable - ttl?: number -}) { - const cached = await storage.getItem(key) - if (!cached || cached.version !== version || cached.expires < Date.now()) { - try { - const data = await fetcher() - await storage.setItem(key, { expires: Date.now() + (options?.ttl || 1000 * 60 * 60 * 24 * 7), version, data }) - return data - } - catch (err) { - if (options?.onError) { - return options.onError(err) - } - throw err - } - } - return cached.data -} diff --git a/src/css/parse.ts b/src/css/parse.ts index 685f61fe..310d6920 100644 --- a/src/css/parse.ts +++ b/src/css/parse.ts @@ -1,17 +1,6 @@ -import { findAll, parse, type Declaration } from 'css-tree' +import type { Declaration } from 'css-tree' -import type { LocalFontSource, NormalizedFontFaceData, RemoteFontSource } from '../types' -import { formatPriorityList } from '../css/render' - -const extractableKeyMap: Record = { - 'src': 'src', - 'font-display': 'display', - 'font-weight': 'weight', - 'font-style': 'style', - 'font-feature-settings': 'featureSettings', - 'font-variations-settings': 'variationSettings', - 'unicode-range': 'unicodeRange', -} +import type { FontFaceData } from '../types' const weightMap: Record = { 100: 'Thin', @@ -31,106 +20,10 @@ const styleMap: Record = { normal: '', } -export function extractFontFaceData(css: string, family?: string): NormalizedFontFaceData[] { - const fontFaces: NormalizedFontFaceData[] = [] - - for (const node of findAll(parse(css), node => node.type === 'Atrule' && node.name === 'font-face')) { - if (node.type !== 'Atrule' || node.name !== 'font-face') { - continue - } - - if (family) { - const isCorrectFontFace = node.block?.children.some((child) => { - if (child.type !== 'Declaration' || child.property !== 'font-family') { - return false - } - - const value = extractCSSValue(child) as string | string[] - const slug = family.toLowerCase() - if (typeof value === 'string' && value.toLowerCase() === slug) { - return true - } - if (Array.isArray(value) && value.length > 0 && value.some(v => v.toLowerCase() === slug)) { - return true - } - return false - }) - - // Don't extract font face data from this `@font-face` rule if it doesn't match the specified family - if (!isCorrectFontFace) { - continue - } - } - - const data: Partial = {} - for (const child of node.block?.children || []) { - if (child.type === 'Declaration' && child.property in extractableKeyMap) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const value = extractCSSValue(child) as any - data[extractableKeyMap[child.property]!] = child.property === 'src' && !Array.isArray(value) ? [value] : value - } - } - fontFaces.push(data as NormalizedFontFaceData) - } - - return mergeFontSources(fontFaces) -} - function processRawValue(value: string) { return value.split(',').map(v => v.trim().replace(/^(?['"])(.*)\k$/, '$2')) } -function extractCSSValue(node: Declaration) { - if (node.value.type == 'Raw') { - return processRawValue(node.value.value) - } - - const values = [] as Array - let buffer = '' - for (const child of node.value.children) { - if (child.type === 'Function') { - if (child.name === 'local' && child.children.first?.type === 'String') { - values.push({ name: child.children.first.value }) - } - if (child.name === 'format' && child.children.first?.type === 'String') { - (values.at(-1) as RemoteFontSource).format = child.children.first.value - } - if (child.name === 'tech' && child.children.first?.type === 'String') { - (values.at(-1) as RemoteFontSource).tech = child.children.first.value - } - } - if (child.type === 'Url') { - values.push({ url: child.value }) - } - if (child.type === 'Identifier') { - buffer = buffer ? `${buffer} ${child.name}` : child.name - } - if (child.type === 'String') { - values.push(child.value) - } - if (child.type === 'Operator' && child.value === ',' && buffer) { - values.push(buffer) - buffer = '' - } - if (child.type === 'UnicodeRange') { - values.push(child.value) - } - if (child.type === 'Number') { - values.push(Number(child.value)) - } - } - - if (buffer) { - values.push(buffer) - } - - if (values.length === 1) { - return values[0] - } - - return values -} - // https://developer.mozilla.org/en-US/docs/Web/CSS/font-family /* A generic family name only */ const _genericCSSFamilies = [ @@ -218,33 +111,7 @@ export function extractFontFamilies(node: Declaration) { return families } -function mergeFontSources(data: NormalizedFontFaceData[]) { - const mergedData: NormalizedFontFaceData[] = [] - for (const face of data) { - const keys = Object.keys(face).filter(k => k !== 'src') as Array - const existing = mergedData.find(f => (Object.keys(f).length === keys.length + 1) && keys.every(key => f[key]?.toString() === face[key]?.toString())) - if (existing) { - existing.src.push(...face.src) - } - else { - mergedData.push(face) - } - } - - // Sort font sources by priority - for (const face of mergedData) { - face.src.sort((a, b) => { - // Prioritise local fonts (with 'name' property) over remote fonts, and then formats by formatPriorityList - const aIndex = 'format' in a ? formatPriorityList.indexOf(a.format || 'woff2') : -2 - const bIndex = 'format' in b ? formatPriorityList.indexOf(b.format || 'woff2') : -2 - return aIndex - bIndex - }) - } - - return mergedData -} - -export function addLocalFallbacks(fontFamily: string, data: NormalizedFontFaceData[]) { +export function addLocalFallbacks(fontFamily: string, data: FontFaceData[]) { for (const face of data) { const style = (face.style ? styleMap[face.style] : '') ?? '' diff --git a/src/css/render.ts b/src/css/render.ts index a959bea2..f713782b 100644 --- a/src/css/render.ts +++ b/src/css/render.ts @@ -1,9 +1,9 @@ import { hasProtocol } from 'ufo' import { extname, relative } from 'pathe' import { getMetricsForFamily, generateFontFace as generateFallbackFontFace, readMetrics } from 'fontaine' -import type { FontSource, NormalizedFontFaceData, RemoteFontSource } from '../types' +import type { FontSource, FontFaceData, RemoteFontSource } from '../types' -export function generateFontFace(family: string, font: NormalizedFontFaceData) { +export function generateFontFace(family: string, font: FontFaceData) { return [ '@font-face {', ` font-family: '${family}';`, @@ -19,7 +19,7 @@ export function generateFontFace(family: string, font: NormalizedFontFaceData) { ].filter(Boolean).join('\n') } -export async function generateFontFallbacks(family: string, data: NormalizedFontFaceData, fallbacks?: Array<{ name: string, font: string }>) { +export async function generateFontFallbacks(family: string, data: FontFaceData, fallbacks?: Array<{ name: string, font: string }>) { if (!fallbacks?.length) return [] const fontURL = data.src!.find(s => 'url' in s) as RemoteFontSource | undefined @@ -45,7 +45,6 @@ const formatMap: Record = { eot: 'embedded-opentype', svg: 'svg', } -export const formatPriorityList = Object.values(formatMap) const extensionMap = Object.fromEntries(Object.entries(formatMap).map(([key, value]) => [value, key])) export const formatToExtension = (format?: string) => format && format in extensionMap ? '.' + extensionMap[format] : undefined @@ -80,7 +79,7 @@ function renderFontSrc(sources: Exclude[]) { }).join(', ') } -export function relativiseFontSources(font: NormalizedFontFaceData, relativeTo: string) { +export function relativiseFontSources(font: FontFaceData, relativeTo: string) { return { ...font, src: font.src.map((source) => { @@ -91,5 +90,5 @@ export function relativiseFontSources(font: NormalizedFontFaceData, relativeTo: url: relative(relativeTo, source.url), } }), - } satisfies NormalizedFontFaceData + } satisfies FontFaceData } diff --git a/src/devtools.ts b/src/devtools.ts index a7ca5e11..e5645924 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -4,7 +4,7 @@ import { addCustomTab, extendServerRpc, onDevToolsInitialized } from '@nuxt/devt import type { BirpcGroup } from 'birpc' import { DEVTOOLS_RPC_NAMESPACE, DEVTOOLS_UI_PATH, DEVTOOLS_UI_PORT } from './constants' -import type { NormalizedFontFaceData } from './types' +import type { FontFaceData } from './types' import { generateFontFace } from './css/render' @@ -50,7 +50,7 @@ export function setupDevToolsUI() { interface SharedFontDetails { fontFamily: string - fonts: NormalizedFontFaceData[] + fonts: FontFaceData[] } export interface ManualFontDetails extends SharedFontDetails { @@ -91,7 +91,7 @@ export function setupDevtoolsConnection(enabled: boolean) { export interface ServerFunctions { getFonts: () => Array - generateFontFace: (fontFamily: string, font: NormalizedFontFaceData) => string + generateFontFace: (fontFamily: string, font: FontFaceData) => string } export interface ClientFunctions { diff --git a/src/module.ts b/src/module.ts index 705b6863..1d1c9146 100644 --- a/src/module.ts +++ b/src/module.ts @@ -2,37 +2,40 @@ import { addBuildPlugin, addTemplate, defineNuxtModule, useNuxt } from '@nuxt/ki import { createJiti } from 'jiti' import type { ResourceMeta } from 'vue-bundle-renderer' import { join, relative } from 'pathe' - +import { createUnifont, providers } from 'unifont' +import type { Provider, ProviderFactory } from 'unifont' import { withoutLeadingSlash } from 'ufo' + import local from './providers/local' -import google from './providers/google' -import googleicons from './providers/googleicons' -import bunny from './providers/bunny' -import fontshare from './providers/fontshare' -import adobe from './providers/adobe' -import fontsource from './providers/fontsource' +import { storage } from './cache' import { FontFamilyInjectionPlugin, type FontFaceResolution } from './plugins/transform' import { generateFontFace } from './css/render' import { addLocalFallbacks } from './css/parse' import type { GenericCSSFamily } from './css/parse' import { setupPublicAssetStrategy } from './assets' -import type { FontFamilyManualOverride, FontFamilyProviderOverride, FontProvider, ModuleHooks, ModuleOptions, NormalizedFontFaceData } from './types' +import type { FontFamilyManualOverride, FontFamilyProviderOverride, FontProvider, ModuleHooks, ModuleOptions, FontFaceData } from './types' import { setupDevtoolsConnection } from './devtools' import { logger } from './logger' +import { toUnifontProvider } from './utils' export type { - FontProvider, FontFaceData, + ResolveFontOptions, + LocalFontSource, + RemoteFontSource, + // for backwards compatibility + FontFaceData as NormalizedFontFaceData, + ResolveFontOptions as ResolveFontFacesOptions, +} from 'unifont' + +export type { + FontProvider, FontFallback, FontFamilyManualOverride, FontFamilyOverrides, FontFamilyProviderOverride, FontProviderName, - NormalizedFontFaceData, - ResolveFontFacesOptions, - LocalFontSource, - RemoteFontSource, FontSource, ModuleOptions, } from './types' @@ -94,12 +97,12 @@ export default defineNuxtModule({ }, providers: { local, - adobe, - google, - googleicons, - bunny, - fontshare, - fontsource, + adobe: providers.adobe, + google: providers.google, + googleicons: providers.googleicons, + bunny: providers.bunny, + fontshare: providers.fontshare, + fontsource: providers.fontsource, }, }, async setup(options, nuxt) { @@ -129,33 +132,39 @@ export default defineNuxtModule({ const providers = await resolveProviders(options.providers) const prioritisedProviders = new Set() + // TODO: export Unifont type + let unifont: Awaited> + // Allow registering and disabling providers nuxt.hook('modules:done', async () => { await nuxt.callHook('fonts:providers', providers) - const setups: Array> = [] - for (const key in providers) { - const provider = providers[key]! + const resolvedProviders: Array = [] + for (const [key, provider] of Object.entries(providers)) { if (options.providers?.[key] === false || (options.provider && options.provider !== key)) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete providers[key] } - else if (provider.setup) { - setups.push(provider.setup(options[key as 'google' | 'local' | 'adobe'] || {}, nuxt)) + else { + const unifontProvider = provider instanceof Function ? provider : toUnifontProvider(key, provider) + const providerOptions = (options[key as 'google' | 'local' | 'adobe'] || {}) as Record + resolvedProviders.push(unifontProvider(providerOptions)) } } - await Promise.all(setups) + for (const val of options.priority || []) { if (val in providers) prioritisedProviders.add(val) } for (const provider in providers) { prioritisedProviders.add(provider) } + + unifont = await createUnifont(resolvedProviders, { storage }) }) const { normalizeFontData } = setupPublicAssetStrategy(options.assets) const { exposeFont } = setupDevtoolsConnection(nuxt.options.dev && !!options.devtools) - function addFallbacks(fontFamily: string, font: NormalizedFontFaceData[]) { + function addFallbacks(fontFamily: string, font: FontFaceData[]) { if (options.experimental?.disableLocalFallbacks) { return font } @@ -199,7 +208,7 @@ export default defineNuxtModule({ // Handle explicit provider if (override?.provider) { if (override.provider in providers) { - const result = await providers[override.provider]!.resolveFontFaces!(fontFamily, defaults) + const result = await unifont.resolveFont(fontFamily, defaults, [override.provider]) // Rewrite font source URLs to be proxied/local URLs const fonts = normalizeFontData(result?.fonts || []) if (!fonts.length || !result) { @@ -223,31 +232,27 @@ export default defineNuxtModule({ logger.warn(`Unknown provider \`${override.provider}\` for font family \`${fontFamily}\`. Falling back to default providers.`) } - for (const key of prioritisedProviders) { - const provider = providers[key]! - if (provider.resolveFontFaces) { - const result = await provider.resolveFontFaces(fontFamily, defaults) - if (result) { - // Rewrite font source URLs to be proxied/local URLs - const fonts = normalizeFontData(result.fonts) - if (fonts.length > 0) { - const fontsWithLocalFallbacks = addFallbacks(fontFamily, fonts) - exposeFont({ - type: 'auto', - fontFamily, - provider: key, - fonts: fontsWithLocalFallbacks, - }) - return { - fallbacks: result.fallbacks || defaults.fallbacks, - fonts: fontsWithLocalFallbacks, - } - } - if (override) { - logger.warn(`Could not produce font face declaration for \`${fontFamily}\` with override.`) - } + const result = await unifont.resolveFont(fontFamily, defaults, [...prioritisedProviders]) + if (result) { + // Rewrite font source URLs to be proxied/local URLs + const fonts = normalizeFontData(result.fonts) + if (fonts.length > 0) { + const fontsWithLocalFallbacks = addFallbacks(fontFamily, fonts) + // TODO: expose provider name in result + exposeFont({ + type: 'auto', + fontFamily, + provider: result.provider || 'unknown', + fonts: fontsWithLocalFallbacks, + }) + return { + fallbacks: result.fallbacks || defaults.fallbacks, + fonts: fontsWithLocalFallbacks, } } + if (override) { + logger.warn(`Could not produce font face declaration for \`${fontFamily}\` with override.`) + } } } @@ -364,10 +369,10 @@ async function resolveProviders(_providers: ModuleOptions['providers'] = {}) { delete providers[key] } if (typeof value === 'string') { - providers[key] = await jiti.import(value) as FontProvider + providers[key] = await jiti.import(value) as ProviderFactory | FontProvider } } - return providers as Record + return providers as Record } declare module '@nuxt/schema' { diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index dc7b7163..8b390d91 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -7,13 +7,13 @@ import type { ESBuildOptions } from 'vite' import { dirname } from 'pathe' import { withLeadingSlash } from 'ufo' -import type { Awaitable, NormalizedFontFaceData, RemoteFontSource } from '../types' +import type { Awaitable, FontFaceData, RemoteFontSource } from '../types' import type { GenericCSSFamily } from '../css/parse' import { extractEndOfFirstChild, extractFontFamilies, extractGeneric } from '../css/parse' import { generateFontFace, generateFontFallbacks, relativiseFontSources } from '../css/render' export interface FontFaceResolution { - fonts?: NormalizedFontFaceData[] + fonts?: FontFaceData[] fallbacks?: string[] } @@ -21,7 +21,7 @@ interface FontFamilyInjectionPluginOptions { resolveFontFace: (fontFamily: string, fallbackOptions?: { fallbacks: string[], generic?: GenericCSSFamily }) => Awaitable dev: boolean processCSSVariables?: boolean - shouldPreload: (fontFamily: string, font: NormalizedFontFaceData) => boolean + shouldPreload: (fontFamily: string, font: FontFaceData) => boolean fontsToPreload: Map> } diff --git a/src/providers/adobe.ts b/src/providers/adobe.ts deleted file mode 100644 index 2ff3dbef..00000000 --- a/src/providers/adobe.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { hash } from 'ohash' - -import type { FontProvider, ResolveFontFacesOptions } from '../types' -import { extractFontFaceData } from '../css/parse' -import { cachedData } from '../cache' -import { $fetch } from '../fetch' -import { logger } from '../logger' - -interface ProviderOption { - id?: string[] | string -} - -export default { - async setup(options: ProviderOption) { - if (!options.id) { - return - } - await initialiseFontMeta(typeof options.id === 'string' ? [options.id] : options.id) - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isAdobeFont(fontFamily)) { - return - } - - return { - fonts: await cachedData(`adobe:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults), { - onError(err) { - logger.error(`Could not fetch metadata for \`${fontFamily}\` from \`adobe\`.`, err) - return [] - }, - }), - } - }, -} satisfies FontProvider - -const fontAPI = $fetch.create({ - baseURL: 'https://typekit.com', -}) - -const fontCSSAPI = $fetch.create({ - baseURL: 'https://use.typekit.net', -}) - -interface AdobeFontMeta { - kits: AdobeFontKit[] -} - -interface AdobeFontAPI { - kit: AdobeFontKit -} - -interface AdobeFontKit { - id: string - families: AdobeFontFamily[] -} - -interface AdobeFontFamily { - id: string - name: string - slug: string - css_names: string[] - css_stack: string - variations: string[] -} - -let fonts: AdobeFontMeta -const familyMap = new Map() - -async function getAdobeFontMeta(id: string): Promise { - const { kit } = await fontAPI(`/api/v1/json/kits/${id}/published`, { responseType: 'json' }) - return kit -} - -async function initialiseFontMeta(kits: string[]) { - fonts = { - kits: await Promise.all(kits.map(id => cachedData(`adobe:meta-${id}.json`, () => getAdobeFontMeta(id), { - onError() { - logger.error('Could not download `adobe` font metadata. `@nuxt/fonts` will not be able to inject `@font-face` rules for adobe.') - return null - }, - }))).then(r => r.filter((meta): meta is AdobeFontKit => !!meta)), - } - for (const kit in fonts.kits) { - const families = fonts.kits[kit]!.families - for (const family in families) { - familyMap.set(families[family]!.name, families[family]!.id) - } - } -} - -function isAdobeFont(family: string) { - return familyMap.has(family) -} - -async function getFontDetails(family: string, variants: ResolveFontFacesOptions) { - variants.weights = variants.weights.map(String) - - for (const kit in fonts.kits) { - const font = fonts.kits[kit]!.families.find(f => f.name === family)! - if (!font) { - continue - } - - const styles: string[] = [] - for (const style of font.variations) { - if (style.includes('i') && !variants.styles.includes('italic')) { - continue - } - if (!variants.weights.includes(String(style.slice(-1) + '00'))) { - continue - } - styles.push(style) - } - if (styles.length === 0) { - continue - } - const css = await fontCSSAPI(`${fonts.kits[kit]!.id}.css`) - - // TODO: Not sure whether this css_names array always has a single element. Still need to investigate. - const cssName = font.css_names[0] ?? family.toLowerCase().split(' ').join('-') - - return extractFontFaceData(css, cssName) - } - - return [] -} diff --git a/src/providers/bunny.ts b/src/providers/bunny.ts deleted file mode 100644 index 46607773..00000000 --- a/src/providers/bunny.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { hash } from 'ohash' - -import type { FontProvider, ResolveFontFacesOptions } from '../types' -import { extractFontFaceData } from '../css/parse' -import { cachedData } from '../cache' -import { $fetch } from '../fetch' -import { logger } from '../logger' - -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isBunnyFont(fontFamily)) { - return - } - - return { - fonts: await cachedData(`bunny:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults), { - onError(err) { - logger.error(`Could not fetch metadata for \`${fontFamily}\` from \`bunny\`.`, err) - return [] - }, - }), - } - }, -} satisfies FontProvider - -/** internal */ - -const fontAPI = $fetch.create({ - baseURL: 'https://fonts.bunny.net', -}) - -interface BunnyFontMeta { - [key: string]: { - category: string - defSubset: string - familyName: string - isVariable: boolean - styles: string[] - variants: Record - weights: number[] - } -} - -let fonts: BunnyFontMeta -const familyMap = new Map() - -async function initialiseFontMeta() { - fonts = await cachedData('bunny:meta.json', () => fontAPI('/list', { responseType: 'json' }), { - onError() { - logger.error('Could not download `bunny` font metadata. `@nuxt/fonts` will not be able to inject `@font-face` rules for bunny.') - return {} - }, - }) - for (const id in fonts) { - familyMap.set(fonts[id]!.familyName!, id) - } -} - -function isBunnyFont(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 styleMap = { - italic: 'i', - oblique: 'i', - normal: '', - } - const styles = new Set(variants.styles.map(i => styleMap[i])) - if (weights.length === 0 || styles.size === 0) return [] - - const resolvedVariants = weights.flatMap(w => [...styles].map(s => `${w}${s}`)) - - const css = await fontAPI('/css', { - query: { - family: id + ':' + resolvedVariants.join(','), - }, - }) - - // TODO: support subsets - return extractFontFaceData(css) -} diff --git a/src/providers/fontshare.ts b/src/providers/fontshare.ts deleted file mode 100644 index 4b3e304a..00000000 --- a/src/providers/fontshare.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { hash } from 'ohash' - -import type { FontProvider, ResolveFontFacesOptions } from '../types' -import { extractFontFaceData } from '../css/parse' -import { cachedData } from '../cache' -import { $fetch } from '../fetch' -import { logger } from '../logger' - -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isFontshareFont(fontFamily)) { - return - } - - return { - fonts: await cachedData(`fontshare:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults), { - onError(err) { - logger.error(`Could not fetch metadata for \`${fontFamily}\` from \`fontshare\`.`, err) - return [] - }, - }), - } - }, -} satisfies FontProvider - -/** internal */ - -const fontAPI = $fetch.create({ - baseURL: 'https://api.fontshare.com/v2', -}) - -interface FontshareFontMeta { - slug: string - name: string - styles: Array<{ - default: boolean - file: string - id: string - is_italic: boolean - is_variable: boolean - properties: { - ascending_leading: number - body_height: null - cap_height: number - descending_leading: number - max_char_width: number - x_height: number - y_max: number - y_min: number - } - weight: { - label: string - name: string - native_name: null - number: number - weight: number - } - }> -} - -let fonts: FontshareFontMeta[] -const families = new Set() - -async function initialiseFontMeta() { - fonts = await cachedData('fontshare:meta.json', async () => { - const fonts: FontshareFontMeta[] = [] - let offset = 0 - let chunk - do { - chunk = await fontAPI<{ fonts: FontshareFontMeta[], has_more: boolean }>('/fonts', { - responseType: 'json', - query: { - offset, - limit: 100, - }, - }) - fonts.push(...chunk.fonts) - offset++ - } while (chunk.has_more) - return fonts - }, { - onError() { - logger.error('Could not download `fontshare` font metadata. `@nuxt/fonts` will not be able to inject `@font-face` rules for fontshare.') - return [] - }, - }) - for (const font of fonts) { - families.add(font.name) - } -} - -function isFontshareFont(family: string) { - return families.has(family) -} - -async function getFontDetails(family: string, variants: ResolveFontFacesOptions) { - // https://api.fontshare.com/v2/css?f[]=alpino@300 - const font = fonts.find(f => f.name === family)! - const numbers: number[] = [] - for (const style of font.styles) { - if (style.is_italic && !variants.styles.includes('italic')) { - continue - } - if (!variants.weights.includes(String(style.weight.number))) { - continue - } - numbers.push(style.weight.number) - } - - if (numbers.length === 0) return [] - - const css = await fontAPI(`/css?f[]=${font.slug + '@' + numbers.join(',')}`) - - // TODO: support subsets and axes - return extractFontFaceData(css) -} diff --git a/src/providers/fontsource.ts b/src/providers/fontsource.ts deleted file mode 100644 index 0929e2e2..00000000 --- a/src/providers/fontsource.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { hash } from 'ohash' - -import type { FontProvider, NormalizedFontFaceData, ResolveFontFacesOptions } from '../types' -import { cachedData } from '../cache' -import { $fetch } from '../fetch' -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 - -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 -} - -interface FontsourceVariableAxesData { - default: string - min: string - max: string - step: string -} - -interface FontsourceVariableFontDetail { - axes: Record - family: string -} - -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)) - const subsets = variants.subsets ? variants.subsets.filter(subset => font.subsets.includes(subset)) : [font.defSubset] - if (weights.length === 0 || styles.length === 0) return [] - - const fontDetail = await fontAPI(`/fonts/${font.id}`, { responseType: 'json' }) - const fontFaceData: NormalizedFontFaceData[] = [] - - for (const subset of subsets) { - for (const style of styles) { - if (font.variable) { - const variableAxes = await cachedData(`fontsource:${font.family}-axes.json`, () => fontAPI(`/variable/${font.id}`, { responseType: 'json' }), { - onError() { - logger.error(`Could not download variable axes metadata for ${font.family} from \`fontsource\`. \`@nuxt/fonts\` will not be able to inject variable axes for ${font.family}.`) - return undefined - }, - }) - if (variableAxes && variableAxes.axes['wght']) { - fontFaceData.push({ - style, - weight: [Number(variableAxes.axes['wght'].min), Number(variableAxes.axes['wght'].max)], - src: [ - { url: `https://cdn.jsdelivr.net/fontsource/fonts/${font.id}:vf@latest/${subset}-wght-${style}.woff2`, format: 'woff2' }, - ], - unicodeRange: fontDetail.unicodeRange[subset]?.split(','), - }) - } - } - for (const weight of weights) { - const variantUrl = fontDetail.variants[weight]![style]![subset]!.url - fontFaceData.push({ - style, - weight, - src: Object.entries(variantUrl).map(([format, url]) => ({ url, format })), - unicodeRange: fontDetail.unicodeRange[subset]?.split(','), - }) - } - } - } - - return fontFaceData -} diff --git a/src/providers/google.ts b/src/providers/google.ts deleted file mode 100644 index 057cd60e..00000000 --- a/src/providers/google.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { hash } from 'ohash' - -import type { FontProvider, ResolveFontFacesOptions } from '../types' -import { extractFontFaceData } from '../css/parse' -import { cachedData } from '../cache' -import { $fetch } from '../fetch' -import { logger } from '../logger' - -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isGoogleFont(fontFamily)) { - return - } - - return { - fonts: await cachedData(`google:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults), { - onError(err) { - logger.error(`Could not fetch metadata for \`${fontFamily}\` from \`google\`.`, err) - return [] - }, - }), - } - }, -} satisfies FontProvider - -interface FontIndexMeta { - family: string - subsets: string[] - fonts: Record - axes: Array<{ - tag: string - min: number - max: number - defaultValue: number - }> -} - -/** internal */ - -let fonts: FontIndexMeta[] - -async function fetchFontMetadata() { - return await $fetch<{ familyMetadataList: FontIndexMeta[] }>('https://fonts.google.com/metadata/fonts', { responseType: 'json' }) - .then(r => r.familyMetadataList) -} - -async function initialiseFontMeta() { - fonts = await cachedData('google:meta.json', fetchFontMetadata, { - onError() { - logger.error('Could not download `google` font metadata. `@nuxt/fonts` will not be able to inject `@font-face` rules for google.') - return [] - }, - }) -} - -function isGoogleFont(family: string) { - return fonts.some(font => font.family === family) -} - -const styleMap = { - italic: '1', - oblique: '1', - normal: '0', -} -async function getFontDetails(family: string, variants: ResolveFontFacesOptions) { - const font = fonts.find(font => font.family === family)! - const styles = [...new Set(variants.styles.map(i => styleMap[i]))].sort() - - const variableWeight = font.axes.find(a => a.tag === 'wght') - const weights = variableWeight - ? [`${variableWeight.min}..${variableWeight.max}`] - : variants.weights.filter(weight => weight in font.fonts) - - if (weights.length === 0 || styles.length === 0) return [] - - const resolvedVariants = weights.flatMap(w => [...styles].map(s => `${s},${w}`)).sort() - - let css = '' - - for (const extension in userAgents) { - css += await $fetch('/css2', { - baseURL: 'https://fonts.googleapis.com', - headers: { 'user-agent': userAgents[extension as keyof typeof userAgents] }, - query: { - family: family + ':' + 'ital,wght@' + resolvedVariants.join(';'), - }, - }) - } - - // TODO: support subsets - return extractFontFaceData(css) -} - -const userAgents = { - woff2: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - ttf: 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/534.54.16 (KHTML, like Gecko) Version/5.1.4 Safari/534.54.16', - // eot: 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0)', - // woff: 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0', - // svg: 'Mozilla/4.0 (iPad; CPU OS 4_0_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/4.1 Mobile/9A405 Safari/7534.48.3', -} diff --git a/src/providers/googleicons.ts b/src/providers/googleicons.ts deleted file mode 100644 index f17a82ec..00000000 --- a/src/providers/googleicons.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { hash } from 'ohash' - -import type { FontProvider } from '../types' -import { extractFontFaceData } from '../css/parse' -import { cachedData } from '../cache' -import { $fetch } from '../fetch' -import { logger } from '../logger' - -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isGoogleIcon(fontFamily)) { - return - } - - return { - fonts: await cachedData(`googleicons:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily), { - onError(err) { - logger.error(`Could not fetch metadata for \`${fontFamily}\` from \`googleicons\`.`, err) - return [] - }, - }), - } - }, -} satisfies FontProvider - -/** internal */ - -let fonts: string[] - -async function fetchFontMetadata() { - const response: { families: string[] } = JSON.parse((await $fetch( - 'https://fonts.google.com/metadata/icons?key=material_symbols&incomplete=true', - )).split('\n').slice(1).join('\n')) // remove the first line which makes it an invalid JSON - - return response.families -} - -async function initialiseFontMeta() { - fonts = await cachedData('googleicons:meta.json', fetchFontMetadata, { - onError() { - logger.error('Could not download `googleicons` font metadata. `@nuxt/fonts` will not be able to inject `@font-face` rules for googleicons.') - return [] - }, - }) -} - -function isGoogleIcon(family: string) { - return fonts.includes(family) -} - -async function getFontDetails(family: string) { - let css = '' - - if (family.includes('Icons')) { - css += await $fetch('/css2', { - baseURL: 'https://fonts.googleapis.com/icon', - query: { - family: family, - }, - }) - } - - for (const extension in userAgents) { - // Legacy Material Icons - if (family.includes('Icons')) { - css += await $fetch('/icon', { - baseURL: 'https://fonts.googleapis.com', - headers: { 'user-agent': userAgents[extension as keyof typeof userAgents] }, - query: { - family: family, - }, - }) - } - // New Material Symbols - else { - css += await $fetch('/css2', { - baseURL: 'https://fonts.googleapis.com', - headers: { 'user-agent': userAgents[extension as keyof typeof userAgents] }, - query: { - family: family + ':' + 'opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200', - }, - }) - } - } - - return extractFontFaceData(css) -} - -const userAgents = { - woff2: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36', - ttf: 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/534.54.16 (KHTML, like Gecko) Version/5.1.4 Safari/534.54.16', - // eot: 'Mozilla/5.0 (compatible; MSIE 8.0; Windows NT 6.1; Trident/4.0)', - // woff: 'Mozilla/5.0 (Windows NT 6.1; WOW64; rv:27.0) Gecko/20100101 Firefox/27.0', - // svg: 'Mozilla/4.0 (iPad; CPU OS 4_0_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/4.1 Mobile/9A405 Safari/7534.48.3', -} diff --git a/src/providers/local.ts b/src/providers/local.ts index 40954447..f2ee5ef1 100644 --- a/src/providers/local.ts +++ b/src/providers/local.ts @@ -2,73 +2,117 @@ import { glob } from 'tinyglobby' import { join, extname, relative, resolve } from 'pathe' import { filename } from 'pathe/utils' import { anyOf, createRegExp, not, wordBoundary } from 'magic-regexp' - +import { defineFontProvider } from 'unifont' import { withLeadingSlash, withTrailingSlash } from 'ufo' -import type { FontFaceData, FontProvider } from '../types' +import { useNuxt } from '@nuxt/kit' -const providerContext = { - rootPaths: [] as string[], - registry: {} as Record, -} +import type { FontFaceData } from '../types' +import { parseFont } from '../css/render' -export default { - setup(_options, nuxt) { - // TODO: rework when providers can respond with font metric data - // Scan for all font files in public asset directories - nuxt.hook('nitro:init', async (nitro) => { - for (const assetsDir of nitro.options.publicAssets) { - const possibleFontFiles = await glob(['**/*.{ttf,woff,woff2,eot,otf}'], { - absolute: true, - cwd: assetsDir.dir, - }) - providerContext.rootPaths.push(withTrailingSlash(assetsDir.dir)) - for (const file of possibleFontFiles) { - registerFont(file.replace(assetsDir.dir, join(assetsDir.dir, assetsDir.baseURL || '/'))) - } - } +export default defineFontProvider('local', () => { + const providerContext = { + rootPaths: [] as string[], + registry: {} as Record, + } + + const nuxt = useNuxt() + + function registerFont(path: string) { + const slugs = generateSlugs(path) + for (const slug of slugs) { + providerContext.registry[slug] ||= [] + providerContext.registry[slug]!.push(path) + } + } + + function unregisterFont(path: string) { + const slugs = generateSlugs(path) + for (const slug of slugs) { + providerContext.registry[slug] ||= [] + providerContext.registry[slug] = providerContext.registry[slug]!.filter(p => p !== path) + } + } - // Sort rootPaths so we resolve to most specific path first - providerContext.rootPaths = providerContext.rootPaths.sort((a, b) => b.length - a.length) + const extensionPriority = ['.woff2', '.woff', '.ttf', '.otf', '.eot'] + function lookupFont(family: string, suffixes: Array): string[] { + const slug = [fontFamilyToSlug(family), ...suffixes].join('-') + const paths = providerContext.registry[slug] + if (!paths || paths.length === 0) { + return [] + } + + const fonts = new Set() + for (const path of paths) { + const base = providerContext.rootPaths.find(root => path.startsWith(root)) + fonts.add(base ? withLeadingSlash(relative(base, path)) : path) + } + + return [...fonts].sort((a, b) => { + const extA = extname(a) + const extB = extname(b) + + return extensionPriority.indexOf(extA) - extensionPriority.indexOf(extB) }) + } - // Update registry when files change - nuxt.hook('builder:watch', (event, relativePath) => { - relativePath = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, relativePath)) - const path = resolve(nuxt.options.srcDir, relativePath) - if (event === 'add' && isFontFile(path)) { - registerFont(path) + // TODO: rework when providers can respond with font metric data + // Scan for all font files in public asset directories + nuxt.hook('nitro:init', async (nitro) => { + for (const assetsDir of nitro.options.publicAssets) { + const possibleFontFiles = await glob(['**/*.{ttf,woff,woff2,eot,otf}'], { + absolute: true, + cwd: assetsDir.dir, + }) + providerContext.rootPaths.push(withTrailingSlash(assetsDir.dir)) + for (const file of possibleFontFiles) { + registerFont(file.replace(assetsDir.dir, join(assetsDir.dir, assetsDir.baseURL || '/'))) } - if (event === 'unlink' && isFontFile(path)) { - unregisterFont(path) - } - }) - }, - resolveFontFaces(fontFamily, defaults) { - const fonts: FontFaceData[] = [] - - // Resolve font files for each combination of weight, style and subset - for (const weight of defaults.weights) { - for (const style of defaults.styles) { - for (const subset of defaults.subsets) { - const resolved = lookupFont(fontFamily, [weightMap[weight] || weight, style, subset]) - if (resolved.length > 0) { - fonts.push({ - src: resolved, - weight, - style, - }) + } + + // Sort rootPaths so we resolve to most specific path first + providerContext.rootPaths = providerContext.rootPaths.sort((a, b) => b.length - a.length) + }) + + // Update registry when files change + nuxt.hook('builder:watch', (event, relativePath) => { + relativePath = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, relativePath)) + const path = resolve(nuxt.options.srcDir, relativePath) + if (event === 'add' && isFontFile(path)) { + registerFont(path) + } + if (event === 'unlink' && isFontFile(path)) { + unregisterFont(path) + } + }) + + return { + resolveFont(fontFamily, options) { + const fonts: FontFaceData[] = [] + + // Resolve font files for each combination of weight, style and subset + for (const weight of options.weights) { + for (const style of options.styles) { + for (const subset of options.subsets) { + const resolved = lookupFont(fontFamily, [weightMap[weight] || weight, style, subset]) + if (resolved.length > 0) { + fonts.push({ + src: resolved.map(url => parseFont(url)), + weight, + style, + }) + } } } } - } - if (fonts.length > 0) { - return { - fonts, + if (fonts.length > 0) { + return { + fonts, + } } - } - }, -} satisfies FontProvider + }, + } +}) const FONT_RE = /\.(?:ttf|woff|woff2|eot|otf)(?:\?[^.]+)?$/ const NON_WORD_RE = /\W+/g @@ -131,44 +175,6 @@ function generateSlugs(path: string) { return [...slugs] } -function registerFont(path: string) { - const slugs = generateSlugs(path) - for (const slug of slugs) { - providerContext.registry[slug] ||= [] - providerContext.registry[slug]!.push(path) - } -} - -function unregisterFont(path: string) { - const slugs = generateSlugs(path) - for (const slug of slugs) { - providerContext.registry[slug] ||= [] - providerContext.registry[slug] = providerContext.registry[slug]!.filter(p => p !== path) - } -} - -const extensionPriority = ['.woff2', '.woff', '.ttf', '.otf', '.eot'] -function lookupFont(family: string, suffixes: Array): string[] { - const slug = [fontFamilyToSlug(family), ...suffixes].join('-') - const paths = providerContext.registry[slug] - if (!paths || paths.length === 0) { - return [] - } - - const fonts = new Set() - for (const path of paths) { - const base = providerContext.rootPaths.find(root => path.startsWith(root)) - fonts.add(base ? withLeadingSlash(relative(base, path)) : path) - } - - return [...fonts].sort((a, b) => { - const extA = extname(a) - const extB = extname(b) - - return extensionPriority.indexOf(extA) - extensionPriority.indexOf(extB) - }) -} - function fontFamilyToSlug(family: string) { return family.toLowerCase().replace(NON_WORD_RE, '') } diff --git a/src/types.ts b/src/types.ts index 9c17818f..4ae1376d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { Nuxt } from '@nuxt/schema' +import type { Provider, ProviderFactory, providers } from 'unifont' import type { GenericCSSFamily } from './css/parse' @@ -15,10 +16,8 @@ export interface LocalFontSource { name: string } -export type FontSource = string | LocalFontSource | RemoteFontSource - export interface FontFaceData { - src: FontSource | Array + src: Array /** * The font-display descriptor. * @default 'swap' @@ -31,18 +30,13 @@ export interface FontFaceData { /** A font-style value. */ style?: string /** The range of Unicode code points to be used from the font. */ - unicodeRange?: string | string[] + unicodeRange?: string[] /** Allows control over advanced typographic features in OpenType fonts. */ featureSettings?: string /** Allows low-level control over OpenType or TrueType font variations, by specifying the four letter axis names of the features to vary, along with their variation values. */ variationSettings?: string } -export interface NormalizedFontFaceData extends Omit { - src: Array - unicodeRange?: string[] -} - export interface FontFallback { family?: string as: string @@ -61,9 +55,12 @@ export interface ResolveFontFacesOptions { styles: Array<'normal' | 'italic' | 'oblique'> // TODO: improve support and support unicode range subsets: string[] - fallbacks: string[] + fallbacks?: string[] } +/** + * @deprecated Use `Provider` types from `unifont` + */ export interface FontProvider> { /** * The setup function will be called before the first `resolveFontFaces` call and is a good @@ -81,7 +78,7 @@ export interface FontProvider> { * Return data used to generate @font-face declarations. * @see https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face */ - fonts: FontFaceData | FontFaceData[] + fonts: FontFaceData[] fallbacks?: string[] }> } @@ -107,11 +104,21 @@ export interface FontFamilyProviderOverride extends FontFamilyOverrides, Partial provider?: FontProviderName } -export interface FontFamilyManualOverride extends FontFamilyOverrides, FontFaceData { +export type FontSource = string | LocalFontSource | RemoteFontSource + +export interface RawFontFaceData extends Omit { + src: FontSource | Array + unicodeRange?: string | string[] +} + +export interface FontFamilyManualOverride extends FontFamilyOverrides, RawFontFaceData { /** Font families to generate fallback metrics for. */ fallbacks?: string[] } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ProviderOption = ((options: any) => Provider) | string | false + export interface ModuleOptions { /** * Specify overrides for individual font families. @@ -138,9 +145,13 @@ export interface ModuleOptions { fallbacks?: Partial> }> providers?: { - google?: FontProvider | string | false - local?: FontProvider | string | false - [key: string]: FontProvider | string | false | undefined + adobe?: ProviderOption + bunny?: ProviderOption + fontshare?: ProviderOption + fontsource?: ProviderOption + google?: ProviderOption + googleicons?: ProviderOption + [key: string]: FontProvider | ProviderOption | undefined } /** Configure the way font assets are exposed */ assets: { @@ -152,14 +163,20 @@ export interface ModuleOptions { /** Currently font assets are exposed as public assets as part of the build. This will be configurable in future */ strategy?: 'public' } - /** Options passed directly to `google` font provider */ - google?: Record /** Options passed directly to `local` font provider (none currently) */ local?: Record /** Options passed directly to `adobe` font provider */ - adobe?: { - id: string | string[] - } + adobe?: typeof providers.adobe extends ProviderFactory ? O : Record + /** Options passed directly to `bunny` font provider */ + bunny?: typeof providers.bunny extends ProviderFactory ? O : Record + /** Options passed directly to `fontshare` font provider */ + fontshare?: typeof providers.fontshare extends ProviderFactory ? O : Record + /** Options passed directly to `fontsource` font provider */ + fontsource?: typeof providers.fontsource extends ProviderFactory ? O : Record + /** Options passed directly to `google` font provider */ + google?: typeof providers.google extends ProviderFactory ? O : Record + /** Options passed directly to `googleicons` font provider */ + googleicons?: typeof providers.googleicons extends ProviderFactory ? O : Record /** * An ordered list of providers to check when resolving font families. * @@ -193,5 +210,5 @@ export interface ModuleOptions { } export interface ModuleHooks { - 'fonts:providers': (providers: FontProvider) => void | Promise + 'fonts:providers': (providers: Record) => void | Promise } diff --git a/src/utils.ts b/src/utils.ts index 8bba039e..5b6187ce 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,28 @@ +import { useNuxt } from '@nuxt/kit' +import { defineFontProvider as defineUnifontProvider } from 'unifont' import type { FontProvider } from './types' +/** + * @deprecated Use `defineFontProvider` from `unifont` instead. + */ export function defineFontProvider(options: FontProvider) { return options } export type { FontProvider } from './types' + +// This needs to convert custom font providers to unifont-style providers +export function toUnifontProvider>(name: string, provider: FontProvider) { + return defineUnifontProvider(name, async (options) => { + const nuxt = useNuxt() + await provider.setup?.(options, nuxt) + + return { + async resolveFontFaces(fontFamily, options) { + const result = await provider.resolveFontFaces!(fontFamily, options) + + return result || undefined + }, + } + }) +} diff --git a/test/extract.test.ts b/test/extract.test.ts deleted file mode 100644 index 97df1dce..00000000 --- a/test/extract.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { extractFontFaceData } from '../src/css/parse' - -describe('extract font face from CSS', () => { - it('should add declarations for `font-family`', async () => { - expect(extractFontFaceData(` - @font-face { - font-family: 'Open Sans'; - font-style: normal; - font-display: swap; - font-weight: 500; - src: local("Open Sans"), url(./files/open-sans-latin-500-normal.woff2) format('woff2'), url(./files/open-sans-latin-500-normal.woff) format('woff'); - 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; - } - `)) - .toMatchInlineSnapshot(` - [ - { - "display": "swap", - "src": [ - { - "name": "Open Sans", - }, - { - "format": "woff2", - "url": "./files/open-sans-latin-500-normal.woff2", - }, - { - "format": "woff", - "url": "./files/open-sans-latin-500-normal.woff", - }, - ], - "style": "normal", - "unicodeRange": [ - "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", - ], - "weight": 500, - }, - ] - `) - }) -}) diff --git a/test/providers/local.test.ts b/test/providers/local.test.ts index 4ea095b4..7646a86f 100644 --- a/test/providers/local.test.ts +++ b/test/providers/local.test.ts @@ -1,13 +1,18 @@ import { fileURLToPath } from 'node:url' import fsp from 'node:fs/promises' -import type { Nuxt } from '@nuxt/schema' import type { Nitro, NitroOptions } from 'nitropack' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { dirname, join } from 'pathe' +import { createUnifont } from 'unifont' import localProvider from '../../src/providers/local' +const mockUseNuxt = vi.hoisted(() => vi.fn()) +vi.mock('@nuxt/kit', () => ({ + useNuxt: mockUseNuxt, +})) + describe('local font provider', () => { it('should scan for font files', async () => { const cleanup = await createFixture('scanning', [ @@ -19,7 +24,7 @@ describe('local font provider', () => { 'font.txt', ].flatMap(l => [`public/${l}`, `layer/public/${l}`])) const provider = await setupFixture(['scanning/public', 'scanning/layer/public']) - const faces = provider.resolveFontFaces('font', { + const faces = await provider.resolveFont('font', { fallbacks: [], weights: ['normal'], styles: ['normal'], @@ -30,16 +35,32 @@ describe('local font provider', () => { "fonts": [ { "src": [ - "/font.woff2", - "/font.woff", - "/font.ttf", - "/font.otf", - "/font.eot", + { + "format": "woff2", + "url": "/font.woff2", + }, + { + "format": "woff", + "url": "/font.woff", + }, + { + "format": "truetype", + "url": "/font.ttf", + }, + { + "format": "opentype", + "url": "/font.otf", + }, + { + "format": "embedded-opentype", + "url": "/font.eot", + }, ], "style": "normal", "weight": "normal", }, ], + "provider": "local", } `) await cleanup() @@ -57,52 +78,73 @@ describe('local font provider', () => { 'public/MyFontbold-latin.woff', ]) const provider = await setupFixture(['resolve-weights/public']) - expect(provider.resolveFontFaces('MyFont', { + expect(await provider.resolveFont('MyFont', { fallbacks: [], weights: ['normal'], styles: ['normal'], subsets: ['latin'], - })?.fonts).toMatchInlineSnapshot(` + }).then(r => r.fonts)).toMatchInlineSnapshot(` [ { "src": [ - "/MyFont-normal.woff2", - "/MyFont.woff", + { + "format": "woff2", + "url": "/MyFont-normal.woff2", + }, + { + "format": "woff", + "url": "/MyFont.woff", + }, ], "style": "normal", "weight": "normal", }, ] `) - expect(provider.resolveFontFaces('MyFont', { + expect(await provider.resolveFont('MyFont', { fallbacks: [], weights: ['bold'], styles: ['normal'], subsets: ['latin'], - })?.fonts).toMatchInlineSnapshot(` + }).then(r => r.fonts)).toMatchInlineSnapshot(` [ { "src": [ - "/MyFont_bold.woff2", - "/MyFontbold-latin.woff", - "/MyFontbold-latin.ttf", - "/MyFont.700.eot", + { + "format": "woff2", + "url": "/MyFont_bold.woff2", + }, + { + "format": "woff", + "url": "/MyFontbold-latin.woff", + }, + { + "format": "truetype", + "url": "/MyFontbold-latin.ttf", + }, + { + "format": "embedded-opentype", + "url": "/MyFont.700.eot", + }, ], "style": "normal", "weight": "bold", }, ] `) - expect(provider.resolveFontFaces('MyFont', { + expect(await provider.resolveFont('MyFont', { fallbacks: [], weights: ['extra-light'], styles: ['normal'], subsets: ['latin'], - })?.fonts).toMatchInlineSnapshot(` + }).then(r => r.fonts)).toMatchInlineSnapshot(` [ { "src": [ - "/My-Font.200.woff2", + { + "format": "woff2", + "url": "/My-Font.200.woff2", + }, ], "style": "normal", "weight": "extra-light", @@ -127,13 +169,9 @@ async function createFixture(slug: string, files: string[]) { return () => fsp.rm(join(fixturePath, slug), { recursive: true, force: true }) } -type DeepPartial = { - [P in keyof T]?: DeepPartial -} - async function setupFixture(publicAssetDirs: string[]) { let promise: Promise - const mockNuxt = { + mockUseNuxt.mockImplementation(() => ({ hook: (event: string, callback: (nitro: Nitro) => Promise) => { if (event === 'nitro:init') { promise = callback({ @@ -143,8 +181,8 @@ async function setupFixture(publicAssetDirs: string[]) { } as Partial as Nitro) } }, - } satisfies DeepPartial as unknown as Nuxt - await localProvider.setup({}, mockNuxt) + })) + const unifont = await createUnifont([localProvider()]) await promise! - return localProvider + return unifont } From e8c1673ae52e812d1fbd5b2120e36c298be57a94 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Oct 2024 18:50:46 +0200 Subject: [PATCH 2/6] fix: update `toUnifontProvider` --- src/utils.ts | 2 +- test/basic.test.ts | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 5b6187ce..5a13492e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -18,7 +18,7 @@ export function toUnifontProvider> await provider.setup?.(options, nuxt) return { - async resolveFontFaces(fontFamily, options) { + async resolveFont(fontFamily, options) { const result = await provider.resolveFontFaces!(fontFamily, options) return result || undefined diff --git a/test/basic.test.ts b/test/basic.test.ts index 1cde21bf..bfcebd2e 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -22,8 +22,6 @@ describe('providers', async () => { const html = await $fetch('/providers/adobe') expect(extractFontFaces('Aleo', html)).toMatchInlineSnapshot(` [ - "@font-face{font-family:Aleo;src:local("Aleo Bold Italic"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:700;font-style:italic}", - "@font-face{font-family:Aleo;src:local("Aleo Bold"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:700;font-style:normal}", "@font-face{font-family:Aleo;src:local("Aleo Regular Italic"),local("Aleo Italic"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:400;font-style:italic}", "@font-face{font-family:Aleo;src:local("Aleo Regular"),local("Aleo"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:400;font-style:normal}", ] @@ -31,9 +29,7 @@ describe('providers', async () => { expect(extractFontFaces('Barlow Semi Condensed', html)).toMatchInlineSnapshot(` [ "@font-face{font-family:Barlow Semi Condensed;src:local("Barlow Semi Condensed Regular"),local("Barlow Semi Condensed"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:400;font-style:normal}", - "@font-face{font-family:Barlow Semi Condensed;src:local("Barlow Semi Condensed Bold Italic"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:700;font-style:italic}", "@font-face{font-family:Barlow Semi Condensed;src:local("Barlow Semi Condensed Regular Italic"),local("Barlow Semi Condensed Italic"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:400;font-style:italic}", - "@font-face{font-family:Barlow Semi Condensed;src:local("Barlow Semi Condensed Bold"),url(/_fonts/file.woff2) format(woff2),url(/_fonts/file.woff) format(woff),url(/_fonts/file.otf) format(opentype);font-display:auto;font-weight:700;font-style:normal}", ] `) }) From 0deef98eb9147633e8a8332f7d2a0ae623a53ee2 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Oct 2024 19:02:14 +0200 Subject: [PATCH 3/6] test: ignore type error in legacy api --- playground/providers/custom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/playground/providers/custom.ts b/playground/providers/custom.ts index 0c48760e..07c0bf30 100644 --- a/playground/providers/custom.ts +++ b/playground/providers/custom.ts @@ -6,6 +6,7 @@ export default { // Do some stuff resolvableFonts.add('SomeFontFromCustomProvider') }, + // @ts-expect-error testing legacy API async resolveFontFaces(fontFamily) { if (!resolvableFonts.has(fontFamily)) { return From 869e0da9db5077affdf7215658d9b2ef35b8812d Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Oct 2024 19:08:34 +0200 Subject: [PATCH 4/6] chore: fix type import --- client/app.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/app.vue b/client/app.vue index fc317065..025b0527 100644 --- a/client/app.vue +++ b/client/app.vue @@ -3,7 +3,7 @@ import { onDevtoolsClientConnected } from '@nuxt/devtools-kit/iframe-client' import type { ClientFunctions, ServerFunctions, ManualFontDetails, ProviderFontDetails } from '../src/devtools' import { DEVTOOLS_RPC_NAMESPACE } from '../src/constants' -import type { NormalizedFontFaceData } from '../src/types' +import type { FontFaceData } from '../src/types' type AnnotatedFont = (ManualFontDetails | ProviderFontDetails) & { css?: string @@ -42,7 +42,7 @@ function removeDuplicates(arr return array.filter((item, index) => index === array.findIndex(other => JSON.stringify(other) === JSON.stringify(item))) } -function prettyURL(font: NormalizedFontFaceData) { +function prettyURL(font: FontFaceData) { const firstRemoteSource = font.src.find(i => 'url' in i) if (firstRemoteSource) { return firstRemoteSource.originalURL || firstRemoteSource.url From c716533d8fdd9c19d0c443db5635ee388404fba7 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Oct 2024 19:18:28 +0200 Subject: [PATCH 5/6] chore: remove some unused types --- src/css/render.ts | 3 ++- src/plugins/transform.ts | 3 ++- src/types.ts | 29 +++++------------------------ 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/css/render.ts b/src/css/render.ts index f713782b..18cde4ab 100644 --- a/src/css/render.ts +++ b/src/css/render.ts @@ -1,7 +1,8 @@ import { hasProtocol } from 'ufo' import { extname, relative } from 'pathe' import { getMetricsForFamily, generateFontFace as generateFallbackFontFace, readMetrics } from 'fontaine' -import type { FontSource, FontFaceData, RemoteFontSource } from '../types' +import type { RemoteFontSource } from 'unifont' +import type { FontSource, FontFaceData } from '../types' export function generateFontFace(family: string, font: FontFaceData) { return [ diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts index 8b390d91..9ba3aa2e 100644 --- a/src/plugins/transform.ts +++ b/src/plugins/transform.ts @@ -6,8 +6,9 @@ import type { TransformOptions } from 'esbuild' import type { ESBuildOptions } from 'vite' import { dirname } from 'pathe' import { withLeadingSlash } from 'ufo' +import type { RemoteFontSource } from 'unifont' -import type { Awaitable, FontFaceData, RemoteFontSource } from '../types' +import type { Awaitable, FontFaceData } from '../types' import type { GenericCSSFamily } from '../css/parse' import { extractEndOfFirstChild, extractFontFamilies, extractGeneric } from '../css/parse' import { generateFontFace, generateFontFallbacks, relativiseFontSources } from '../css/render' diff --git a/src/types.ts b/src/types.ts index 4ae1376d..7455288f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,21 +1,10 @@ import type { Nuxt } from '@nuxt/schema' -import type { Provider, ProviderFactory, providers } from 'unifont' +import type { LocalFontSource, Provider, ProviderFactory, providers, RemoteFontSource, ResolveFontOptions } from 'unifont' import type { GenericCSSFamily } from './css/parse' export type Awaitable = T | Promise -export interface RemoteFontSource { - url: string - originalURL?: string - format?: string - tech?: string -} - -export interface LocalFontSource { - name: string -} - export interface FontFaceData { src: Array /** @@ -50,14 +39,6 @@ export interface FontFallback { // sizeAdjust?: string // size-adjust // } -export interface ResolveFontFacesOptions { - weights: string[] - styles: Array<'normal' | 'italic' | 'oblique'> - // TODO: improve support and support unicode range - subsets: string[] - fallbacks?: string[] -} - /** * @deprecated Use `Provider` types from `unifont` */ @@ -73,7 +54,7 @@ export interface FontProvider> { * If nothing is returned then this provider doesn't handle the font family and we * will continue calling `resolveFontFaces` in other providers. */ - resolveFontFaces?: (fontFamily: string, options: ResolveFontFacesOptions) => Awaitable Awaitable & { weights: Array }> { +export interface FontFamilyProviderOverride extends FontFamilyOverrides, Partial & { weights: Array }> { /** The provider to use when resolving this font. */ provider?: FontProviderName } @@ -140,8 +121,8 @@ export interface ModuleOptions { defaults?: Partial<{ preload: boolean weights: Array - styles: ResolveFontFacesOptions['styles'] - subsets: ResolveFontFacesOptions['subsets'] + styles: ResolveFontOptions['styles'] + subsets: ResolveFontOptions['subsets'] fallbacks?: Partial> }> providers?: { From 0bd1d87067c6ac3b1a6f3d067978119002a893c9 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 6 Oct 2024 19:24:13 +0200 Subject: [PATCH 6/6] docs: update custom provider docs --- docs/content/1.get-started/4.providers.md | 56 ++++++++++++----------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/content/1.get-started/4.providers.md b/docs/content/1.get-started/4.providers.md index 8948010c..7b740ea5 100644 --- a/docs/content/1.get-started/4.providers.md +++ b/docs/content/1.get-started/4.providers.md @@ -64,29 +64,29 @@ You should read [their terms in full](https://www.adobe.com/legal/terms.html) be ## Custom Providers -The provider API is likely to evolve in the next few releases of Nuxt Fonts, but at the moment it looks like this: +The core of the provider API in Nuxt Fonts has been extracted to a common library that can be used by any framework - [`unifont`](https://github.com/unjs/unifont). + +The provider API is likely to evolve in the next few releases of `unifont`, but at the moment it looks like this: ```ts -import { defineFontProvider } from '@nuxt/fonts/utils' - -export default defineFontProvider({ - async setup () { - // do some setup - }, - async resolveFontFaces (fontFamily, defaults) { - if (fontFamily === 'My Font Family') { - return { - fonts: [ - { - src: [ - { url: 'https://cdn.org/my-font.woff2', format: 'woff2' }, - // this will be inferred as a `woff` format file - 'https://cdn.org/my-font.woff', - ], - weight: 400, - style: 'normal', - } - ] +import { defineFontProvider } from 'unifont' + +export default defineFontProvider('some-custom-provider', async (options) => { + // do some setup + return { + async resolveFont (fontFamily, options) { + if (fontFamily === 'My Font Family') { + return { + fonts: [ + { + src: [ + { url: 'https://cdn.org/my-font.woff2', format: 'woff2' }, + ], + weight: 400, + style: 'normal', + } + ] + } } } } @@ -97,12 +97,14 @@ Module authors can also add their own providers (or remove existing ones) in the ```ts nuxt.hook('fonts:providers', providers => { - providers.push({ - async setup () { - /** some setup */ - }, - async resolveFontFaces (fontFamily, defaults) { - /** resolve font faces */ + delete providers.adobe + + providers['custom-provider'] = defineFontProvider('custom-provider', async () => { + /** some setup */ + return { + async resolveFont (fontFamily, options) { + /** resolve font faces */ + } } }) })