CONTRIBUTING Documentation

Official TypeScript developer resources and documentation for crxjs/CONTRIBUTING. Verified technical context for AI.

0 stars TypeScript TypeScript 1 file ยท ~62,981 tokens
RAW Doc

1. Project Overview & Quickstart (crxjs/CONTRIBUTING)

Technical Documentation: crxjs/CONTRIBUTING

โ„น๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference

CONTRIBUTING

Agent Skills & Rules

  • Discovered 21 agent rule(s) and prompt configurations.

--- METRICS ---

2. Official Technical Reference & Guides (crxjs/website)

File: content/3.awesome/index.md


title: Awesome CRXJS fullpage: true icon: i-mdi:newspaper

::awesome
::


File: content/2.concepts/1.manifest.md


title: Manifest icon: 'carbon:cloud-satellite-config'

::alert{type="secondary"}
Compared to manifest.json, we strongly recommend using defineManifest as it provides stronger type support and dynamic features.
::

Manifest Configuration

::tabs
::div{label="defineManifest" icon="catppuccin:folder-config"}
CRXJS exports a helper function called defineManifest.
It's similar to Vite's defineConfig and supports autocompletion and dynamic or asynchronous definitions.

Basic Usage

ts [manifest.config.ts]
import { defineManifest } from '@crxjs/vite-plugin'

export default defineManifest({
  manifest_version: 3,
  name: 'CRXJS from scratch',
  version: '1.0.0',
  action: {
    default_popup: 'index.html',
  },
})

::collapsible
#title

Version Number Configuration Using `defineManifest`

#content
::stack
:read-more{title="Chrome Extension Version Format" to="https://developer.chrome.com/docs/extensions/mv3/manifest/version/"}
```typescript [manifest.config.ts]
import { defineManifest } from '@crxjs/vite-plugin'
import packageJson from './package.json'
const { version } = packageJson

text
// Convert from Semver (example: 0.1.0-beta6)
  const [major, minor, patch, label = '0'] = version
    // can only contain digits, dots, or dash
    .replace(/[^\d.-]+/g, '')
    // split into version parts
    .split(/[.-]/)

  export default defineManifest(async (env) => ({
    manifest_version: 3,
    name:
      env.mode === 'staging'
        ? '[INTERNAL] CRXJS Power Tools'
        : 'CRXJS Power Tools',
    // up to four numbers separated by dots
    version: `${major}.${minor}.${patch}.${label}`,
    // semver is OK in "version_name"
    version_name: version,
  }))
  ```
::

::
::

::div{label="manifest.json" icon="vscode-icons:file-type-json"}
When using a JSON file, consider utilizing the schema from JSON Schema Store to leverage autocompletion and validation capabilities.

text
Configure VSCode to use the JSON schema by adding this to your settings file:


```json [.vscode/settings.json]
{
  "json.schemas": [
    {
      "fileMatch": ["manifest.json"],
      "url": "https://json.schemastore.org/chrome-manifest.json"
    }
  ]
}
```

### Basic Usage
```json [manifest.json]
{
  "manifest_version": 3,
  "name": "CRXJS from scratch",
  "version": "1.0.0",
  "action": {
    "default_popup": "index.html"
  }
}
```

::
::

Path Resolution Rules

1. Base Definition

All manifest resource configuration paths are resolved relative to the Vite project root directory (i.e., the directory where vite.config.ts is located).

2. Correct Configuration Example

json [manifest.json]
{
  "options_page": "options.html",
  "devtools_page": "pages/devtools.html"
}

Features:

  • Uses simple relative paths (no ./ prefix required)
  • Supports multi-level directory nesting

3. Common Error Patterns

json [manifest.json]
{
  "options_page": "./src/options.html",
  "devtools_page": "/Users/project/pages/devtools.html"
}

Prohibited behaviors:

  • Using path modifiers like ./ or ../
  • Hardcoding operating system-level absolute paths

File: content/2.concepts/2.pages.md


title: Pages icon: 'carbon:ibm-cloud-pak-manta-automated-data-lineage'

Extra HTML pages

If you need to declare extra HTML pages beyond those the manifest accommodates,
place them in the Vite config under build.rollupOptions.input.

This example includes a welcome page to open when the user installs the
extension.

javascript [vite.config.ts]
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        welcome: 'pages/welcome.html',
      },
    },
  },
})

Vite will serve these input pages during development and include an optimized
version of them in the production build.


File: content/2.concepts/3.background.md


title: Background icon: 'carbon:server-proxy'

Add a service worker to your extension in the manifest under
background.service_worker. CRXJS uses module-type service workers because Vite
uses the ES module format.

json [manifest.json]
{
  "background": {
    "service_worker": "src/background.ts",
    "type": "module"
  }
}

CRXJS loads the service worker from the Vite Dev Server during development, and
HMR causes a full extension reload when Vite detects a change to the background
code.

Learn more about extension service workers in the
Chrome Developer Docs.


File: content/2.concepts/4.content.md


title: Content Scripts icon: 'lucide:code-xml'

Since @crxjs/vite-plugin v2.7.0, content script HMR works in both Chrome's isolated world and main world.

::alert{type="warning" icon="lucide:triangle-alert"}
For React projects, use @vitejs/plugin-react. HMR with @vitejs/plugin-react-swc is not covered by CRXJS's integration tests and has a known unresolved report. If you choose the SWC plugin, verify both extension-page and content-script HMR in your project.
::

Execution model

A content script is extension JavaScript that Chrome runs on designated web
pages. The page where it runs is called the host page.

By default, Chrome runs content scripts in an isolated world. The content script
and host page share the same DOM, but their JavaScript global scopes and
variables are separate. A content script configured with world: 'MAIN' runs in
the host page's JavaScript environment instead. See Chrome's
content script documentation.

Declare a content script

Declare static content scripts under content_scripts in the manifest. The
required matches patterns determine which host pages receive the script.

ts [manifest.config.ts]
import { defineManifest } from '@crxjs/vite-plugin'

export default defineManifest({
  manifest_version: 3,
  name: 'My Extension',
  version: '1.0.0',
  content_scripts: [{
    js: ['src/content/main.ts'],
    matches: ['https://example.com/*'],
  }],
})

CSS isolation

JavaScript isolation does not isolate CSS. Styles imported by a content script
participate in the host document's cascade: host-page rules can affect the UI
created by your content script, and broad content-script selectors can affect
the host page. Use narrowly scoped selectors or mount complex UI inside a Shadow
DOM when you need a style boundary.

Static Assets

Feel free to import static assets! CRXJS automatically declares imported content
script dependencies as web_accessible_resources in the manifest.

Use the extension URL

Content scripts share the origin of the host page, so convert imported static
assets to the extension origin using the Chrome API.

js
import logo from './logo.png'
const url = chrome.runtime.getURL(logo)

IIFE content scripts

CRXJS normally runs content scripts through an async loader. That loader enables
Vite HMR and regular ESM imports, but it can be too late for document_start
scripts that must patch host page APIs before page code runs.

Use an IIFE content script for timing-sensitive work like intercepting fetch,
XMLHttpRequest, DOM events, or globals in the main world. CRXJS emits the
script as a standalone bundle with its imports inlined, and Chrome runs that
file directly.

::alert{type="warning" icon="lucide:triangle-alert"}
IIFE content scripts do not receive in-place Vite HMR. During development CRXJS
rebuilds the standalone file, but the updated script only runs after the host
page reloads or navigates again.
::

Keep most of your extension logic in the default isolated world content script.
It stays separated from page JavaScript, can use the extension APIs available to
content scripts, and receives full Vite HMR in development.

Use a MAIN world IIFE only for the part that must run inside the host page
environment. Treat it as a small bridge: install the early hook at
document_start, then pass commands or data to the isolated content script with
window.postMessage, CustomEvent, or DOM events. This keeps the non-HMR script
small and stable.

Enable IIFE output

::tabs
::div{label="File name" icon="lucide:file-code"}
Add .iife before the source extension. This works for
.iife.ts, .iife.tsx, .iife.js, and .iife.jsx.

ts [manifest.config.ts]
import { defineManifest } from '@crxjs/vite-plugin'

export default defineManifest({
  manifest_version: 3,
  name: 'My Extension',
  version: '1.0.0',
  content_scripts: [{
    js: ['src/content/interceptor.iife.ts'],
    matches: ['https://example.com/*'],
    run_at: 'document_start',
    world: 'MAIN',
  }],
})

::

::div{label="standaloneFiles" icon="lucide:file-plus-2"}
If you cannot rename the file, list it in contentScripts.standaloneFiles.

ts [vite.config.ts]
import { crx } from '@crxjs/vite-plugin'
import { defineConfig } from 'vite'
import manifest from './manifest.config'

export default defineConfig({
  plugins: [
    crx({
      manifest,
      contentScripts: {
        standaloneFiles: ['src/content/interceptor.ts'],
      },
    }),
  ],
})

::

::div{label="Dynamic" icon="lucide:zap"}
For scripts registered with chrome.scripting, import the generated file path
with either ?iife or the .iife filename convention plus ?script.

ts [background.ts]
import interceptor from './content/interceptor.ts?iife'
// import interceptor from './content/interceptor.iife.ts?script'

chrome.scripting.registerContentScripts([{
  id: 'network-interceptor',
  js: [interceptor],
  matches: ['https://example.com/*'],
  runAt: 'document_start',
  world: 'MAIN',
}])

::
::

HTML in content scripts

It is possible to inject an extension page into a host page using an iframe. The
host page CSP does not affect the injected iframe even if the host page
specifies the frame-src policy.

An injected extension page loads inside a cross-origin iframe, so it does not
have access to the host page DOM like a content script.

js [content-script.js]
const src = chrome.runtime.getURL('pages/iframe.html')

const iframe = new DOMParser().parseFromString(
  `<iframe class="crx" src="${src}"></iframe>`,
).body.firstElementChild

document.body.append(iframe)

Injected extension pages do have access to the full Chrome API, however.

::stack

::div{class="p-3"}
If you load an HTML file from a content script, you need to declare the file as
a web-accessible resource.
::

ts [manifest.config.ts] {15-18}
import { defineManifest } from '@crxjs/vite-plugin'
import pkg from './package.json'

export default defineManifest({
  manifest_version: 3,
  name: pkg.name,
  version: pkg.version,
  action: {
    default_popup: 'src/popup/index.html',
  },
  content_scripts: [{
    js: ['src/content/main.ts'],
    matches: ['https://*/*'],
  }],
  web_accessible_resources: [{
      resources: ["pages/iframe.html"],
      matches: ["https://*.google.com/*"]
  }]
})

::div{class="p-3"}
You will also need to add the HTML file to your Vite config under
build.rollupOptions.input.
::

js [vite.config.ts]
export default defineConfig({
  build: {
    rollupOptions: {
      input: {
        welcome: 'pages/iframe.html',
      },
    },
  },
})

::

Imported HTML

If you need to render complex HTML in a content script without a framework, an
HTML file can serve as a static fragment by importing it as text using the
?raw query. This technique does not require the file to be web-accessible, and
you don't need to declare it in the Vite config.

js
import html from './root.html?raw'

const iframe = new DOMParser().parseFromString(html).body.firstElementChild
iframe.src = chrome.runtime.getURL('pages/iframe.html')

document.body.append(iframe)

Importing an HTML file as text lets you take advantage of IDE language services
for HTML files. Depending on your HTML, this technique may be more concise than
using document.createElement().


File: content/1.guide/2.installation/1.create-crxjs.md


title: create-crxjs icon: 'lucide:rocket'

Creating a CRXJS Application

::steps

Get Starter Template

:pm-x{command="create-crxjs"}

bash
cd [project-name]

Install Dependencies

:pm-install

Development Server

:pm-run{script="dev"}

Project Structure

::temp-tabs
::


File: content/1.guide/2.installation/2.from-scratch.md


title: From Scratch icon: 'lucide:arrow-down-0-1'

install CRXJS from scratch

::steps

Create a new project

shell
mkdir crxjs-project
cd  crxjs-project

Create `package.json`

json{3} [package.json]
{
  "name": "crxjs-project",
  "type": "module",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  }
}

Install `Vite` and `CRXJS`

:pm-install{name="vite @crxjs/vite-plugin" save-dev}

Create `index.html`

html [index.html]
<body>
  <h1>Hello CRXJS</h1>
</body>

<style>
  body {
    width: 160px;
    color: #f3bae5;
    text-align: center;
    background-color: #288cd7;
  }
</style>

Create `manifest.config.js`

js [manifest.config.js]
import { defineManifest } from '@crxjs/vite-plugin'

export default defineManifest({
  manifest_version: 3,
  name: 'CRXJS from scratch',
  version: '1.0.0',
  action: {
    default_popup: 'index.html',
  },
})

Create `vite.config.js`

js [vite.config.js]
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.config.js'

export default defineConfig({
  plugins: [
    crx({ manifest }),
  ],
  server: {
    cors: {
      origin: [
        /chrome-extension:\/\//,
      ],
    },
  },
})

Project Structure

::file-tree

title: Project Structure
icon: lucide:folder-tree
autoSlash: false
tree:

  • crxjs-project:
    • index.html
    • ^manifest.config.js^
    • package.json
    • vite.config.js

::

Development Server

:pm-run{script="dev"}
::


File: content/1.guide/5.test/1.installation.md


title: Quick Start icon: 'lucide:rocket'

Install Dependencies

::pm-install{name="vitest-environment-web-ext" save-dev}
::

Configure Vitest

:::code-group

ts [vitest.config.ts]
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
      },
    },
  },
})

:::

Type Definitions

To enable better type checking and IntelliSense, you need to configure the types.

json [tsconfig.json]
{
  "compilerOptions": {
    "types": ["vitest-environment-web-ext/types"]
  }
}

Update `package.json`

json{3} [package.json]
{
  "scripts": {
    "test": "vitest"
  }
}

Write Tests

:::code-group

ts [test/extension.test.ts]
import { expect, test } from 'vitest'

test('popup should display correctly', async () => {
  const popupPage = await browser.getPopupPage()

  const title = await popupPage.title()
  expect(title).toBeTruthy()
})

:::

Run Tests

::pm-run{script="test"}
::


File: content/1.guide/5.test/2.options.md


title: Configuration Options icon: 'lucide:settings'

Basic Configuration

path

  • Type: string
  • Required: Yes

The path to the browser extension file or directory. Can be a .crx, .xpi file, or a directory containing the extension manifest.

ts [vitest.config.ts]
export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
      },
    },
  },
})

compiler

  • Type: string | false
  • Default: false

The build command to execute before running tests.

ts [vitest.config.ts]
export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
        compiler: 'npm run build',
      },
    },
  },
})

Set to false to disable compilation.

autoLaunch :badge[Advanced]{}

  • Type: boolean
  • Default: true

Whether to automatically load and launch the browser extension.

ts [vitest.config.ts]
export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
        autoLaunch: false,
      },
    },
  },
})

When set to false, you need to manually load the extension.

ts [test/extension.test.ts]
import { beforeAll, describe, expect, it } from 'vitest'

describe('browser extension test', () => {
  beforeAll(async () => {
    browser.loadExtension('./dist')
    await browser.launch()
  })

  it('popup should display correctly', async () => {
    const popupPage = await browser.getPopupPage()

    const title = await popupPage.title()
    expect(title).toBeTruthy()
  })
})

Playwright Configuration

playwright.slowMo

  • Type: number
  • Default: 100

Slows down Playwright operations by the specified milliseconds. Useful for debugging tests to observe operations more clearly.

ts [vitest.config.ts]
export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
        playwright: {
          slowMo: 500,
        },
      },
    },
  },
})

playwright.userDataDir

  • Type: string | boolean
  • Default: false

Browser user data directory cache path.

  • true: Use default path ./.vitest-web-ext-cache
  • string: Use custom path
  • false: Disable caching
ts [vitest.config.ts]
export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
        playwright: {
          userDataDir: './.vitest-cache',
        },
      },
    },
  },
})

Complete Example

ts [vitest.config.ts]
export default defineConfig({
  test: {
    environment: 'web-ext',
    environmentOptions: {
      'web-ext': {
        path: './dist',
        compiler: 'npm run build',
        autoLaunch: true,
        playwright: {
          slowMo: 100,
          userDataDir: true,
        },
      },
    },
  },
})

File: content/1.guide/5.test/3.api.md


title: API icon: 'lucide:code'

Overview

vitest-environment-web-ext injects some convenient methods into the test context, allowing you to quickly access various parts of the browser extension.

context

The Playwright context of the test environment, which can be used to create custom pages or perform more low-level operations.

ts [test/extension.test.ts]
import { expect, test } from 'vitest'

test('custom test', async () => {
  const newPage = await context.newPage()
  await newPage.goto('https://example.com')
  expect(await newPage.title()).toBe('Example')
})

browser

getPopupPage()

Gets the popup page of the browser extension.

ts [test/extension.test.ts]
import { expect, test } from 'vitest'

test('popup should display correctly', async () => {
  const popupPage = await browser.getPopupPage()
  
  const title = await popupPage.title()
  expect(title).toBeTruthy()
  
  const content = await popupPage.textContent('body')
  expect(content).toContain('Hello')
})

getSidePanelPage()

Gets the side panel page of the browser extension.

ts [test/extension.test.ts]
import { expect, test } from 'vitest'

test('side panel should display correctly', async () => {
  const sidePanelPage = await browser.getSidePanelPage()
  
  const title = await sidePanelPage.title()
  expect(title).toBeTruthy()
})

getManifest()

Gets the manifest.json content of the browser extension.

ts [test/extension.test.ts]
import { expect, test } from 'vitest'

test('manifest should contain correct configuration', async () => {
  const manifest = await browser.getManifest()
  
  expect(manifest.name).toBe('My Extension')
  expect(manifest.version).toBe('1.0.0')
  expect(manifest.manifest_version).toBe(3)
})

::tip
If you have other needs, feel free to submit an Issue or Pull Request to help improve this project.
::


File: content/1.guide/1.introduction.md


title: Introduction icon: 'lucide:info'

CRXJS is committed to providing developers with an efficient, modern browser extension development experience. By directly parsing the manifest.json file, it enables a zero-configuration development workflow and supports global Hot Module Replacement (HMR), including content scripts in both the isolated world and main world since @crxjs/vite-plugin v2.7.0.
CRXJS is compatible with major frontend frameworks and suitable for various technology stacks, helping developers focus on feature implementation without worrying about tedious build and configuration details.

Background and Motivation

With the rapid development of Web technologies, developers have higher requirements for the usability and efficiency of toolchains. While modern build tools like Vite and Webpack have greatly improved the web application development experience, the browser extension development field still has the following pain points:

  • Complex configuration and excessive boilerplate code
  • Limited hot-reload support for content scripts and page scripts
  • Inflexible integration with different frontend frameworks
  • Separation of build process from mainstream web application development
    CRXJS provides a unified, concise and efficient solution to the above problems.

Core Features

  • Hot Module Replacement (HMR)

Supports global hot updates for page scripts and for content scripts in both the isolated world and main world since @crxjs/vite-plugin v2.7.0. During development, code changes can be reflected immediately without manual refresh or extension reload, significantly improving development efficiency.

  • Zero Configuration Development

With manifest.json as the only configuration entry, it automatically identifies and packages all extension resources. No need to manually maintain complex build scripts, lowering the entry barrier.

  • Framework Agnostic

Compatible with various frontend frameworks such as React, Vue, Svelte, SolidJS and vanilla JavaScript, adapting to different projects and team technology choices.

  • Modern Development Experience

Based on Vite, integrating modern frontend development features such as fast build, module hot replacement, and static resource import, making the extension development process consistent with mainstream web application development.

Community and Support

CRXJS is an open-source project with an active community, and contributions are welcome. You can interact with the community through the following channels:

Thank you for your participation and support!


File: content/1.guide/3.loading.md


title: Loading icon: 'lucide:hard-drive-upload'

Loading your extension in the browser

::tabs
::div{label="Chrome" icon="logos:chrome"}
::steps
### Development Server
:pm-run{script="dev"}

text
### Open Browser
  
  
    Open Chrome and navigate to 
    
    ::CopyText{text="chrome://extensions"}
    ::
  

  ### Open Developer Mode
  

  ### Load Unpacked
  Click on the "Load unpacked" button and select the `dist` directory of your project.
::

::

::div{label="Edge" icon="logos:microsoft-edge"}
::steps
### Development Server
:pm-run{script="dev"}

text
### Open Browser
  
  
    Open Edge and navigate to

    ::CopyText{text="edge://extensions"}
    ::
  
  
  ### Open Developer Mode
  

  ### Load Unpacked
  Click on the "Load unpacked" button and select the `dist` directory of your project.
::

::
::

Developing the popup

An extension popup normally closes when it loses focus. To keep it open while
editing, right-click the extension's toolbar icon and select Inspect popup.
The popup stays open while its DevTools window is attached.

Without a framework-specific HMR handler, changes to the popup's HTML or
JavaScript reload the extension page. Imported CSS can update in place without a
full reload, which lets the current page state remain intact. Framework plugins
such as React or Vue can provide more granular component updates.