Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use a hash based on content instead of guid #30

Merged
merged 7 commits into from
Apr 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,7 @@ node_modules/
# Output of 'npm pack'
*.tgz

dist
# ide
.idea

dist
9 changes: 0 additions & 9 deletions src/__snapshots__/utils.test.ts.snap

This file was deleted.

11 changes: 11 additions & 0 deletions src/fixtures/vite.no-inline.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineConfig, mergeConfig } from 'vite';
import configBasic from './vite.basic.config';

export default mergeConfig(
configBasic,
defineConfig({
build: {
assetsInlineLimit: 0,
},
}),
);
82 changes: 77 additions & 5 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { describe, it, beforeAll, afterAll, expect } from 'vitest';
import { build, createServer, preview, normalizePath } from 'vite';
import type { RollupOutput } from 'rollup';
import type { PreviewServer, ViteDevServer, InlineConfig } from 'vite';
import { base64ToArrayBuffer } from './utils';

// #region test utils
const root = new URL('./fixtures/', import.meta.url);
Expand All @@ -12,10 +13,14 @@ const types = ['svg', 'eot', 'woff', 'woff2', 'ttf'];
const normalizeLineBreak = (input: string) => input.replace(/\r\n/g, '\n');
const fileURLToNormalizedPath = (url: URL) => normalizePath(fileURLToPath(url));

const getConfig = (): InlineConfig => ({
const enum ConfigType {
Basic = './vite.basic.config.ts',
NoInline = './vite.no-inline.config.ts',
}
const getConfig = (configType: ConfigType): InlineConfig => ({
logLevel: 'silent',
root: fileURLToNormalizedPath(root),
configFile: fileURLToNormalizedPath(new URL('./vite.basic.config.ts', root)),
configFile: fileURLToNormalizedPath(new URL(configType, root)),
});

const getServerPort = (server: ViteDevServer | PreviewServer) => {
Expand Down Expand Up @@ -65,10 +70,12 @@ const loadFileContent = async (path: string, encoding: BufferEncoding | 'buffer'
// #endregion

describe('serve - handles virtual import and has all font types available', () => {
const buildConfig = getConfig(ConfigType.Basic);

let server: ViteDevServer;

beforeAll(async () => {
const createdServer = await createServer(getConfig());
const createdServer = await createServer(buildConfig);
server = await createdServer.listen();
});

Expand All @@ -90,11 +97,76 @@ describe('serve - handles virtual import and has all font types available', () =
});

describe('build', () => {
const buildConfig = getConfig(ConfigType.Basic);

let output: RollupOutput['output'];
let server: PreviewServer;
let cssContent: string | undefined;

const typeToMimeMap: Record<string, string> = {
svg: 'image/svg+xml',
eot: 'application/vnd.ms-fontobject',
woff: 'font/woff',
woff2: 'font/woff2',
ttf: 'font/ttf',
};

beforeAll(async () => {
output = ((await build(buildConfig)) as RollupOutput).output;
server = await preview(buildConfig);
server.printUrls();

const cssFileName = output.find(({ type, name }) => type === 'asset' && name === 'index.css')!.fileName;
cssContent = await fetchTextContent(server, `/${cssFileName}`);
});

afterAll(() => {
server.httpServer.close();
});

it.concurrent('injects fonts css to page', async () => {
expect(cssContent).toMatch(/^@font-face{font-family:iconfont;/);
});

types.forEach(async type => {
it.concurrent(`has font of type ${type} available`, async () => {
const res = await loadFileContent(`fonts/iconfont.${type}`, 'buffer');
let expected: ArrayBuffer | string | undefined;

const iconAsset = output.find(({ fileName }) => fileName.startsWith('assets/iconfont-') && fileName.endsWith(type));
if (iconAsset) {
const iconAssetName = iconAsset.fileName;
expected = await fetchBufferContent(server, `/${iconAssetName}`);
} else if (cssContent) {
// File asset not found in output, check if it's inlined in CSS

const regex = /url\(data:(?<mime>.+?);base64,(?<data>.*?)\) format\("(?<format>.+?)"\)/g;

let m;
while ((m = regex.exec(cssContent)) !== null) {
if (m?.groups && 'mime' in m.groups && 'data' in m.groups) {
const typeMime = typeToMimeMap[type];
if (m.groups.mime === typeMime) {
expected = base64ToArrayBuffer(m.groups.data);
}
}
}
}

expect(res).not.toEqual(undefined);
expect(res).toStrictEqual(expected);
});
});
});

describe('build:no-inline', () => {
const buildConfig = getConfig(ConfigType.NoInline);

let output: RollupOutput['output'];
let server: PreviewServer;
beforeAll(async () => {
output = ((await build(getConfig())) as RollupOutput).output;
server = await preview(getConfig());
output = ((await build(buildConfig)) as RollupOutput).output;
server = await preview(buildConfig);
server.printUrls();
});

Expand Down
33 changes: 27 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { promisify } from 'util';
import _webfontGenerator from '@vusion/webfonts-generator';
import { setupWatcher, MIME_TYPES, guid, ensureDirExistsAndWriteFile } from './utils';
atlowChemi marked this conversation as resolved.
Show resolved Hide resolved
import { setupWatcher, MIME_TYPES, ensureDirExistsAndWriteFile, getTmpDir, getBufferHash, rmDir } from './utils';
import { parseOptions, parseFiles } from './optionParser';
import type { ModuleGraph, ModuleNode } from 'vite';
import type { GeneratedFontTypes, WebfontsGeneratorResult } from '@vusion/webfonts-generator';
import type { IconPluginOptions } from './optionParser';
import type { GeneratedWebfont } from './types/generatedWebfont';
import type { CompatiblePlugin, PublicApi } from './types/publicApi';
import { join as pathJoin } from 'node:path';

const ac = new AbortController();
const webfontGenerator = promisify(_webfontGenerator);
const DEFAULT_MODULE_ID = 'vite-svg-2-webfont.css';
const TMP_DIR = getTmpDir();

function getVirtualModuleId<T extends string>(moduleId: T): `virtual:${T}` {
return `virtual:${moduleId}`;
Expand All @@ -34,6 +36,7 @@ export function viteSvgToWebfont<T extends GeneratedFontTypes = GeneratedFontTyp
let _reloadModule: undefined | ((module: ModuleNode) => void);
let generatedFonts: undefined | Pick<WebfontsGeneratorResult<T>, 'generateCss' | 'generateHtml' | T>;
const generatedWebfonts: GeneratedWebfont[] = [];
const tmpGeneratedWebfonts: GeneratedWebfont[] = [];
const moduleId = options.moduleId ?? DEFAULT_MODULE_ID;
const virtualModuleId = getVirtualModuleId(moduleId);
const resolvedVirtualModuleId = getResolvedVirtualModuleId(virtualModuleId);
Expand Down Expand Up @@ -91,6 +94,15 @@ export function viteSvgToWebfont<T extends GeneratedFontTypes = GeneratedFontTyp
}
return resolvedVirtualModuleId;
},
generateBundle(_, bundle) {
for (const { type, href } of tmpGeneratedWebfonts) {
for (const chunk of Object.values(bundle)) {
if (chunk.name && href.endsWith(chunk.name)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, I can't find a way to get bundled asset filename by its entry name so here's the workaround where we're comparing the generated asset names with the fonts we generated earlier.

generatedWebfonts.push({ type, href: `/${chunk.fileName}` });
}
}
}
},
transform(_code, id) {
if (id !== resolvedVirtualModuleId) {
return undefined;
Expand All @@ -109,13 +121,21 @@ export function viteSvgToWebfont<T extends GeneratedFontTypes = GeneratedFontTyp
}
await generate();
if (isBuild && !options.inline) {
const emitted = processedOptions.types.map<[T, string]>(type => [
type,
`/${this.getFileName(this.emitFile({ type: 'asset', fileName: `assets/${processedOptions.fontName}-${guid()}.${type}`, source: generatedFonts?.[type] }))}`,
]);
const emitted = processedOptions.types.map<[T, string]>(type => {
if (!generatedFonts?.[type]) {
throw new Error(`Failed to generate font of type ${type}`);
}

const fileContents = Buffer.from(generatedFonts[type]);
const hash = getBufferHash(fileContents);
const filePath = pathJoin(TMP_DIR, `${processedOptions.fontName}-${hash}.${type}`);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've replaced the guid() call with hash based on a file contents. Technically speaking, there's no need to add some uniqueness here because the tmp dir is already unique, but it will make easier name-based comparation later in generateBundle() hook (which is required to keep external API working).

ensureDirExistsAndWriteFile(fileContents, filePath); // write font file to a temporary dir
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we're writing file to a system temporary dir. So this is workaround for a this.emitFile() mechanism which require us to wait a render-hooks which is called too lately.

Because we're emitting the file explicitly, Rollup will successfully find it and will not produce any warnings and will not be switched into a fallback logical branch.


return [type, filePath];
});

emitted.forEach(([type, href]) => {
generatedWebfonts.push({ type, href });
tmpGeneratedWebfonts.push({ type, href });
});
fileRefs = Object.fromEntries(emitted) as { [Ref in T]: string };
}
Expand Down Expand Up @@ -143,6 +163,7 @@ export function viteSvgToWebfont<T extends GeneratedFontTypes = GeneratedFontTyp
},
buildEnd() {
ac.abort();
rmDir(TMP_DIR);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just cleaning up the temporary files

},
};
}
Expand Down
44 changes: 27 additions & 17 deletions src/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,23 +96,6 @@ describe('utils', () => {
});
});

describe.concurrent('guid', () => {
it.concurrent('should generate a string', ({ expect }) => {
const spy = vi.spyOn(Math, 'random').mockReturnValue(0.2);
expect(utils.guid()).to.matchSnapshot();
expect(utils.guid(1)).to.matchSnapshot();
expect(utils.guid(10)).to.matchSnapshot();
expect(utils.guid(20)).to.matchSnapshot();
spy.mockRestore();
});
it.concurrent('should default to a string length of 8', ({ expect }) => {
expect(utils.guid()).to.have.lengthOf(8);
});
it.concurrent('should return a string of requested length', ({ expect }) => {
expect(utils.guid(16)).to.have.lengthOf(16);
});
});

describe.concurrent('hasFileExtension', () => {
it.concurrent('should return true for normal file', () => {
expect(utils.hasFileExtension('example.svg')).to.be.true;
Expand Down Expand Up @@ -153,4 +136,31 @@ describe('utils', () => {
expect(fs.writeFile).toBeCalledWith(file, content);
});
});

describe.concurrent('getBufferHash', () => {
const testData: [string, string][] = [
// [string, sha256 of string]
['test data 1', '05e8fdb3598f91bcc3ce41a196e587b4592c8cdfc371c217274bfda2d24b1b4e'],
['test data 2', '26637da1bd793f9011a3d304372a9ec44e36cc677d2bbfba32a2f31f912358fe'],
['test data 3', 'b2ce6625a947373fe8d578dca152cf152a5bd8aeca805b2d3b1fb4a340e1a123'],
['test data 4', '1e2b98ff6439d48d42ae71c0ea44f3c1e03665a34d1c368ac590aec5dadc48eb'],
['test data 5', '225b2e6c5664bb388cc40c9abeb289f9569ebc683ed4fdd76fef8421c32369b5'],
];

it.each(testData)('should generate a correct hash for "%s"', (data, hash) => {
const calculatedHash = utils.getBufferHash(Buffer.from(data));
expect(calculatedHash).toEqual(hash);
});
});

describe.concurrent('base64ToArrayBuffer', () => {
const strings = ['vite-svg-2-webfont', 'test-string-1', 'test-string-2'];

it.each(strings)('should match "%s"', string => {
const stringAsBase64 = Buffer.from(string).toString('base64');
const arrayBuffer = utils.base64ToArrayBuffer(stringAsBase64);
const decodedString = new TextDecoder('utf-8').decode(arrayBuffer);
expect(decodedString).toEqual(string);
});
});
});
33 changes: 23 additions & 10 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { constants } from 'fs';
import { resolve, dirname } from 'path';
import { constants, rm as fsRm, mkdtempSync } from 'fs';
import { resolve, dirname, join as pathJoin } from 'path';
import { watch, access, mkdir, writeFile } from 'fs/promises';
import type { FileChangeInfo } from 'fs/promises';
import type { GeneratedFontTypes } from '@vusion/webfonts-generator';
import { tmpdir as osTmpdir } from 'node:os';
import { createHash } from 'node:crypto';

let watcher: ReturnType<typeof watch> | undefined;
export const MIME_TYPES: Record<GeneratedFontTypes, string> = {
Expand Down Expand Up @@ -49,14 +51,8 @@ export async function setupWatcher(folderPath: string, signal: AbortSignal, hand
}
}

const alphabet = 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM1234567890';
export function guid(length = 8) {
let result = '';
for (let i = 0; i < length; i++) {
const index = Math.floor(Math.random() * alphabet.length);
result += alphabet[index];
}
return result;
export function getBufferHash(buf: Buffer) {
atlowChemi marked this conversation as resolved.
Show resolved Hide resolved
return createHash('sha256').update(buf).digest('hex');
stryaponoff marked this conversation as resolved.
Show resolved Hide resolved
}

export function hasFileExtension(fileName?: string | null | undefined) {
Expand All @@ -69,3 +65,20 @@ export async function ensureDirExistsAndWriteFile(content: string | Buffer, dest
await mkdir(dirname(dest), options);
await writeFile(dest, content);
}

export function getTmpDir() {
return mkdtempSync(pathJoin(osTmpdir(), '__vite-svg-2-webfont-'));
}

export function rmDir(path: string) {
fsRm(path, { force: true, recursive: true }, () => {});
}

export function base64ToArrayBuffer(base64: string) {
const binaryString = Buffer.from(base64, 'base64').toString('binary');
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}