Content Script CSS Injection in CRXJS: How the Vite Plugin Automates Manifest CSS
The CRXJS Vite plugin automatically discovers CSS imports from your content scripts and injects them into manifest.json during the renderCrxManifest hook, eliminating manual CSS entry maintenance.
The crxjs/chrome-extension-tools repository provides a specialized Vite plugin that automates the tedious task of tracking CSS dependencies for Chrome extension content scripts. Located at packages/vite-plugin/src/node/plugin-contentScripts_css.ts, the pluginContentScriptsCss function bridges Vite's module graph with Chrome's manifest format. This article examines the exact implementation details of how the plugin discovers, validates, and injects CSS assets into the final extension manifest.
How plugin-contentScripts_css.ts Works
The plugin operates as a three-stage pipeline that reads user configuration, traverses the manifest during the build process, and mutates entries to include discovered CSS files. It runs with enforce: 'post' to ensure it executes after other CRX plugins have populated the internal metadata stores.
Plugin Registration and Configuration
At lines 8–10, the plugin registers itself with Vite using a namespaced identifier and explicit execution order:
name: 'crx:content-scripts-css',
enforce: 'post'
The post enforce value guarantees the plugin runs after the core content script processing completes. During the config hook (lines 11–14), the plugin retrieves the complete CRX configuration via getOptions and extracts the user preference:
let injectCss: boolean
// ...
injectCss = contentScripts.injectCss ?? true
This defaults to true when the user omits the contentScripts.injectCss option, enabling automatic injection by default.
Manifest Transformation Logic
The heart of the plugin resides in the renderCrxManifest hook (lines 15–28). This hook receives the partially constructed manifest and mutates it in-place before Vite writes the final output:
- Guard clauses: The plugin first checks
if (injectCss)(line 16) andif (manifest.content_scripts)(lines 17–18) to skip processing when disabled or when no content scripts exist. - Script iteration: It walks the manifest array with
for (const script of manifest.content_scripts)(lines 19–20). - File name resolution: For each script, it checks
if (script.js)and iteratesfor (const fileName of script.js)(lines 21–22) to handle cases where a single content script entry contains multiple JavaScript files.
Metadata Lookup and Error Handling
For every JavaScript file referenced in the manifest, the plugin performs a strict lookup against the internal contentScripts map (lines 23–27):
if (contentScripts.has(fileName)) {
const { css } = contentScripts.get(fileName)!
if (css?.length) script.css = [script.css ?? [], css].flat()
} else {
throw new Error(`Content script is undefined by fileName: ${fileName}`)
}
This lookup retrieves the ContentScript metadata object containing the css?: string[] array populated during earlier build phases. If the file name is not registered—indicating a typo or build misconfiguration—the plugin throws an explicit error rather than silently omitting CSS entries.
CSS Array Merging
At lines 26–27, the plugin handles both newly discovered and pre-existing CSS entries:
if (css?.length) script.css = [script.css ?? [], css].flat()
This expression preserves any manually declared css arrays in the user's manifest while appending auto-discovered imports, flattening the result into a single consolidated array for Chrome to inject at runtime.
The contentScripts Metadata Store
The injection mechanism relies on the RxMap defined in packages/vite-plugin/src/node/contentScripts.ts. This reactive map stores ContentScript objects indexed by multiple keys—including fileName, id, and loaderName—to ensure lookups succeed regardless of how the script was referenced.
According to lines 9–28 of contentScripts.ts, the ContentScript interface includes:
export interface ContentScript {
// ...
css?: string[]
}
The map subscription logic (lines 43–66) mirrors each entry under alternative keys, enabling the contentScripts.has(fileName) check to work reliably even when scripts are referenced by different identifiers throughout the build pipeline.
Configuration Options
The contentScripts.injectCss boolean controls the entire injection behavior. When set to false, the plugin bypasses all CSS processing and returns the manifest unmodified, allowing developers to manually curate their CSS arrays.
Practical Implementation Examples
Default Auto-Injection Behavior
When injectCss is omitted or set to true, CSS imports are discovered automatically:
// crx.config.ts
import { defineCrxManifest } from 'chrome-extension-tools';
export default defineCrxManifest({
manifest: {
name: 'My Extension',
version: '1.0',
content_scripts: [
{
matches: ['<all_urls>'],
js: ['src/content/main.ts'], // imports './styles.css'
},
],
},
});
The plugin detects that main.ts imports styles.css, stores this relationship in the contentScripts map, and emits a manifest containing "css": ["src/content/styles.css"].
Disabling Automatic Injection
To prevent automatic CSS injection and maintain manual control:
// crx.config.ts
export default defineCrxManifest({
manifest: {
content_scripts: [{
matches: ['<all_urls>'],
js: ['src/content/main.ts'],
}],
},
contentScripts: {
injectCss: false,
},
});
The resulting manifest.json omits the css field entirely for this entry, even if the script imports CSS modules.
Merging with Existing CSS Entries
When you pre-declare CSS entries alongside auto-discovered imports:
// crx.config.ts
export default defineCrxManifest({
manifest: {
content_scripts: [
{
matches: ['https://example.com/*'],
js: ['src/content/extra.ts'],
css: ['custom/override.css'],
},
],
},
});
If extra.ts also imports extra.css, the plugin produces a merged array:
"css": ["custom/override.css", "src/content/extra.css"]
Summary
- Automatic Discovery: The plugin traverses the Vite module graph to find CSS imports within content scripts and injects them into
manifest.jsonwithout manual configuration. - Strict Validation: The plugin throws
Content script is undefined by fileName: ${fileName}if a manifest entry references a script not registered in the internalcontentScriptsmap, preventing silent build failures. - Non-Destructive Merging: Pre-existing
cssarrays in the manifest are preserved and concatenated with auto-discovered entries usingArray.flat(). - Configurable Behavior: The
contentScripts.injectCssoption (defaulttrue) allows developers to disable automatic injection when manual CSS management is preferred. - Execution Timing: With
enforce: 'post', the plugin runs after other CRX plugins have populated thecontentScriptsmetadata store, ensuring accurate lookups.
Frequently Asked Questions
How do I disable automatic CSS injection in CRXJS?
Set contentScripts.injectCss to false in your CRX configuration. When disabled, the renderCrxManifest hook returns the manifest unmodified, and you must manually specify all CSS files in your manifest.content_scripts entries.
What happens if a content script file is not found during CSS injection?
The plugin throws a clear runtime error: Content script is undefined by fileName: ${fileName}. This occurs at lines 23–27 of plugin-contentScripts_css.ts when the contentScripts map lacks an entry for a file name listed in your manifest's js array, typically indicating a typo or build configuration issue.
Can I combine manually declared CSS with auto-injected CSS?
Yes. The plugin uses [script.css ?? [], css].flat() to merge arrays. Any CSS entries you manually define in the manifest are preserved, and discovered imports are appended to create a unified list for Chrome to inject.
When does the CSS injection plugin run during the Vite build?
The plugin executes during the renderCrxManifest hook with enforce: 'post', ensuring it runs after the core content script plugins have analyzed the module graph and populated the contentScripts RxMap with CSS metadata.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →