diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..7762a5f --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,15 @@ +Copyright + +Permission is hereby granted, free of charge, +to any person obtaining a copy of this software and associated documentation files (the “Software”), +to deal in the Software without restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, +and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/__fixtures__/css-entry-points/first.js b/__fixtures__/css-entry-points/first.js new file mode 100644 index 0000000..6545bca --- /dev/null +++ b/__fixtures__/css-entry-points/first.js @@ -0,0 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: CC0-1.0 + */ +import './shared.js' + +window.alert('Hello world from first.js') diff --git a/__fixtures__/css-entry-points/global.css b/__fixtures__/css-entry-points/global.css new file mode 100644 index 0000000..3fb855a --- /dev/null +++ b/__fixtures__/css-entry-points/global.css @@ -0,0 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: CC0-1.0 + */ +.color { + background-color: red; +} \ No newline at end of file diff --git a/__fixtures__/css-entry-points/second.js b/__fixtures__/css-entry-points/second.js new file mode 100644 index 0000000..f32c8fc --- /dev/null +++ b/__fixtures__/css-entry-points/second.js @@ -0,0 +1,9 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: CC0-1.0 + */ +import './global.css' + +window.onload = async () => { + await import('./shared.js') +} diff --git a/__fixtures__/css-entry-points/shared.css b/__fixtures__/css-entry-points/shared.css new file mode 100644 index 0000000..1056251 --- /dev/null +++ b/__fixtures__/css-entry-points/shared.css @@ -0,0 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: CC0-1.0 + */ +.color { + background-color: blue !important; +} \ No newline at end of file diff --git a/__fixtures__/css-entry-points/shared.js b/__fixtures__/css-entry-points/shared.js new file mode 100644 index 0000000..3ee9af8 --- /dev/null +++ b/__fixtures__/css-entry-points/shared.js @@ -0,0 +1,7 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * SPDX-License-Identifier: CC0-1.0 + */ +import './shared.css' + +window.something = () => 'Just so the module will not be empty' diff --git a/__tests__/css-entry-points.spec.ts b/__tests__/css-entry-points.spec.ts new file mode 100644 index 0000000..1f1ad42 --- /dev/null +++ b/__tests__/css-entry-points.spec.ts @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * + * SPDX-License-Identifier: MIT + */ + +import type { RollupOutput, OutputAsset } from 'rollup' +import { build } from 'vite' +import { describe, it, expect } from 'vitest' +import { CSSEntryPointsPlugin } from '../lib/plugins/CSSEntryPoints' +import { resolve } from 'path' + +const root = resolve(import.meta.dirname, '../__fixtures__/css-entry-points') + +describe('CSS entry point plugin', () => { + it('minifies using esbuild by default', async () => { + const { output } = await build({ + configFile: false, + root, + appType: 'custom', + plugins: [CSSEntryPointsPlugin()], + build: { + cssCodeSplit: true, + rollupOptions: { + input: { + first: resolve(root, './first.js'), + second: resolve(root, './second.js'), + }, + output: { + assetFileNames: 'assets/[name].[ext]', + chunkFileNames: 'chunks/[name].js', + entryFileNames: '[name].js', + }, + }, + }, + }) as RollupOutput + + // Has correct first entry + const firstCSS = output.find(({ fileName }) => fileName === 'assets/first.css') as OutputAsset + expect(firstCSS.source).toMatch(/@import '\.\/[^.]+\.chunk\.css'/) + // Has correct second entry + const secondCSS = output.find(({ fileName }) => fileName === 'assets/second.css') as OutputAsset + expect(secondCSS.source).toMatch(/@import '\.\/[^.]+\.chunk\.css'/) + }) +}) diff --git a/lib/plugins/CSSEntryPoints.ts b/lib/plugins/CSSEntryPoints.ts new file mode 100644 index 0000000..015ba82 --- /dev/null +++ b/lib/plugins/CSSEntryPoints.ts @@ -0,0 +1,112 @@ +/** + * SPDX-FileCopyrightText: 2024 Ferdinand Thiessen + * + * SPDX-License-Identifier: MIT + */ + +// eslint-disable-next-line n/no-extraneous-import +import type { OutputOptions, PreRenderedAsset } from 'rollup' +import type { Plugin } from 'vite' + +import { basename, dirname, join, normalize } from 'path' + +interface CSSEntryPointsPluginOptions { + /** + * Also create empty CSS entry points for JS entry points without styles + * @default false + */ + createEmptyEntryPoints?: boolean +} + +/** + * A vite plugin to properly extract synchronously imported CSS from JS entry points + * + * @param options Configuration for the plugin + */ +export function CSSEntryPointsPlugin(options?: CSSEntryPointsPluginOptions) { + const pluginOptions = { + createEmptyEntryPoints: false, + ...options, + } + + return { + name: 'css-entry-points-plugin', + + // We use this to adjust the asset file names for CSS files so we ensure entry points are unique + config(config) { + /** + * Create a wrapper function to rename non entry css assets + * @param config Original assets file name config + */ + function fixupAssetFileNames(config: Required) { + // Return a wrapper function + return (info: PreRenderedAsset) => { + // If the original assets name option is a function we need to call it otherwise just use the template string + const name = typeof config === 'function' ? config(info) : config + // Only handle CSS files not extracted by this plugin + if (info.name.endsWith('.css') && !String(info.source).startsWith('/* extracted by css-entry-points-plugin */')) { + // The new name should have the same path but instead of the .css extension it is .chunk.css + return name.replace(/(.css|.\[ext\]|\[extname\])$/, '.chunk.css') + } + return name + } + } + + // If there is any output option we need to fix the assetFileNames + if (config.build?.rollupOptions?.output) { + for (const output of [config.build.rollupOptions.output].flat()) { + if (output.assetFileNames === undefined) { + continue + } + output.assetFileNames = fixupAssetFileNames(output.assetFileNames) + } + } + }, + + generateBundle(options, bundle) { + for (const chunk of Object.values(bundle)) { + // Only handle entry points + if (chunk.type !== 'chunk' || !chunk.isEntry) { + continue + } + + // Set of all synchronously imported CSS of this entry point + const importedCSS = new Set(chunk.viteMetadata?.importedCss ?? []) + const getImportedCSS = (importedNames: string[]) => { + for (const importedName of importedNames) { + const importedChunk = bundle[importedName] + // Skip non chunks + if (importedChunk.type !== 'chunk') { + continue + } + // First add the css modules imported by imports + getImportedCSS(importedChunk.imports ?? []) + // Now merge the imported CSS into the list + ;(importedChunk.viteMetadata?.importedCss ?? []) + .forEach((name: string) => importedCSS.add(name)) + } + } + getImportedCSS(chunk.imports) + + // Skip empty entries if not configured to output empty CSS + if (importedCSS.size === 0 && !pluginOptions.createEmptyEntryPoints) { + continue + } + + const source = [...importedCSS.values()] + .map((css) => `@import './${basename(css)}';`) + .join('\n') + + const cssName = `${chunk.name}.css` + const path = dirname(typeof options.assetFileNames === 'string' ? options.assetFileNames : options.assetFileNames({ type: 'asset', source: '', name: 'name.css' })) + this.emitFile({ + type: 'asset', + name: `\0${cssName}`, + fileName: normalize(join(path, cssName)), + needsCodeReference: false, + source: `/* extracted by css-entry-points-plugin */\n${source}`, + }) + } + } + } as Plugin +}