Skip to content

Commit a1641ef

Browse files
committed
feat(fontless): extract core utilities from nuxt/fonts into new package
2 parents 0997750 + f847d0e commit a1641ef

File tree

12 files changed

+1011
-0
lines changed

12 files changed

+1011
-0
lines changed

packages/fontless/src/assets.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { hash } from 'ohash'
2+
import { extname } from 'pathe'
3+
import { filename } from 'pathe/utils'
4+
import { hasProtocol, joinRelativeURL, joinURL } from 'ufo'
5+
import type { FontFaceData } from 'unifont'
6+
import type { RawFontFaceData } from './types'
7+
import { formatToExtension, parseFont } from './css/render'
8+
9+
function toArray<T>(value?: T | T[]): T[] {
10+
return !value || Array.isArray(value) ? value as T[] : [value]
11+
}
12+
13+
export interface NormalizeFontDataContext {
14+
dev: boolean
15+
renderedFontURLs: Map<string, string>
16+
assetsBaseURL: string
17+
callback?: (filename: string, url: string) => void
18+
}
19+
20+
export function normalizeFontData(context: NormalizeFontDataContext, faces: RawFontFaceData | FontFaceData[]): FontFaceData[] {
21+
const data: FontFaceData[] = []
22+
for (const face of toArray(faces)) {
23+
data.push({
24+
...face,
25+
unicodeRange: toArray(face.unicodeRange),
26+
src: toArray(face.src).map((src) => {
27+
const source = typeof src === 'string' ? parseFont(src) : src
28+
if ('url' in source && hasProtocol(source.url, { acceptRelative: true })) {
29+
source.url = source.url.replace(/^\/\//, 'https://')
30+
const _url = source.url.replace(/\?.*/, '')
31+
const MAX_FILENAME_PREFIX_LENGTH = 50
32+
const file = [
33+
// TODO: investigate why negative ignore pattern below is being ignored
34+
hash(filename(_url) || _url).replace(/^-+/, '').slice(0, MAX_FILENAME_PREFIX_LENGTH),
35+
hash(source).replace(/-/, '_') + (extname(source.url) || formatToExtension(source.format) || ''),
36+
].filter(Boolean).join('-')
37+
38+
context.renderedFontURLs.set(file, source.url)
39+
source.originalURL = source.url
40+
41+
source.url = context.dev
42+
? joinRelativeURL(context.assetsBaseURL, file)
43+
: joinURL(context.assetsBaseURL, file)
44+
45+
context.callback?.(file, source.url)
46+
}
47+
48+
return source
49+
}),
50+
})
51+
}
52+
return data
53+
}

packages/fontless/src/css/parse.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import type { Declaration } from 'css-tree'
2+
import type { FontFaceData } from 'unifont'
3+
4+
const weightMap: Record<string, string> = {
5+
100: 'Thin',
6+
200: 'ExtraLight',
7+
300: 'Light',
8+
400: 'Regular',
9+
500: 'Medium',
10+
600: 'SemiBold',
11+
700: 'Bold',
12+
800: 'ExtraBold',
13+
900: 'Black',
14+
}
15+
16+
const styleMap: Record<string, string> = {
17+
italic: 'Italic',
18+
oblique: 'Oblique',
19+
normal: '',
20+
}
21+
22+
function processRawValue(value: string) {
23+
return value.split(',').map(v => v.trim().replace(/^(?<quote>['"])(.*)\k<quote>$/, '$2'))
24+
}
25+
26+
// https://developer.mozilla.org/en-US/docs/Web/CSS/font-family
27+
/* A generic family name only */
28+
const _genericCSSFamilies = [
29+
'serif',
30+
'sans-serif',
31+
'monospace',
32+
'cursive',
33+
'fantasy',
34+
'system-ui',
35+
'ui-serif',
36+
'ui-sans-serif',
37+
'ui-monospace',
38+
'ui-rounded',
39+
'emoji',
40+
'math',
41+
'fangsong',
42+
] as const
43+
export type GenericCSSFamily = typeof _genericCSSFamilies[number]
44+
const genericCSSFamilies = new Set(_genericCSSFamilies)
45+
46+
/* Global values */
47+
const globalCSSValues = new Set([
48+
'inherit',
49+
'initial',
50+
'revert',
51+
'revert-layer',
52+
'unset',
53+
])
54+
55+
export function extractGeneric(node: Declaration) {
56+
if (node.value.type == 'Raw') {
57+
return
58+
}
59+
60+
for (const child of node.value.children) {
61+
if (child.type === 'Identifier' && genericCSSFamilies.has(child.name as GenericCSSFamily)) {
62+
return child.name as GenericCSSFamily
63+
}
64+
}
65+
}
66+
67+
export function extractEndOfFirstChild(node: Declaration) {
68+
if (node.value.type == 'Raw') {
69+
return
70+
}
71+
for (const child of node.value.children) {
72+
if (child.type === 'String') {
73+
return child.loc!.end.offset!
74+
}
75+
if (child.type === 'Operator' && child.value === ',') {
76+
return child.loc!.start.offset!
77+
}
78+
}
79+
return node.value.children.last!.loc!.end.offset!
80+
}
81+
82+
export function extractFontFamilies(node: Declaration) {
83+
if (node.value.type == 'Raw') {
84+
return processRawValue(node.value.value)
85+
}
86+
87+
const families = [] as string[]
88+
// Use buffer strategy to handle unquoted 'minified' font-family names
89+
let buffer = ''
90+
for (const child of node.value.children) {
91+
if (child.type === 'Identifier' && !genericCSSFamilies.has(child.name as GenericCSSFamily) && !globalCSSValues.has(child.name)) {
92+
buffer = buffer ? `${buffer} ${child.name}` : child.name
93+
}
94+
if (buffer && child.type === 'Operator' && child.value === ',') {
95+
families.push(buffer.replace(/\\/g, ''))
96+
buffer = ''
97+
}
98+
if (buffer && child.type === 'Dimension') {
99+
buffer = (buffer + ' ' + child.value + child.unit).trim()
100+
}
101+
if (child.type === 'String') {
102+
families.push(child.value)
103+
}
104+
}
105+
106+
if (buffer) {
107+
families.push(buffer)
108+
}
109+
110+
return families
111+
}
112+
113+
export function addLocalFallbacks(fontFamily: string, data: FontFaceData[]) {
114+
for (const face of data) {
115+
const style = (face.style ? styleMap[face.style] : '') ?? ''
116+
117+
if (Array.isArray(face.weight)) {
118+
face.src.unshift(({ name: ([fontFamily, 'Variable', style].join(' ')).trim() }))
119+
}
120+
else if (face.src[0] && !('name' in face.src[0])) {
121+
const weights = (Array.isArray(face.weight) ? face.weight : [face.weight])
122+
.map(weight => weightMap[weight])
123+
.filter(Boolean)
124+
125+
for (const weight of weights) {
126+
if (weight === 'Regular') {
127+
face.src.unshift({ name: ([fontFamily, style].join(' ')).trim() })
128+
}
129+
face.src.unshift({ name: ([fontFamily, weight, style].join(' ')).trim() })
130+
}
131+
}
132+
}
133+
return data
134+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { hasProtocol } from 'ufo'
2+
import { extname, relative } from 'pathe'
3+
import { getMetricsForFamily, generateFontFace as generateFallbackFontFace, readMetrics } from 'fontaine'
4+
import type { RemoteFontSource, FontFaceData } from 'unifont'
5+
import type { FontSource } from '../types'
6+
7+
export function generateFontFace(family: string, font: FontFaceData) {
8+
return [
9+
'@font-face {',
10+
` font-family: '${family}';`,
11+
` src: ${renderFontSrc(font.src)};`,
12+
` font-display: ${font.display || 'swap'};`,
13+
font.unicodeRange && ` unicode-range: ${font.unicodeRange};`,
14+
font.weight && ` font-weight: ${Array.isArray(font.weight) ? font.weight.join(' ') : font.weight};`,
15+
font.style && ` font-style: ${font.style};`,
16+
font.stretch && ` font-stretch: ${font.stretch};`,
17+
font.featureSettings && ` font-feature-settings: ${font.featureSettings};`,
18+
font.variationSettings && ` font-variation-settings: ${font.variationSettings};`,
19+
`}`,
20+
].filter(Boolean).join('\n')
21+
}
22+
23+
export async function generateFontFallbacks(family: string, data: FontFaceData, fallbacks?: Array<{ name: string, font: string }>) {
24+
if (!fallbacks?.length) return []
25+
26+
const fontURL = data.src!.find(s => 'url' in s) as RemoteFontSource | undefined
27+
const metrics = await getMetricsForFamily(family) || (fontURL && await readMetrics(fontURL.originalURL || fontURL.url))
28+
29+
if (!metrics) return []
30+
31+
const css: string[] = []
32+
for (const fallback of fallbacks) {
33+
css.push(generateFallbackFontFace(metrics, {
34+
...fallback,
35+
metrics: await getMetricsForFamily(fallback.font) || undefined,
36+
}))
37+
}
38+
return css
39+
}
40+
41+
const formatMap: Record<string, string> = {
42+
woff2: 'woff2',
43+
woff: 'woff',
44+
otf: 'opentype',
45+
ttf: 'truetype',
46+
eot: 'embedded-opentype',
47+
svg: 'svg',
48+
}
49+
const extensionMap = Object.fromEntries(Object.entries(formatMap).map(([key, value]) => [value, key]))
50+
export const formatToExtension = (format?: string) => format && format in extensionMap ? '.' + extensionMap[format] : undefined
51+
52+
export function parseFont(font: string) {
53+
// render as `url("url/to/font") format("woff2")`
54+
if (font.startsWith('/') || hasProtocol(font)) {
55+
const extension = extname(font).slice(1)
56+
const format = formatMap[extension]
57+
58+
return {
59+
url: font,
60+
format,
61+
} satisfies RemoteFontSource as RemoteFontSource
62+
}
63+
64+
// render as `local("Font Name")`
65+
return { name: font }
66+
}
67+
68+
function renderFontSrc(sources: Exclude<FontSource, string>[]) {
69+
return sources.map((src) => {
70+
if ('url' in src) {
71+
let rendered = `url("${src.url}")`
72+
for (const key of ['format', 'tech'] as const) {
73+
if (key in src) {
74+
rendered += ` ${key}(${src[key]})`
75+
}
76+
}
77+
return rendered
78+
}
79+
return `local("${src.name}")`
80+
}).join(', ')
81+
}
82+
83+
export function relativiseFontSources(font: FontFaceData, relativeTo: string) {
84+
return {
85+
...font,
86+
src: font.src.map((source) => {
87+
if ('name' in source) return source
88+
if (!source.url.startsWith('/')) return source
89+
return {
90+
...source,
91+
url: relative(relativeTo, source.url),
92+
}
93+
}),
94+
} satisfies FontFaceData
95+
}

packages/fontless/src/defaults.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { providers } from 'unifont'
2+
3+
import type { FontlessOptions } from './types'
4+
5+
export const defaultValues = {
6+
weights: [400],
7+
styles: ['normal', 'italic'] as const,
8+
subsets: [
9+
'cyrillic-ext',
10+
'cyrillic',
11+
'greek-ext',
12+
'greek',
13+
'vietnamese',
14+
'latin-ext',
15+
'latin',
16+
],
17+
fallbacks: {
18+
'serif': ['Times New Roman'],
19+
'sans-serif': ['Arial'],
20+
'monospace': ['Courier New'],
21+
'cursive': [],
22+
'fantasy': [],
23+
'system-ui': [
24+
'BlinkMacSystemFont',
25+
'Segoe UI',
26+
'Roboto',
27+
'Helvetica Neue',
28+
'Arial',
29+
],
30+
'ui-serif': ['Times New Roman'],
31+
'ui-sans-serif': ['Arial'],
32+
'ui-monospace': ['Courier New'],
33+
'ui-rounded': [],
34+
'emoji': [],
35+
'math': [],
36+
'fangsong': [],
37+
},
38+
} satisfies FontlessOptions['defaults']
39+
40+
export const defaultOptions: FontlessOptions = {
41+
processCSSVariables: 'font-prefixed-only',
42+
experimental: {
43+
disableLocalFallbacks: false,
44+
},
45+
defaults: {},
46+
assets: {
47+
prefix: '/_fonts',
48+
},
49+
local: {},
50+
google: {},
51+
adobe: {
52+
id: '',
53+
},
54+
providers: {
55+
adobe: providers.adobe,
56+
google: providers.google,
57+
googleicons: providers.googleicons,
58+
bunny: providers.bunny,
59+
fontshare: providers.fontshare,
60+
fontsource: providers.fontsource,
61+
},
62+
}

packages/fontless/src/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export { fontless } from './vite'
2+
3+
export { normalizeFontData } from './assets'
4+
export type { NormalizeFontDataContext } from './assets'
5+
6+
export { generateFontFace, parseFont } from './css/render'
7+
8+
export { defaultOptions, defaultValues } from './defaults'
9+
10+
export { resolveProviders } from './providers'
11+
12+
export { createResolver } from './resolve'
13+
export type { Resolver } from './resolve'
14+
15+
export { resolveMinifyCssEsbuildOptions, transformCSS } from './utils'
16+
export type { FontFamilyInjectionPluginOptions } from './utils'
17+
18+
export type {
19+
FontlessOptions,
20+
FontFallback,
21+
ManualFontDetails,
22+
ProviderFontDetails,
23+
FontFamilyManualOverride,
24+
FontFamilyOverrides,
25+
FontFamilyProviderOverride,
26+
FontProviderName,
27+
FontSource,
28+
} from './types'

packages/fontless/src/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "fontless",
3+
"version": "0.11.3",
4+
"type": "module",
5+
"exports": {
6+
".": "./index.ts"
7+
},
8+
"main": "./index.ts"
9+
}

0 commit comments

Comments
 (0)