Writing a provider
Wrap a foundry API, an internal font server, or a folder on disk.
A provider is a function that returns up to three methods. defineFontProvider() gives it a name and types its options:
import { defineFontProvider } from 'unifont'
export interface FoundryOptions {
token: string
}
export const foundry = defineFontProvider('foundry', async (options: FoundryOptions, ctx) => {
// Runs once, when the instance is created.
return {
listFonts() { /* … */ },
getFontProperties(family) { /* … */ },
resolveFont(family, options) { /* … */ },
}
})Use it like any built-in:
const unifont = await createUnifont([
foundry({ token: process.env.FOUNDRY_TOKEN }),
providers.google(),
])The context
unifont passes two globally-configured primitives into each provider via a ctx.
ctx.fetch retries failures and rewrites the built-in providers' URLs to apiBase. Your own endpoints aren't rewritten, so a custom provider works in a browser only if its API sends CORS headers or you proxy it yourself.
ctx.storage.getItem(key, init) is a cache that fills itself. Pass a function and it runs once, then reads from whatever storage the user configured:
export const foundry = defineFontProvider('foundry', async (options: FoundryOptions, ctx) => {
const families = await ctx.storage.getItem('foundry:index.json', () =>
ctx.fetch('https://api.foundry.example/families').then(res => res.json()),
)
// …
})listFonts()
Optional. Return the family names you can resolve, or undefined if listing doesn't make sense for your source.
listFonts() {
return families.map(family => family.name)
}getFontProperties()
Optional. Return undefined for a family you don't know, and unifont will move on to the next provider.
getFontProperties(family) {
const match = families.find(item => item.name === family)
if (!match) {
return undefined
}
return {
weights: match.weights,
styles: match.styles,
subsets: match.subsets,
formats: ['woff2'],
}
}resolveFont()
The only method that's required for every provider. Return undefined for families you don't know. Otherwise return fonts, and fallbacks if you have them. Key your cache on the options, because the same family may resolve differently from one request to the next:
import { hash } from 'ohash'
async resolveFont(family, options) {
const match = families.find(item => item.name === family)
if (!match) {
return
}
return {
fonts: await ctx.storage.getItem(`foundry:${family}-${hash(options)}.json`, async () => {
const css = await ctx.fetch(match.cssUrl).then(res => res.text())
return parseIntoFontFaceData(css)
}),
fallbacks: ['sans-serif'],
}
}Most providers are really just a CSS parser. If your source hands you a stylesheet, read the built-in ones in the repository: google.ts for subset-split woff2, fontshare.ts for whole-file families, npm.ts for local resolution.
Family options
Declare a family options type and it's inferred at the call site, under your provider's name:
export interface FoundryFamilyOptions {
optical?: 'display' | 'text'
}
import type { ResolveFontOptions } from 'unifont'
export const foundry = defineFontProvider('foundry', async (options: FoundryOptions, ctx) => ({
async resolveFont(family, options: ResolveFontOptions<FoundryFamilyOptions>) {
options.options?.optical // typed
// …
},
}))await unifont.resolveFont('Foundry Sans', {
options: {
foundry: { optical: 'display' },
},
})Failing
If a provider throws while starting up, it's dropped with a console error and the rest carry on, unless the user set throwOnError.
Don't try to avoid that by returning empty data when something fails. An empty result means "I know this family and it has nothing", which stops anything else being asked. Throw, or return undefined.