Skip to content

Commit

Permalink
feat: add extractFontFaceData utility
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe committed Feb 20, 2024
1 parent e369485 commit 0409ed1
Show file tree
Hide file tree
Showing 5 changed files with 253 additions and 111 deletions.
119 changes: 119 additions & 0 deletions src/css/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { findAll, parse, type Declaration } from 'css-tree'

import type { FontFaceData, FontSource, RemoteFontSource } from '../types'

export function extractFontFaceData (css: string): FontFaceData[] {
const fontFaces: FontFaceData[] = []

for (const node of findAll(parse(css), node => node.type === 'Atrule' && node.name === 'font-face')) {
if (node.type !== 'Atrule' || node.name !== 'font-face') { continue }

const data: Partial<FontFaceData> = {}
const keyMap: Record<string, keyof FontFaceData> = {
src: 'src',
'font-display': 'display',
'font-weight': 'weight',
'font-style': 'style',
'font-feature-settings': 'featureSettings',
'font-variations-settings': 'variationSettings',
'unicode-range': 'unicodeRange',
}
for (const child of node.block?.children || []) {
if (child.type !== 'Declaration') { continue }
if (child.property in keyMap) {
data[keyMap[child.property]!] = extractCSSValue(child) as any
}
}
fontFaces.push(data as FontFaceData)
}

return fontFaces
}

function extractCSSValue (node: Declaration) {
if (node.value.type == 'Raw') {
return [node.value.value]
}

const values = [] as Array<string | number | FontSource>
for (const child of node.value.children) {
if (child.type === 'Function') {
if (child.name === 'local' && child.children.first?.type === 'String') {
values.push({ name: child.children.first.value })
}
if (child.name === 'format' && child.children.first?.type === 'String') {
(values.at(-1) as RemoteFontSource).format = child.children.first.value
}
if (child.name === 'tech' && child.children.first?.type === 'String') {
(values.at(-1) as RemoteFontSource).tech = child.children.first.value
}
}
if (child.type === 'Url') {
values.push({
url: child.value,
})
}
if (child.type === 'Identifier') {
values.push(child.name)
}
if (child.type === 'String') {
values.push(child.value)
}
if (child.type === 'UnicodeRange') {
values.push(child.value)
}
if (child.type === 'Number') {
values.push(Number(child.value))
}
}

if (values.length === 1) {
return values[0]
}

return values
}


// https://developer.mozilla.org/en-US/docs/Web/CSS/font-family
const genericCSSFamilies = new Set([
/* A generic family name only */
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'emoji',
'math',
'fangsong',

/* Global values */
'inherit',
'initial',
'revert',
'revert-layer',
'unset',
])

export function extractFontFamilies (node: Declaration) {
if (node.value.type == 'Raw') {
return [node.value.value]
}

const families = [] as string[]
for (const child of node.value.children) {
if (child.type === 'Identifier' && !genericCSSFamilies.has(child.name)) {
families.push(child.name)
}
if (child.type === 'String') {
families.push(child.value)
}
}

return families
}
67 changes: 67 additions & 0 deletions src/css/render.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { hasProtocol } from 'ufo'
import type { FontFaceData, FontSource } from '../types'
import { extname } from 'pathe'

export function generateFontFaces (family: string, source: FontFaceData | FontFaceData[]) {
const sources = Array.isArray(source) ? source : [source]
const declarations: string[] = []
for (const font of sources) {
const src = Array.isArray(font.src) ? font.src : [font.src]
const sources = src.map(s => typeof s === 'string' ? parseFont(s) : s)

declarations.push([
'@font-face {',
` font-family: '${family}';`,
` src: ${renderFontSrc(sources)};`,
` font-display: ${font.display || 'swap'};`,
font.unicodeRange && ` unicode-range: ${font.unicodeRange};`,
font.weight && ` font-weight: ${font.weight};`,
font.style && ` font-style: ${font.style};`,
font.featureSettings && ` font-feature-settings: ${font.featureSettings};`,
font.variationSettings && ` font-variation-settings: ${font.variationSettings};`,
`}`
].filter(Boolean).join('\n'))
}

return declarations
}

const formatMap: Record<string, string> = {
otf: 'opentype',
woff: 'woff',
woff2: 'woff2',
ttf: 'truetype',
eot: 'embedded-opentype',
svg: 'svg',
}

function parseFont (font: string) {
// render as `url("url/to/font") format("woff2")`
if (font.startsWith('/') || hasProtocol(font)) {
const extension = extname(font).slice(1)
const format = formatMap[extension]

return {
url: font,
format
}
}

// render as `local("Font Name")`
return { name: font }
}

function renderFontSrc (sources: Exclude<FontSource, string>[]) {
return sources.map(src => {
if ('url' in src) {
let rendered = `url("${src.url}")`
for (const key of ['format', 'tech'] as const) {
if (key in src) {
rendered += ` ${key}(${src[key]})`
}
}
return rendered
}
return `local("${src.name}")`
}).join(', ')
}
3 changes: 2 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { addBuildPlugin, addTemplate, defineNuxtModule, resolveAlias, resolvePat
import google from './providers/google'
import local from './providers/local'

import { FontFamilyInjectionPlugin, generateFontFaces } from './plugins/transform'
import { FontFamilyInjectionPlugin } from './plugins/transform'
import { generateFontFaces } from './css/render'
import type { FontFaceData, FontFamilyManualOverride, FontFamilyProviderOverride, FontProvider, ModuleOptions, ResolveFontFacesOptions } from './types'

export type { ModuleOptions } from './types'
Expand Down
114 changes: 4 additions & 110 deletions src/plugins/transform.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createUnplugin } from 'unplugin'
import { parse, walk, type Declaration } from 'css-tree'
import { parse, walk } from 'css-tree'
import MagicString from 'magic-string'
import { extname } from 'pathe'
import { hasProtocol } from 'ufo'

import type { Awaitable, FontFaceData, FontSource } from '../types'
import type { Awaitable, FontFaceData } from '../types'
import { extractFontFamilies } from '../css/parse'
import { generateFontFaces } from '../css/render'

interface FontFamilyInjectionPluginOptions {
resolveFontFace: (fontFamily: string) => Awaitable<FontFaceData | FontFaceData[] | undefined>
Expand Down Expand Up @@ -89,109 +89,3 @@ function isCSS (id: string) {
return IS_CSS_RE.test(id)
}

// https://developer.mozilla.org/en-US/docs/Web/CSS/font-family
const genericCSSFamilies = new Set([
/* A generic family name only */
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'emoji',
'math',
'fangsong',

/* Global values */
'inherit',
'initial',
'revert',
'revert-layer',
'unset',
])

export function generateFontFaces (family: string, source: FontFaceData | FontFaceData[]) {
const sources = Array.isArray(source) ? source : [source]
const declarations: string[] = []
for (const font of sources) {
const src = Array.isArray(font.src) ? font.src : [font.src]
const sources = src.map(s => typeof s === 'string' ? parseFont(s) : s)

declarations.push([
'@font-face {',
` font-family: '${family}';`,
` src: ${renderFontSrc(sources)};`,
` font-display: ${font.display || 'swap'};`,
font.unicodeRange && ` unicode-range: ${font.unicodeRange};`,
font.weight && ` font-weight: ${font.weight};`,
font.style && ` font-style: ${font.style};`,
font.featureSettings && ` font-feature-settings: ${font.featureSettings};`,
font.variationSettings && ` font-variation-settings: ${font.variationSettings};`,
`}`
].filter(Boolean).join('\n'))
}

return declarations
}

const formatMap: Record<string, string> = {
otf: 'opentype',
woff: 'woff',
woff2: 'woff2',
ttf: 'truetype',
eot: 'embedded-opentype',
svg: 'svg',
}

function parseFont (font: string) {
// render as `url("url/to/font") format("woff2")`
if (font.startsWith('/') || hasProtocol(font)) {
const extension = extname(font).slice(1)
const format = formatMap[extension]

return {
url: font,
format
}
}

// render as `local("Font Name")`
return { name: font }
}

function renderFontSrc (sources: Exclude<FontSource, string>[]) {
return sources.map(src => {
if ('url' in src) {
let rendered = `url("${src.url}")`
for (const key of ['format', 'tech'] as const) {
if (key in src) {
rendered += ` ${key}(${src[key]})`
}
}
return rendered
}
return `local("${src.name}")`
}).join(', ')
}

function extractFontFamilies (node: Declaration) {
if (node.value.type == 'Raw') {
return [node.value.value]
}

const families = [] as string[]
for (const child of node.value.children) {
if (child.type === 'Identifier' && !genericCSSFamilies.has(child.name)) {
families.push(child.name)
}
if (child.type === 'String') {
families.push(child.value)
}
}

return families
}
61 changes: 61 additions & 0 deletions test/extract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import { extractFontFaceData } from '../src/css/parse'

describe('extract font face from CSS', () => {
it('should add declarations for `font-family`', async () => {
expect(extractFontFaceData(`
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-display: swap;
font-weight: 500;
src: local("Open Sans"), url(./files/open-sans-latin-500-normal.woff2) format('woff2'), url(./files/open-sans-latin-500-normal.woff) format('woff');
unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;
}
`))
.toMatchInlineSnapshot(`
[
{
"display": "swap",
"src": [
{
"name": "Open Sans",
},
{
"format": "woff2",
"url": "./files/open-sans-latin-500-normal.woff2",
},
{
"format": "woff",
"url": "./files/open-sans-latin-500-normal.woff",
},
],
"style": "normal",
"unicodeRange": [
"U+0000-00FF",
"U+0131",
"U+0152-0153",
"U+02BB-02BC",
"U+02C6",
"U+02DA",
"U+02DC",
"U+0304",
"U+0308",
"U+0329",
"U+2000-206F",
"U+2074",
"U+20AC",
"U+2122",
"U+2191",
"U+2193",
"U+2212",
"U+2215",
"U+FEFF",
"U+FFFD",
],
"weight": 500,
},
]
`)
})
})

0 comments on commit 0409ed1

Please sign in to comment.