diff --git a/packages/vite/src/node/__tests__/asset.spec.ts b/packages/vite/src/node/__tests__/asset.spec.ts new file mode 100644 index 00000000000000..6e6b969bcbd38c --- /dev/null +++ b/packages/vite/src/node/__tests__/asset.spec.ts @@ -0,0 +1,156 @@ +import { assetFileNamesToFileName, getAssetHash } from '../plugins/asset' + +describe('getAssetHash', () => { + test('8-digit hex', () => { + const hash = getAssetHash(Buffer.alloc(0)) + + expect(hash).toMatch(/^[\da-f]{8}$/) + }) +}) + +describe('assetFileNamesToFileName', () => { + // on Windows, both forward slashes and backslashes may appear in the input + const sourceFilepaths: readonly string[] = + process.platform === 'win32' + ? ['C:/path/to/source/input.png', 'C:\\path\\to\\source\\input.png'] + : ['/path/to/source/input.png'] + + for (const sourceFilepath of sourceFilepaths) { + const content = Buffer.alloc(0) + const contentHash = 'abcd1234' + + // basic examples + + test('a string with no placeholders', () => { + const fileName = assetFileNamesToFileName( + 'output.png', + sourceFilepath, + contentHash, + content + ) + + expect(fileName).toBe('output.png') + }) + + test('a string with placeholders', () => { + const fileName = assetFileNamesToFileName( + 'assets/[name]/[ext]/[extname]/[hash]', + sourceFilepath, + contentHash, + content + ) + + expect(fileName).toBe('assets/input/png/.png/abcd1234') + }) + + // function examples + + test('a function that uses asset information', () => { + const fileName = assetFileNamesToFileName( + (options) => + `assets/${options.name.replace(/^C:|[/\\]/g, '')}/${options.type}/${ + options.source.length + }`, + sourceFilepath, + contentHash, + content + ) + + expect(fileName).toBe('assets/pathtosourceinput.png/asset/0') + }) + + test('a function that returns a string with no placeholders', () => { + const fileName = assetFileNamesToFileName( + () => 'output.png', + sourceFilepath, + contentHash, + content + ) + + expect(fileName).toBe('output.png') + }) + + test('a function that returns a string with placeholders', () => { + const fileName = assetFileNamesToFileName( + () => 'assets/[name]/[ext]/[extname]/[hash]', + sourceFilepath, + contentHash, + content + ) + + expect(fileName).toBe('assets/input/png/.png/abcd1234') + }) + + // invalid cases + + test('a string with an invalid placeholder', () => { + expect(() => { + assetFileNamesToFileName( + 'assets/[invalid]', + sourceFilepath, + contentHash, + content + ) + }).toThrowError( + 'invalid placeholder [invalid] in assetFileNames "assets/[invalid]"' + ) + + expect(() => { + assetFileNamesToFileName( + 'assets/[name][invalid][extname]', + sourceFilepath, + contentHash, + content + ) + }).toThrowError( + 'invalid placeholder [invalid] in assetFileNames "assets/[name][invalid][extname]"' + ) + }) + + test('a function that returns a string with an invalid placeholder', () => { + expect(() => { + assetFileNamesToFileName( + () => 'assets/[invalid]', + sourceFilepath, + contentHash, + content + ) + }).toThrowError( + 'invalid placeholder [invalid] in assetFileNames "assets/[invalid]"' + ) + + expect(() => { + assetFileNamesToFileName( + () => 'assets/[name][invalid][extname]', + sourceFilepath, + contentHash, + content + ) + }).toThrowError( + 'invalid placeholder [invalid] in assetFileNames "assets/[name][invalid][extname]"' + ) + }) + + test('a number', () => { + expect(() => { + assetFileNamesToFileName( + 9876 as unknown as string, + sourceFilepath, + contentHash, + content + ) + }).toThrowError('assetFileNames must be a string or a function') + }) + + test('a function that returns a number', () => { + expect(() => { + assetFileNamesToFileName( + () => 9876 as unknown as string, + sourceFilepath, + contentHash, + content + ) + }).toThrowError('assetFileNames must return a string') + }) + } +}) diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index cc440afb5fae41..7f224e1ae222cc 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -185,37 +185,44 @@ export function getAssetFilename( return assetHashToFilenameMap.get(config)?.get(hash) } -function assetFileNamesToFileName( +/** + * converts the source filepath of the asset to the output filename based on the assetFileNames option. \ + * this function imitates the behavior of rollup.js. \ + * https://rollupjs.org/guide/en/#outputassetfilenames + * + * @example + * ```ts + * const content = Buffer.from('text'); + * const fileName = assetFileNamesToFileName( + * 'assets/[name].[hash][extname]', + * '/path/to/file.txt', + * getAssetHash(content), + * content + * ) + * // fileName: 'assets/file.982d9e3e.txt' + * ``` + * + * @param assetFileNames filename pattern. e.g. `'assets/[name].[hash][extname]'` + * @param file filepath of the asset + * @param contentHash hash of the asset. used for `'[hash]'` placeholder + * @param content content of the asset. passed to `assetFileNames` if `assetFileNames` is a function + * @returns output filename + */ +export function assetFileNamesToFileName( + assetFileNames: Exclude, file: string, contentHash: string, - content: string | Buffer, - config: ResolvedConfig + content: string | Buffer ): string { const basename = path.basename(file) // placeholders for `assetFileNames` - // see https://rollupjs.org/guide/en/#outputassetfilenames for available placeholders // `hash` is slightly different from the rollup's one const extname = path.extname(basename) const ext = extname.substr(1) const name = basename.slice(0, -extname.length) const hash = contentHash - let assetFileNames: OutputOptions['assetFileNames'] - const output = config.build?.rollupOptions?.output - // only the object format is currently considered here - if (output && !Array.isArray(output)) { - assetFileNames = output.assetFileNames - } - // defaults to '/[name].[hash][extname]' - // slightly different from rollup's one ('assets/[name]-[hash][extname]') - if (assetFileNames == null) { - assetFileNames = path.posix.join( - config.build.assetsDir, - '[name].[hash][extname]' - ) - } - if (typeof assetFileNames === 'function') { assetFileNames = assetFileNames({ name: file, @@ -297,11 +304,17 @@ async function fileToBuiltUrl( const contentHash = getAssetHash(content) const { search, hash } = parseUrl(id) const postfix = (search || '') + (hash || '') + const output = config.build?.rollupOptions?.output + const assetFileNames = + (output && !Array.isArray(output) ? output.assetFileNames : undefined) ?? + // defaults to '/[name].[hash][extname]' + // slightly different from rollup's one ('assets/[name]-[hash][extname]') + path.posix.join(config.build.assetsDir, '[name].[hash][extname]') const fileName = assetFileNamesToFileName( + assetFileNames, file, contentHash, - content, - config + content ) if (!map.has(contentHash)) { map.set(contentHash, fileName)