diff --git a/fixtures/vanilla/public/biantaoti.woff b/fixtures/vanilla/public/biantaoti.woff new file mode 100644 index 0000000..f84acf2 Binary files /dev/null and b/fixtures/vanilla/public/biantaoti.woff differ diff --git a/fixtures/vanilla/src/biantaoti.woff b/fixtures/vanilla/src/biantaoti.woff index c890cff..f84acf2 100644 Binary files a/fixtures/vanilla/src/biantaoti.woff and b/fixtures/vanilla/src/biantaoti.woff differ diff --git a/fixtures/vanilla/src/main.ts b/fixtures/vanilla/src/main.ts index 296fb18..04796cb 100644 --- a/fixtures/vanilla/src/main.ts +++ b/fixtures/vanilla/src/main.ts @@ -2,6 +2,17 @@ import "./style.css"; import typescriptLogo from "./typescript.svg"; import viteLogo from "/vite.svg"; import { setupCounter } from "./counter.ts"; +import Biantaoti from "./biantaoti.woff"; + +const fontFace = `@font-face { + font-family: BTT2; + src: url("${Biantaoti}"); +}`; + +const fontFace2 = `@font-face { + font-family: BTT2; + src: url("./biantaoti.woff"); +}`; document.querySelector("#app")!.innerHTML = `
@@ -13,6 +24,8 @@ document.querySelector("#app")!.innerHTML = `

Vite + TypeScript

Font test. 中文测试. 1234567
+
Font test. 中文测试. 1234567
+
Font test. 中文测试. 1234567
diff --git a/fixtures/vanilla/src/style.css b/fixtures/vanilla/src/style.css index 3deece9..9837cf1 100644 --- a/fixtures/vanilla/src/style.css +++ b/fixtures/vanilla/src/style.css @@ -103,4 +103,9 @@ button:focus-visible { @font-face { font-family: BTT2; src: url("./biantaoti.woff") format("woff"); +} + +@font-face { + font-family: BTT3; + src: url("/biantaoti.woff") format("woff"); } \ No newline at end of file diff --git a/fixtures/vanilla/vite.config.ts b/fixtures/vanilla/vite.config.ts index 8288a9c..4d1084e 100644 --- a/fixtures/vanilla/vite.config.ts +++ b/fixtures/vanilla/vite.config.ts @@ -6,13 +6,23 @@ export default defineConfig(() => { return { plugins: [ FontCarrier({ - fonts: [{ - url: "./src/assets/fonts/biantaoti.woff", - input: "中文", - }, + fonts: [ + { + path: "./src/assets/fonts/biantaoti.woff", + input: "中文", + }, + { + path: "./src/assets/fonts/biantaoti2.woff", + input: "中文", + }, ], }), Inspect(), ], + resolve: { + alias: { + "@": "./src", + }, + }, }; }); diff --git a/package.json b/package.json index 624ea82..86c3582 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "bugs": "https://github.com/Bernankez/vite-plugin-font-carrier/issues", "keywords": [ + "vite", "font", "optimization", "extractor", @@ -59,9 +60,18 @@ "lint": "eslint .", "fix": "eslint . --fix" }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + }, "dependencies": { "@types/font-carrier": "^0.3.3", - "font-carrier": "^0.3.1" + "font-carrier": "^0.3.1", + "kolorist": "^1.8.0" }, "devDependencies": { "@bernankez/eslint-config": "^0.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e8dc87..494d6de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ dependencies: font-carrier: specifier: ^0.3.1 version: 0.3.1 + kolorist: + specifier: ^1.8.0 + version: 1.8.0 devDependencies: '@bernankez/eslint-config': @@ -3818,6 +3821,10 @@ packages: engines: {node: '>=6'} dev: true + /kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + dev: false + /levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} diff --git a/src/env.d.ts b/src/env.d.ts deleted file mode 100644 index 68f1721..0000000 --- a/src/env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare global { - function defineFontFace(options: import('.').FontFaceOptions): string; -} - -export {} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index d488d0c..2d46390 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,79 +1,176 @@ -import { type PluginOption } from "vite"; +import { basename, resolve } from "node:path"; +import { type IndexHtmlTransformContext, type LogLevel, type Logger, type PluginOption, type ResolveFn, type ResolvedConfig, createLogger } from "vite"; +import type { Font as FCFont } from "font-carrier"; +import fontCarrier from "font-carrier"; +import { bold, green, yellow } from "kolorist"; import { version } from "../package.json"; import { matchFontFace, matchUrl } from "./match"; +import { getFileHash, resolvePath } from "./utils"; export interface FontCarrierOptions { fonts: Font[]; cwd?: string; + type?: FCFont.FontType; + logLevel?: LogLevel; + clearScreen?: boolean; } export interface Font { - url: string; + path: string; input: string; + type?: FCFont.FontType; } -export const FontCarrier: (options: FontCarrierOptions) => PluginOption = (options) => { - const { cwd = process.cwd(), fonts } = options; - - // const fontPath = resolve(cwd, path); +type OutputBundle = Exclude; - // if (!output) { - // output = fontPath; - // } +type OutputAssetType = T extends { type: "asset" } ? T : never; +type OutputAsset = OutputAssetType; - // const font = fontCarrier.transfer(fontPath); - - // console.log(font.getFontface().options); +interface FontInfo { + /** Absolute path */ + path: string; + /** File base name */ + filename: string; + hash: string; + hashname: string; + input: string; + /** Has compressed */ + compressed: boolean; + linkedBundle?: OutputAsset; + /** Output font type */ + type: FCFont.FontType; +} - // font.min("中文135"); +export const FontCarrier: (options: FontCarrierOptions) => PluginOption = (options) => { + const { cwd = process.cwd(), fonts, type, logLevel, clearScreen } = options; - // const res = font.output({ - // path: output.split(".").slice(0, -1).join("."), - // types: ["woff2"], - // }); + const DEFAULT_FONT_TYPE: FCFont.FontType = "woff2"; + const PUBLIC_DIR = resolve(cwd, "public"); + const LOG_PREFIX = "[vite-plugin-font-carrier]"; - // console.log(res); + const fontList = fonts.map(font => ({ + path: resolve(cwd, font.path), + input: font.input, + type: font.type || type || DEFAULT_FONT_TYPE, + matched: false, + })); - // const fontMap = new Map(); + const fontCollection: FontInfo[] = []; - // css file url => Fonts - const fontMap = new Map(); + let resolvedConfig: ResolvedConfig; + let resolver: ResolveFn; + let logger: Logger; return { name: "vite-plugin-font-carrier", version, - enforce: "pre", - transform(code, id) { - const fontFaces = matchFontFace(code); - if (!fontFaces) { - return; - } - const urls = fontFaces.map(fc => matchUrl(fc)).flat().filter(url => url) as string[]; - if (!urls) { - return; - } - console.log(fontMap); + configResolved(config) { + resolvedConfig = config; + resolver = resolvedConfig.createResolver(); + logger = logLevel ? createLogger(logLevel, { allowClearScreen: clearScreen }) : config.logger; }, - generateBundle: { + transform: { order: "pre", - handler(outputOptions, bundle, isWrite) { - // console.log(outputOptions); - console.log(Object.values(bundle).map((v) => { - if (v.type === "asset") { - // console.log(v); - // console.log(v.fileName); - } else { - // console.log(v); - // console.log(v.viteMetadata); + async handler(code, id, options) { + const font = fontList.find(font => font.path === id); + if (font) { + // Font imported by js/ts file + const hash = getFileHash(id); + if (hash) { + fontCollection.push({ + path: id, + filename: basename(id), + hash, + hashname: "", + input: font.input, + compressed: false, + type: font.type, + }); + return; + } + } + // Get font url from source code + const fontFaces = matchFontFace(code); + if (!fontFaces) { + return; + } + // Each fontFace can have multiple Urls + const urls = fontFaces.map(fc => matchUrl(fc)).flat().filter(url => url) as string[]; + if (!urls) { + return; + } + for (const url of urls) { + const path = await resolvePath({ + id: url, + importer: id, + publicDir: PUBLIC_DIR, + root: resolvedConfig.root, + resolver, + ssr: options?.ssr, + }); + const font = fontCollection.find(font => font.path === path); + if (font) { + return; + } + const fontListItem = fontList.find(font => font.path === path); + if (!fontListItem) { + return; + } + const hash = getFileHash(path); + if (hash) { + const fc: FontInfo = { + path, + filename: basename(path), + hash, + hashname: "", + input: fontListItem.input, + compressed: false, + type: fontListItem.type, + }; + fontCollection.push(fc); + fontListItem.matched = true; } - return v.fileName; - })); - // console.log(Object.values(bundle).map(v => v.fileName)); + } }, }, + generateBundle(outputOptions, outputBundle, isWrite) { + Object.entries(outputBundle).forEach(([filename, bundle]) => { + if (bundle.type === "asset") { + // Link font filename and hashname + if (bundle.source instanceof Uint8Array) { + const filterFonts = fontCollection.filter(font => font.filename === bundle.name); + if (filterFonts.length > 0) { + const assetHash = getFileHash(bundle.source); + const asset = filterFonts.find(font => font.hash === assetHash); + if (asset) { + asset.hashname = bundle.fileName; + asset.linkedBundle = bundle; + } + } + } + } else { + bundle.viteMetadata?.importedAssets.forEach((asset) => { + const font = fontCollection.find(font => font.hashname === asset); + if (font) { + if (!font.compressed && font.linkedBundle) { + const buffer = Buffer.from(font.linkedBundle.source); + const fc = fontCarrier.transfer(buffer); + fc.min(font.input); + const outputs = fc.output({ + types: [font.type], + }) as unknown as { [K in FCFont.FontType]: Buffer }; + font.linkedBundle.source = outputs[font.type]; + font.compressed = true; + logger.info(`\n${bold(green(LOG_PREFIX))} ${bold(font.filename)} compressed.`); + } + } + }); + } + }); + const names = fontList.filter(font => !font.matched).map(font => basename(font.path)); + if (names.length) { + logger.warn(`${bold(yellow(LOG_PREFIX))} ${bold(names.join(", "))} mistached.`); + } + }, }; }; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..de19097 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,42 @@ +import { type BinaryLike, createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { ResolveFn } from "vite"; + +export function getFileHash(path: string | BinaryLike) { + if (typeof path === "string") { + try { + const buffer = readFileSync(path); + const hash = createHash("sha256").update(buffer).digest("hex"); + return hash; + } catch (e) { + return undefined; + } + } else { + const hash = createHash("sha256").update(path).digest("hex"); + return hash; + } +} + +export interface ResolvePathOptions { + id: string; + importer: string; + publicDir: string; + root: string; + resolver: ResolveFn; + ssr?: boolean; +} + +export async function resolvePath(options: ResolvePathOptions) { + const { id, importer, publicDir, root, resolver, ssr } = options; + let path = await resolver(id, importer, false, ssr); + if (path) { + if (!isAbsolute(path)) { + // Path alias + path = resolve(root, path); + } + } else { + path = resolve(publicDir, `.${id}`); + } + return path; +}