From ba1ef527896ecf4c83b6d6c653eef351d2fb1693 Mon Sep 17 00:00:00 2001 From: indooorsman Date: Tue, 22 Mar 2022 13:04:59 +0800 Subject: [PATCH] v2.2.5 (#28) --- index.d.ts | 18 +++- index.js | 6 +- lib/cache.js | 17 +++ lib/plugin.js | 290 ++++++++++++++++++++++++++++++++++---------------- lib/utils.js | 128 ++++++++++------------ lib/v1.js | 14 +-- package.json | 5 +- 7 files changed, 303 insertions(+), 175 deletions(-) create mode 100644 lib/cache.js diff --git a/index.d.ts b/index.d.ts index a14629a..1cd43e7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,4 +1,4 @@ -import type { Plugin } from 'esbuild'; +import type { Plugin, PluginBuild } from 'esbuild'; declare type GenerateScopedNameFunction = (name: string, filename: string, css: string) => string; @@ -47,12 +47,28 @@ declare interface PluginOptions { generateScopedName?: CssModulesOptions['generateScopedName']; cssModulesOption?: CssModulesOptions; v2?: boolean; + root?: string; + package?: { + name: string, + main?: string, + module?: string, + version?: string + } +} + +declare interface BuildContext { + buildId: string; + buildRoot: string; + packageRoot?: string; } declare function CssModulesPlugin(options?: PluginOptions): Plugin; declare namespace CssModulesPlugin { export type Options = PluginOptions; + export interface Build extends PluginBuild { + context: BuildContext; + } } export = CssModulesPlugin; diff --git a/index.js b/index.js index ac0e538..31daade 100644 --- a/index.js +++ b/index.js @@ -8,15 +8,15 @@ const { pluginName } = require('./lib/utils'); const CssModulesPlugin = (options = {}) => { return { name: pluginName, - setup(build) { + setup: async (build) => { const { bundle } = build.initialOptions; const { v2 } = options; const useV2 = v2 && bundle; if (useV2) { - plugin.setup(build, options); + await plugin.setup(build, options); } else { - pluginV1.setup(build, options); + await pluginV1.setup(build, options); } } }; diff --git a/lib/cache.js b/lib/cache.js new file mode 100644 index 0000000..5c0f695 --- /dev/null +++ b/lib/cache.js @@ -0,0 +1,17 @@ +const cacheHolder = { cache: new Map() }; + +module.exports = { + get(key) { + const ref = cacheHolder.cache.get(key); + if (ref) { + return ref.deref(); + } + }, + set(key, data) { + const wr = new WeakRef(data); + cacheHolder.cache.set(key, wr); + }, + clear() { + cacheHolder.cache.clear(); + } +}; diff --git a/lib/plugin.js b/lib/plugin.js index 9ed2393..8de7ce8 100644 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -1,166 +1,278 @@ const path = require('path'); const { createHash } = require('crypto'); -const fse = require('fs-extra'); -const { getAbsoluteUrl, getLogger, tmpBuildDirName, buildInjectCode } = require('./utils.js'); +const { readFileSync } = require('fs'); +const { readFile, appendFile } = require('fs/promises'); +const { + getLogger, + buildInjectCode, + pluginName, + getRootDir, + pluginNamespace, + builtCssSurfix +} = require('./utils.js'); const cssHandler = require('@parcel/css'); const camelCase = require('lodash/camelCase'); +const { v4 } = require('uuid'); +const cache = require('./cache.js'); /** * buildCssModulesJs - * @param {{fullPath: string; outDir: string; options: import('..').Options; digest: string; build: import('esbuild').PluginBuild}} params - * @returns {Promise<{builtCssFullPath: string; jsContent: string;}>} + * @param {{fullPath: string; options: import('..').Options; digest: string; build: import('..').Build}} params + * @returns {Promise<{resolveDir: string; js: string; css: string; exports: Record}>} */ -const buildCssModulesJs = async ({ fullPath, outDir, options, digest, build }) => { - const inject = options && options.inject; +const buildCssModulesJs = async ({ fullPath, options, build }) => { + const { buildId } = build.context; const resolveDir = path.dirname(fullPath); const classPrefix = path.basename(fullPath, path.extname(fullPath)).replace(/\./g, '-') + '__'; + const originCss = await readFile(fullPath); /** * @type {import('@parcel/css').BundleOptions} */ const bundleConfig = { filename: fullPath, - minify: !!inject, - sourceMap: true, + code: originCss, + minify: true, + sourceMap: !options.inject, cssModules: true, - analyzeDependencies: true + analyzeDependencies: false }; - const { code, exports = {}, map, dependencies = [] } = cssHandler.bundle(bundleConfig); - - let finalCssContent = code.toString('utf-8'); + const { code, exports = {}, map } = cssHandler.transform(bundleConfig); + let cssModulesContent = code.toString('utf-8'); const cssModulesJSON = {}; Object.keys(exports).forEach((originClass) => { const patchedClass = exports[originClass].name; cssModulesJSON[camelCase(originClass)] = classPrefix + patchedClass; - finalCssContent = finalCssContent.replace( + cssModulesContent = cssModulesContent.replace( new RegExp(`\\.${patchedClass}`, 'g'), '.' + classPrefix + patchedClass ); }); - const classNames = JSON.stringify(cssModulesJSON, null, 2); - - const urls = dependencies.filter((d) => d.type === 'url'); - const urlFullPathMap = {}; - urls.forEach(({ url, placeholder }) => { - const assetAbsolutePath = getAbsoluteUrl(resolveDir, url); - if (inject) { - urlFullPathMap[placeholder] = assetAbsolutePath; - } else { - finalCssContent = finalCssContent.replace( - new RegExp(`${placeholder}`, 'g'), - assetAbsolutePath - ); - } - }); + const classNamesMapString = JSON.stringify(cssModulesJSON); - let injectCode = ''; - let builtCssFullPath = null; - - if (inject) { - const injectOptions = { urlFullPathMap, injectEvent: options.injectEvent }; - const typeofInject = typeof inject; - if (typeofInject === 'boolean') { - injectCode = buildInjectCode('head', finalCssContent, digest, injectOptions); - } else if (typeofInject === 'string') { - injectCode = buildInjectCode(inject, finalCssContent, digest, injectOptions); - } else if (typeofInject === 'function') { - injectCode = inject(finalCssContent, digest, injectOptions); - } else { - throw new Error('type of `inject` must be boolean or string or function'); - } - } else { - const cssFileName = path.basename(fullPath); - const builtCssFileName = cssFileName.replace(/\.modules?\.css$/i, '.module.built.css'); - builtCssFullPath = path.resolve(outDir, builtCssFileName); - fse.ensureDirSync(outDir); - if (map) { - finalCssContent += `\n/*# sourceMappingURL=data:application/json;base64,${map.toString( - 'base64' - )} */`; - } - fse.writeFileSync(builtCssFullPath, finalCssContent, { encoding: 'utf-8' }); + const cssFileName = path.basename(fullPath); + const builtCssFileName = cssFileName.replace(/\.modules?\.css$/i, builtCssSurfix); - // fix path issue on Windows: https://github.com/indooorsman/esbuild-css-modules-plugin/issues/12 - injectCode = `import "${builtCssFullPath.split(path.sep).join(path.posix.sep)}";`; + let cssWithSourceMap = cssModulesContent; + if (map) { + cssWithSourceMap += `\n/*# sourceMappingURL=data:application/json;base64,${map.toString( + 'base64' + )} */`; } - const jsContent = `${injectCode}\nexport default ${classNames};`; + // fix path issue on Windows: https://github.com/indooorsman/esbuild-css-modules-plugin/issues/12 + const cssImportPath = './' + builtCssFileName.split(path.sep).join(path.posix.sep).trim(); + const injectCode = `import "${cssImportPath}";`; + + const exportDefault = ` +export default new Proxy(${classNamesMapString}, { + get: function(source, key) { + setTimeout(() => { + window.__inject_${buildId}__ && window.__inject_${buildId}__(); + }, 0); + return source[key]; + } +}); + `; + + const js = `${injectCode}\n${exportDefault};`; return { - jsContent, - builtCssFullPath + js, + css: cssWithSourceMap, + exports, + resolveDir }; }; /** * onLoad - * @param {import('esbuild').PluginBuild} build + * @param {import('..').Build} build * @param {import('..').Options} options * @param {import('esbuild').OnLoadArgs} args * @return {(import('esbuild').OnLoadResult | null | undefined | Promise)} */ const onLoad = async (build, options, args) => { - const { outdir } = build.initialOptions; - const rootDir = process.cwd(); const log = getLogger(build); - const fullPath = args.path; - const hex = createHash('sha256').update(fullPath).digest('hex'); - const digest = hex.slice(hex.length - 255, hex.length); - const tmpRoot = path.resolve(process.cwd(), outdir, tmpBuildDirName); - const tmpDir = path.resolve(tmpRoot, digest); + const { path: fullPath } = args; + const { buildRoot } = build.context; + + const cached = cache.get(fullPath); + if (cached) { + log('return cache for', fullPath); + return cached; + } - const tmpCssFile = path.join(tmpDir, fullPath.replace(rootDir, '')); - const tmpCssDir = path.dirname(tmpCssFile); + const rpath = path.relative(buildRoot, fullPath); + const hex = createHash('sha256').update(rpath).digest('hex'); + const digest = hex.slice(hex.length - 255, hex.length); - const { jsContent, builtCssFullPath } = await buildCssModulesJs({ + const { js, resolveDir, css, exports } = await buildCssModulesJs({ fullPath, options, digest, - outDir: tmpCssDir, build }); - builtCssFullPath && log('built css:', builtCssFullPath.replace(tmpDir, '')); - - return { - contents: jsContent, + const result = { + pluginName, + resolveDir, + pluginData: { + css, + exports, + digest + }, + contents: js, loader: 'js' }; + cache.set(fullPath, result); + + return result; }; /** * onEnd - * @param {import('esbuild').PluginBuild} build + * @param {import('..').Build} build * @param {import('..').Options} options * @param {import('esbuild').BuildResult} result */ -const onEnd = (build, options, result) => { +const onEnd = async (build, options, result) => { if (build.initialOptions.watch) { return; } - getLogger(build)('clean temp files...'); - try { - const { outdir } = build.initialOptions; - const tmpRoot = path.resolve(process.cwd(), outdir, tmpBuildDirName); + const { buildId, buildRoot } = build.context; + const log = getLogger(build); + + if (options.inject === true || typeof options.inject === 'string') { + const cssContents = []; + const entries = build.initialOptions.entryPoints.map((entry) => path.resolve(buildRoot, entry)); + log('entries:', entries); + let injectTo = null; + Object.keys(result.metafile?.outputs ?? []).forEach((f) => { + if ( + result.metafile.outputs[f].entryPoint && + entries.includes(path.resolve(buildRoot, result.metafile.outputs[f].entryPoint)) && + path.extname(f) === '.js' + ) { + injectTo = path.resolve(buildRoot, f); + } + if (path.extname(f) === '.css') { + const fullpath = path.resolve(buildRoot, f); + const css = readFileSync(fullpath); + cssContents.push(`\n/* ${f} */\n${css}\n`); + } + }); + log('inject css to', injectTo); + if (injectTo && cssContents.length) { + const allCss = cssContents.join(''); + const container = typeof options.inject === 'string' ? options.inject : 'head'; + const injectedCode = buildInjectCode(container, allCss, buildId, options); - fse.removeSync(tmpRoot); - } catch (error) {} + await appendFile(injectTo, injectedCode, { encoding: 'utf-8' }); + } + } +}; + +/** + * prepareBuild + * @param {import('..').Build} build + * @param {import('..').Options} options + * @return {Promise} + */ +const prepareBuild = async (build, options) => { + const buildId = v4().replace(/[^0-9a-zA-Z]/g, ''); + build.initialOptions.metafile = true; + const packageRoot = options.root; + const buildRoot = getRootDir(build); + + build.context = { + buildId, + buildRoot, + packageRoot + }; +}; + +/** + * onLoadBuiltCss + * @param {import('esbuild').OnLoadArgs} args + * @returns {import('esbuild').OnLoadResult} + */ +const onLoadBuiltCss = async ({ pluginData }) => { + const { css, resolveDir } = pluginData; + + /** + * @type {import('esbuild').OnLoadResult} + */ + const result = { + contents: css, + loader: 'css', + pluginName, + resolveDir, + pluginData + }; + + return result; +}; + +/** + * onResolveBuiltCss + * @param {import('esbuild').OnResolveArgs} args + * @returns {import('esbuild').OnResolveResult} + */ +const onResolveBuiltCss = async (args) => { + const { resolveDir, path: p, pluginData } = args; + + /** + * @type {import('esbuild').OnResolveResult} + */ + const result = { + namespace: pluginNamespace, + path: p, + external: false, + pluginData: { + ...pluginData, + resolveDir, + path: p + }, + sideEffects: true, + pluginName + }; + + return result; }; /** * setup - * @param {import('esbuild').PluginBuild} build + * @param {import('..').Build} build * @param {import('..').Options} options - * @return {void} + * @returns {Promise} */ -const setup = (build, options) => { - build.onLoad({ filter: /\.modules?\.css$/ }, async (args) => { +const setup = async (build, options) => { + await prepareBuild(build, options); + + const log = getLogger(build); + + build.onLoad({ filter: /\.modules?\.css$/i }, async (args) => { + log('loading', args.path); return await onLoad(build, options, args); }); - build.onEnd((result) => { - onEnd(build, options, result); + build.onResolve({ filter: new RegExp(builtCssSurfix, 'i') }, onResolveBuiltCss); + + build.onLoad( + { + filter: new RegExp(builtCssSurfix, 'i'), + namespace: pluginNamespace + }, + async (args) => { + log('loading built css', args.path); + return await onLoadBuiltCss(args); + } + ); + + build.onEnd(async (result) => { + await onEnd(build, options, result); }); }; diff --git a/lib/utils.js b/lib/utils.js index ed65e74..12157ea 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1,11 +1,11 @@ const path = require('path'); -const pluginName = require('../package.json').name; +const pluginName = require('../package.json').name.toLowerCase(); const pluginNamespace = `${pluginName}-namespace`; -const tmpBuildDirName = `.esbuild_plugin_css_modules`; +const builtCssSurfix = `.${pluginName}.module.built.css` /** * getLogger - * @param {import('esbuild').PluginBuild} build + * @param {import('..').Build} build * @returns {(...args: any[]) => void} */ const getLogger = (build) => { @@ -18,94 +18,76 @@ const getLogger = (build) => { return () => void 0; }; -/** - * getAbsoluteUrl - * @param {string} resolveDir - * @param {string} url - * @returns {string} - */ -const getAbsoluteUrl = (resolveDir, url) => { - const pureUrl = url.replace(/\"/g, '').replace(/\'/g, ''); - if (path.isAbsolute(pureUrl) || pureUrl.startsWith('http')) { - return pureUrl; - } - return path.resolve(resolveDir, pureUrl); -}; - /** * buidInjectCode * @param {string} injectToSelector * @param {string} css * @param {string} digest + * @param {import('..').Options} options * @returns {string} */ -const buildInjectCode = (injectToSelector = 'head', css, digest, { urlFullPathMap, injectEvent }) => { - const patchedPlaceholders = []; - const imports = Object.keys(urlFullPathMap) - .map((placeholder) => { - // placeholder can start with number - patchedPlaceholders.push('__' + placeholder); - return `import __${placeholder} from '${urlFullPathMap[placeholder]}';`; - }) - .join('\n'); - return `${imports} +const buildInjectCode = ( + injectToSelector = 'head', + css, + digest, + options +) => { + if (typeof options.inject === 'function') { + return ` (function(){ - const injectEvent = ${injectEvent ? `"${injectEvent}"` : 'undefined'} - let css = \`${css}\`; - ${ - patchedPlaceholders.length - ? ` - const placeholders = \`${patchedPlaceholders.join(',')}\`.split(','); - const urls = [${patchedPlaceholders.join(',')}]; - placeholders.forEach(function(p, index) { - const originPlaceholder = p.replace(/^__/, ''); - css = css.replace(new RegExp(\`"\${originPlaceholder}"\`, 'g'), urls[index]); - }); - ` - : '' + const css = \`${css}\`; + const digest = \`${digest}\`; + const doInject = () => { + ${options.inject(css, digest)}; + delete window.__inject_${digest}__; + }; + window.__inject_${digest}__ = doInject; +})(); + ` } - - const __inject = function() { - const doInject = () => { - let root = document.querySelector('${injectToSelector}'); - if (root && root.shadowRoot) { - root = root.shadowRoot; - } - if (!root) { - root = document.head; - console.warn('[esbuild-css-modules-plugin]', 'can not find element \`${injectToSelector}\`, append style to', root); - } - if (!root.querySelector('#_${digest}')) { - const el = document.createElement('style'); - el.id = '_${digest}'; - el.textContent = css; - root.appendChild(el); - } + return ` +(function(){ + const css = \`${css}\`; + const doInject = () => { + let root = document.querySelector('${injectToSelector}'); + if (root && root.shadowRoot) { + root = root.shadowRoot; } - if (injectEvent) { - window.addEventListener(injectEvent, function() { - doInject(); - }); - } else { - doInject(); + if (!root) { + root = document.head; } + let container = root.querySelector('#_${digest}'); + if (!container) { + container = document.createElement('style'); + container.id = '_${digest}'; + root.appendChild(container); + } + const text = document.createTextNode(css); + container.appendChild(text); + delete window.__inject_${digest}__; } - if (!injectEvent && document.readyState !== 'interactive' && document.readyState !== 'complete') { - window.addEventListener('DOMContentLoaded', function() { - __inject(); - }); - } else { - __inject(); - } + window.__inject_${digest}__ = doInject; })(); `; }; +/** + * getRootDir + * @param {import('..').Build} build + * @returns {string} + */ +const getRootDir = (build) => { + const { absWorkingDir } = build.initialOptions; + const abs = absWorkingDir ? absWorkingDir : process.cwd(); + const rootDir = path.isAbsolute(abs) ? abs : path.resolve(process.cwd(), abs); + return rootDir; +}; + module.exports = { pluginName, pluginNamespace, getLogger, - getAbsoluteUrl, - tmpBuildDirName, - buildInjectCode + getRootDir, + buildInjectCode, + builtCssSurfix }; diff --git a/lib/v1.js b/lib/v1.js index fd27f8b..5e67c21 100644 --- a/lib/v1.js +++ b/lib/v1.js @@ -1,13 +1,12 @@ const path = require('path'); const { createHash } = require('crypto'); -const fse = require('fs-extra'); +const { readFile, writeFile } = require('fs/promises'); const postcss = require('postcss'); const cssModules = require('postcss-modules'); const util = require('util'); const tmp = require('tmp'); const hash = createHash('sha256'); -const readFile = util.promisify(fse.readFile); -const writeFile = util.promisify(fse.writeFile); +const fse = require('fs-extra'); const ensureDir = util.promisify(fse.ensureDir); const { pluginNamespace, getLogger } = require('./utils.js'); @@ -151,10 +150,11 @@ const onLoadFactory = (build) => (args) => { /** * setup for v1 - * @param {import('esbuild').PluginBuild} build - * @param {import('..').Options} options + * @param {import('esbuild').PluginBuild} build + * @param {import('..').Options} options + * @returns {Promise} */ -const setup = (build, options) => { +const setup = async (build, options) => { build.onResolve( { filter: /\.modules?\.css$/, namespace: 'file' }, onResolveFactory(build, options) @@ -164,7 +164,7 @@ const setup = (build, options) => { { filter: /\.modules?\.css\.js$/, namespace: pluginNamespace }, onLoadFactory(build, options) ); -} +}; module.exports = { setup diff --git a/package.json b/package.json index b6a8e43..094d357 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "esbuild-css-modules-plugin", - "version": "2.2.4", + "version": "2.2.5", "description": "A esbuild plugin to bundle css modules into js(x)/ts(x).", "main": "index.js", "keywords": [ @@ -30,7 +30,8 @@ "lodash": "^4.17.21", "postcss": "^8.4.12", "postcss-modules": "^4.3.1", - "tmp": "^0.2.1" + "tmp": "^0.2.1", + "uuid": "^8.3.2" }, "publishConfig": { "access": "public"