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
68 changes: 68 additions & 0 deletions packages/preset-web-fonts/src/local-font.ts
@@ -0,0 +1,68 @@
/**
* Inspired by:
* https://github.com/feat-agency/vite-plugin-webfont-dl/blob/master/src/downloader.ts
*/
import { $fetch } from 'ofetch'

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

const defaultFontCssFilename = 'fonts.css'

// eslint-disable-next-line node/prefer-global/process
const isNode = typeof process !== 'undefined' && process.stdout && !process.versions.deno

interface UseLocalFontOptions {
downloadDir: string
}

export async function useLocalFont(css: string, { downloadDir }: UseLocalFontOptions) {
if (!isNode)
return

const { resolve } = await import('node:path')
const { writeFile, mkdir } = await import('node:fs/promises')

await mkdir(downloadDir, { recursive: true })

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

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

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

async function saveFont(url: string, path: string) {
if (await fileExists(path))
return
const { writeFile } = await import('node:fs/promises')
const { Buffer } = await 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) {
if (!isNode)
return ''

const { resolve } = await import('node:path')
const { readFile } = await 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' })
}
51 changes: 14 additions & 37 deletions packages/preset-web-fonts/src/preset.ts
Expand Up @@ -6,6 +6,9 @@ import { GoogleFontsProvider } from './providers/google'
import { FontshareProvider } from './providers/fontshare'
import { NoneProvider } from './providers/none'
import type { Provider, ResolvedWebFontMeta, WebFontMeta, WebFontsOptions, WebFontsProviders } from './types'
import { getRemoteFontsCSS } from './remote-font'
import { resolveDownloadDir } from './util'
import { readFontCSS } from './local-font'

const builtinProviders = {
google: GoogleFontsProvider,
Expand Down Expand Up @@ -45,60 +48,34 @@ export function createWebFontPreset(fetcher: (url: string) => Promise<any>) {
inlineImports = true,
themeKey = 'fontFamily',
customFetch = fetcher,
downloadLocally = false,
downloadDir = 'public',
} = 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 (downloadLocally) {
const resolvedDownloadDir = await resolveDownloadDir(downloadDir)
return await readFontCSS(resolvedDownloadDir)
}

return preflights.filter(Boolean).join('\n')
else { return getRemoteFontsCSS(fontObject, { inlineImports, customFetch }) }
},
layer: inlineImports ? undefined : LAYER_IMPORTS,
},
],
options: {
downloadLocally,
downloadDir,
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')
}
14 changes: 14 additions & 0 deletions packages/preset-web-fonts/src/types.ts
Expand Up @@ -53,6 +53,20 @@ export interface WebFontsOptions {
* @default undefined
*/
customFetch?: (url: string) => Promise<any>

/**
* Download fonts locally
*
* @default false
*/
downloadLocally?: boolean

/**
* Where to download the fonts
*
* @default 'process.cwd()/public/fonts'
*/
downloadDir?: string | (() => Promise<string>)
}

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

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

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

if (typeof downloadDir === 'function')
return await downloadDir()
else if (typeof downloadDir === 'string')
return resolve(cwd(), downloadDir)
return `${cwd()}/public/fonts`
}
4 changes: 3 additions & 1 deletion packages/vite/src/index.ts
@@ -1,6 +1,6 @@
import process from 'node:process'
import type { Plugin } from 'vite'
import type { UnocssPluginContext, UserConfigDefaults } from '@unocss/core'
import type { Preset, UnocssPluginContext, UserConfigDefaults } from '@unocss/core'

Check failure on line 3 in packages/vite/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

'Preset' is defined but never used

Check failure on line 3 in packages/vite/src/index.ts

View workflow job for this annotation

GitHub Actions / lint

'Preset' is defined but never used
import UnocssInspector from '@unocss/inspector'
import { createContext } from './integration'
import { ChunkModeBuildPlugin } from './modes/chunk-build'
Expand All @@ -12,6 +12,7 @@
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 @@
ConfigHMRPlugin(ctx),
...createTransformerPlugins(ctx),
...createDevtoolsPlugin(ctx, inlineConfig),
...createWebFontPlugins(ctx),
{
name: 'unocss:api',
api: <UnocssVitePluginAPI>{
Expand Down
37 changes: 37 additions & 0 deletions packages/vite/src/web-fonts.ts
@@ -0,0 +1,37 @@
import type { Plugin } from 'vite'
import { type PreflightContext, type Preset, type UnocssPluginContext, resolvePreset } from '@unocss/core'

Check failure on line 2 in packages/vite/src/web-fonts.ts

View workflow job for this annotation

GitHub Actions / lint

'PreflightContext' is defined but never used

Check failure on line 2 in packages/vite/src/web-fonts.ts

View workflow job for this annotation

GitHub Actions / lint

'resolvePreset' is defined but never used

Check failure on line 2 in packages/vite/src/web-fonts.ts

View workflow job for this annotation

GitHub Actions / lint

'PreflightContext' is defined but never used

Check failure on line 2 in packages/vite/src/web-fonts.ts

View workflow job for this annotation

GitHub Actions / lint

'resolvePreset' is defined but never used
import { useLocalFont } from '../../preset-web-fonts/src/local-font'
import { resolveDownloadDir } from '../../preset-web-fonts/src/util'
import { getRemoteFontsCSS } from '../../preset-web-fonts/src/remote-font'

// eslint-disable-next-line node/prefer-global/process
const isNode = typeof process !== 'undefined' && process.stdout && !process.versions.deno

export function createWebFontPlugins(ctx: UnocssPluginContext): Plugin[] {
return [
{
name: `unocss:web-fonts-local:dev`,
enforce: 'pre',
apply: 'serve',
userquin marked this conversation as resolved.
Show resolved Hide resolved
async configureServer(_server) {
if (!isNode)
return

const webFontPreset = lookupPreset(ctx, '@unocss/preset-web-fonts')

if (!webFontPreset || !webFontPreset.options?.downloadLocally)
return

const { $fetch } = await import('ofetch')
const fontCSS = await getRemoteFontsCSS(webFontPreset.options.fontObject, { inlineImports: true, customFetch: $fetch })
const downloadDir = await resolveDownloadDir(webFontPreset.options.downloadDir)
await useLocalFont(fontCSS, { downloadDir })
},
},
]
}

function lookupPreset<P extends Preset<any>>(ctx: UnocssPluginContext, presetName: P['name']) {
const preset: P | undefined = ctx.uno.config?.presets?.find(p => p.name === presetName) as any
return preset
}
2 changes: 1 addition & 1 deletion playground/src/App.vue
@@ -1,5 +1,5 @@
<template>
<div class=":uno: font-sans leading-1em">
<div class=":uno: font-serif leading-[1em]">
<Playground />
</div>
</template>
4 changes: 2 additions & 2 deletions playground/src/components/HeaderBar.vue
Expand Up @@ -23,7 +23,7 @@ function handleReset() {

<template>
<div
class="flex items-center px-2 op-60 bg-gray/10"
class="flex items-center px2 op-60 bg-gray/10 font-sans"
border="l t gray-400/20" w-full
>
<div flex items-center gap-2>
Expand All @@ -36,7 +36,7 @@ function handleReset() {
</div>
</div>

<div class="pl-1 ml-auto space-x-2 text-sm md:text-base flex items-center flex-nowrap">
<div class="pl1 ml-auto space-x-2 text-sm md:text-base flex items-center flex-nowrap">
<button
:class="copied ? 'i-ri-checkbox-circle-line text-green' : 'i-ri-share-line'"
icon-btn
Expand Down
2 changes: 1 addition & 1 deletion playground/src/composables/constants.ts
Expand Up @@ -2,7 +2,7 @@ import defaultConfigRaw from '../../../packages/shared-docs/src/defaultConfig.ts
import { version } from '../../../package.json'

export const defaultHTML = `
<div h-full text-center flex select-none all:transition-400>
<div h-full text-center flex select-none all:transition-400 font-serif>
<div ma>
<div text-5xl fw100 animate-bounce-alt animate-count-infinite animate-duration-1s>
UnoCSS
Expand Down
11 changes: 10 additions & 1 deletion playground/uno.config.ts
Expand Up @@ -7,11 +7,12 @@ import {
transformerDirectives,
transformerVariantGroup,
} from 'unocss'
import presetWebFonts from '../packages/preset-web-fonts/src/'

export default defineConfig({
theme: {
fontFamily: {
sans: '\'Inter\', sans-serif',
sans: '\'Lato\', sans-serif',
mono: '\'Fira Code\', monospace',
},
},
Expand All @@ -22,6 +23,14 @@ export default defineConfig({
presetAttributify(),
presetUno(),
presetIcons(),
presetWebFonts({
downloadLocally: true,
provider: 'google',
fonts: {
sans: 'Lato',
serif: 'Merriweather',
},
}),
],
transformers: [
transformerCompileClass(),
Expand Down
3 changes: 2 additions & 1 deletion playground/vite.config.ts
Expand Up @@ -3,7 +3,8 @@ import Vue from '@vitejs/plugin-vue'
import Inspect from 'vite-plugin-inspect'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import UnoCSS from '@unocss/vite'
// import UnoCSS from '@unocss/vite'
import UnoCSS from '../packages/vite/src'
import { alias } from '../alias'

export default defineConfig({
Expand Down