Skip to content

Commit

Permalink
feat(web-fonts): add downloadLocally options
Browse files Browse the repository at this point in the history
  • Loading branch information
onmax committed Apr 14, 2024
1 parent 25e97ca commit ca5a114
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 44 deletions.
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))
}

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 { 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
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',
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

0 comments on commit ca5a114

Please sign in to comment.