Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(web-fonts): add downloadLocally options #3723

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -25,3 +25,4 @@ interactive/public/shiki
.svelte-kit
.eslintcache
vite.config.ts.timestamp-*
unocss-fonts/
13 changes: 13 additions & 0 deletions examples/nuxt3/nuxt.config.ts
Expand Up @@ -6,6 +6,19 @@ export default defineNuxtConfig({
attributify: true,
icons: true,
components: false,
theme: {
fontFamily: {
sans: 'sans-serif',
},
},
webFonts: {
downloadLocally: true,
provider: 'google',
fonts: {
sans: 'Lato',
serif: 'Merriweather',
},
},
shortcuts: [
['btn', 'px-4 py-1 rounded inline-block bg-teal-600 text-white cursor-pointer hover:bg-teal-700 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50'],
],
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/build.config.ts
Expand Up @@ -8,5 +8,6 @@ export default defineBuildConfig({
declaration: true,
externals: [
'@nuxt/schema',
'pathe',
],
})
4 changes: 4 additions & 0 deletions packages/nuxt/src/index.ts
Expand Up @@ -10,6 +10,7 @@ import { loadConfig } from '@unocss/config'
import type { UserConfig } from '@unocss/core'
import { resolveOptions } from './options'
import type { UnocssNuxtOptions } from './types'
import { configureWebFontPreset } from './web-fonts'

export { UnocssNuxtOptions }

Expand Down Expand Up @@ -38,6 +39,9 @@ export default defineNuxtModule<UnocssNuxtOptions>({
// preset shortcuts
resolveOptions(options)

// configure local webfonts preset
configureWebFontPreset(nuxt, options)

options.mode ??= 'global'
const InjectModes: VitePluginConfig['mode'][] = ['global', 'dist-chunk']

Expand Down
23 changes: 23 additions & 0 deletions packages/nuxt/src/web-fonts.ts
@@ -0,0 +1,23 @@
import type { Preset } from '@unocss/core'
import type { Nuxt } from '@nuxt/schema'
import { dirname, relative } from 'pathe'
import { defaultFontCssFilename } from '@unocss/preset-web-fonts/local-font'
import type { UnocssNuxtOptions } from './types'

export function configureWebFontPreset(nuxt: Nuxt, options: UnocssNuxtOptions) {
const webFontPreset = lookupPreset(options, '@unocss/preset-web-fonts')
if (webFontPreset && !!webFontPreset.options?.downloadLocally) {
const downloadDir = `${nuxt.options.dir.public}/unocss-fonts`
webFontPreset.options.downloadLocally = {
downloadDir,
downloadBasePath: nuxt.options.app.baseURL,
}
nuxt.options.css ??= []
nuxt.options.css.push(`~/${relative(dirname(nuxt.options.dir.public), downloadDir)}/${defaultFontCssFilename}`.replaceAll('\\', '/'))
Copy link
Member

@userquin userquin Apr 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should use unshift?

}
}

function lookupPreset<P extends Preset<any>>(options: UnocssNuxtOptions, presetName: P['name']) {
const preset: P | undefined = (options.presets || []).flat().find(p => p.name === presetName) as any
return preset
}
2 changes: 2 additions & 0 deletions packages/preset-web-fonts/build.config.ts
Expand Up @@ -3,6 +3,8 @@ import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: [
'src/index',
'src/local-font',
'src/remote-font',
],
clean: true,
declaration: true,
Expand Down
16 changes: 16 additions & 0 deletions packages/preset-web-fonts/package.json
Expand Up @@ -27,11 +27,27 @@
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./local-font": {
"types": "./dist/local-font.d.mts",
"default": "./dist/local-font.mjs"
},
"./remote-font": {
"types": "./dist/remote-font.d.mts",
"default": "./dist/remote-font.mjs"
}
},
"main": "dist/index.mjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"typesVersions": {
"*": {
"*": [
"./dist/*",
"./*"
]
}
},
"files": [
"*.css",
"dist"
Expand Down
66 changes: 66 additions & 0 deletions packages/preset-web-fonts/src/local-font.ts
@@ -0,0 +1,66 @@
/**
* Inspired by:
* https://github.com/feat-agency/vite-plugin-webfont-dl/blob/master/src/downloader.ts
*/
import { resolveDownloadDir } from './util'

const fontUrlRegex = /[-a-z0-9@:%_+.~#?&/=]+\.(?:woff2?|eot|ttf|otf|svg)/gi

export const defaultFontCssFilename = 'fonts.css'

interface UseLocalFontOptions {
downloadDir: string
downloadBasePath: string
}

export { resolveDownloadDir }

export async function useLocalFont(css: string, { downloadDir, downloadBasePath }: UseLocalFontOptions) {
const [{ mkdir, writeFile }, { resolve }] = await Promise.all([
import('node:fs/promises'),
import('node:path'),
])
await mkdir(downloadDir, { recursive: true })

// Download the fonts locally and update the font.css file
for (const url of css.match(fontUrlRegex) || []) {
const name = url.split('/').pop()!
await saveFont(url, resolve(downloadDir, name))
css = css.replaceAll(url, `${downloadBasePath}${name}`)
}

// Save the updated font.css file
const fontCssPath = resolve(downloadDir, defaultFontCssFilename)
await writeFile(fontCssPath, css)
}

async function fileExists(path: string) {
const { lstat } = await import('node:fs/promises')
return await lstat(path).then(({ isFile }) => isFile()).catch(() => false)
}

async function saveFont(url: string, path: string) {
if (await fileExists(path))
return

const [{ writeFile }, { $fetch }, { Buffer }] = await Promise.all([
import('node:fs/promises'),
import('ofetch'),
import('node:buffer'),
])
const response = await $fetch(url, { headers: { responseType: 'arraybuffer' } }) as ArrayBuffer
const content = new Uint8Array(response)
await writeFile(path, Buffer.from(content))
}

export async function readFontCSS(downloadDir: string) {
const [{ resolve }, { readFile }] = await Promise.all([
import('node:path'),
import('node:fs/promises'),
])
const fontCssPath = resolve(downloadDir, defaultFontCssFilename)
if (!await fileExists(fontCssPath))
return '/* [preset-web-font] This code will be replaced with the local CSS once it is downloaded */'

return await readFile(fontCssPath, { encoding: 'utf-8' })
}
50 changes: 14 additions & 36 deletions packages/preset-web-fonts/src/preset.ts
Expand Up @@ -5,6 +5,7 @@ import { BunnyFontsProvider } from './providers/bunny'
import { GoogleFontsProvider } from './providers/google'
import { FontshareProvider } from './providers/fontshare'
import { NoneProvider } from './providers/none'
import { isNode } from './util'
import type { Provider, ResolvedWebFontMeta, WebFontMeta, WebFontsOptions, WebFontsProviders } from './types'

const builtinProviders = {
Expand Down Expand Up @@ -45,60 +46,37 @@ export function createWebFontPreset(fetcher: (url: string) => Promise<any>) {
inlineImports = true,
themeKey = 'fontFamily',
customFetch = fetcher,
downloadLocally,
} = options

const fontObject = Object.fromEntries(
Object.entries(options.fonts || {})
.map(([name, meta]) => [name, toArray(meta).map(m => normalizedFontMeta(m, defaultProvider))]),
)
const fonts = Object.values(fontObject).flatMap(i => i)

const importCache: Record<string, Promise<string>> = {}

async function importUrl(url: string) {
if (inlineImports) {
if (!importCache[url]) {
importCache[url] = customFetch(url).catch((e) => {
console.error('Failed to fetch web fonts')
console.error(e)
// eslint-disable-next-line node/prefer-global/process
if (typeof process !== 'undefined' && process.env.CI)
throw e
})
}
return await importCache[url]
}
else {
return `@import url('${url}');`
}
}

const enabledProviders = new Set(fonts.map(i => i.provider))

const preset: Preset<any> = {
name: '@unocss/preset-web-fonts',
preflights: [
{
async getCSS() {
const preflights: (string | undefined)[] = []

for (const provider of enabledProviders) {
const fontsForProvider = fonts.filter(i => i.provider.name === provider.name)

if (provider.getImportUrl) {
const url = provider.getImportUrl(fontsForProvider)
if (url)
preflights.push(await importUrl(url))
}

preflights.push(provider.getPreflight?.(fontsForProvider))
if (!isNode || !downloadLocally) {
const { getRemoteFontsCSS } = await import('./remote-font')
return getRemoteFontsCSS(fontObject, { inlineImports, customFetch })
}

return preflights.filter(Boolean).join('\n')
const { readFontCSS, resolveDownloadDir } = await import('./local-font')
const resolvedDownloadDir = await resolveDownloadDir(
downloadLocally === true ? undefined : downloadLocally.downloadDir,
)
return readFontCSS(resolvedDownloadDir)
},
layer: inlineImports ? undefined : LAYER_IMPORTS,
},
],
options: {
downloadLocally,
fontObject,
},
}

if (extendTheme) {
Expand Down
44 changes: 44 additions & 0 deletions packages/preset-web-fonts/src/remote-font.ts
@@ -0,0 +1,44 @@
import type { ResolvedWebFontMeta, WebFontsOptions } from './types'

type UseRemoteFontOptions = Required<Pick<WebFontsOptions, 'inlineImports' | 'customFetch'>>

export async function getRemoteFontsCSS(fontObject: { [k: string]: ResolvedWebFontMeta[] }, { inlineImports, customFetch }: UseRemoteFontOptions) {
const fonts = Object.values(fontObject).flatMap(i => i)

const importCache: Record<string, Promise<string>> = {}

async function importUrl(url: string) {
if (inlineImports) {
if (!importCache[url]) {
importCache[url] = customFetch(url).catch((e) => {
console.error('Failed to fetch web fonts')
console.error(e)
// eslint-disable-next-line node/prefer-global/process
if (typeof process !== 'undefined' && process.env.CI)
throw e
})
}
return await importCache[url]
}
else {
return `@import url('${url}');`
}
}

const preflights: (string | undefined)[] = []
const enabledProviders = new Set(fonts.map(i => i.provider))

for (const provider of enabledProviders) {
const fontsForProvider = fonts.filter(i => i.provider.name === provider.name)

if (provider.getImportUrl) {
const url = provider.getImportUrl(fontsForProvider)
if (url)
preflights.push(await importUrl(url))
}

preflights.push(provider.getPreflight?.(fontsForProvider))
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing for downloadLocally

}

return preflights.filter(Boolean).join('\n')
}
24 changes: 24 additions & 0 deletions packages/preset-web-fonts/src/types.ts
Expand Up @@ -53,6 +53,30 @@ export interface WebFontsOptions {
* @default undefined
*/
customFetch?: (url: string) => Promise<any>

/**
* Download fonts locally.
*
* @default false
*/
downloadLocally?: boolean | {
/**
* Where to download the fonts.
*
* This option will be overridden by integrations:
* - Vite, Astro and Nuxt will use Vite [publicDir](): `${publicDir}/unocss-fonts`
* - SvelteKit will use `static` folder: `static/unocss-fonts`
*
* @default 'process.cwd()/public/unocss-fonts'
*/
downloadDir?: string
/**
*
*
* @default /
*/
downloadBasePath?: string
}
}

export interface Provider {
Expand Down
15 changes: 15 additions & 0 deletions packages/preset-web-fonts/src/util.ts
@@ -0,0 +1,15 @@
// eslint-disable-next-line node/prefer-global/process
export const isNode = typeof process !== 'undefined' && process.stdout && !process.versions.deno

export async function resolveDownloadDir(downloadDir?: string | (() => string)) {
if (!isNode)
return ''

if (typeof downloadDir === 'function')
return downloadDir()

const { resolve } = await import('node:path')
const { cwd } = await import('node:process')

return typeof downloadDir === 'string' ? resolve(cwd(), downloadDir) : `${cwd()}/public/unocss-fonts`
}
5 changes: 5 additions & 0 deletions packages/vite/build.config.ts
Expand Up @@ -9,5 +9,10 @@ export default defineBuildConfig({
declaration: true,
externals: [
'vite',
'ofetch',
'destr',
'ufo',
'@unocss/preset-web-fonts/local-font',
'@unocss/preset-web-fonts/remote-font',
],
})
2 changes: 2 additions & 0 deletions packages/vite/src/index.ts
Expand Up @@ -12,6 +12,7 @@ import { ConfigHMRPlugin } from './config-hmr'
import type { VitePluginConfig } from './types'
import { createTransformerPlugins } from './transformers'
import { createDevtoolsPlugin } from './devtool'
import { createWebFontPlugins } from './web-fonts'

export * from './types'
export * from './modes/chunk-build'
Expand Down Expand Up @@ -43,6 +44,7 @@ export default function UnocssPlugin<Theme extends object>(
ConfigHMRPlugin(ctx),
...createTransformerPlugins(ctx),
...createDevtoolsPlugin(ctx, inlineConfig),
...createWebFontPlugins(ctx),
{
name: 'unocss:api',
api: <UnocssVitePluginAPI>{
Expand Down