diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 59640f1..b45ae11 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,12 +1,12 @@ -name: "CodeQL" +name: CodeQL on: push: - branches: [ "main" ] + branches: [main] pull_request: - branches: [ "main" ] + branches: [main] schedule: - - cron: "31 15 * * 5" + - cron: '31 15 * * 5' jobs: analyze: @@ -22,7 +22,7 @@ jobs: strategy: fail-fast: false matrix: - language: [ javascript ] + language: [javascript] steps: - name: Checkout @@ -40,4 +40,4 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: - category: "/language:${{ matrix.language }}" + category: '/language:${{ matrix.language }}' diff --git a/eslint.config.js b/eslint.config.js index 3b614a0..241f249 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,3 +1,9 @@ import antfu from '@antfu/eslint-config' -export default antfu() +export default antfu().append({ + files: ['playground/**'], + rules: { + 'antfu/no-top-level-await': 'off', + 'no-console': 'off', + }, +}) diff --git a/package.json b/package.json index 4d346e1..c7df8d0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "unifont", "type": "module", "version": "0.0.0", - "packageManager": "pnpm@9.11.0", + "packageManager": "pnpm@9.12.0", "description": "Framework agnostic tools for accessing data from font CDNs and providers", "license": "MIT", "repository": "danielroe/unifont", @@ -28,8 +28,16 @@ "test:unit": "vitest", "test:types": "tsc --noEmit" }, + "dependencies": { + "css-tree": "^3.0.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.4", + "ohash": "^1.1.4", + "ufo": "^1.5.4" + }, "devDependencies": { "@antfu/eslint-config": "latest", + "@types/css-tree": "^2.3.8", "@types/node": "20.16.10", "@vitest/coverage-v8": "latest", "bumpp": "9.6.1", diff --git a/playground/index.js b/playground/index.js index 11982fe..996b8b3 100644 --- a/playground/index.js +++ b/playground/index.js @@ -1,7 +1,11 @@ -import assert from 'node:assert' -import * as pkg from 'unifont' +// @ts-check -// eslint-disable-next-line no-console -console.log(pkg.welcome()) +import { createUnifont, providers } from 'unifont' -assert.strictEqual(pkg.welcome(), 'hello world') +const unifont = await createUnifont([ + providers.google(), +]) + +const fonts = await unifont.resolveFontFace('Poppins') + +console.log(fonts) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be9b71e..6240005 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,29 @@ overrides: importers: .: + dependencies: + css-tree: + specifier: ^3.0.0 + version: 3.0.0 + defu: + specifier: ^6.1.4 + version: 6.1.4 + node-fetch-native: + specifier: ^1.6.4 + version: 1.6.4 + ohash: + specifier: ^1.1.4 + version: 1.1.4 + ufo: + specifier: ^1.5.4 + version: 1.5.4 devDependencies: '@antfu/eslint-config': specifier: latest version: 3.7.3(@typescript-eslint/utils@8.7.0(eslint@9.11.1(jiti@1.21.6))(typescript@5.6.2))(@vue/compiler-sfc@3.5.8)(eslint@9.11.1(jiti@1.21.6))(typescript@5.6.2)(vitest@2.1.1(@types/node@20.16.10)) + '@types/css-tree': + specifier: ^2.3.8 + version: 2.3.8 '@types/node': specifier: 20.16.10 version: 20.16.10 @@ -871,6 +890,9 @@ packages: resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} + '@types/css-tree@2.3.8': + resolution: {integrity: sha512-zABG3nI2UENsx7AQv63tI5/ptoAG/7kQR1H0OvG+WTWYHOR5pfAT3cGgC8SdyCrgX/TTxJBZNmx82IjCXs1juQ==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -1300,6 +1322,10 @@ packages: resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-tree@3.0.0: + resolution: {integrity: sha512-o88DVQ6GzsABn1+6+zo2ct801dBO5OASVyxbbvA2W20ue2puSh/VOuqUj90eUeMSX/xqGqBmOKiRQN7tJOuBXw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.1.0: resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} engines: {node: '>= 6'} @@ -2086,6 +2112,9 @@ packages: mdn-data@2.0.30: resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + mdn-data@2.10.0: + resolution: {integrity: sha512-qq7C3EtK3yJXMwz1zAab65pjl+UhohqMOctTgcqjLOWABqmwj+me02LSsCuEUxnst9X1lCBpoE0WArGKgdGDzw==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -3715,6 +3744,8 @@ snapshots: '@trysound/sax@0.2.0': {} + '@types/css-tree@2.3.8': {} + '@types/debug@4.1.12': dependencies: '@types/ms': 0.7.34 @@ -4214,6 +4245,11 @@ snapshots: mdn-data: 2.0.30 source-map-js: 1.2.1 + css-tree@3.0.0: + dependencies: + mdn-data: 2.10.0 + source-map-js: 1.2.1 + css-what@6.1.0: {} cssesc@3.0.0: {} @@ -5203,6 +5239,8 @@ snapshots: mdn-data@2.0.30: {} + mdn-data@2.10.0: {} + merge-stream@2.0.0: {} merge2@1.4.1: {} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..0c3cb1a --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,29 @@ +type Awaitable = T | Promise + +export interface Storage { + getItem: (key: string) => Awaitable + setItem: (key: string, value: unknown) => Awaitable +} + +export function memoryStorage() { + const cache = new Map() + return { + getItem(key: string) { + return cache.get(key) + }, + setItem(key: string, value: unknown) { + cache.set(key, value) + }, + } satisfies Storage +} + +export function createAsyncStorage(storage: Storage) { + return { + async getItem(key: string, init?: () => Promise) { + return await storage.getItem(key) ?? (init ? await init() : null) + }, + async setItem(key: string, value: unknown) { + await storage.setItem(key, value) + }, + } +} diff --git a/src/css/parse.ts b/src/css/parse.ts new file mode 100644 index 0000000..eb57fdf --- /dev/null +++ b/src/css/parse.ts @@ -0,0 +1,149 @@ +import type { FontFaceData, LocalFontSource, RemoteFontSource } from '../types' + +import { type Declaration, findAll, parse } from 'css-tree' + +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', +} + +const formatMap: Record = { + woff2: 'woff2', + woff: 'woff', + otf: 'opentype', + ttf: 'truetype', + eot: 'embedded-opentype', + svg: 'svg', +} + +const formatPriorityList = Object.values(formatMap) + +export function extractFontFaceData(css: string, family?: string): FontFaceData[] { + const fontFaces: FontFaceData[] = [] + + 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) { + const value = extractCSSValue(child) as any + data[extractableKeyMap[child.property]!] = child.property === 'src' && !Array.isArray(value) ? [value] : value + } + } + fontFaces.push(data as FontFaceData) + } + + 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 +} + +function mergeFontSources(data: FontFaceData[]) { + const mergedData: FontFaceData[] = [] + 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 +} diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 0000000..ae41c7b --- /dev/null +++ b/src/fetch.ts @@ -0,0 +1,24 @@ +import defu from 'defu' +import { fetch } from 'node-fetch-native/proxy' +import { joinURL, withQuery } from 'ufo' + +interface Mini$FetchOptions extends RequestInit { + baseURL?: string + responseType?: 'json' | 'arrayBuffer' + query?: Record +} + +function mini$fetch(url: string, options?: Mini$FetchOptions) { + if (options?.baseURL) { + url = joinURL(options.baseURL, url) + } + if (options?.query) { + url = withQuery(url, options.query) + } + return fetch(url, options) + .then(r => options?.responseType === 'json' ? r.json() : options?.responseType === 'arrayBuffer' ? r.arrayBuffer() : r.text()) as Promise +} + +export const $fetch = Object.assign(mini$fetch, { + create: (defaults?: Mini$FetchOptions) => (url: string, options?: Mini$FetchOptions) => mini$fetch(url, defu(options, defaults)), +}) diff --git a/src/index.ts b/src/index.ts index 663a54a..ba188b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,66 @@ -export const welcome = () => 'hello world' +import type { InitializedProvider, Provider, ResolveFontFacesOptions } from './types' +import { createAsyncStorage, memoryStorage, type Storage } from './cache' + +export * as providers from './providers' +export { defineFontProvider } from './types' + +export interface UnifontOptions { + storage?: Storage +} + +export const defaultResolveOptions: ResolveFontFacesOptions = { + weights: ['400'], + styles: ['normal', 'italic'] as const, + subsets: [ + 'cyrillic-ext', + 'cyrillic', + 'greek-ext', + 'greek', + 'vietnamese', + 'latin-ext', + 'latin', + ], +} + +export async function createUnifont(providers: Provider[], options?: UnifontOptions) { + const stack: Record = {} + const unifontContext = { + storage: createAsyncStorage(options?.storage ?? memoryStorage()), + } + + for (const provider of providers) { + try { + const initializedProvider = await provider(unifontContext) + if (initializedProvider) { + stack[provider._name] = initializedProvider + } + } + catch (err) { + console.error(`Could not initialize provider \`${provider._name}\`. \`unifont\` will not be able to process fonts provided by this provider.`, err) + } + } + + const allProviders = Object.keys(stack) + + async function resolveFontFace(fontFamily: string, options = defaultResolveOptions, providers = allProviders) { + for (const id of providers) { + const provider = stack[id] + if (provider?.resolveFontFaces) { + try { + const result = await provider.resolveFontFaces(fontFamily, options) + if (result) { + return result + } + } + catch (err) { + console.error(`Could not resolve font face for \`${fontFamily}\` from \`${id}\` provider.`, err) + } + } + } + return { fonts: [] } + } + + return { + resolveFontFace, + } +} diff --git a/src/providers.ts b/src/providers.ts new file mode 100644 index 0000000..9ae3eb6 --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,6 @@ +export { default as adobe } from './providers/adobe' +export { default as bunny } from './providers/bunny' +export { default as fontshare } from './providers/fontshare' +export { default as fontsource } from './providers/fontsource' +export { default as google } from './providers/google' +export { default as googleicons } from './providers/googleicons' diff --git a/src/providers/adobe.ts b/src/providers/adobe.ts index 2ff3dbe..e6fa147 100644 --- a/src/providers/adobe.ts +++ b/src/providers/adobe.ts @@ -1,126 +1,99 @@ 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' +import { defineFontProvider, type ResolveFontFacesOptions } from '../types' interface ProviderOption { - id?: string[] | string + 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() +const fontAPI = $fetch.create({ baseURL: 'https://typekit.com' }) +const fontCSSAPI = $fetch.create({ baseURL: 'https://use.typekit.net' }) async function getAdobeFontMeta(id: string): Promise { - const { kit } = await fontAPI(`/api/v1/json/kits/${id}/published`, { responseType: 'json' }) + const { kit } = await fontAPI<{ kit: AdobeFontKit }>(`/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)), +export default defineFontProvider('adobe', async (options, ctx) => { + if (!options.id) { + return } - 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) -} + const familyMap = new Map() + const fonts = { + kits: [] as AdobeFontKit[], + } -async function getFontDetails(family: string, variants: ResolveFontFacesOptions) { - variants.weights = variants.weights.map(String) + const kits = typeof options.id === 'string' ? [options.id] : options.id + await Promise.all(kits.map(async (id) => { + const meta = await ctx.storage.getItem(`adobe:meta-${id}.json`, () => getAdobeFontMeta(id)) + if (!meta) { + throw new TypeError('No font metadata found in adobe response.') + } - for (const kit in fonts.kits) { - const font = fonts.kits[kit]!.families.find(f => f.name === family)! - if (!font) { - continue + fonts.kits.push(meta) + for (const family of meta.families) { + familyMap.set(family.name, family.id) } + })) + + async function getFontDetails(family: string, options: ResolveFontFacesOptions) { + options.weights = options.weights.map(String) - const styles: string[] = [] - for (const style of font.variations) { - if (style.includes('i') && !variants.styles.includes('italic')) { + for (const kit of fonts.kits) { + const font = kit.families.find(f => f.name === family) + if (!font) { continue } - if (!variants.weights.includes(String(style.slice(-1) + '00'))) { + + const styles: string[] = [] + for (const style of font.variations) { + if (style.includes('i') && !options.styles.includes('italic')) { + continue + } + if (!options.weights.includes(String(`${style.slice(-1)}00`))) { + continue + } + styles.push(style) + } + if (styles.length === 0) { continue } - styles.push(style) - } - if (styles.length === 0) { - continue + const css = await fontCSSAPI(`${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) } - 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 [] + } + + return { + async resolveFontFaces(family, options) { + if (!familyMap.has(family)) { + return + } - return extractFontFaceData(css, cssName) + const fonts = await ctx.storage.getItem(`adobe:${family}-${hash(options)}-data.json`, () => getFontDetails(family, options)) + return { fonts } + }, } +}) - return [] +interface AdobeFontKit { + id: string + families: AdobeFontFamily[] +} + +interface AdobeFontFamily { + id: string + name: string + slug: string + css_names: string[] + css_stack: string + variations: string[] } diff --git a/src/providers/bunny.ts b/src/providers/bunny.ts index 4660777..dfbf8d9 100644 --- a/src/providers/bunny.ts +++ b/src/providers/bunny.ts @@ -1,37 +1,58 @@ 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' +import { defineFontProvider, type ResolveFontFacesOptions } from '../types' -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isBunnyFont(fontFamily)) { - return - } +const fontAPI = $fetch.create({ baseURL: 'https://fonts.bunny.net' }) + +export default defineFontProvider('bunny', async (_options, ctx) => { + const familyMap = new Map() + + const fonts = await ctx.storage.getItem('bunny:meta.json', () => fontAPI('/list', { responseType: 'json' })) + for (const [id, family] of Object.entries(fonts)) { + familyMap.set(family.familyName, id) + } - 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 [] - }, - }), + async function getFontDetails(family: string, options: ResolveFontFacesOptions) { + const id = familyMap.get(family) as keyof typeof fonts + const font = fonts[id]! + const weights = options.weights.filter(weight => font.weights.includes(Number(weight))) + const styleMap = { + italic: 'i', + oblique: 'i', + normal: '', } - }, -} satisfies FontProvider + const styles = new Set(options.styles.map(i => styleMap[i])) + if (weights.length === 0 || styles.size === 0) + return [] -/** internal */ + 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) + } + + return { + async resolveFontFaces(fontFamily, defaults) { + if (!familyMap.has(fontFamily)) { + return + } -const fontAPI = $fetch.create({ - baseURL: 'https://fonts.bunny.net', + const fonts = await ctx.storage.getItem(`bunny:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults)) + return { fonts } + }, + } }) +/** internal */ + interface BunnyFontMeta { [key: string]: { category: string @@ -43,46 +64,3 @@ interface BunnyFontMeta { 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 index 4b3e304..6fa8493 100644 --- a/src/providers/fontshare.ts +++ b/src/providers/fontshare.ts @@ -1,37 +1,73 @@ 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' +import { defineFontProvider, type ResolveFontFacesOptions } from '../types' -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isFontshareFont(fontFamily)) { - return - } +const fontAPI = $fetch.create({ baseURL: 'https://api.fontshare.com/v2' }) +export default defineFontProvider('fontshare', async (_options, ctx) => { + const fontshareFamilies = new Set() - 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 [] + const fonts = await ctx.storage.getItem('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 + }) + + for (const font of fonts) { + fontshareFamilies.add(font.name) + } + + async function getFontDetails(family: string, options: 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 && !options.styles.includes('italic')) { + continue + } + if (!options.weights.includes(String(style.weight.number))) { + continue + } + numbers.push(style.weight.number) } - }, -} satisfies FontProvider -/** internal */ + if (numbers.length === 0) + return [] + + const css = await fontAPI(`/css?f[]=${`${font.slug}@${numbers.join(',')}`}`) + + // TODO: support subsets and axes + return extractFontFaceData(css) + } + + return { + async resolveFontFaces(fontFamily, defaults) { + if (!fontshareFamilies.has(fontFamily)) { + return + } -const fontAPI = $fetch.create({ - baseURL: 'https://api.fontshare.com/v2', + const fonts = await ctx.storage.getItem(`fontshare:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily, defaults)) + + return { fonts } + }, + } }) +/** internal */ + interface FontshareFontMeta { slug: string name: string @@ -60,60 +96,3 @@ interface FontshareFontMeta { } }> } - -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 index 0929e2e..b2c323d 100644 --- a/src/providers/fontsource.ts +++ b/src/providers/fontsource.ts @@ -1,48 +1,88 @@ 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 - } +import { defineFontProvider, type FontFaceData, type ResolveFontFacesOptions } from '../types' + +const fontAPI = $fetch.create({ baseURL: 'https://api.fontsource.org/v1' }) - 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 [] - }, - }), +export default defineFontProvider('fontsource', async (_options, ctx) => { + const fonts = await ctx.storage.getItem('fontsource:meta.json', () => fontAPI('/fonts', { responseType: 'json' })) + const familyMap = new Map() + + for (const meta of fonts) { + familyMap.set(meta.family, meta) + } + + async function getFontDetails(family: string, options: ResolveFontFacesOptions) { + const font = familyMap.get(family)! + const weights = options.weights.filter(weight => font.weights.includes(Number(weight))) + const styles = options.styles.filter(style => font.styles.includes(style)) + const subsets = options.subsets ? options.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: FontFaceData[] = [] + + for (const subset of subsets) { + for (const style of styles) { + if (font.variable) { + try { + const variableAxes = await ctx.storage.getItem(`fontsource:${font.family}-axes.json`, () => fontAPI(`/variable/${font.id}`, { responseType: 'json' })) + 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(','), + }) + } + } + catch { + console.error(`Could not download variable axes metadata for \`${font.family}\` from \`fontsource\`. \`unifont\` will not be able to inject variable axes for ${font.family}.`) + } + } + 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(','), + }) + } + } } - }, -} satisfies FontProvider -const fontAPI = $fetch.create({ - baseURL: 'https://api.fontsource.org/v1', -}) + return fontFaceData + } -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 + return { + async resolveFontFaces(fontFamily, options) { + if (!familyMap.has(fontFamily)) { + return + } + + const fonts = await ctx.storage.getItem(`fontsource:${fontFamily}-${hash(options)}-data.json`, () => getFontDetails(fontFamily, options)) + return { fonts } + }, } +}) + +interface FontsourceFontMeta { + id: string + family: string + subsets: string[] + weights: number[] + styles: string[] + defSubset: string + variable: boolean + lastModified: string + category: string + version: string + type: string } interface FontsourceFontFile { @@ -88,68 +128,3 @@ 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 index 057cd60..6afa00c 100644 --- a/src/providers/google.ts +++ b/src/providers/google.ts @@ -1,30 +1,67 @@ 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' +import { defineFontProvider, type ResolveFontFacesOptions } from '../types' -export default { - async setup() { - await initialiseFontMeta() - }, - async resolveFontFaces(fontFamily, defaults) { - if (!isGoogleFont(fontFamily)) { - return - } +export default defineFontProvider('google', async (_options, ctx) => { + const googleFonts = await ctx.storage.getItem('google:meta.json', () => $fetch<{ familyMetadataList: FontIndexMeta[] }>('https://fonts.google.com/metadata/fonts', { responseType: 'json' }).then(r => r.familyMetadataList)) + + const styleMap = { + italic: '1', + oblique: '1', + normal: '0', + } + + 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', + } + + async function getFontDetails(family: string, options: ResolveFontFacesOptions) { + const font = googleFonts.find(font => font.family === family)! + const styles = [...new Set(options.styles.map(i => styleMap[i]))].sort() + + const variableWeight = font.axes.find(a => a.tag === 'wght') + const weights = variableWeight + ? [`${variableWeight.min}..${variableWeight.max}`] + : options.weights.filter(weight => weight in font.fonts) + + if (weights.length === 0 || styles.length === 0) + 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 [] + 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(';')}`, }, - }), + }) } - }, -} satisfies FontProvider + + // TODO: support subsets + return extractFontFaceData(css) + } + + return { + async resolveFontFaces(fontFamily, options) { + if (!googleFonts.some(font => font.family === fontFamily)) { + return + } + + const fonts = await ctx.storage.getItem(`google:${fontFamily}-${hash(options)}-data.json`, () => getFontDetails(fontFamily, options)) + return { fonts } + }, + } +}) interface FontIndexMeta { family: string @@ -42,67 +79,3 @@ interface FontIndexMeta { 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 index f17a82e..767d6c3 100644 --- a/src/providers/googleicons.ts +++ b/src/providers/googleicons.ts @@ -1,98 +1,72 @@ 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' +import { defineFontProvider } from '../types' -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 -} +export default defineFontProvider('googleicons', async (_options, ctx) => { + const googleIcons = await ctx.storage.getItem('googleicons:meta.json', async () => { + 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 -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 [] - }, + return response.families }) -} - -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, - }, - }) + 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', } - for (const extension in userAgents) { - // Legacy Material Icons + async function getFontDetails(family: string) { + let css = '' + 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] }, + baseURL: 'https://fonts.googleapis.com/icon', query: { - family: family + ':' + 'opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200', + 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, + }, + }) + } + // 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) } - return extractFontFaceData(css) -} + return { + async resolveFontFaces(fontFamily, defaults) { + if (!googleIcons.includes(fontFamily)) { + return + } -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', -} + const fonts = await ctx.storage.getItem(`googleicons:${fontFamily}-${hash(defaults)}-data.json`, () => getFontDetails(fontFamily)) + return { fonts } + }, + } +}) diff --git a/src/providers/local.ts b/src/providers/local.ts deleted file mode 100644 index 4095444..0000000 --- a/src/providers/local.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 { withLeadingSlash, withTrailingSlash } from 'ufo' -import type { FontFaceData, FontProvider } from '../types' - -const providerContext = { - rootPaths: [] as string[], - registry: {} as Record, -} - -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 || '/'))) - } - } - - // 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) - } - }) - }, - 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, - }) - } - } - } - } - - if (fonts.length > 0) { - return { - fonts, - } - } - }, -} satisfies FontProvider - -const FONT_RE = /\.(?:ttf|woff|woff2|eot|otf)(?:\?[^.]+)?$/ -const NON_WORD_RE = /\W+/g - -export const isFontFile = (id: string) => FONT_RE.test(id) - -// TODO: support without hyphen -// TODO: support reading font metrics -const weightMap: Record = { - 100: 'thin', - 200: 'extra-light', - 300: 'light', - 400: 'normal', - 500: 'medium', - 600: 'semi-bold', - 700: 'bold', - 800: 'extra-bold', - 900: 'black', -} - -const weights = Object.entries(weightMap).flatMap(e => e).filter(r => r !== 'normal') -const WEIGHT_RE = createRegExp(anyOf(...new Set([...weights, ...weights.map(w => w.replace('-', ''))])).groupedAs('weight').after(not.digit).before(not.digit.or(wordBoundary)), ['i']) - -const styles = ['italic', 'oblique'] as const -const STYLE_RE = createRegExp(anyOf(...styles).groupedAs('style').before(not.wordChar.or(wordBoundary)), ['i']) - -const subsets = [ - 'cyrillic-ext', - 'cyrillic', - 'greek-ext', - 'greek', - 'vietnamese', - 'latin-ext', - 'latin', -] as const -const SUBSET_RE = createRegExp(anyOf(...subsets).groupedAs('subset').before(not.wordChar.or(wordBoundary)), ['i']) - -function generateSlugs(path: string) { - let name = filename(path) - - const weight = name.match(WEIGHT_RE)?.groups?.weight || 'normal' - const style = name.match(STYLE_RE)?.groups?.style || 'normal' - const subset = name.match(SUBSET_RE)?.groups?.subset || 'latin' - - for (const slug of [weight, style, subset]) { - name = name.replace(slug, '') - } - - const slugs = new Set() - - for (const slug of [name.replace(/\.\w*$/, ''), name.replace(/[._-]\w*$/, '')]) { - slugs.add([ - fontFamilyToSlug(slug.replace(/[\W_]+$/, '')), - weightMap[weight] || weight, - style, - subset, - ].join('-').toLowerCase()) - } - - 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 new file mode 100644 index 0000000..f3e94a8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,82 @@ +type Awaitable = T | Promise + +interface ProviderContext { + storage: { + getItem: { + (key: string): Promise + (key: string, init: () => Awaitable): Promise + } + setItem: (key: string, value: unknown) => Awaitable + } +} + +export interface ResolveFontFacesOptions { + weights: string[] + styles: Array<'normal' | 'italic' | 'oblique'> + // TODO: improve support and support unicode range + subsets: string[] + fallbacks?: string[] +} + +export interface RemoteFontSource { + url: string + originalURL?: string + format?: string + tech?: string +} + +export interface LocalFontSource { + name: string +} + +export interface FontFaceData { + src: Array + /** + * The font-display descriptor. + * @default 'swap' + */ + display?: 'auto' | 'block' | 'swap' | 'fallback' | 'optional' + /** A font-weight value. */ + weight?: string | number | [number, number] + /** A font-stretch value. */ + stretch?: string + /** A font-style value. */ + style?: string + /** The range of Unicode code points to be used from the font. */ + 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 InitializedProvider { + resolveFontFaces: (family: string, options: ResolveFontFacesOptions) => Awaitable +} + +interface ProviderDefinition { + (options: T, ctx: ProviderContext): Awaitable +} + +export interface Provider { + _name: string + (ctx: ProviderContext): Awaitable +} + +type ProviderFactory = + unknown extends T + ? () => Provider + : Partial extends T + ? (options?: T) => Provider + : (options: T) => Provider + +export function defineFontProvider(name: string, provider: ProviderDefinition): ProviderFactory { + return ((options: T) => Object.assign(provider.bind(null, options || {} as T), { _name: name })) as ProviderFactory +} diff --git a/test/index.test.ts b/test/index.test.ts index 963d58f..40e9585 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,8 +1,28 @@ import { describe, expect, it } from 'vitest' -import { welcome } from '../src' +import { createUnifont, providers } from '../src' describe('unifont', () => { - it('works', () => { - expect(welcome()).toMatchInlineSnapshot('"hello world"') + it('works with no providers', async () => { + const unifont = await createUnifont([]) + const { fonts } = await unifont.resolveFontFace('Poppins') + expect(fonts).toMatchInlineSnapshot(`[]`) + }) + + it('correctly types options for providers', async () => { + providers.google() + // @ts-expect-error options must be provided + providers.adobe() + + expect(true).toBe(true) + }) + + it('works with google provider', async () => { + const unifont = await createUnifont([ + providers.google(), + ]) + + const { fonts } = await unifont.resolveFontFace('Poppins') + + expect(fonts).toHaveLength(6) }) }) diff --git a/tsconfig.json b/tsconfig.json index 7b6d6b4..c8cef40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,26 @@ { "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "Bundler", + "target": "es2022", + "lib": [ + "es2022" + ], + "moduleDetection": "force", + "module": "preserve", + "resolveJsonModule": true, + "allowJs": true, "strict": true, + "noImplicitOverride": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, "skipLibCheck": true }, - "include": ["src", "test", "playground"] + "include": [ + "src", + "test", + "playground" + ] }