Skip to content

Commit

Permalink
refactor: implement unifont
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe committed Oct 3, 2024
1 parent 4607fe3 commit e743386
Show file tree
Hide file tree
Showing 20 changed files with 825 additions and 701 deletions.
12 changes: 6 additions & 6 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
name: "CodeQL"
name: CodeQL

on:
push:
branches: [ "main" ]
branches: [main]
pull_request:
branches: [ "main" ]
branches: [main]
schedule:
- cron: "31 15 * * 5"
- cron: '31 15 * * 5'

jobs:
analyze:
Expand All @@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ javascript ]
language: [javascript]

steps:
- name: Checkout
Expand All @@ -40,4 +40,4 @@ jobs:
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
category: '/language:${{ matrix.language }}'
8 changes: 7 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import antfu from '@antfu/eslint-config'

export default antfu()
export default antfu().append({
files: ['playground/**'],
rules: {
'antfu/no-top-level-await': 'off',
'no-console': 'off',
},
})
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "unifont",
"type": "module",
"version": "0.0.0",
"packageManager": "pnpm@9.11.0",
"packageManager": "pnpm@9.12.0",
"description": "Framework agnostic tools for accessing data from font CDNs and providers",
"license": "MIT",
"repository": "danielroe/unifont",
Expand All @@ -28,8 +28,16 @@
"test:unit": "vitest",
"test:types": "tsc --noEmit"
},
"dependencies": {
"css-tree": "^3.0.0",
"defu": "^6.1.4",
"node-fetch-native": "^1.6.4",
"ohash": "^1.1.4",
"ufo": "^1.5.4"
},
"devDependencies": {
"@antfu/eslint-config": "latest",
"@types/css-tree": "^2.3.8",
"@types/node": "20.16.10",
"@vitest/coverage-v8": "latest",
"bumpp": "9.6.1",
Expand Down
14 changes: 9 additions & 5 deletions playground/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import assert from 'node:assert'
import * as pkg from 'unifont'
// @ts-check

// eslint-disable-next-line no-console
console.log(pkg.welcome())
import { createUnifont, providers } from 'unifont'

assert.strictEqual(pkg.welcome(), 'hello world')
const unifont = await createUnifont([
providers.google(),
])

const fonts = await unifont.resolveFontFace('Poppins')

console.log(fonts)
38 changes: 38 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type Awaitable<T> = T | Promise<T>

export interface Storage {
getItem: (key: string) => Awaitable<any | null>
setItem: (key: string, value: unknown) => Awaitable<void>
}

export function memoryStorage() {
const cache = new Map<string, unknown>()
return {
getItem(key: string) {
return cache.get(key)
},
setItem(key: string, value: unknown) {
cache.set(key, value)
},
} satisfies Storage
}

export function createAsyncStorage(storage: Storage) {
return {
async getItem<T = unknown>(key: string, init?: () => Promise<T>) {
return await storage.getItem(key) ?? (init ? await init() : null)
},
async setItem(key: string, value: unknown) {
await storage.setItem(key, value)
},
}
}
149 changes: 149 additions & 0 deletions src/css/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type { FontFaceData, LocalFontSource, RemoteFontSource } from '../types'

import { type Declaration, findAll, parse } from 'css-tree'

const extractableKeyMap: 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',
}

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

const formatPriorityList = Object.values(formatMap)

export function extractFontFaceData(css: string, family?: 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
}

if (family) {
const isCorrectFontFace = node.block?.children.some((child) => {
if (child.type !== 'Declaration' || child.property !== 'font-family') {
return false
}

const value = extractCSSValue(child) as string | string[]
const slug = family.toLowerCase()
if (typeof value === 'string' && value.toLowerCase() === slug) {
return true
}
if (Array.isArray(value) && value.length > 0 && value.some(v => v.toLowerCase() === slug)) {
return true
}
return false
})

// Don't extract font face data from this `@font-face` rule if it doesn't match the specified family
if (!isCorrectFontFace) {
continue
}
}

const data: Partial<FontFaceData> = {}
for (const child of node.block?.children || []) {
if (child.type === 'Declaration' && child.property in extractableKeyMap) {
const value = extractCSSValue(child) as any
data[extractableKeyMap[child.property]!] = child.property === 'src' && !Array.isArray(value) ? [value] : value
}
}
fontFaces.push(data as FontFaceData)
}

return mergeFontSources(fontFaces)
}

function processRawValue(value: string) {
return value.split(',').map(v => v.trim().replace(/^(?<quote>['"])(.*)\k<quote>$/, '$2'))
}

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

const values = [] as Array<string | number | RemoteFontSource | LocalFontSource>
let buffer = ''
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') {
buffer = buffer ? `${buffer} ${child.name}` : child.name
}
if (child.type === 'String') {
values.push(child.value)
}
if (child.type === 'Operator' && child.value === ',' && buffer) {
values.push(buffer)
buffer = ''
}
if (child.type === 'UnicodeRange') {
values.push(child.value)
}
if (child.type === 'Number') {
values.push(Number(child.value))
}
}

if (buffer) {
values.push(buffer)
}

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

return values
}

function mergeFontSources(data: FontFaceData[]) {
const mergedData: FontFaceData[] = []
for (const face of data) {
const keys = Object.keys(face).filter(k => k !== 'src') as Array<keyof typeof face>
const existing = mergedData.find(f => (Object.keys(f).length === keys.length + 1) && keys.every(key => f[key]?.toString() === face[key]?.toString()))
if (existing) {
existing.src.push(...face.src)
}
else {
mergedData.push(face)
}
}

// Sort font sources by priority
for (const face of mergedData) {
face.src.sort((a, b) => {
// Prioritise local fonts (with 'name' property) over remote fonts, and then formats by formatPriorityList
const aIndex = 'format' in a ? formatPriorityList.indexOf(a.format || 'woff2') : -2
const bIndex = 'format' in b ? formatPriorityList.indexOf(b.format || 'woff2') : -2
return aIndex - bIndex
})
}

return mergedData
}
24 changes: 24 additions & 0 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import defu from 'defu'
import { fetch } from 'node-fetch-native/proxy'
import { joinURL, withQuery } from 'ufo'

interface Mini$FetchOptions extends RequestInit {
baseURL?: string
responseType?: 'json' | 'arrayBuffer'
query?: Record<string, any>
}

function mini$fetch<T = unknown>(url: string, options?: Mini$FetchOptions) {
if (options?.baseURL) {
url = joinURL(options.baseURL, url)
}
if (options?.query) {
url = withQuery(url, options.query)
}
return fetch(url, options)
.then(r => options?.responseType === 'json' ? r.json() : options?.responseType === 'arrayBuffer' ? r.arrayBuffer() : r.text()) as Promise<T>
}

export const $fetch = Object.assign(mini$fetch, {
create: (defaults?: Mini$FetchOptions) => <T = unknown> (url: string, options?: Mini$FetchOptions) => mini$fetch<T>(url, defu(options, defaults)),
})
Loading

0 comments on commit e743386

Please sign in to comment.