UNPKG

esbuild-css-modules-plugin

Version:

A esbuild plugin to bundle css modules into js(x)/ts(x), based on extremely fast [Lightning CSS](https://lightningcss.dev/)

385 lines (356 loc) 13 kB
import { basename, dirname, extname, normalize, relative, resolve, sep } from 'node:path'; import { CSSTransformer, CSSInjector } from './lib/css.helper.js'; import { contentPlaceholder, digestPlaceholder, ensureFile, genDigest, getModulesCssRegExp, injectorVirtualPath, pluginCssNamespace, pluginJsNamespace, pluginName, simpleMinifyCss, validateOptions } from './lib/utils.js'; import { compact } from 'lodash-es'; import { readFile, rename, writeFile } from 'node:fs/promises'; import { patchContext } from './lib/context.js'; /** * @param {import('esbuild').PluginBuild} build * @param {import('./index.js').Options} _options */ export const setup = (build, _options) => { const options = _options || {}; validateOptions(options); const patchedBuild = patchContext(build, options); const { log, buildId, buildRoot } = patchedBuild.context; log(`initialize build context with options:`, options); log(`root of this build(#${buildId}):`, buildRoot); const modulesCssRegExp = getModulesCssRegExp(options); const bundle = patchedBuild.initialOptions.bundle ?? false; const forceBuild = options.force ?? false; const injectCss = options.inject ?? false; const cssLoader = patchedBuild.initialOptions.loader?.['.css'] ?? 'css'; const jsLoader = patchedBuild.initialOptions.loader?.['.js'] ?? 'js'; const outJsExt = patchedBuild.initialOptions.outExtension?.['.js'] ?? '.js'; const forceInlineImages = !!options.forceInlineImages; const emitDts = options.emitDeclarationFile; const warnMetafile = () => { if (patchedBuild.initialOptions.metafile) { return; } const warnings = patchedBuild.esbuild.formatMessagesSync( [ { text: '`metafile` is not enabled, it may not work properly, please consider to set `metafile` to true is your esbuild configuration.', pluginName: pluginName } ], { kind: 'warning', color: true } ); console.log(warnings.join('\n')); }; patchedBuild.onLoad({ filter: /.+/, namespace: pluginCssNamespace }, (args) => { const { path } = args; log(`[${pluginCssNamespace}] on load:`, args); const realPath = resolve(buildRoot, path.replace(`${pluginCssNamespace}:`, '')); return { contents: CSSTransformer.getInstance(patchedBuild)?.getCachedResult(realPath)?.css, loader: cssLoader, resolveDir: dirname(realPath) }; }); patchedBuild.onLoad( { filter: new RegExp(`^:.*${injectorVirtualPath}$`), namespace: pluginJsNamespace }, (args) => { log(`[${pluginJsNamespace}] on load injector:`, args); return { contents: CSSInjector.getInstance(patchedBuild)?.libCode, loader: jsLoader }; } ); patchedBuild.onResolve( { filter: new RegExp(`^(${pluginCssNamespace}|${pluginJsNamespace}):`), namespace: 'file' }, (args) => { const { path } = args; const [, ns, originPath] = path.match(new RegExp(`^(${pluginCssNamespace}|${pluginJsNamespace}):(.+)$`)) ?? []; log(`[${ns}] on resolve :`, args); /** @type {import('esbuild').OnResolveResult} */ const r = { namespace: ns, path: originPath, pluginData: { ...(args.pluginData ?? {}) } }; if (path.endsWith(`:${injectorVirtualPath}`)) { r.path = path.replace(pluginJsNamespace, ''); } log('resolved:', r); return r; } ); patchedBuild.onLoad({ filter: modulesCssRegExp, namespace: 'file' }, async (args) => { if (!emitDts && !bundle && !forceBuild) { return undefined; } log('[file] on load:', args); const { path } = args; const rpath = relative(buildRoot, path); const prefix = basename(rpath, extname(path)) .replace(/[^a-zA-Z0-9]/g, '-') .replace(/^\-*/, ''); const suffix = patchedBuild.context.packageVersion?.replace(/[^a-zA-Z0-9]/g, '') ?? ''; const buildResult = CSSTransformer.getInstance(patchedBuild).bundle(path, { prefix, suffix, forceInlineImages, emitDeclarationFile: !!emitDts }); if (emitDts) { if (rpath.startsWith('..')) { log(`skip emit dts for file outside of build root:`, rpath); } else { /** @type {('.d.css.ts'|'.css.d.ts')[]} */ const dtsExts = []; /** @type {import('./index.js').EmitDts} */ let outdirs = {}; if (emitDts === '.d.css.ts' || emitDts === '.css.d.ts') { dtsExts.push(emitDts); } else if (emitDts === true) { dtsExts.push('.d.css.ts', '.css.d.ts'); } else if (typeof emitDts === 'object') { outdirs = { ...emitDts }; if (emitDts['*']) { dtsExts.push('.d.css.ts', '.css.d.ts'); } else { emitDts['.css.d.ts'] && dtsExts.push('.css.d.ts'); emitDts['.d.css.ts'] && dtsExts.push('.d.css.ts'); } } const outdir = resolve(buildRoot, patchedBuild.initialOptions.outdir ?? ''); const outbase = patchedBuild.initialOptions.outbase; dtsExts.forEach(async (dtsExt) => { let outDtsfile = resolve(outdir, rpath).replace(/\.css$/i, dtsExt); const dtsOutdir = outdirs[dtsExt] || outdirs['*']; if (dtsOutdir) { outDtsfile = resolve(buildRoot, dtsOutdir, rpath).replace(/\.css$/i, dtsExt); } if (outbase) { let normalized = normalize(outbase); if (normalized.endsWith(sep)) { normalized = compact(normalized.split(sep)).join(sep); } if (normalized !== '.') { outDtsfile = resolve(outDtsfile.replace(normalized, '')); } } log(`emit dts:`, patchedBuild.context.relative(outDtsfile)); await ensureFile(outDtsfile, buildResult?.dts ?? ''); }); } } if (!bundle && forceBuild) { log('force build modules css:', rpath); if (injectCss) { const anotherBuildOptions = { ...patchedBuild.initialOptions }; delete anotherBuildOptions.entryPoints; delete anotherBuildOptions.plugins; const { outputFiles } = await patchedBuild.esbuild.build({ ...anotherBuildOptions, absWorkingDir: buildRoot, stdin: { contents: buildResult?.css ?? '', resolveDir: dirname(path), sourcefile: rpath, loader: 'css' }, bundle: true, minify: true, sourcemap: false, write: false, outExtension: { '.css': '.css' } }); return { contents: buildResult?.js ?.replace( contentPlaceholder, JSON.stringify(outputFiles.find((f) => basename(f.path) === 'stdin.css')?.text ?? '') ) .replace(digestPlaceholder, JSON.stringify(genDigest(rpath, buildId))), loader: jsLoader, watchFiles: [path, ...(buildResult?.composedFiles ?? [])], resolveDir: dirname(path), pluginData: { originCssPath: path } }; } else { const anotherBuildOptions = { ...patchedBuild.initialOptions }; delete anotherBuildOptions.entryPoints; delete anotherBuildOptions.plugins; delete anotherBuildOptions.outdir; await patchedBuild.esbuild.build({ ...anotherBuildOptions, absWorkingDir: buildRoot, stdin: { contents: buildResult?.css ?? '', resolveDir: dirname(path), sourcefile: rpath, loader: 'css' }, bundle: true, sourcemap: false, outfile: resolve( buildRoot, patchedBuild.initialOptions.outdir ?? '.', relative( patchedBuild.initialOptions.outbase ?? '.', rpath.replace(/\.css$/i, '.built.css') ) ) }); return { contents: `import './${basename(path).replace(/\.css$/i, '.built.css')}';\n${ buildResult?.js }`, loader: jsLoader, watchFiles: [path, ...(buildResult?.composedFiles ?? [])], resolveDir: dirname(path), pluginData: { originCssPath: path } }; } } else if (bundle) { return { contents: buildResult?.js, loader: jsLoader, watchFiles: [path, ...(buildResult?.composedFiles ?? [])], resolveDir: dirname(path), pluginData: { originCssPath: path } }; } }); const dispose = () => { CSSInjector.getInstance(patchedBuild)?.dispose(); CSSTransformer.getInstance(patchedBuild)?.dispose(); }; patchedBuild.onEnd(async (r) => { if (!bundle && forceBuild) { /** @type {[string, Record<string, string>][]} */ const jsFiles = []; /** @type {[string, string][]} */ const moduleJsFiles = []; warnMetafile(); Object.entries(r.metafile?.outputs ?? {}).forEach(([js, meta]) => { if (meta.entryPoint && modulesCssRegExp.test(meta.entryPoint)) { moduleJsFiles.push([meta.entryPoint, js]); } if (meta.entryPoint && !modulesCssRegExp.test(meta.entryPoint)) { let shouldPush = false; /** @type {Record<string, string>} */ const defines = {}; meta.imports?.forEach((imp) => { if (modulesCssRegExp.test(imp.path)) { shouldPush = true; defines[imp.path] = imp.path + outJsExt; } }); if (shouldPush) { jsFiles.push([js, defines]); } } }); await Promise.all([ ...moduleJsFiles.map(([src, dist]) => { const fp = resolve(buildRoot, dist); const filename = basename(src) + outJsExt; const finalPath = resolve(dirname(fp), filename); log(`rename ${dist} to ${filename}`); return rename(fp, finalPath); }), ...jsFiles.map(([js, places]) => { const fulljs = resolve(buildRoot, js); return readFile(fulljs, { encoding: 'utf8' }) .then((content) => { let newContent = content; Object.entries(places).forEach(([f, t]) => { log(`fix import path in ${js}: ${f} ===> ${t}`); newContent = newContent.replaceAll(f, t); }); return newContent; }) .then((nc) => { return writeFile(fulljs, nc, { encoding: 'utf8' }); }); }) ]); return dispose(); } if (!injectCss || !bundle) { return dispose(); } /** @type {[string, string][]} */ const filesToBuild = []; warnMetafile(); const cssOutputsMap = Object.entries(r.metafile?.outputs ?? {}).reduce((m, [o, { inputs }]) => { const keys = Object.keys(inputs); if (keys.length === 1 && new RegExp(`^${pluginCssNamespace}:.+\.css$`).test(keys[0])) { m[keys[0].replace(`${pluginCssNamespace}:`, '')] = o; } return m; }, {}); Object.entries(r.metafile?.outputs ?? {}).forEach(([outfile, meta]) => { if (meta.cssBundle) { filesToBuild.push([outfile, meta.cssBundle]); } else { const inputs = Object.keys(meta.inputs); inputs.forEach((item) => { if (item.endsWith(`:${injectorVirtualPath}`)) { const sourceCss = item .replace(pluginJsNamespace, '') .replace(`:${injectorVirtualPath}`, '') .replace(/^:+/, ''); filesToBuild.push([outfile, cssOutputsMap[sourceCss]]); } }); } }); log('build inject js code for', filesToBuild); await Promise.all( filesToBuild.map(([f, c]) => { const fullJsPath = resolve(buildRoot, f); const fullCssPath = resolve(buildRoot, c); return Promise.all([ readFile(fullCssPath, { encoding: 'utf8' }), readFile(fullJsPath, { encoding: 'utf8' }) ]) .then(([css, js]) => { const cssContent = simpleMinifyCss(css, patchedBuild.esbuild); const digest = genDigest(c, buildId); const newJs = js .replaceAll(contentPlaceholder, `globalThis['__css-content-${digest}__']`) .replaceAll(digestPlaceholder, `globalThis['__css-digest-${digest}__']`); return `globalThis['__css-content-${digest}__']=${JSON.stringify( cssContent )};globalThis['__css-digest-${digest}__']=${JSON.stringify(digest)};\n${newJs}`; }) .then((newJs) => writeFile(fullJsPath, newJs, { encoding: 'utf8' })); }) ); dispose(); }); }; /** * @type {(options: import('./index.js').Options) => import('esbuild').Plugin} */ const CssModulesPlugin = (options) => { return { name: pluginName, setup: (build) => setup(build, options) }; }; export default CssModulesPlugin;