import { createHash } from 'node:crypto'; import fsn from 'node:fs'; import path from 'node:path'; import MagicString from 'magic-string'; import type { Plugin } from 'rollup'; import { VirtualPath } from '@boost/common'; import type { TSESTree } from '@typescript-eslint/types'; import { ASSETS, TEXT_ASSETS } from '../../constants'; import type { FileSystem } from '../../FileSystem'; function isAsset(id: string): boolean { return ASSETS.some((ext) => id.endsWith(ext)); } function isRequireStatement(node: TSESTree.Expression): node is TSESTree.CallExpression { return ( node && node.type === 'CallExpression' && node.callee && node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments.length > 0 && node.arguments[0] && node.arguments[0].type === 'Literal' ); } export interface CopyAssetsOptions { dir?: string; fs: FileSystem; root: string; } export function copyAndRefAssets( { dir: customDir, fs, root }: CopyAssetsOptions, assetsToCopyInit: Record = {}, ): Plugin { const dir = customDir ?? path.join(root, 'assets'); const assetsToCopy = assetsToCopyInit; const assetSourceMap = new Set(); const pattern = /^\.{1,2}(\/|\\)/; function findMatchingSource(relSource: string): string | undefined { let source = relSource; if (source.startsWith('node:') || source.startsWith('bun:')) { return undefined; } while (source.startsWith('.')) { source = source.replace(pattern, ''); } for (const id of assetSourceMap) { if (id.endsWith(source)) { return id; } } return undefined; } function determineNewAsset(absSource: string): VirtualPath { const id = new VirtualPath(absSource); const ext = id.ext(); const name = id.name(true); // Generate a hash of the source file path, // and have it match between nix and windows const hash = createHash('sha256') .update(id.path().replace(new VirtualPath(root).path(), '')) .digest('hex') .slice(0, 8); // Create a new path that points to the assets folder const newId = new VirtualPath(dir, `${name}-${hash}${ext}`); assetsToCopy[id.path()] = newId; return newId; } return { name: 'packemon-copy-and-ref-assets', // Delete old assets to remove any possible stale assets buildStart() { if (fs.exists(dir) && process.env.NODE_ENV !== 'test') { fs.removeDir(dir); } }, // Find assets and mark as external resolveId(source, importer) { if (isAsset(source) && importer) { const id = path.join(path.dirname(importer), source); // Check that the file actually exists, because they may be // using path aliases, or bundler specific syntax if (source.startsWith('.') && fs.exists(id)) { assetSourceMap.add(id); return { id, external: true }; } } return null; }, // Update import/require declarations to new asset paths renderChunk(code, chunk, options) { let ast: TSESTree.Program; try { ast = this.parse(code) as unknown as TSESTree.Program; } catch { // Unknown syntax may fail parsing, not much we can do here? return null; } const magicString = new MagicString(code); let hasChanged = false; // eslint-disable-next-line complexity ast.body.forEach((node) => { let source: TSESTree.Literal | undefined; // import './styles.css'; if (node.type === 'ImportDeclaration') { ({ source } = node); // require('./styles.css'); } else if (node.type === 'ExpressionStatement' && isRequireStatement(node.expression)) { source = node.expression.arguments[0] as TSESTree.Literal; // const foo = require('./styles.css'); } else if ( node.type === 'VariableDeclaration' && node.declarations.length > 0 && node.declarations[0].init && isRequireStatement(node.declarations[0].init) ) { source = node.declarations[0].init.arguments[0] as TSESTree.Literal; } if (!source?.value) { return; } const relSource = String(source.value); if (!ASSETS.some((ext) => relSource.endsWith(ext))) { return; } const absSource = findMatchingSource(relSource); if (absSource) { const newId = determineNewAsset(absSource); const parentDir = path.dirname(chunk.facadeModuleId!); const importPath = options.preserveModules ? new VirtualPath(path.relative(parentDir, newId.path())).path() : `../assets/${newId.name()}`; // @ts-expect-error Not typed // eslint-disable-next-line @typescript-eslint/no-unsafe-argument magicString.overwrite(source.start, source.end, `'${importPath}'`); hasChanged = true; } }); if (!hasChanged) { return null; } return { code: magicString.toString(), map: null, }; }, // Copy all found assets async generateBundle(options, bundle) { // Only create the folder if we have assets to copy, // otherwise it throws off `files` and other detection! if (Object.keys(assetsToCopy).length > 0) { fs.createDirAll(dir); } // We don't use `assetFileNames` or `emitFile` as we want a single assets folder // at the root of the package, which Rollup does not allow. It wants // multiple asset folders within each format! await Promise.all( // eslint-disable-next-line @typescript-eslint/require-await Object.entries(assetsToCopy).map(async ([oldId, newId]) => { const newName = newId.name(); const isStringSource = TEXT_ASSETS.some((ext) => newName.endsWith(ext)); // eslint-disable-next-line no-param-reassign bundle[newId.path()] = { fileName: `../assets/${newName}`, name: undefined, needsCodeReference: false, source: isStringSource ? fs.readFile(oldId) : fsn.readFileSync(oldId, 'utf8'), type: 'asset', // Required for types names: [], originalFileNames: [], originalFileName: null, }; }), ); }, }; }