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/)

358 lines (315 loc) 10 kB
import { bundle as bundleModulesCss, transform } from 'lightningcss'; import { dirname, relative, resolve } from 'node:path'; import { contentPlaceholder, digestPlaceholder, fixImportPath, pluginCssNamespace, validateNamedExport } from './utils.js'; import { camelCase, sortBy, uniqBy, upperFirst, pick } from 'lodash-es'; import { injectorVirtualPath, pluginJsNamespace } from './utils.js'; import { readFileSync } from 'node:fs'; const lightningcssOptions = [ 'targets', 'drafts', 'nonStandard', 'pseudoClasses', 'errorRecovery', 'visitor', 'customAtRules' ]; export class CSSInjector { /** @type {Map<import('../index.js').Build, CSSInjector>} */ static __instances__ = new Map(); /** @type {string} */ libCode; /** @type {import('../index.js').Build} */ build; /** @type {import('../index.js').Options['inject']} */ inject; /** * @param {import('../index.js').Build} build */ constructor(build) { this.build = build; this.inject = build.context.options?.inject; this.libCode = this.genLibCode(); } /** * @param {import('../index.js').Build} build * @returns {CSSInjector|null} */ static getInstance(build) { if (!build.context.options?.inject) { return null; } let instance = CSSInjector.__instances__.get(build); if (!instance) { instance = new CSSInjector(build); CSSInjector.__instances__.set(build, instance); } return instance; } genCustomInject() { if (typeof this.inject !== 'function') { return ''; } const isEsbuildBundleMode = this.build.initialOptions.bundle ?? false; return ` const content = ${contentPlaceholder}; const digest = ${digestPlaceholder}; const inject = () => { setTimeout(() => { ${this.inject('content', 'digest')} }, 0); }; ${isEsbuildBundleMode ? 'export { inject };' : ''} `; } genDefaultInject() { const containerSelector = typeof this.inject === 'string' ? this.inject : 'head'; const isEsbuildBundleMode = this.build.initialOptions.bundle ?? false; return ` const content = ${contentPlaceholder}; const digest = ${digestPlaceholder}; const inject = () => { setTimeout(() => { if (!globalThis.document) { return; } let root = globalThis.document.querySelector(${JSON.stringify(containerSelector)}); if (root && root.shadowRoot) { root = root.shadowRoot; } if (!root) { root = globalThis.document.head; } let container = root.querySelector("#_" + digest); if (!container) { container = globalThis.document.createElement("style"); container.id = "_" + digest; const text = globalThis.document.createTextNode(content); container.appendChild(text); root.appendChild(container); } }, 0); }; ${isEsbuildBundleMode ? 'export { inject };' : ''} `; } genLibCode() { return typeof this.inject === 'function' ? this.genCustomInject() : this.genDefaultInject(); } genImportCode(cssPath = '') { const isEsbuildBundleMode = this.build.initialOptions.bundle ?? false; return isEsbuildBundleMode ? `import { inject } from "${pluginJsNamespace}:${fixImportPath( cssPath )}:${injectorVirtualPath}";` : this.libCode; } dispose() { CSSInjector.__instances__.delete(this.build); } } export class CSSTransformer { /** @type {Map<import('../index.js').Build, CSSTransformer>} */ static __instances__ = new Map(); /** * @param {import('../index.js').Build} build */ constructor(build) { this.build = build; /** @type {Map<string, {css: string; js: string; dts?: string; composedFiles: string[]}>} */ this.__result_cache__ = new Map(); } /** * @param {import("../index.js").Build} build */ static getInstance(build) { let instance = CSSTransformer.__instances__.get(build); if (!instance) { instance = new CSSTransformer(build); CSSTransformer.__instances__.set(build, instance); } return instance; } /** * @param {import('lightningcss').CSSModuleExports | void} exports * @param {string} fullpath * @param {boolean} emitDts * @returns {{js: string, dts?: string}} */ genModulesJs(exports, fullpath, emitDts) { const { options, buildRoot } = this.build.context; const relativePath = relative(buildRoot, fullpath); const supportNamedExports = options.namedExports ?? false; const isEsbuildBundleMode = this.build.initialOptions.bundle ?? false; /** @type {string[]} */ const jsLines = isEsbuildBundleMode ? [`import "${pluginCssNamespace}:${fixImportPath(relativePath)}";`] : []; /** @type {string[]} */ const dtsLines = []; const { localsConvention = 'camelCaseOnly' } = options; const keepOrigin = !localsConvention?.endsWith('Only'); const useCamel = localsConvention?.startsWith('camelCase'); const usePascal = localsConvention?.startsWith('pascalCase'); /** @type {Set<string>} */ const nameSet = new Set(); /** @type {[string, string][]} */ const jsNames = []; /** @type {[string, string][]} */ const originNames = []; sortBy(Object.entries(exports ?? {}), '0').forEach(([origin, local]) => { const jsName = useCamel ? camelCase(origin) : usePascal ? upperFirst(camelCase(origin)) : camelCase(origin); const composesNames = local.composes?.map?.((item) => item.name ?? '')?.join(' '); const localName = `${composesNames ? composesNames + ' ' : ''}${local.name}`; if (supportNamedExports) { const isNameValid = validateNamedExport(jsName); if (!isNameValid) { throw new Error(`class name cannot be a js keyword: \`${jsName}\` in ${relativePath}`); } if (!nameSet.has(jsName)) { jsLines.push(`export const ${jsName} = "${localName}";`); emitDts && dtsLines.push(`export declare const ${jsName}: string;`); } } jsNames.push([jsName, supportNamedExports ? jsName : `"${localName}"`]); if (keepOrigin && origin !== jsName) { originNames.push([origin, supportNamedExports ? jsName : `"${localName}"`]); nameSet.add(origin); } nameSet.add(jsName); }); const uniqNames = uniqBy([...jsNames, ...originNames], '0'); emitDts && dtsLines.push(` declare const ClassNames: { ${uniqNames.map(([o]) => ` "${o}": string;`).join('\n')} }; export default ClassNames; `); if (options.inject) { const injectorCode = CSSInjector.getInstance(this.build)?.genImportCode(relativePath) ?? ''; jsLines.push(` ${injectorCode} export default new Proxy({ ${uniqNames.map(([o, l]) => ` "${o}": ${l}`).join(',\n')} }, { get: function(source, key) { inject(); return source[key]; } }); `); } else { jsLines.push(` export default { ${uniqNames.map(([o, l]) => ` "${o}": ${l}`).join(',\n')} }; `); } const jsContents = jsLines.join('\n'); return { js: jsContents, dts: emitDts ? dtsLines.join('\n') : undefined }; } /** * @param {string} fullpath */ getCachedResult(fullpath) { return this.__result_cache__.get(fullpath); } /** * @param {string} fullpath the absolute path of css file * @param {{prefix?: string; suffix?: string; forceInlineImages?: boolean; emitDeclarationFile?: boolean;}} [opt] */ bundle(fullpath, opt) { this.__result_cache__.delete(fullpath); const { options } = this.build.context; const bundleCssConfig = { filename: fullpath, cssModules: { dashedIdents: options?.dashedIndents, pattern: options?.pattern ?? `${opt?.prefix ?? ''}__[local]_[hash]__${opt?.suffix ?? ''}` }, drafts: { customMedia: true, nesting: true }, errorRecovery: true, minify: false, sourceMap: false, projectRoot: this.build.context.buildRoot, targets: { chrome: 112 << 16 }, ...pick(options, lightningcssOptions) }; /** @type {{code: Buffer, exports: import('lightningcss').CSSModuleExports}} */ // @ts-ignore const r = bundleModulesCss(bundleCssConfig); const t = transform({ ...bundleCssConfig, code: readFileSync(fullpath) }); /** @type {string[]} */ const composedFiles = []; Object.values(t.exports ?? {}).forEach((exp) => { exp.composes?.forEach((c) => { // @ts-ignore if (c.specifier) { // @ts-ignore composedFiles.push(resolve(dirname(fullpath), c.specifier)); } }); }); const { code, exports } = r; let originCss = code ? code.toString('utf8') : ''; if (opt?.forceInlineImages) { const { outputFiles } = this.build.esbuild.buildSync({ stdin: { contents: originCss, resolveDir: dirname(fullpath), loader: 'css' }, outdir: dirname(fullpath), sourcemap: false, minify: false, write: false, bundle: true, loader: { '.png': 'dataurl', '.jpg': 'dataurl', '.jpeg': 'dataurl', '.webp': 'dataurl', '.bmp': 'dataurl', '.gif': 'dataurl', '.apng': 'dataurl', '.avif': 'dataurl', '.svg': 'dataurl', '.ico': 'dataurl', '.cur': 'dataurl', '.tif': 'dataurl', '.tiff': 'dataurl' } }); originCss = outputFiles.find((f) => f.path.endsWith(`stdin.css`))?.text ?? originCss; } const { js, dts } = this.genModulesJs(exports, fullpath, !!opt?.emitDeclarationFile); /** @type {{css: string; js: string; dts?: string; composedFiles: string[]}} */ const result = { css: originCss, composedFiles, js, dts }; this.__result_cache__.set(fullpath, result); return result; } dispose() { this.__result_cache__.clear(); CSSTransformer.__instances__.delete(this.build); } }