feat: CSS entrypoints (#61)
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/webextension-polyfill": "^0.10.0",
|
||||
"sass": "^1.64.0",
|
||||
"wxt": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
background-color: red;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
color: blue;
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
||||
{ text: 'Background', link: '/guide/background.md' },
|
||||
{ text: 'Bookmarks', link: '/guide/bookmarks.md' },
|
||||
{ text: 'Content Scripts', link: '/guide/content-scripts.md' },
|
||||
{ text: 'CSS', link: '/guide/css.md' },
|
||||
{ text: 'Devtools', link: '/guide/devtools.md' },
|
||||
{ text: 'History', link: '/guide/history.md' },
|
||||
{ text: 'Newtab', link: '/guide/newtab.md' },
|
||||
|
||||
@@ -38,7 +38,7 @@ When defining multiple content scripts, content script entrypoints that have the
|
||||
|
||||
## CSS
|
||||
|
||||
To include CSS with your content script, import the CSS file at the top of your entrypoint:
|
||||
To include CSS with your content script, import the CSS file at the top of your entrypoint.
|
||||
|
||||
```
|
||||
|
||||
@@ -60,3 +60,18 @@ export default defineContentScript({
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Any styles imported in your content script will be added to that content script's `css` array in your `manifest.json`:
|
||||
|
||||
```json
|
||||
// .output/chrome-mv3/manifest.json
|
||||
{
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["*://google.com/*", "*://duckduckgo.com/*"],
|
||||
"js": ["content-scripts/overlay.js"],
|
||||
"css": ["content-scripts/overlay.css"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# CSS
|
||||
|
||||
WXT can build CSS entrypoints individually. CSS entrypoints are always unlisted.
|
||||
|
||||
See [Content Script CSS](/guide/content-scripts.md#css) documentation for the recomended approach to include CSS with a content script.
|
||||
|
||||
:::info
|
||||
If the recommended approach doesn't work for your use case, you can use any of the filename patterns below to build the styles separate from the JS and use the [`transformManifest` hook](/config.md#transformmanifest) to manually add your CSS file to the manifest.
|
||||
:::
|
||||
|
||||
## Filenames
|
||||
|
||||
When a filename matches the pattern below, WXT will transform it and output it with the rest of your extension.
|
||||
|
||||
- `entrypoints/content.(css|scss|sass|less|styl|stylus)`
|
||||
- `entrypoints/<name>.(css|scss|sass|less|styl|stylus)`
|
||||
- `entrypoints/<name>/index.(css|scss|sass|less|styl|stylus)`
|
||||
- `entrypoints/<name>.content.(css|scss|sass|less|styl|stylus)`
|
||||
- `entrypoints/<name>.content/index.(css|scss|sass|less|styl|stylus)`
|
||||
|
||||
## Definition
|
||||
|
||||
```css
|
||||
body {
|
||||
/* Plain CSS file */
|
||||
}
|
||||
```
|
||||
|
||||
Follow Vite's guide to setup a preprocessor: https://vitejs.dev/guide/features.html#css-pre-processors
|
||||
|
||||
```sh
|
||||
pnpm i sass
|
||||
```
|
||||
|
||||
```scss
|
||||
body {
|
||||
h1 {
|
||||
/* ...*/
|
||||
}
|
||||
}
|
||||
```
|
||||
+1
-3
@@ -78,7 +78,6 @@
|
||||
"linkedom": "^0.14.26",
|
||||
"minimatch": "^9.0.3",
|
||||
"picocolors": "^1.0.0",
|
||||
"picomatch": "^2.3.1",
|
||||
"unimport": "^3.0.8",
|
||||
"vite": "^4.3.9",
|
||||
"vite-tsconfig-paths": "^4.2.0",
|
||||
@@ -91,7 +90,6 @@
|
||||
"@types/fs-extra": "^11.0.1",
|
||||
"@types/lodash.merge": "^4.6.7",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/picomatch": "^2.3.0",
|
||||
"@vitest/coverage-v8": "^0.32.2",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"npm-run-all": "^4.1.5",
|
||||
@@ -125,4 +123,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+17
-10
@@ -56,9 +56,6 @@ importers:
|
||||
picocolors:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
picomatch:
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1
|
||||
unimport:
|
||||
specifier: ^3.0.8
|
||||
version: 3.0.8
|
||||
@@ -90,9 +87,6 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^20.3.1
|
||||
version: 20.3.1
|
||||
'@types/picomatch':
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^0.32.2
|
||||
version: 0.32.2(vitest@0.32.4)
|
||||
@@ -142,6 +136,9 @@ importers:
|
||||
'@types/webextension-polyfill':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
sass:
|
||||
specifier: ^1.64.0
|
||||
version: 1.64.0
|
||||
wxt:
|
||||
specifier: workspace:*
|
||||
version: link:..
|
||||
@@ -1046,10 +1043,6 @@ packages:
|
||||
/@types/node@20.3.1:
|
||||
resolution: {integrity: sha512-EhcH/wvidPy1WeML3TtYFGR83UzjxeWRen9V402T8aUGYsCHOmfoisV3ZSg03gAFIbLq8TnWOJ0f4cALtnSEUg==}
|
||||
|
||||
/@types/picomatch@2.3.0:
|
||||
resolution: {integrity: sha512-O397rnSS9iQI4OirieAtsDqvCj4+3eY1J+EPdNTKuHuRWIfUoGyzX294o8C4KJYaLqgSrd2o60c5EqCU8Zv02g==}
|
||||
dev: true
|
||||
|
||||
/@types/web-bluetooth@0.0.17:
|
||||
resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==}
|
||||
dev: true
|
||||
@@ -3172,6 +3165,10 @@ packages:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
dev: false
|
||||
|
||||
/immutable@4.3.1:
|
||||
resolution: {integrity: sha512-lj9cnmB/kVS0QHsJnYKD1uo3o39nrbKxszjnqS9Fr6NB7bZzW45U6WSGBPKXDL/CvDKqDNPA4r3DoDQ8GTxo2A==}
|
||||
dev: true
|
||||
|
||||
/import-fresh@3.3.0:
|
||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4803,6 +4800,16 @@ packages:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
dev: false
|
||||
|
||||
/sass@1.64.0:
|
||||
resolution: {integrity: sha512-m7YtAGmQta9uANIUJwXesAJMSncqH+3INc8kdVXs6eV6GUC8Qu2IYKQSN8PRLgiQfpca697G94klm2leYMxSHw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
dependencies:
|
||||
chokidar: 3.5.3
|
||||
immutable: 4.3.1
|
||||
source-map-js: 1.0.2
|
||||
dev: true
|
||||
|
||||
/sax@1.2.4:
|
||||
resolution: {integrity: sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==}
|
||||
dev: false
|
||||
|
||||
@@ -433,6 +433,73 @@ describe('findEntrypoints', () => {
|
||||
outputDir: config.outDir,
|
||||
},
|
||||
],
|
||||
[
|
||||
'injected/index.ts',
|
||||
{
|
||||
type: 'unlisted-script',
|
||||
name: 'injected',
|
||||
inputPath: resolve(config.entrypointsDir, 'injected/index.ts'),
|
||||
outputDir: config.outDir,
|
||||
},
|
||||
],
|
||||
|
||||
// unlisted-style
|
||||
[
|
||||
'iframe.scss',
|
||||
{
|
||||
type: 'unlisted-style',
|
||||
name: 'iframe',
|
||||
inputPath: resolve(config.entrypointsDir, 'iframe.scss'),
|
||||
outputDir: config.outDir,
|
||||
},
|
||||
],
|
||||
[
|
||||
'iframe.css',
|
||||
{
|
||||
type: 'unlisted-style',
|
||||
name: 'iframe',
|
||||
inputPath: resolve(config.entrypointsDir, 'iframe.css'),
|
||||
outputDir: config.outDir,
|
||||
},
|
||||
],
|
||||
|
||||
// content-script-style
|
||||
[
|
||||
'content.css',
|
||||
{
|
||||
type: 'content-script-style',
|
||||
name: 'content',
|
||||
inputPath: resolve(config.entrypointsDir, 'content.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'overlay.content.css',
|
||||
{
|
||||
type: 'content-script-style',
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'content/index.css',
|
||||
{
|
||||
type: 'content-script-style',
|
||||
name: 'content',
|
||||
inputPath: resolve(config.entrypointsDir, 'content/index.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
},
|
||||
],
|
||||
[
|
||||
'overlay.content/index.css',
|
||||
{
|
||||
type: 'content-script-style',
|
||||
name: 'overlay',
|
||||
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.css'),
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
},
|
||||
],
|
||||
])('should find entrypoint for %s', async (path, expected) => {
|
||||
globMock.mockResolvedValueOnce([path]);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
EntrypointGroup,
|
||||
InternalConfig,
|
||||
} from '../types';
|
||||
import * as plugins from '../vite-plugins';
|
||||
import * as wxtPlugins from '../vite-plugins';
|
||||
import { removeEmptyDirs } from '../utils/removeEmptyDirs';
|
||||
import { getEntrypointBundlePath } from '../utils/entrypoints';
|
||||
import fs from 'fs-extra';
|
||||
@@ -45,7 +45,16 @@ async function buildSingleEntrypoint(
|
||||
? `virtual:wxt-${entrypoint.type}?${entrypoint.inputPath}`
|
||||
: entrypoint.inputPath;
|
||||
|
||||
const plugins: NonNullable<vite.UserConfig['plugins']> = [];
|
||||
if (
|
||||
entrypoint.type === 'content-script-style' ||
|
||||
entrypoint.type === 'unlisted-style'
|
||||
) {
|
||||
plugins.push(wxtPlugins.cssEntrypoints(entrypoint, config));
|
||||
}
|
||||
|
||||
const libMode: vite.UserConfig = {
|
||||
plugins,
|
||||
build: {
|
||||
lib: {
|
||||
entry,
|
||||
@@ -90,7 +99,7 @@ async function buildMultipleEntrypoints(
|
||||
config: InternalConfig,
|
||||
): Promise<BuildStepOutput> {
|
||||
const multiPage: vite.UserConfig = {
|
||||
plugins: [plugins.multipageMove(entrypoints, config)],
|
||||
plugins: [wxtPlugins.multipageMove(entrypoints, config)],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: entrypoints.reduce<Record<string, string>>((input, entry) => {
|
||||
|
||||
@@ -10,13 +10,14 @@ import {
|
||||
PopupEntrypoint,
|
||||
} from '../types';
|
||||
import fs from 'fs-extra';
|
||||
import picomatch from 'picomatch';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { parseHTML } from 'linkedom';
|
||||
import JSON5 from 'json5';
|
||||
import { importTsFile } from '../utils/importTsFile';
|
||||
import glob from 'fast-glob';
|
||||
import { getEntrypointName } from '../utils/entrypoints';
|
||||
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '../vite-plugins/noopBackground';
|
||||
import { CSS_EXTENSIONS_PATTERN } from '../utils/paths';
|
||||
|
||||
/**
|
||||
* Return entrypoints and their configuration by looking through the project's files.
|
||||
@@ -39,7 +40,7 @@ export async function findEntrypoints(
|
||||
relativePaths.map(async (relativePath) => {
|
||||
const path = resolve(config.entrypointsDir, relativePath);
|
||||
const matchingGlob = pathGlobs.find((glob) =>
|
||||
picomatch.isMatch(relativePath, glob),
|
||||
minimatch(relativePath, glob),
|
||||
);
|
||||
|
||||
if (matchingGlob == null) {
|
||||
@@ -74,6 +75,14 @@ export async function findEntrypoints(
|
||||
path,
|
||||
);
|
||||
break;
|
||||
case 'content-script-style':
|
||||
entrypoint = {
|
||||
type,
|
||||
name: getEntrypointName(config.entrypointsDir, path),
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
entrypoint = {
|
||||
type,
|
||||
@@ -243,7 +252,7 @@ async function getContentScriptEntrypoint(
|
||||
type: 'content-script',
|
||||
name: getEntrypointName(config.entrypointsDir, path),
|
||||
inputPath: path,
|
||||
outputDir: resolve(config.outDir, 'content-scripts'),
|
||||
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -278,6 +287,10 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
|
||||
'content/index.ts?(x)': 'content-script',
|
||||
'*.content.ts?(x)': 'content-script',
|
||||
'*.content/index.ts?(x)': 'content-script',
|
||||
[`content.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
|
||||
[`*.content.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
|
||||
[`content/index.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
|
||||
[`*.content/index.${CSS_EXTENSIONS_PATTERN}`]: 'content-script-style',
|
||||
|
||||
'popup.html': 'popup',
|
||||
'popup/index.html': 'popup',
|
||||
@@ -288,7 +301,12 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
|
||||
'*.html': 'unlisted-page',
|
||||
'*/index.html': 'unlisted-page',
|
||||
'*.ts': 'unlisted-script',
|
||||
'*/index.ts': 'unlisted-script',
|
||||
[`*.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
|
||||
[`*/index.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
|
||||
|
||||
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files
|
||||
'*/*': 'ignored',
|
||||
};
|
||||
|
||||
const CONTENT_SCRIPT_OUT_DIR = 'content-scripts';
|
||||
|
||||
@@ -261,7 +261,9 @@ export interface GenericEntrypoint extends BaseEntrypoint {
|
||||
| 'sidepanel'
|
||||
| 'devtools'
|
||||
| 'unlisted-page'
|
||||
| 'unlisted-script';
|
||||
| 'unlisted-script'
|
||||
| 'unlisted-style'
|
||||
| 'content-script-style';
|
||||
}
|
||||
|
||||
export interface BackgroundEntrypoint extends BaseEntrypoint {
|
||||
|
||||
@@ -56,6 +56,18 @@ const sandbox2: Entrypoint = {
|
||||
inputPath: '/sandbox2.html',
|
||||
outputDir: '/.output/sandbox2',
|
||||
};
|
||||
const unlistedStyle: Entrypoint = {
|
||||
type: 'unlisted-style',
|
||||
name: 'injected',
|
||||
inputPath: '/injected.scss',
|
||||
outputDir: '/.output',
|
||||
};
|
||||
const contentScriptStyle: Entrypoint = {
|
||||
type: 'content-script-style',
|
||||
name: 'injected',
|
||||
inputPath: '/overlay.content.scss',
|
||||
outputDir: '/.output/content-scripts',
|
||||
};
|
||||
|
||||
describe('groupEntrypoints', () => {
|
||||
it('should keep scripts separate', () => {
|
||||
@@ -72,6 +84,19 @@ describe('groupEntrypoints', () => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should keep styles separate', () => {
|
||||
const entrypoints: Entrypoint[] = [
|
||||
unlistedStyle,
|
||||
contentScriptStyle,
|
||||
popup,
|
||||
];
|
||||
const expected = [unlistedStyle, contentScriptStyle, [popup]];
|
||||
|
||||
const actual = groupEntrypoints(entrypoints);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should group extension pages together', () => {
|
||||
const entrypoints: Entrypoint[] = [
|
||||
popup,
|
||||
|
||||
@@ -43,6 +43,8 @@ const ENTRY_TYPE_TO_GROUP_MAP: Record<Entrypoint['type'], Group> = {
|
||||
background: 'no-group',
|
||||
'content-script': 'no-group',
|
||||
'unlisted-script': 'no-group',
|
||||
'unlisted-style': 'no-group',
|
||||
'content-script-style': 'no-group',
|
||||
};
|
||||
|
||||
type Group = 'extension-page' | 'sandbox-page' | 'no-group';
|
||||
|
||||
@@ -15,3 +15,8 @@ export function normalizePath(path: string): string {
|
||||
export function unnormalizePath(path: string): string {
|
||||
return nodePath.normalize(path);
|
||||
}
|
||||
|
||||
export const CSS_EXTENSIONS = ['css', 'scss', 'sass', 'less', 'styl', 'stylus'];
|
||||
|
||||
// .module.css files are not supported because these are global CSS files, so using CSS modules doesn't make sense.
|
||||
export const CSS_EXTENSIONS_PATTERN = `+(${CSS_EXTENSIONS.join('|')})`;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as vite from 'vite';
|
||||
import { Entrypoint, InternalConfig } from '../types';
|
||||
import { getEntrypointBundlePath } from '../utils/entrypoints';
|
||||
|
||||
/**
|
||||
* Rename CSS entrypoint outputs to ensure a JS file is not generated, and that the CSS file is
|
||||
* placed in the correct place.
|
||||
*
|
||||
* It:
|
||||
* 1. Renames CSS files to their final paths
|
||||
* 2. Removes the JS file that get's output by lib mode
|
||||
*
|
||||
* THIS PLUGIN SHOULD ONLY BE APPLIED TO CSS LIB MODE BUILDS. It should not be added to every build.
|
||||
*/
|
||||
export function cssEntrypoints(
|
||||
entrypoint: Entrypoint,
|
||||
config: InternalConfig,
|
||||
): vite.Plugin {
|
||||
return {
|
||||
name: 'wxt:css-entrypoint',
|
||||
config() {
|
||||
return {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: () =>
|
||||
getEntrypointBundlePath(entrypoint, config.outDir, '.css'),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
generateBundle(_, bundle) {
|
||||
Object.keys(bundle).forEach((file) => {
|
||||
if (file.endsWith('.js')) delete bundle[file];
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './unimport';
|
||||
export * from './virtualEntrypoint';
|
||||
export * from './tsconfigPaths';
|
||||
export * from './noopBackground';
|
||||
export * from './cssEntrypoints';
|
||||
|
||||
@@ -15,6 +15,8 @@ import { normalizePath } from '../utils/paths';
|
||||
* Assets (JS and CSS) are output to the `<outDir>/assets` directory, and don't need to be modified.
|
||||
* HTML files access them via absolute URLs, so we don't need to update any import paths in the HTML
|
||||
* files either.
|
||||
*
|
||||
* THIS PLUGIN SHOULD ONLY BE APPLIED TO MULTIPAGE BUILDS. It should not be added to every build.
|
||||
*/
|
||||
export function multipageMove(
|
||||
entrypoints: Entrypoint[],
|
||||
|
||||
Reference in New Issue
Block a user