Compare commits

..

72 Commits

Author SHA1 Message Date
GitHub Actions 236a135ac2 chore(release): v0.2.4 2023-07-16 19:41:24 +00:00
Aaron dca43efe0d feat: Add wxt zip command (#47) 2023-07-16 14:38:56 -05:00
GitHub Actions eedb136e13 chore(release): v0.2.3 2023-07-16 17:05:39 +00:00
Aaron c71b5e12f2 fix: Some fields cannot be overridden from config.manifest (#46) 2023-07-16 12:03:14 -05:00
Aaron Klinker 495c5c8e4c fix: Read boolean maniest options from meta tags correctly 2023-07-16 11:29:34 -05:00
Aaron Klinker 63c4359d83 Fix changelog 2023-07-16 11:17:33 -05:00
Aaron efc3230674 fix: Correctly lookup open port (#45) 2023-07-16 11:14:50 -05:00
GitHub Actions 69e840b984 chore(release): v0.2.2 2023-07-16 15:47:47 +00:00
Aaron Klinker 2fb5a542d3 fix: Register content scripts correctly 2023-07-16 10:37:42 -05:00
GitHub Actions 84657e035a chore(release): v0.2.1 2023-07-16 15:14:44 +00:00
Aaron Klinker 6f5bf89645 feat: Support all content script options 2023-07-16 10:10:25 -05:00
Aaron Klinker 90fa6bfc40 fix: Remove HMR log
It was causing too many logs during development.
2023-07-15 14:11:22 -05:00
GitHub Actions 992452cdae chore(release): v0.2.0 2023-07-15 18:52:37 +00:00
Aaron Klinker 5c7e041f7f chore: Don't format lockfile 2023-07-15 13:50:05 -05:00
Aaron Klinker 3db5cec2ca feat: Recongize unnamed content scripts (content.ts and content/index.ts) 2023-07-15 13:48:56 -05:00
Aaron Klinker 375a2a6640 docs: Add inline JSDoc for public types 2023-07-14 22:41:46 -05:00
Aaron Klinker 77cbfc1985 docs: Add docs for each type of entrypoint 2023-07-14 22:28:08 -05:00
Aaron Klinker 5b48ae99f4 feat!: Rename defineBackgroundScript to defineBackground
BREAKING CHANGE: Rename `defineBackgroundScript` to `defineBackground`. Update your background entrypoints
2023-07-14 20:03:12 -05:00
Aaron Klinker c1ea9ba4a3 chore: Run wxt prepare on postinstall 2023-07-14 15:50:50 -05:00
Aaron Klinker f28a29e118 docs: Update templates 2023-07-14 15:46:11 -05:00
GitHub Actions 6f5fa9f51c chore(release): v0.1.6 2023-07-14 14:42:45 +00:00
Aaron Klinker 977246f9ed fix: Add logs when a hot reload happens 2023-07-14 09:39:40 -05:00
Aaron 602dd42e5c chore: Svelte template (#34) 2023-07-14 09:20:49 -05:00
Aaron Klinker ea92a2743b fix: Resolve tsconfig paths in vite 2023-07-14 08:35:48 -05:00
Aaron 2daa4d6900 chore: React and Vue starter templates (#33) 2023-07-13 18:50:07 -05:00
GitHub Actions b5a9d8082d chore(release): v0.1.5 2023-07-13 22:53:25 +00:00
Aaron Klinker 371be99770 fix: Include vite/client types 2023-07-13 17:36:52 -05:00
GitHub Actions 09928e8a15 chore(release): v0.1.4 2023-07-13 21:46:50 +00:00
Aaron Klinker 0a26673172 docs: Update CLI screenshot 2023-07-13 16:40:06 -05:00
Aaron Klinker 68611ae944 chore: Update prettier ignore 2023-07-13 16:37:15 -05:00
Aaron Klinker 49965e7786 fix: Fix config hook implementations for vite plugins
Turns out the returned config is merged with the user config, so we don't have to do that ourselves.
2023-07-13 16:36:39 -05:00
Aaron Klinker fa2b6566d8 fix: Fix regression where manifest was not listed first in build summary 2023-07-13 16:09:23 -05:00
Aaron 2d1c41eff1 Fix broken link in README.md 2023-07-11 17:59:01 -05:00
GitHub Actions 10fba3de49 chore(release): v0.1.3 2023-07-11 22:54:00 +00:00
Aaron aa16014650 feat: Add tsconfig path aliases (#32) 2023-07-11 17:39:13 -05:00
Aaron Klinker 0ce7c9d183 chore: Upgrade to pnpm 8 2023-07-11 17:35:45 -05:00
Aaron Klinker ac0b28e608 fix: Cleanup build summary with sourcemaps 2023-07-11 17:18:22 -05:00
Aaron 80fed4a451 docs: Create documentation site (#31) 2023-07-11 14:22:47 -05:00
Aaron Klinker 05ca9985d5 fix: Merge manifest option from both inline and user config 2023-07-11 10:24:55 -05:00
GitHub Actions 145038c6a2 chore(release): v0.1.2 2023-07-11 14:45:39 +00:00
Aaron Klinker 6e7184d98d fix: Generate valid type for EntrypointPath when there are no entrypoints 2023-07-11 09:42:08 -05:00
Aaron Klinker ee49837804 feat: Accept a function for config.manifest 2023-07-11 09:41:43 -05:00
Aaron Klinker 67f972eab8 Remove milestone warning from README 2023-07-10 23:19:27 -05:00
Aaron Klinker b29c3c6fdd fix: Only add imports to JS files 2023-07-10 23:19:13 -05:00
Aaron Klinker ef6001e0c0 types: Change config.vite to UserConfig 2023-07-10 23:00:30 -05:00
Aaron Klinker 636aa48e95 fix: Add missing types for webextension-polyfill and the manifest option 2023-07-10 22:55:40 -05:00
Aaron Klinker ec66b6172c Update changelog after release 2023-07-10 22:22:31 -05:00
GitHub Actions 1edd94e8f4 chore(release): v0.1.1 2023-07-11 03:21:27 +00:00
Aaron 2efc193b45 fix: Unable to read entrypoint options (#28) 2023-07-10 22:18:20 -05:00
Aaron Klinker 2e51e7349d fix: Allow dashes in entrypoint names 2023-07-10 22:16:08 -05:00
Aaron Klinker 28733df5f9 chore: Update changelog after release 2023-07-10 18:34:55 -05:00
GitHub Actions 0110a2bd4e chore(release): v0.1.0 2023-07-10 23:24:09 +00:00
Aaron Klinker afe838a348 feat!: Initial release 2023-07-10 18:21:01 -05:00
Aaron Klinker 0255028ce2 docs: Update feature list 2023-07-10 18:03:27 -05:00
Aaron 249aa5eccc feat: Content scripts reloading (#25) 2023-07-10 17:47:52 -05:00
Aaron Klinker b7c078f4e4 ci: Create github release 2023-07-03 17:43:10 -05:00
GitHub Actions f6b6b8857d chore(release): v0.0.2 2023-07-03 02:54:39 +00:00
Aaron Klinker 54eb118f8c chore: Update cli screenshot 2023-07-02 21:50:42 -05:00
Aaron 75eb3de89e chore: Rename templates to virtual-modules (#24) 2023-07-02 21:49:01 -05:00
Aaron Klinker e14f81d97a docs: Fix milestone link in README 2023-07-02 21:38:40 -05:00
Aaron cd714ea196 feat: Reload HTML files individually (#23) 2023-07-02 21:37:28 -05:00
Aaron Klinker 684197dbe3 docs: Add milestone progress badge to README 2023-07-02 21:36:38 -05:00
Aaron Klinker c51f0e055f fix: Show Vite warnings and errors 2023-07-02 20:40:34 -05:00
Aaron Klinker 7f3a1e8d91 fix: Separate template builds to prevent sharing chunks 2023-07-02 20:40:13 -05:00
Aaron Klinker 37986bf230 fix: Overwrite files with the same name when renaming entrypoints in dev mode 2023-07-02 20:11:21 -05:00
Aaron 13a713cc28 feat: Setup background script web socket/reload (#22) 2023-07-02 20:00:18 -05:00
Aaron Klinker a78aada96e chore: Refactor build outputs to support transpiled templates 2023-07-02 17:05:23 -05:00
Aaron 3307fc42a0 feat: Reload extension when source code is changed (#17) 2023-07-02 16:04:13 -05:00
Aaron 8be53efd24 chore: Refactor build output type (#19) 2023-07-01 13:58:44 -05:00
Aaron f02abc5227 fix: Remove hash from content script css outputs (#20) 2023-07-01 13:54:18 -05:00
Aaron Klinker 2dd7a99937 fix: Output chunks to a chunks directory 2023-06-28 16:35:11 -05:00
Aaron Klinker a9af4d3b67 chore: Update changelog 2023-06-26 10:14:17 -05:00
159 changed files with 7883 additions and 4938 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 280 KiB

+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 7
version: 8
- name: Setup NodeJS
uses: actions/setup-node@v3
@@ -47,4 +47,4 @@ jobs:
pnpm publish
- name: GitHub Release
run: pnpx changelogen@latest gh release
run: pnpx changelogen@latest gh release --token ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Setup PNPM
uses: pnpm/action-setup@v2
with:
version: 7
version: 8
- name: Setup NodeJS
uses: actions/setup-node@v3
+3
View File
@@ -15,3 +15,6 @@ node_modules
TODOs.md
web-ext.config.js
web-ext.config.ts
templates/*/pnpm-lock.yaml
templates/*/yarn.lock
templates/*/package-lock.json
-1
View File
@@ -1,2 +1 @@
ignore-workspace-root-check=true
use-node-version=18.16.0
+4
View File
@@ -1,3 +1,7 @@
.output
coverage
dist
e2e/project
.wxt
docs/.vitepress/cache
pnpm-lock.yaml
+257 -1
View File
@@ -1,5 +1,262 @@
# Changelog
## v0.2.4
[compare changes](https://github.com/aklinker1/wxt/compare/v0.2.3...v0.2.4)
### 🚀 Enhancements
- Add `wxt zip` command ([#47](https://github.com/aklinker1/wxt/pull/47))
### ❤️ Contributors
- Aaron
## v0.2.3
[compare changes](https://github.com/aklinker1/wxt/compare/v0.2.2...v0.2.3)
### 🩹 Fixes
- Correctly lookup open port ([#45](https://github.com/aklinker1/wxt/pull/45))
- Read boolean maniest options from meta tags correctly ([495c5c8](https://github.com/aklinker1/wxt/commit/495c5c8))
- Some fields cannot be overridden from `config.manifest` ([#46](https://github.com/aklinker1/wxt/pull/46))
### ❤️ Contributors
- Aaron
- Aaron Klinker
## v0.2.2
[compare changes](https://github.com/aklinker1/wxt/compare/v0.2.1...v0.2.2)
### 🩹 Fixes
- Register content scripts correctly in dev mode ([2fb5a54](https://github.com/aklinker1/wxt/commit/2fb5a54))
### ❤️ Contributors
- Aaron Klinker
## v0.2.1
[compare changes](https://github.com/aklinker1/wxt/compare/v0.2.0...v0.2.1)
### 🚀 Enhancements
- Support all content script options ([6f5bf89](https://github.com/aklinker1/wxt/commit/6f5bf89))
### 🩹 Fixes
- Remove HMR log ([90fa6bf](https://github.com/aklinker1/wxt/commit/90fa6bf))
### ❤️ Contributors
- Aaron Klinker
## v0.2.0
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.6...v0.2.0)
### 🚀 Enhancements
- ⚠️ Rename `defineBackgroundScript` to `defineBackground` ([5b48ae9](https://github.com/aklinker1/wxt/commit/5b48ae9))
- Recongize unnamed content scripts (`content.ts` and `content/index.ts`) ([3db5cec](https://github.com/aklinker1/wxt/commit/3db5cec))
### 📖 Documentation
- Update templates ([f28a29e](https://github.com/aklinker1/wxt/commit/f28a29e))
- Add docs for each type of entrypoint ([77cbfc1](https://github.com/aklinker1/wxt/commit/77cbfc1))
- Add inline JSDoc for public types ([375a2a6](https://github.com/aklinker1/wxt/commit/375a2a6))
### 🏡 Chore
- Run `wxt prepare` on `postinstall` ([c1ea9ba](https://github.com/aklinker1/wxt/commit/c1ea9ba))
- Don't format lockfile ([5c7e041](https://github.com/aklinker1/wxt/commit/5c7e041))
#### ⚠️ Breaking Changes
- ⚠️ Rename `defineBackgroundScript` to `defineBackground` ([5b48ae9](https://github.com/aklinker1/wxt/commit/5b48ae9))
### ❤️ Contributors
- Aaron Klinker
## v0.1.6
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.5...v0.1.6)
### 🩹 Fixes
- Resolve tsconfig paths in vite ([ea92a27](https://github.com/aklinker1/wxt/commit/ea92a27))
- Add logs when a hot reload happens ([977246f](https://github.com/aklinker1/wxt/commit/977246f))
### 🏡 Chore
- React and Vue starter templates ([#33](https://github.com/aklinker1/wxt/pull/33))
- Svelte template ([#34](https://github.com/aklinker1/wxt/pull/34))
### ❤️ Contributors
- Aaron Klinker
- Aaron
## v0.1.5
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.4...v0.1.5)
### 🩹 Fixes
- Include `vite/client` types ([371be99](https://github.com/aklinker1/wxt/commit/371be99))
### ❤️ Contributors
- Aaron Klinker
## v0.1.4
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.3...v0.1.4)
### 🩹 Fixes
- Fix regression where manifest was not listed first in build summary ([fa2b656](https://github.com/aklinker1/wxt/commit/fa2b656))
- Fix config hook implementations for vite plugins ([49965e7](https://github.com/aklinker1/wxt/commit/49965e7))
### 📖 Documentation
- Update CLI screenshot ([0a26673](https://github.com/aklinker1/wxt/commit/0a26673))
### 🏡 Chore
- Update prettier ignore ([68611ae](https://github.com/aklinker1/wxt/commit/68611ae))
### ❤️ Contributors
- Aaron Klinker
## v0.1.3
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.2...v0.1.3)
### 🚀 Enhancements
- Add tsconfig path aliases ([#32](https://github.com/aklinker1/wxt/pull/32))
### 🩹 Fixes
- Merge `manifest` option from both inline and user config ([05ca998](https://github.com/aklinker1/wxt/commit/05ca998))
- Cleanup build summary with sourcemaps ([ac0b28e](https://github.com/aklinker1/wxt/commit/ac0b28e))
### 📖 Documentation
- Create documentation site ([#31](https://github.com/aklinker1/wxt/pull/31))
### 🏡 Chore
- Upgrade to pnpm 8 ([0ce7c9d](https://github.com/aklinker1/wxt/commit/0ce7c9d))
### ❤️ Contributors
- Aaron
- Aaron Klinker
## v0.1.2
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.1...v0.1.2)
### 🚀 Enhancements
- Accept a function for `config.manifest` ([ee49837](https://github.com/aklinker1/wxt/commit/ee49837))
### 🩹 Fixes
- Add missing types for `webextension-polyfill` and the `manifest` option ([636aa48](https://github.com/aklinker1/wxt/commit/636aa48))
- Only add imports to JS files ([b29c3c6](https://github.com/aklinker1/wxt/commit/b29c3c6))
- Generate valid type for `EntrypointPath` when there are no entrypoints ([6e7184d](https://github.com/aklinker1/wxt/commit/6e7184d))
### 🌊 Types
- Change `config.vite` to `UserConfig` ([ef6001e](https://github.com/aklinker1/wxt/commit/ef6001e))
### ❤️ Contributors
- Aaron Klinker
## v0.1.1
[compare changes](https://github.com/aklinker1/wxt/compare/v0.1.0...v0.1.1)
### 🩹 Fixes
- Allow dashes in entrypoint names ([2e51e73](https://github.com/aklinker1/wxt/commit/2e51e73))
- Unable to read entrypoint options ([#28](https://github.com/aklinker1/wxt/pull/28))
### ❤️ Contributors
- Aaron Klinker
## v0.1.0
Initial release of WXT. Full support for production builds and initial toolkit for development:
- HMR support when HTML page dependencies change
- Reload extension when background changes
- Reload HTML pages when saving them directly
- Re-register and reload tabs when content scripts change
[compare changes](https://github.com/aklinker1/wxt/compare/v0.0.2...v0.1.0)
### 🚀 Enhancements
- Content scripts reloading ([#25](https://github.com/aklinker1/wxt/pull/25))
### 📖 Documentation
- Update feature list ([0255028](https://github.com/aklinker1/wxt/commit/0255028))
### 🤖 CI
- Create github release ([b7c078f](https://github.com/aklinker1/wxt/commit/b7c078f))
### ❤️ Contributors
- Aaron Klinker
## v0.0.2
[compare changes](https://github.com/aklinker1/wxt/compare/v0.0.1...v0.0.2)
### 🚀 Enhancements
- Reload extension when source code is changed ([#17](https://github.com/aklinker1/wxt/pull/17))
- Setup background script web socket/reload ([#22](https://github.com/aklinker1/wxt/pull/22))
- Reload HTML files individually ([#23](https://github.com/aklinker1/wxt/pull/23))
### 🩹 Fixes
- Output chunks to a chunks directory ([2dd7a99](https://github.com/aklinker1/wxt/commit/2dd7a99))
- Remove hash from content script css outputs ([#20](https://github.com/aklinker1/wxt/pull/20))
- Overwrite files with the same name when renaming entrypoints in dev mode ([37986bf](https://github.com/aklinker1/wxt/commit/37986bf))
- Separate template builds to prevent sharing chunks ([7f3a1e8](https://github.com/aklinker1/wxt/commit/7f3a1e8))
- Show Vite warnings and errors ([c51f0e0](https://github.com/aklinker1/wxt/commit/c51f0e0))
### 📖 Documentation
- Add milestone progress badge to README ([684197d](https://github.com/aklinker1/wxt/commit/684197d))
- Fix milestone link in README ([e14f81d](https://github.com/aklinker1/wxt/commit/e14f81d))
### 🏡 Chore
- Refactor build output type ([#19](https://github.com/aklinker1/wxt/pull/19))
- Refactor build outputs to support transpiled templates ([a78aada](https://github.com/aklinker1/wxt/commit/a78aada))
- Rename `templates` to `virtual-modules` ([#24](https://github.com/aklinker1/wxt/pull/24))
- Update cli screenshot ([54eb118](https://github.com/aklinker1/wxt/commit/54eb118))
### ❤️ Contributors
- Aaron Klinker
## v0.0.1
[compare changes](https://github.com/aklinker1/wxt/compare/v0.0.0...v0.0.1)
@@ -73,4 +330,3 @@
### ❤️ Contributors
- Aaron Klinker
- Aaron
+5 -12
View File
@@ -2,35 +2,28 @@
<p align="center"><i>Next gen framework for developing web extensions.<br/>Powered by <a href="https://vitejs.dev/" target="_blank">Vite</a>. Inspired by <a href="https://nuxt.com/" target="_blank">Nuxt</a>.</i></p>
![Example CLI Output](./.github/assets/cli-output.png)
> **Warning**
>
> WXT is not ready for use yet. Production builds work, but dev mode is incomplete.
>
> See the [Initial Release Candidate](https://github.com/aklinker1/wxt/milestone/1) milestone for more details.
![Example CLI Output](./docs/assets/cli-output.png)
## Features
- 🌐 Supports all browsers
- ✅ Supports both MV2 and MV3
- 📂 Directory based entrypoints
- Dev mode with HMR & auto-reload
- 📂 File based entrypoints
- 🚔 TypeScript
- 🦾 Auto-imports
- ⚡ Dev mode with HMR
- ⬇️ Download and bundle remote URL imports
- 🎨 Frontend framework agnostic: works with Vue, React, Svelte, etc
### Todo
- 🤖 Automated publishing
- 🖍️ Quickly bootstrap a new project
- 📏 Bundle analysis
- ⚡ Dev mode with HMR and **_auto-reload_**
- 🤖 Automated publishing
## Get Started
Checkout the [installation guide](https://wxtjs.dev/get-started) to get started with WXT.
Checkout the [installation guide](https://wxt.dev/get-started/installation.html) to get started with WXT.
## Contributors
+3 -2
View File
@@ -1,5 +1,5 @@
{
"name": "Demo",
"name": "WXT Demo",
"version": "1.0.0",
"description": "Demo extension for WXT",
"type": "module",
@@ -11,7 +11,8 @@
"build:all:chrome-mv2": "wxt build --mv2",
"build:all:firefox-mv3": "wxt build -b firefox --mv3",
"build:all:firefox-mv2": "wxt build -b firefox",
"prepare": "pnpm -w build && wxt prepare"
"zip": "pnpm -w build && wxt zip",
"postinstall": "pnpm -w build && wxt prepare"
},
"dependencies": {
"webextension-polyfill": "^0.10.0"
+1 -1
View File
@@ -1,4 +1,4 @@
export default defineBackgroundScript(() => {
export default defineBackground(() => {
console.log(browser.runtime.id);
logId();
console.log({
@@ -4,7 +4,7 @@ export default defineContentScript({
matches: ['*://*/*'],
async main() {
console.log(browser.runtime.id);
logId();
mountContentScriptUi();
},
});
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts" setup>
defineProps<{
name: string;
icon?: string;
}>();
</script>
<template>
<img
:src="`https://raw.githubusercontent.com/PKief/vscode-material-icon-theme/main/icons/${
name?.toLowerCase() ?? icon
}.svg`"
:alt="`${name} Logo`"
/>
</template>
<style scoped>
img {
display: inline;
transform: translateY(5px);
margin-right: 8px;
width: 20px;
}
</style>
+72
View File
@@ -0,0 +1,72 @@
import { defineConfig } from 'vitepress';
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: 'WXT',
description: 'Next gen framework for developing web extensions',
lastUpdated: true,
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
// logo: '/logo.svg',
editLink: {
pattern: 'https://github.com/aklinker1/wxt/edit/main/docs/:path',
},
search: {
provider: 'local',
},
nav: [
{ text: 'Get Started', link: '/get-started/installation.md' },
{ text: 'Guide', link: '/guide/auto-imports.md' },
{ text: 'Config', link: '/config.md' },
{ text: 'API', link: '/api.md' },
],
sidebar: {
'/get-started/': [
{
text: 'Get Started',
items: [
{ text: 'Introduction', link: '/get-started/introduction.md' },
{ text: 'Installation', link: '/get-started/installation.md' },
{ text: 'Configuration', link: '/get-started/configuration.md' },
{ text: 'Entrypoints', link: '/get-started/entrypoints.md' },
{ text: 'Assets', link: '/get-started/assets.md' },
{ text: 'Build Targets', link: '/get-started/build-targets.md' },
{ text: 'Publishing', link: '/get-started/publishing.md' },
{ text: 'Testing', link: '/get-started/testing.md' },
],
},
],
'/guide/': [
{
text: 'Guide',
items: [
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
{ text: 'Manifest.json', link: '/guide/manifest.md' },
{ text: 'Extension APIs', link: '/guide/extension-apis.md' },
],
},
{
text: 'Entrypoints',
items: [
{ text: 'Background', link: '/guide/background.md' },
{ text: 'Bookmarks', link: '/guide/bookmarks.md' },
{ text: 'Content Scripts', link: '/guide/content-scripts.md' },
{ text: 'Devtools', link: '/guide/devtools.md' },
{ text: 'History', link: '/guide/history.md' },
{ text: 'Newtab', link: '/guide/newtab.md' },
{ text: 'Options', link: '/guide/options.md' },
{ text: 'Popup', link: '/guide/popup.md' },
{ text: 'Sandbox', link: '/guide/sandbox.md' },
{ text: 'Unlisted Pages', link: '/guide/unlisted-pages.md' },
{ text: 'Unlisted Scripts', link: '/guide/unlisted-scripts.md' },
],
},
],
},
socialLinks: [{ icon: 'github', link: 'https://github.com/aklinker1/wxt' }],
},
});
+9
View File
@@ -0,0 +1,9 @@
import DefaultTheme from 'vitepress/theme';
import Icon from '../components/Icon.vue';
export default {
extends: DefaultTheme,
enhanceApp(ctx) {
ctx.app.component('Icon', Icon);
},
};
+5
View File
@@ -0,0 +1,5 @@
# API Reference
:::warning 🚧&ensp;Under construction
This documentation does not exist yet.
:::
Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

+5
View File
@@ -0,0 +1,5 @@
# Config
:::warning 🚧&ensp;Under construction
This documentation does not exist yet.
:::
+85
View File
@@ -0,0 +1,85 @@
# Assets
WXT has two directories for storing assets like CSS, images, or fonts.
- `<srcDir>/public`: Store files that will be copied into the output directory as-is
- `<srcDir>/assets`: Store files that will be processed by Vite during the build process
## `/public` Directory
Place static files like the extension icon or `_locales/` directory here. These files will be copied over to the output directory without being transformed by Vite.
```
<srcDir>
└─ public/
├─ icon-16.png
├─ icon-32.png
├─ icon-48.png
├─ icon-96.png
└─ icon-128.png
```
### Example
You can reference these files by using absolute paths in HTML files or `browser.runtime.getURL` in content scripts.
:::code-group
```html [popup.html]
<img src="/icon-128.png" />
```
```ts [content.ts]
defineContentScript({
main() {
const image = document.createElement('img');
image.src = browser.runtime.getURL('/icon-128.png');
document.body.append(image);
},
});
```
:::
## `/assets` Directory
Files in the assets directory will be processed by Vite. They are imported in your source code, and will be transformed or renamed in the output directory.
```
<srcDir>
└─ assets/
├─ style.css
└─ illustration.svg
```
### Example
:::code-group
```html [popup.html]
<html>
<head>
<link rel="stylesheet" href="~/assets/style.css" />
<!-- ... -->
</head>
<body>
<img src="~/illustration.svg" />
<!-- ... -->
</body>
</html>
```
```ts [content.ts]
import '~/assets/style.css';
import illustration from '~/assets/style.svg';
defineContentScript({
main() {
const image = document.createElement('img');
image.src = illustration;
document.body.append(image);
},
});
```
:::
+46
View File
@@ -0,0 +1,46 @@
# Build Targets
You can build an extension for any combination of browser and manifest verison. Different browsers and manifest versions support different APIs and entrypoints, so be sure to check that your extension functions as expected for each target.
Separate build targets are written to their own output directories:
```
<root>
└─ .output
├─ chrome-mv3
├─ firefox-mv2
├─ edge-mv3
└─ ...
```
## Target Browser
To build for a specific browser, pass the `-b --browser` flag from the CLI:
```
wxt --browser firefox
wxt build --browser firefox
```
By default, it will build for `chrome`. When excluding the [`--mv2` or `--mv3` flags](#target-manifest-version), it will default to the commonly accepted manifest version used with that browser.
| Browser | Default Manifest Version |
| ---------------- | :----------------------: |
| `chrome` | 3 |
| `firefox` | 2 |
| `safari` | 2 |
| `edge` | 3 |
| Any other string | 3 |
## Target Manifest Version
To build for a specific manifest version, pass either the `--mv2` flag or `--mv3` flag from the CLI.
```sh
wxt --mv2
wxt build --mv2
```
When the `-b --browser` flag is not passed, it defaults to `chrome`. So here, we're targetting MV2 for Chrome.
+92
View File
@@ -0,0 +1,92 @@
# Configuration
WXT's behavior can be configured via the `wxt.config.ts` file. In this file, you can add Vite plugins, change the directory strucutre of your project, and provide permissions or other fields to the `<outdir>/manifest.json`.
However, since WXT is an opinionated framework, some things cannot be configured.
## Config File
To configure WXT, create a `wxt.config.ts` file in your project root. It should have the following contents:
```ts
import { defineConfig } from 'wxt';
export default defineConfig({
// My WXT config
});
```
:::info
See the [API reference](/api.md) for a full list of options.
:::
## Directory Config
WXT allows you to edit several directories to your liking:
- `root` (default: `process.cwd()`) - Root of the WXT project
- `srcDir` (default: `<root>`) - Location of all your source code
- `entrypointsDir` (default: `<srcDir>/entrypoints`) - Folder containing all the entrypoints.
- `publicDir` (default: `<srcDir>/public`) - Folder containing [public assets](/get-started/assets.md)
### Example
If you want a `src/` directory to contain all your source code, and you want to rename `entrypoints/` to `entries/`, your config would look like this:
```ts
import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
entrypointsDir: 'entries',
});
```
## Vite Config
[Vite](https://vitejs.dev/) is the bundler used to build each entrypoint of your extension. Vite can be configured via the `vite` option.
See [Vite's documentation](https://vitejs.dev/config/) for configuring the bundler.
## Frontend Frameworks
Adding a framework like Vue, React, or Svelte is easy!
In the `wxt.config.ts` file, install and add the framework's Vite plugin to the config.
:::code-group
```ts [Vue]
import { defineConfig } from 'wxt';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
vite: {
plugins: [vue()],
},
});
```
```ts [React]
import { defineConfig } from 'wxt';
import react from '@vitejs/plugin-react';
export default defineConfig({
vite: {
plugins: [react()],
},
});
```
```ts [Svelte]
import { defineConfig } from 'wxt';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
vite: {
plugins: [svelte()],
},
});
```
:::
+89
View File
@@ -0,0 +1,89 @@
# Defining Entrypoints
Entrypoints are any HTML, JS, or CSS file that needs to be bundled and included with the extension.
They may or may not be listed in the extension's `manifest.json`.
## `/entrypoints` Directory
In WXT, entrypoints are defined by adding a file to the `entrypoints/` directory.
For example, a project that looks like this:
```
<root>
├─ entrypoints/
│ ├─ background.ts
│ ├─ content.ts
│ ├─ injected.ts
│ └─ popup.html
└─ wxt.config.ts
```
would result in the following `manifest.json`:
```json
{
// ...
"manifest_version": 3,
"action": {
// ...
"default_popup": "popup.html"
},
"background": {
// ...
"service_worker": "background.js"
},
"content_scripts": [
{
// ...
"js": ["content-scripts/content.js"]
}
]
}
```
If a file uses a [special name recognized by WXT](/get-started/entrypoints.md), it will be added to the manifest. In this case:
- `popup.html` &rarr; `action.default_popup`
- `content.ts` &rarr; `content_scripts.*.js`
- `background.ts` &rarr; `background.service_worker`
But not all entrypoints are added to the `manifest.json`. If they have a name that is not recognized by WXT, they are still built and included in the extension, but they are unlisted and do not show up in the manifest.
In this case, `injected.ts` gets bundled to `<outdir>/injected.js` and is accessible via `browser.runtime.getURL("/injected.js")`.
:::info
See [`/entrypoints` folder](/get-started/entrypoints.md) documentation for a full list of recognized entrypoint filenames.
:::
## Entrypoint Options
Some entrypoints, like content scripts, actions, or the background, can recieve additional options.
In HTML files, options are listed as `meta` tags:
```html
<html>
<head>
<!-- Defining the popup's "default_icon" field -->
<meta name="manifest.default_icon" content="{ '16': '/icon/16.png' }" />
</head>
</html>
```
In TS files, options are apart of the file's default export:
```ts
export default defineContentScript({
matches: ['*://*.google.com/*'],
runAt: 'document_start',
main() {
// ...
},
});
```
:::info
For a full list of entrypoints and each of their options, see the [`/entrypoints` folder](/get-started/entrypoints.md) documentation.
:::
+121
View File
@@ -0,0 +1,121 @@
# Installation
Bootstrap a new project or start from scratch.
## Bootstrap Project
:::warning 🚧&ensp;The `wxt init` command is not implemented yet.
See [From Scratch](#from-scratch) or reference one of the templates below.
:::
:::code-group
```sh [pnpm]
pnpx wxt@latest init <project-name>
```
```sh [npm]
npx wxt@latest init <project-name>
```
:::
There are several starting templates available.
| TypeScript |
| ---------------------------------------------------------------------------------------------------- |
| <Icon name="TypeScript" /> [`vanilla`](https://github.com/aklinker1/wxt/tree/main/templates/vanilla) |
| <Icon name="Vue" /> [`vue`](https://github.com/aklinker1/wxt/tree/main/templates/vue) |
| <Icon name="React" /> [`react`](https://github.com/aklinker1/wxt/tree/main/templates/react) |
| <Icon name="Svelte" /> [`svelte`](https://github.com/aklinker1/wxt/tree/main/templates/svelte) |
> All templates are in TypeScript. WXT does not support JS at this time.
## From Scratch
Create a new NPM project:
:::code-group
```sh [pnpm]
pnpm init <project-name>
cd <project-name>
echo 'shamefully-hoist=true' >> .npmrc
```
```sh [npm]
npm init <project-name>
cd <project-name>
```
```sh [yarn]
yarn init <project-name>
cd <project-name>
```
:::
Then install `wxt`:
:::code-group
```sh [pnpm]
pnpm add wxt
```
```sh [npm]
npm i --save wxt
```
```sh [yarn]
yarn add wxt
```
:::
Add your first entrypoint:
```ts
// entrypoints/background.ts
export default defineBackground(() => {
console.log(`Hello from ${browser.runtime.id}!`);
});
```
Finally, add scripts to your `package.json`:
```json
{
"scripts": {
"dev": "wxt", // [!code ++]
"dev:firefox": "wxt --browser firefox", // [!code ++]
"build": "wxt build", // [!code ++]
"build:firefox": "wxt build --browser firefox", // [!code ++]
"postinstall": "wxt prepare" // [!code ++]
}
}
```
> You can skip `*:firefox` scripts if you don't want to support Firefox
## Development
Once you've installed WXT, you can start the development server using the `dev` script.
```sh
pnpm dev
```
:::tip 🎉&ensp;Well done!
The dev command will build the extension for development, open the browser, and reload the different parts of the extension when you save changes.
:::
## Next Steps
You're ready to build a out your web extension!
- Learn how to [add entrypoints](./entrypoints.md) like the popup, background, or content scripts
- [Configure WXT](./configuration.md) by creating a `wxt.config.ts` file
+32
View File
@@ -0,0 +1,32 @@
# Introduction
WXT is a free and open source framework for building web extensions in an conventional, intuative, and safe way **_for all browsers_**.
WXT comes with full TypeScript support and auto-imports. Sounds familiar? That's right, **_WXT was based off of Nuxt_** and aims to provide the same greate DX and features.
![Example build output](../assets/cli-output.png)
## Conventions
WXT is an optionated framework. This helps keep projects consistent and easy to pick up.
- **Generated manifest**: Based on your project's file structure
- **Entrypoint configuration**: Configure entrypoints from the same file they're declare in
- **Type-safety is a priority**: Out-of-the-box TypeScript support with improved browser API typing
- **Simple output file structure**: Ouptut file paths minimize the path at runtime
## Development
WXT's dev server supports modern features like HMR to provide a lighting fast dev mode.
When changes can't be hot-reloaded, like content scripts or background scripts, they're reloaded individually to prevent reloading the entire extension and slowing down your development cycle.
## Production-ready
Production builds are optimized for store review, changing as few files as possible between builds.
In addition, WXT fully supports Firefox's source code requirements when using a bundler. It will automatically create and upload a ZIP file of your source code.
:::info
See [Publishing](./publishing.md) for more info around production builds.
:::
+5
View File
@@ -0,0 +1,5 @@
# Publishing
:::warning 🚧&ensp;Not implemented yet!
For now, manually zip the output directory and upload to stores by hand.
:::
+5
View File
@@ -0,0 +1,5 @@
# Testing
:::warning 🚧&ensp;Testing utils are not implemented yet!
Eventually, the plan is to have an integration with Vitest.
:::
+66
View File
@@ -0,0 +1,66 @@
# Auto-imports
WXT uses the same tool as Nuxt for auto-imports, [`unimport`](https://github.com/unjs/unimport).
:::info Testing
To setup your test environment for auto-imports, see [Testing](/get-started/testing.md).
:::
## WXT Auto-imports
Some WXT APIs can be used without importing them:
- [`browser`](/config.md#browser) from `webextension-polyfill`
- [`defineContentScript`](/config.md#defiencontentscript) from `wxt/client`
- [`defineBackground`](/config.md#definebackgroundscript) from `wxt/client`
And more. All [`wxt/client`](/config.md#wxtclient) APIs can be used without imports.
## Project Auto-imports
In addition WXT APIs, default and named exports from inside the following directories can be used without listing them in imports.
- `<srcDir>/components/**/*`
- `<srcDir>/composables/**/*`
- `<srcDir>/hooks/**/*`
- `<srcDir>/utils/**/*`
## TypeScript
For TypeScript to work, you need to run the `wxt prepare` command. This will ensure types are generated for auto-imports.
This should be added to your `postinstall` script:
```json
// package.json
{
"scripts": {
"postinstall": "wxt prepare" // [!code ++]
}
}
```
## Customization
You can override the default auto-import behavior in your `wxt.config.ts` file.
See [`unimport`'s documentation](https://github.com/unjs/unimport#configurations) for a complete list of options.
```ts
import { defineConfig } from 'wxt';
export default defineConfig({
imports: {
// Add auto-imports for vue fuctions like createApp, ref, computed, watch, toRaw, etc...
preset: ['vue'],
},
});
```
To disable auto-imports, set `imports: false`
```ts
export default defineConfig({
imports: false,
});
```
+38
View File
@@ -0,0 +1,38 @@
# Background
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/manifest/background/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/background)
For MV2, the background is added as a script to the background page. For MV3, the background becomes a service worker.
## Filenames
`entrypoints/background.ts` is the only recoginzed filename for the background script.
## Definition
:::warning
The main function of the background **_CANNOT BE ASYNC_**. Event listeners must be added syncronously on background startup. If your main function returns a promise, WXT will log an error.
:::
```ts
export default defineBackground(() => {
// Executed when background is loaded
});
```
or
```ts
export default defineBackground({
// Set manifest options
persistent: undefined | true | false,
type: undefined | 'module',
// Executed when background is loaded
main() {
// ...
},
});
```
> All manifest options default to `undefined`.
+26
View File
@@ -0,0 +1,26 @@
# Bookmarks
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/override/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides)
## Filenames
- `entrypoints/bookmarks.html`
- `entrypoints/bookmarks/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+60
View File
@@ -0,0 +1,60 @@
# Content Scripts
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/content_scripts/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Content_scripts)
## Filenames
When a filename matches the pattern below, it is added as a content script in the `manifest.json`.
- `entrypoints/content.tsx?`
- `entrypoints/<name>.content.tsx?`
- `entrypoints/content/index.tsx?`
- `entrypoints/<name>.content/index.tsx?`
## Definition
```ts
export default defineContentScript({
// Set manifest options
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
excludeMatches: undefined | [],
includeGlobs: undefined | [],
excludeGlobs: undefined | [],
allFrames: undefined | [],
runAt: undefined | 'document_start' | 'document_end' | 'document_idle',
matchAboutBlank: undefined | true | false,
matchOriginAsFallback: undefined | true | false,
world: undefined | 'ISOLATED' | 'MAIN',
main() {
// Executed when content script is loaded
},
});
```
> All manifest options default to `undefined`.
## CSS
To include CSS with your content script, import the CSS file at the top of your entrypoint:
```
<srcDir>
└─ entrypoints/
└─ overlay.content/
├─ index.ts
└─ style.css
```
```ts
// entrypoints/overlay.content/index.ts
import './style.css';
export default defineContentScript({
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
main() {
// ...
},
});
```
+26
View File
@@ -0,0 +1,26 @@
# Devtools
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/devtools/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/devtools_page)
## Filenames
- `entrypoints/devtools.html`
- `entrypoints/devtools/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+94
View File
@@ -0,0 +1,94 @@
# Extension APIs
WXT is built on top of [`webextension-polyfill`](https://www.npmjs.com/package/webextension-polyfill), which uses the standard `browser` global.
If you're used to Chrome's `chrome` global, its a simple switch:
1. Replace `chrome` with `browser`
2. [Replace callbacks with async/await](https://developer.chrome.com/docs/extensions/mv3/promises/)
And that's it! Your extension now supports Chrome, Firefox, Safari, Edge, and other Chromium browsers.
## Basic Usage
The `browser` variable is available globally via [auto-imports](/guide/auto-imports.md), or it can be imported manually.
```ts
import browser from 'webextension-polyfill';
```
### Example
Let's save the date the extension was installed. Just like `chrome`, some APIs require the permission is added to your manifest before the API is defined. Here, we need to add the `storage` permission to your manifest.
```ts
// wxt.config.ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
permissions: ['storage'],
},
});
```
Then we can use `browser.storage` to save the install date to local storage.
```ts
// background.ts
export default defineBackground(() => {
browser.runtime.onInstall.addEventListener(({ reason }) => {
if (reason === 'install') {
browser.storage.local.setItem({ installDate: Date.now() });
}
});
});
```
## Messaging
Follow [Chrome's message passing guide](https://developer.chrome.com/docs/extensions/mv3/messaging/) to understand how message passing works in web extensions. In Google's examples, just replace `chrome` with `browser`, and it will work in WXT.
Here's a basic request/response example:
```ts
// background.ts
export default defineBackground(() => {
browser.runtime.onMessage.addEventListener(
(message, sender, sendResponse) => {
console.log(message); // "ping"
// Wait 1 second and respond with "pong"
setTimeout(() => sendResponse('pong'), 1000);
return true;
},
);
});
```
```ts
// popup/main.ts
const res = await browser.runtime.sendMessage('ping');
console.log('res'); // "pong"
```
There are a number of message passing libraries you can use to improve the message passing experience.
Here are some that are compatible with WXT (because they are based off `webextension-polyfill` as well):
- [`@webext-core/messaging`](https://webext-core.aklinker1.io/guide/proxy-service/) - "A light-weight, type-safe wrapper around the `browser.runtime` messaging APIs"
- [`@webext-core/proxy-service`](https://webext-core.aklinker1.io/guide/messaging/) - "Create TRPC-like services that can be called from anywhere but run in the background"
- [`webext-bridge`](https://github.com/zikaari/webext-bridge) - "Messaging in Web Extensions made super easy. Out of the box."
## Browser Differences
Some APIs are only available on certain browsers or manifest versions. You will have to check if an API exists at runtime if it is not in the `browser` standard.
```ts
if ('session' in browser.storage) {
// Do something with the non-standard session storage API
}
```
> If you're using TypeScript, knowing what is non-standard is easy! APIs that are not typed are non-standard.
+26
View File
@@ -0,0 +1,26 @@
# History
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/override/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides)
## Filenames
- `entrypoints/history.html`
- `entrypoints/history/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+114
View File
@@ -0,0 +1,114 @@
# Manifest.json
The manifest.json is generated at build-time based on files in your `entrypoints` directory and your `wxt.config.ts`.
## Customization
While entrypoints are generated and added to the manifest at build-time, you can customize or add to your `manifest.json` in the config file.
```ts
import { defineConfig } from 'wxt';
export default defineConfig({
manifest: {
host_permissions: ['*://google.com/*'],
content_security_policy: {
// ...
},
},
});
```
## `name`
If not provided via the `manifest` config, the [manifest's `name`](https://developer.chrome.com/docs/extensions/mv3/manifest/name/) defaults to your package.json's `name` property.
## `version` and `version_name`
The [manifest's `version` and `version_name`](https://developer.chrome.com/docs/extensions/mv3/manifest/version/) fields are based on your package.json's `version` property.
- `version_name` is the exact string listed in your package.json
- `version` is the string cleaned up, with any invalid suffixes removed
### Example
```json
// package.json
{
"version": "1.3.0-alpha2"
}
```
```json
// .output/<dir>/manifest.json
{
"version": "1.3.0",
"version_name": "1.3.0-alpha2"
}
```
### `icons`
The [manifest's `icons`](https://developer.chrome.com/docs/extensions/mv3/manifest/icons/) property needs to be set in the config file. The files should be added to WXT's [`public` directory](/get-started/assets#public-directory).
```
public/
├─ icon-16.png
├─ icon-24.png
├─ icon-48.png
├─ icon-96.png
└─ icon-128.png
```
```ts
export default defineConfig({
manifest: {
icons: {
16: '/icon-16.png',
24: '/icon-24.png',
48: '/icon-48.png',
96: '/icon-96.png',
128: '/icon-128.png',
},
},
});
```
### Permissions
[Permissions](https://developer.chrome.com/docs/extensions/reference/permissions/) must be listed in the manifest config.
```ts
export default defineConfig({
manifest: {
permissions: ['storage', 'tabs'],
},
});
```
### Localization
Similar to the icon, the [`_locales` directory](https://developer.chrome.com/docs/extensions/reference/i18n/) should be placed inside the the WXT's [`public` directory](/get-started/assets#public-directory).
```
public/
└─ _locales/
├─ en/
│ └─ messages.json
├─ es/
│ └─ messages.json
└─ ko/
└─ messages.json
```
Then you'll need to explicitly override the `name` and `description` properties in your config for them to be localized.
```ts
export default defineConfig({
manifest: {
name: '__MSG_extName__',
description: '__MSG_extDescription__',
default_locale: 'en',
},
});
```
+26
View File
@@ -0,0 +1,26 @@
# Newtab
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/override/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/chrome_url_overrides)
## Filenames
- `entrypoints/newtab.html`
- `entrypoints/newtab/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+31
View File
@@ -0,0 +1,31 @@
# Options
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/options/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui)
## Filenames
- `entrypoints/options.html`
- `entrypoints/options/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Options Title</title>
<meta name="manifest.open_in_tab" content="true|false" />
<meta name="manifest.chrome_style" content="true|false" />
<meta name="manifest.browser_style" content="true|false" />
</head>
<body>
<!-- ... -->
</body>
</html>
```
> All manifest options default to `undefined` when the `meta` tag is not present.
+37
View File
@@ -0,0 +1,37 @@
# Popup
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/action/) &bull; [Firefox Docs](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/action)
## Filenames
- `entrypoints/popup.html`
- `entrypoints/popup/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Default Popup Title</title>
<meta
name="manifest.default_icon"
content="{
16: '/icon-16.png',
24: '/icon-24.png',
...
}"
/>
<meta name="manifest.type" content="page_action|browser_action" />
</head>
<body>
<!-- ... -->
</body>
</html>
```
> All manifest options default to `undefined` when the `meta` tag is not present.
+32
View File
@@ -0,0 +1,32 @@
# Sandbox
[Chrome Docs](https://developer.chrome.com/docs/extensions/mv3/manifest/sandbox/)
:::tip Chromium Only
Firefox does not support sandboxed pages.
:::
## Filenames
- `entrypoints/sandbox.html`
- `entrypoints/<name>.sandbox.html`
- `entrypoints/sandbox/index.html`
- `entrypoints/<name>.sandbox/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+32
View File
@@ -0,0 +1,32 @@
# Side Panel
[Chrome Docs](https://developer.chrome.com/docs/extensions/reference/sidePanel/)
:::tip Chromium Only
Firefox does not support sandboxed pages.
:::
## Filenames
- `entrypoints/sidepanel.html`
- `entrypoints/<name>.sidepanel.html`
- `entrypoints/sidepanel/index.html`
- `entrypoints/<name>.sidepanel/index.html`
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+42
View File
@@ -0,0 +1,42 @@
# Unlisted Pages
HTML pages that are built by Vite, but are not included in the manifest.
### Examples
- Onboarding
- Dashboard
- FAQ
- Help
- Changelog
## Filenames
- `entrypoints/<name>.html`
- `entrypoints/<name>/index.html`
Pages are accessible at `'/<name>.html'`:
```ts
const url = browser.runtime.getURL('/<name>.html');
console.log(url); // "chrome-extension://<id>/<name>.html"
```
## Definition
Plain old HTML file.
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
</head>
<body>
<!-- ... -->
</body>
</html>
```
+16
View File
@@ -0,0 +1,16 @@
# Unlisted Scripts
TypeScript files that are built, but are not included in the manifest.
## Filenames
- `entrypoints/<name>.tsx?`
- `entrypoints/<name>/index.tsx?`
## Definition
Unlike the background and content scripts, you can define this script's logic in the main scope.
```ts
// Code goes here
```
+49
View File
@@ -0,0 +1,49 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
titleTemplate: 'Next Generation Web Extension Framework'
hero:
name: WXT
text: Next gen framework for web extensions
tagline: Powered by Vite, inspired by Nuxt.
actions:
- theme: brand
text: Get Started
link: /get-started/installation
- theme: alt
text: Learn More
link: /get-started/introduction
features:
- icon: 🌐
title: Supported Browsers
details: Chrome, Firefox, Edge, Safari, and any Chromium based browser.
- icon: ✅
title: MV2 and MV3
details: Supports both manifest versions for each browser.
- icon: ⚡
title: Fast Dev Mode
details: HMR for UIs and fast reload for background and content scripts.
- icon: 📂
title: File Based Entrypoints
details: Manifest is generated based on files inside the project.
- icon: 🚔
title: TypeScript
details: Scale projects with full TS support.
- icon: 🦾
title: Auto-imports
details: Nuxt-like auto-imports to speed up development.
- icon: ⬇️
title: Bundle Remote Code
details: Downloads and bundles remote code imported from URLs.
- icon: 🎨
title: Frontend framework agnostic
details: Works with any front-end framework with a Vite plugin.
- icon: 🤖
title: Automated Publishing
details: 'TODO: Automatically zip, upload, and release extensions.'
- icon: 📏
title: Bundle analysis
details: 'TODO: Tools for analyizing the final extension bundle.'
---
+2 -2
View File
@@ -6,7 +6,7 @@ describe('Auto Imports', () => {
const project = new TestProject();
project.addFile(
'entrypoints/background.ts',
'export default defineBackgroundScript(() => {})',
'export default defineBackground(() => {})',
);
project.addFile(
'entrypoints/overlay.content.ts',
@@ -43,7 +43,7 @@ describe('Auto Imports', () => {
export {}
declare global {
const browser: typeof import('webextension-polyfill')
const defineBackgroundScript: typeof import('wxt/client')['defineBackgroundScript']
const defineBackground: typeof import('wxt/client')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/client')['defineContentScript']
const mountContentScriptUi: typeof import('wxt/client')['mountContentScriptUi']
+10 -13
View File
@@ -57,7 +57,7 @@ describe('Manifest Content', () => {
const optionsContent = `
<html>
<head>
<meta name="manifest.open_in_tab" content="true">
<meta name="manifest.open_in_tab" content="false">
<meta name="manifest.chrome_style" content="true">
<meta name="manifest.browser_style" content="true">
</head>
@@ -72,7 +72,7 @@ describe('Manifest Content', () => {
const manifest = await project.getOutputManifest();
expect(manifest.options_ui).toEqual({
open_in_tab: true,
open_in_tab: false,
chrome_style: true,
page: 'options.html',
});
@@ -88,7 +88,7 @@ describe('Manifest Content', () => {
);
expect(manifest.options_ui).toEqual({
open_in_tab: true,
open_in_tab: false,
browser_style: true,
page: 'options.html',
});
@@ -97,7 +97,7 @@ describe('Manifest Content', () => {
describe('background', () => {
const backgroundContent = `
export default defineBackgroundScript({
export default defineBackground({
persistent: true,
type: "module",
main: () => {},
@@ -149,7 +149,7 @@ describe('Manifest Content', () => {
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
run_at: "document_end",
runAt: "document_end",
main: () => {},
})`,
);
@@ -162,7 +162,7 @@ describe('Manifest Content', () => {
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
run_at: "document_end",
runAt: "document_end",
main: () => {},
})`,
);
@@ -175,7 +175,7 @@ describe('Manifest Content', () => {
`import "./style.css";
export default defineContentScript({
matches: ["*://duckduckgo.com/*"],
run_at: "document_end",
runAt: "document_end",
main: () => {},
})`,
);
@@ -191,21 +191,18 @@ describe('Manifest Content', () => {
expect(manifest.content_scripts).toContainEqual({
matches: ['*://duckduckgo.com/*'],
run_at: 'document_end',
css: [expect.stringContaining('assets/four-')],
css: ['assets/four.css'],
js: ['content-scripts/four.js'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
run_at: 'document_end',
css: [
expect.stringContaining('assets/three-'),
expect.stringContaining('assets/two-'),
],
css: ['assets/three.css', 'assets/two.css'],
js: ['content-scripts/three.js', 'content-scripts/two.js'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
css: [expect.stringContaining('assets/one-')],
css: ['assets/one.css'],
js: ['content-scripts/one.js'],
});
});
+30 -10
View File
@@ -13,7 +13,7 @@ describe('Output Directory Structure', () => {
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\"}"
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\"}"
`);
});
@@ -44,33 +44,53 @@ describe('Output Directory Structure', () => {
await project.build();
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
".output/chrome-mv3/assets/one-dedc7a05.css
".output/chrome-mv3/assets/one.css
----------------------------------------
body{color:#00f}
================================================================================
.output/chrome-mv3/assets/two-74d94aed.css
.output/chrome-mv3/assets/two.css
----------------------------------------
body{color:red}
================================================================================
.output/chrome-mv3/content-scripts/one.js
----------------------------------------
(function(){\\"use strict\\";function c(n){return n}const e=\\"\\",t={matches:[\\"*://*/*\\"],main:()=>{}};(async()=>{try{await t.main()}catch(n){console.error(\`The content script crashed on startup!
\`,n)}})()})();
(function(){\\"use strict\\";function i(n){return n}const s=\\"\\",o={matches:[\\"*://*/*\\"],main:()=>{}};function t(n,...e){if(typeof e[0]==\\"string\\"){const c=e.shift();n(\`[wxt] \${c}\`,...e)}else n(\\"[wxt]\\",...e)}var r={debug:(...n)=>t(console.debug,...n),log:(...n)=>t(console.log,...n),warn:(...n)=>t(console.warn,...n),error:(...n)=>t(console.error,...n)};(async()=>{try{await o.main()}catch(n){r.error(\\"The content script crashed on startup!\\",n)}})()})();
================================================================================
.output/chrome-mv3/content-scripts/two.js
----------------------------------------
(function(){\\"use strict\\";function c(n){return n}const e=\\"\\",t={matches:[\\"*://*/*\\"],main:()=>{}};(async()=>{try{await t.main()}catch(n){console.error(\`The content script crashed on startup!
\`,n)}})()})();
(function(){\\"use strict\\";function i(n){return n}const s=\\"\\",o={matches:[\\"*://*/*\\"],main:()=>{}};function t(n,...e){if(typeof e[0]==\\"string\\"){const c=e.shift();n(\`[wxt] \${c}\`,...e)}else n(\\"[wxt]\\",...e)}var r={debug:(...n)=>t(console.debug,...n),log:(...n)=>t(console.log,...n),warn:(...n)=>t(console.warn,...n),error:(...n)=>t(console.error,...n)};(async()=>{try{await o.main()}catch(n){r.error(\\"The content script crashed on startup!\\",n)}})()})();
================================================================================
.output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"css\\":[\\"assets/one-dedc7a05.css\\",\\"assets/two-74d94aed.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"css\\":[\\"assets/one.css\\",\\"assets/two.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
`);
});
it('should allow inputs with invalid JS variable names, like dashes', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/overlay-one.content.ts',
`export default defineContentScript({
matches: ["*://*/*"],
main: () => {},
})`,
);
await project.build();
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
".output/chrome-mv3/content-scripts/overlay-one.js
----------------------------------------
(function(){\\"use strict\\";function i(n){return n}const o={matches:[\\"*://*/*\\"],main:()=>{}};function t(n,...e){if(typeof e[0]==\\"string\\"){const c=e.shift();n(\`[wxt] \${c}\`,...e)}else n(\\"[wxt]\\",...e)}var r={debug:(...n)=>t(console.debug,...n),log:(...n)=>t(console.log,...n),warn:(...n)=>t(console.warn,...n),error:(...n)=>t(console.error,...n)};(async()=>{try{await o.main()}catch(n){r.error(\\"The content script crashed on startup!\\",n)}})()})();
================================================================================
.output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"*://*/*\\"],\\"js\\":[\\"content-scripts/overlay-one.js\\"]}]}"
`);
});
});
File diff suppressed because one or more lines are too long
+8 -9
View File
@@ -55,6 +55,7 @@ export class TestProject {
*/
addFile(filename: string, content?: string) {
this.files.push([filename, content ?? '']);
if (filename === 'wxt.config.ts') this.config = {};
}
/**
@@ -63,15 +64,13 @@ export class TestProject {
async build(config: InlineConfig = {}) {
if (this.config == null) this.setConfigFileConfig();
await Promise.all(
this.files.map(async (file) => {
const [name, content] = file;
const filePath = resolve(this.root, name);
const fileDir = dirname(filePath);
await fs.ensureDir(fileDir);
await fs.writeFile(filePath, content ?? '', 'utf-8');
}),
);
for (const file of this.files) {
const [name, content] = file;
const filePath = resolve(this.root, name);
const fileDir = dirname(filePath);
await fs.ensureDir(fileDir);
await fs.writeFile(filePath, content ?? '', 'utf-8');
}
execSync('npm i --ignore-scripts', { cwd: this.root });
await build({ ...config, root: this.root });
+42 -15
View File
@@ -1,8 +1,12 @@
{
"name": "wxt",
"type": "module",
"version": "0.0.1",
"version": "0.2.4",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18.16.0",
"pnpm": ">=8"
},
"repository": {
"type": "git",
"url": "https://github.com/aklinker1/wxt"
@@ -22,10 +26,9 @@
},
"license": "MIT",
"files": [
"dist",
"templates"
"dist"
],
"bin": "dist/cli/index.cjs",
"bin": "dist/cli.cjs",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -36,56 +39,69 @@
"types": "./dist/index.d.ts"
},
"./client": {
"require": "./dist/client/index.cjs",
"import": "./dist/client/index.js",
"types": "./dist/client/index.d.ts"
"require": "./dist/client.cjs",
"import": "./dist/client.js",
"types": "./dist/client.d.ts"
}
},
"scripts": {
"wxt": "tsx src/cli/index.ts",
"build": "run-s -s build:*",
"build:js": "tsup src/index.ts --sourcemap --dts --format esm,cjs",
"build:cli": "tsup src/cli/index.ts -d dist/cli --sourcemap",
"build:client": "tsup src/client/index.ts -d dist/client --sourcemap --dts --format esm,cjs",
"build": "tsx scripts/build.ts",
"format": "prettier --write .",
"format:check": "prettier --write .",
"compile": "tsc --noEmit",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "simple-git-hooks",
"prepublish": "pnpm -s build"
"prepublish": "pnpm -s build",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs"
},
"dependencies": {
"@types/webextension-polyfill": "^0.10.0",
"@webext-core/fake-browser": "^1.2.2",
"@webext-core/match-patterns": "^1.0.1",
"async-mutex": "^0.4.0",
"c12": "^1.4.2",
"cac": "^6.7.14",
"consola": "^3.1.0",
"fast-glob": "^3.2.12",
"filesize": "^10.0.7",
"fs-extra": "^11.1.1",
"get-port": "^7.0.0",
"jiti": "^1.18.2",
"json5": "^2.2.3",
"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",
"web-ext": "^7.6.2",
"webextension-polyfill": "^0.10.0"
"webextension-polyfill": "^0.10.0",
"zip-dir": "^2.0.0"
},
"devDependencies": {
"@faker-js/faker": "^8.0.2",
"@types/fs-extra": "^11.0.1",
"@types/lodash.merge": "^4.6.7",
"@types/node": "^20.3.1",
"@types/picomatch": "^2.3.0",
"@types/webextension-polyfill": "^0.10.0",
"@vitest/coverage-v8": "^0.32.2",
"lodash.merge": "^4.6.2",
"npm-run-all": "^4.1.5",
"ora": "^6.3.1",
"prettier": "^2.8.8",
"pretty-quick": "^3.1.3",
"simple-git-hooks": "^2.8.1",
"tsup": "^7.0.0",
"tsx": "^3.12.7",
"typescript": "^5.1.3",
"vitest": "^0.32.2",
"vitepress": "1.0.0-beta.5",
"vitest": "^0.32.4",
"vitest-mock-extended": "^1.1.4",
"webextension-polyfill": "^0.10.0"
},
"peerDependencies": {
@@ -94,5 +110,16 @@
"packageManager": "pnpm@8.6.3",
"simple-git-hooks": {
"pre-commit": "pnpm pretty-quick --staged"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"@algolia/client-search",
"search-insights"
],
"allowAny": [
"node-fetch"
]
}
}
}
+2946 -4461
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
import tsup from 'tsup';
import glob from 'fast-glob';
import { printFileList } from '../src/core/log/printFileList';
import { formatDuration } from '../src/core/utils/formatDuration';
import ora from 'ora';
import fs from 'fs-extra';
import { consola } from 'consola';
const spinner = ora('Building WXT').start();
const startTime = Date.now();
const outDir = 'dist';
const virtualEntrypoints = ['background', 'content-script'];
await fs.rm(outDir, { recursive: true, force: true });
await Promise.all([
tsup.build({
entry: {
index: 'src/index.ts',
},
format: ['cjs', 'esm'],
sourcemap: true,
dts: true,
silent: true,
}),
tsup.build({
entry: { cli: 'src/cli/index.ts' },
format: ['cjs'],
sourcemap: 'inline',
silent: true,
}),
tsup.build({
entry: { client: 'src/client/index.ts' },
format: ['esm'],
sourcemap: 'inline',
dts: true,
silent: true,
}),
...virtualEntrypoints.map((entryName) =>
tsup.build({
entry: {
[`virtual-modules/${entryName}-entrypoint`]: `src/client/virtual-modules/${entryName}-entrypoint.ts`,
},
format: ['esm'],
sourcemap: true,
silent: true,
external: [`virtual:user-${entryName}`],
}),
),
tsup.build({
entry: {
'virtual-modules/reload-html': `src/client/virtual-modules/reload-html.ts`,
},
format: ['esm'],
sourcemap: true,
silent: true,
}),
tsup.build({
entry: {
'virtual-modules/fake-browser': `src/client/virtual-modules/fake-browser.ts`,
},
format: ['esm', 'cjs'],
silent: true,
}),
]).catch((err) => {
spinner.fail();
console.error(err);
process.exit(1);
});
spinner.succeed();
const duration = Date.now() - startTime;
const outFiles = await glob(`${outDir}/**`, { absolute: true });
await printFileList(consola.log, outDir, outFiles);
consola.success(`Finished in ${formatDuration(duration)}`);
+16
View File
@@ -0,0 +1,16 @@
declare module 'zip-dir' {
// Represents the options object for zipdir function
interface ZipDirOptions {
saveTo?: string;
filter?: (path: string, stat: import('fs').Stats) => boolean;
each?: (path: string) => void;
}
function zipdir(
dirPath: string,
options?: ZipDirOptions,
callback?: (error: Error | null, buffer: Buffer) => void,
): Promise<Buffer>;
export = zipdir;
}
+1
View File
@@ -0,0 +1 @@
declare const __TEST_SEED__: number;
+1 -1
View File
@@ -23,7 +23,7 @@ export const dev = defineCommand<
};
const server = await wxt.createServer(cliConfig);
await server.listen(server.port);
await server.start();
return true;
});
+1
View File
@@ -3,3 +3,4 @@ export * from './dev';
export * from './init';
export * from './prepare';
export * from './publish';
export * from './zip';
+31
View File
@@ -0,0 +1,31 @@
import * as wxt from '../..';
import { buildInternal } from '../../core/build';
import { getInternalConfig } from '../../core/utils/getInternalConfig';
import { zipExtension } from '../../core/zip';
import { defineCommand } from '../utils/defineCommand';
export const zip = defineCommand<
[
root: string | undefined,
flags: {
mode?: string;
config?: string;
browser?: wxt.TargetBrowser;
mv3?: boolean;
mv2?: boolean;
},
]
>(async (root, flags) => {
const mode = flags.mode ?? 'production';
const cliConfig: wxt.InlineConfig = {
root,
mode,
browser: flags.browser,
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
configFile: flags.config,
};
const config = await getInternalConfig(cliConfig, 'build');
const output = await buildInternal(config);
await zipExtension(config, output);
});
+10
View File
@@ -28,6 +28,16 @@ cli
.option('--mv2', 'target manifest v2')
.action(commands.build);
// ZIP
cli
.command('zip [root]', 'build for production and zip output')
.option('-c, --config <file>', 'use specified config file')
.option('-m, --mode <mode>', 'set env mode')
.option('-b, --browser <browser>', 'specify a browser')
.option('--mv3', 'target manifest v3')
.option('--mv2', 'target manifest v2')
.action(commands.zip);
// PREPARE
cli
.command('prepare [root]', 'prepare')
@@ -1,12 +1,10 @@
import { BackgroundScriptDefintition } from '..';
export function defineBackgroundScript(
main: () => void,
): BackgroundScriptDefintition;
export function defineBackgroundScript(
export function defineBackground(main: () => void): BackgroundScriptDefintition;
export function defineBackground(
definition: BackgroundScriptDefintition,
): BackgroundScriptDefintition;
export function defineBackgroundScript(
export function defineBackground(
arg: (() => void) | BackgroundScriptDefintition,
): BackgroundScriptDefintition {
if (typeof arg === 'function') return { main: arg };
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './defineContentScript';
export * from './defineBackgroundScript';
export * from './defineBackground';
export * from './mountContentScriptUi';
@@ -0,0 +1,11 @@
import browser from 'webextension-polyfill';
/**
* https://developer.chrome.com/blog/longer-esw-lifetimes/
*/
export function keepServiceWorkerAlive() {
setInterval(async () => {
// Calling an async browser API resets the service worker's timeout
await browser.runtime.getPlatformInfo();
}, 5e3);
}
+18
View File
@@ -0,0 +1,18 @@
function print(method: (...args: any[]) => void, ...args: any[]) {
if (typeof args[0] === 'string') {
const message = args.shift();
method(`[wxt] ${message}`, ...args);
} else {
method('[wxt]', ...args);
}
}
/**
* Wrapper around `console` with a "[wxt]" prefix
*/
export const logger = {
debug: (...args: any[]) => print(console.debug, ...args),
log: (...args: any[]) => print(console.log, ...args),
warn: (...args: any[]) => print(console.warn, ...args),
error: (...args: any[]) => print(console.error, ...args),
};
+48
View File
@@ -0,0 +1,48 @@
import browser, { Manifest } from 'webextension-polyfill';
import { logger } from './logger';
import { MatchPattern } from '@webext-core/match-patterns';
export function reloadContentScript(contentScript: Manifest.ContentScript) {
const manifest = browser.runtime.getManifest();
if (manifest.manifest_version == 2) {
void reloadContentScriptMv2(contentScript);
} else {
void reloadContentScriptMv3(contentScript);
}
}
export async function reloadContentScriptMv3(
contentScript: Manifest.ContentScript,
) {
const id = `wxt:${contentScript.js![0]}`;
logger.log('Reloading content script:', contentScript);
const registered = await browser.scripting.getRegisteredContentScripts();
logger.debug('Existing scripts:', registered);
const existing = registered.find((cs) => cs.id === id);
if (existing) {
logger.debug('Updating content script', existing);
await browser.scripting.updateContentScripts([{ ...contentScript, id }]);
} else {
logger.debug('Registering new content script...');
await browser.scripting.registerContentScripts([{ ...contentScript, id }]);
}
const allTabs = await browser.tabs.query({});
const matchPatterns = contentScript.matches.map(
(match) => new MatchPattern(match),
);
const matchingTabs = allTabs.filter((tab) => {
const url = tab.url;
if (!url) return false;
return !!matchPatterns.find((pattern) => pattern.includes(url));
});
await Promise.all(matchingTabs.map((tab) => browser.tabs.reload(tab.id)));
}
export async function reloadContentScriptMv2(
contentScript: Manifest.ContentScript,
) {
throw Error('TODO: reloadContentScriptMv2');
}
+44
View File
@@ -0,0 +1,44 @@
import { logger } from './logger';
interface WebSocketMessage {
type: string;
event: string;
data?: any;
}
/**
* Connect to the websocket and listen for messages.
*
* @param onMessage Optional callback that is called when a message is recieved and we've verified
* it's structure is what we expect.
*/
export function setupWebSocket(
onMessage?: (message: WebSocketMessage) => void,
) {
const serverUrl = `${__DEV_SERVER_PROTOCOL__}//${__DEV_SERVER_HOSTNAME__}:${__DEV_SERVER_PORT__}`;
logger.debug('Connecting to dev server @', serverUrl);
const ws = new WebSocket(serverUrl, 'vite-hmr');
ws.addEventListener('open', () => {
logger.debug('Connected to dev server');
});
ws.addEventListener('close', () => {
logger.debug('Disconnected from dev server');
});
ws.addEventListener('error', (event) => {
logger.error('Failed to connect to dev server', event);
});
ws.addEventListener('message', (e) => {
try {
const message = JSON.parse(e.data) as WebSocketMessage;
if (message.type === 'custom' && message.event?.startsWith?.('wxt:')) {
onMessage?.(message);
}
} catch (err) {
logger.error('Failed to handle message', err);
}
});
return ws;
}
@@ -0,0 +1,42 @@
import definition from 'virtual:user-background';
import { setupWebSocket } from '../utils/setupWebSocket';
import { logger } from '../utils/logger';
import browser from 'webextension-polyfill';
import { keepServiceWorkerAlive } from '../utils/keepServiceWorkerAlive';
import { reloadContentScript } from '../utils/reloadContentScript';
if (__COMMAND__ === 'serve') {
try {
const ws = setupWebSocket((message) => {
if (message.event === 'wxt:reload-extension') browser.runtime.reload();
if (message.event === 'wxt:reload-content-script' && message.data != null)
reloadContentScript(message.data);
});
if (__MANIFEST_VERSION__ === 3) {
// Tell the server the background script is loaded and ready to go
ws.addEventListener('open', () => {
const msg = { type: 'custom', event: 'wxt:background-initialized' };
ws.send(JSON.stringify(msg));
});
// Web Socket will disconnect if the service worker is killed
keepServiceWorkerAlive();
}
} catch (err) {
logger.error('Failed to setup web socket connection with dev server', err);
}
}
try {
const res = definition.main();
// @ts-expect-error: res shouldn't be a promise, but we're checking it anyways
if (res instanceof Promise) {
console.warn(
"The background's main() function return a promise, but it must be synchonous",
);
}
} catch (err) {
logger.error('The background script crashed on startup!');
throw err;
}
@@ -0,0 +1,10 @@
import definition from 'virtual:user-content-script';
import { logger } from '../utils/logger';
(async () => {
try {
await definition.main();
} catch (err) {
logger.error('The content script crashed on startup!', err);
}
})();
@@ -0,0 +1,3 @@
import { fakeBrowser as browser } from '@webext-core/fake-browser';
export default browser;
+20
View File
@@ -0,0 +1,20 @@
/// <reference types="vite/client" />
import { logger } from '../utils/logger';
import { setupWebSocket } from '../utils/setupWebSocket';
if (__COMMAND__ === 'serve') {
try {
setupWebSocket((message) => {
if (message.event === 'wxt:reload-page') {
// We need to remove the initial slash from the path to compare correctly
// "popup.html" === "/popup.html".substring(1)
if (message.data === location.pathname.substring(1)) {
location.reload();
}
}
});
} catch (err) {
logger.error('Failed to setup web socket connection with dev server', err);
}
}
+23
View File
@@ -0,0 +1,23 @@
// Types required to make the virtual modules happy.
declare module '*?raw' {
const content: any;
export default content;
}
declare module 'virtual:user-background' {
const definition: import('../../').BackgroundScriptDefintition;
export default definition;
}
declare module 'virtual:user-content-script' {
const definition: import('../../').ContentScriptDefinition;
export default definition;
}
// Globals defined by the vite-plugins/devServerGlobals.ts and utils/globals.ts
declare const __COMMAND__: 'build' | 'serve';
declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
+106
View File
@@ -0,0 +1,106 @@
import { Manifest } from 'webextension-polyfill';
import { BuildOutput } from './types';
import { buildEntrypoints } from './build/buildEntrypoints';
import { findEntrypoints } from './build/findEntrypoints';
import { generateTypesDir } from './build/generateTypesDir';
import { InternalConfig, EntrypointGroup } from './types';
import { generateMainfest, writeManifest } from './utils/manifest';
import pc from 'picocolors';
import * as vite from 'vite';
import fs from 'fs-extra';
import { groupEntrypoints } from './utils/groupEntrypoints';
import { formatDuration } from './utils/formatDuration';
import { printBuildSummary } from './log/printBuildSummary';
/**
* Builds the extension based on an internal config.
*
* This function:
* 1. Cleans the output directory
* 2. Executes the rebuild function with a blank previous output so everything is built (see
* `rebuild` for more details)
* 3. Prints the summary
*/
export async function buildInternal(
config: InternalConfig,
): Promise<BuildOutput> {
const verb = config.command === 'serve' ? 'Pre-rendering' : 'Building';
const target = `${config.browser}-mv${config.manifestVersion}`;
config.logger.info(
`${verb} ${pc.cyan(target)} for ${pc.cyan(config.mode)} with ${pc.green(
`Vite ${vite.version}`,
)}`,
);
const startTime = Date.now();
// Cleanup
await fs.rm(config.outDir, { recursive: true, force: true });
await fs.ensureDir(config.outDir);
const entrypoints = await findEntrypoints(config);
const groups = groupEntrypoints(entrypoints);
const { output } = await rebuild(config, groups);
// Post-build
config.logger.success(
`Built extension in ${formatDuration(Date.now() - startTime)}`,
);
await printBuildSummary(output, config);
return output;
}
/**
* Given a configuration, list of entrypoints, and an existing, partial output, build the
* entrypoints and merge the new output with the existing output.
*
* This function will:
* 1. Generate the .wxt directory's types
* 2. Build the `entrypointGroups` (and copies public files)
* 3. Generate the latest manifest for all entrypoints
* 4. Write the new manifest to the file system
*/
export async function rebuild(
config: InternalConfig,
entrypointGroups: EntrypointGroup[],
existingOutput: Omit<BuildOutput, 'manifest'> = {
steps: [],
publicAssets: [],
},
): Promise<{ output: BuildOutput; manifest: Manifest.WebExtensionManifest }> {
// Update types directory with new files and types
const allEntrypoints = await findEntrypoints(config);
await generateTypesDir(allEntrypoints, config);
// Build and merge the outputs
const newOutput = await buildEntrypoints(entrypointGroups, config);
const mergedOutput: Omit<BuildOutput, 'manifest'> = {
steps: [...existingOutput.steps, ...newOutput.steps],
publicAssets: [...existingOutput.publicAssets, ...newOutput.publicAssets],
};
const newManifest = await generateMainfest(
allEntrypoints,
mergedOutput,
config,
);
const finalOutput: BuildOutput = {
manifest: newManifest,
...newOutput,
};
// Write manifest
await writeManifest(newManifest, finalOutput, config);
return {
output: {
manifest: newManifest,
steps: [...existingOutput.steps, ...finalOutput.steps],
publicAssets: [
...existingOutput.publicAssets,
...finalOutput.publicAssets,
],
},
manifest: newManifest,
};
}
@@ -7,10 +7,11 @@ import {
PopupEntrypoint,
} from '../../types';
import { resolve } from 'path';
import { FindEntrypointsConfig, findEntrypoints } from '../findEntrypoints';
import { findEntrypoints } from '../findEntrypoints';
import fs from 'fs-extra';
import { importTsFile } from '../../utils/importTsFile';
import glob from 'fast-glob';
import { fakeInternalConfig } from '../../../testing/fake-objects';
vi.mock('../../utils/importTsFile');
const importTsFileMock = vi.mocked(importTsFile);
@@ -24,24 +25,11 @@ const readFileMock = vi.mocked(
);
describe('findEntrypoints', () => {
const config: FindEntrypointsConfig = {
const config = fakeInternalConfig({
root: '/',
entrypointsDir: resolve('/src/entrypoints'),
outDir: resolve('.output'),
logger: {
debug: vi.fn(),
error: vi.fn(),
fatal: (...args) => {
throw Error('logger.fatal called with: ' + JSON.stringify(args));
},
info: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
success: vi.fn(),
},
command: 'build',
mode: 'production',
};
});
it.each<[string, string, PopupEntrypoint]>([
[
@@ -149,6 +137,15 @@ describe('findEntrypoints', () => {
);
it.each<[string, Omit<ContentScriptEntrypoint, 'options'>]>([
[
'content.ts',
{
type: 'content-script',
name: 'content',
inputPath: resolve(config.entrypointsDir, 'content.ts'),
outputDir: resolve(config.outDir, 'content-scripts'),
},
],
[
'overlay.content.ts',
{
@@ -158,6 +155,15 @@ describe('findEntrypoints', () => {
outputDir: resolve(config.outDir, 'content-scripts'),
},
],
[
'content/index.ts',
{
type: 'content-script',
name: 'content',
inputPath: resolve(config.entrypointsDir, 'content/index.ts'),
outputDir: resolve(config.outDir, 'content-scripts'),
},
],
[
'overlay.content/index.ts',
{
@@ -189,7 +195,7 @@ describe('findEntrypoints', () => {
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importTsFileMock).toBeCalledWith(config.root, expected.inputPath);
expect(importTsFileMock).toBeCalledWith(expected.inputPath, config);
},
);
@@ -216,7 +222,7 @@ describe('findEntrypoints', () => {
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importTsFileMock).toBeCalledWith(config.root, expected.inputPath);
expect(importTsFileMock).toBeCalledWith(expected.inputPath, config);
},
);
+47 -28
View File
@@ -1,6 +1,11 @@
import * as vite from 'vite';
import { BuildOutput, Entrypoint, InternalConfig } from '../types';
import { groupEntrypoints } from '../utils/groupEntrypoints';
import {
BuildOutput,
BuildStepOutput,
Entrypoint,
EntrypointGroup,
InternalConfig,
} from '../types';
import * as plugins from '../vite-plugins';
import { removeEmptyDirs } from '../utils/removeEmptyDirs';
import { getEntrypointBundlePath } from '../utils/entrypoints';
@@ -9,25 +14,22 @@ import fs from 'fs-extra';
import { dirname, resolve } from 'path';
export async function buildEntrypoints(
entrypoints: Entrypoint[],
groups: EntrypointGroup[],
config: InternalConfig,
): Promise<BuildOutput> {
const groups = groupEntrypoints(entrypoints);
const outputs: BuildOutput[] = [];
): Promise<Omit<BuildOutput, 'manifest'>> {
const steps: BuildStepOutput[] = [];
for (const group of groups) {
const output = Array.isArray(group)
const step = Array.isArray(group)
? await buildMultipleEntrypoints(group, config)
: await buildSingleEntrypoint(group, config);
outputs.push(output);
steps.push(step);
}
const publicOutput = await copyPublicDirectory(config);
outputs.push(publicOutput);
const publicAssets = await copyPublicDirectory(config);
// Remove any empty directories from moving outputs around
await removeEmptyDirs(config.outDir);
return outputs.flat();
return { publicAssets, steps };
}
/**
@@ -36,23 +38,25 @@ export async function buildEntrypoints(
async function buildSingleEntrypoint(
entrypoint: Entrypoint,
config: InternalConfig,
): Promise<BuildOutput> {
): Promise<BuildStepOutput> {
// Should this entrypoint be wrapped by the vite-plugins/virtualEntrypoint plugin?
const isVirtual = ['background', 'content-script'].includes(entrypoint.type);
const entry = isVirtual
? `virtual:wxt-${entrypoint.type}?${entrypoint.inputPath}`
: entrypoint.inputPath;
const libMode: vite.InlineConfig = {
const libMode: vite.UserConfig = {
build: {
lib: {
entry,
formats: ['iife'],
name: entrypoint.name,
name: '_',
fileName: entrypoint.name,
},
rollupOptions: {
output: {
// There's only a single output for this build, so we use the desired bundle path for the
// entry output (like "content-scripts/overlay.js")
entryFileNames: getEntrypointBundlePath(
entrypoint,
config.outDir,
@@ -61,7 +65,7 @@ async function buildSingleEntrypoint(
// Output content script CSS to assets/ with a hash to prevent conflicts. Defaults to
// "[name].[ext]" in lib mode, which usually results in "style.css". That means multiple
// content scripts with styles would overwrite each other if it weren't changed below.
assetFileNames: `assets/${entrypoint.name}-[hash].[ext]`,
assetFileNames: `assets/${entrypoint.name}.[ext]`,
},
},
},
@@ -72,7 +76,10 @@ async function buildSingleEntrypoint(
) as vite.InlineConfig;
const result = await vite.build(entryConfig);
return getBuildOutput(result);
return {
entrypoints: entrypoint,
chunks: getBuildOutputChunks(result),
};
}
/**
@@ -81,8 +88,8 @@ async function buildSingleEntrypoint(
async function buildMultipleEntrypoints(
entrypoints: Entrypoint[],
config: InternalConfig,
): Promise<BuildOutput> {
const multiPage: vite.InlineConfig = {
): Promise<BuildStepOutput> {
const multiPage: vite.UserConfig = {
plugins: [plugins.multipageMove(entrypoints, config)],
build: {
rollupOptions: {
@@ -90,6 +97,14 @@ async function buildMultipleEntrypoints(
input[entry.name] = entry.inputPath;
return input;
}, {}),
output: {
// Include a hash to prevent conflicts
chunkFileNames: 'chunks/[name]-[hash].js',
// Include a hash to prevent conflicts
entryFileNames: 'chunks/[name]-[hash].js',
// We can't control the "name", so we need a hash to prevent conflicts
assetFileNames: 'assets/[name]-[hash].[ext]',
},
},
},
};
@@ -97,15 +112,18 @@ async function buildMultipleEntrypoints(
const entryConfig = vite.mergeConfig(
multiPage,
config.vite,
) as vite.InlineConfig;
) as vite.UserConfig;
const result = await vite.build(entryConfig);
return getBuildOutput(result);
return {
entrypoints,
chunks: getBuildOutputChunks(result),
};
}
function getBuildOutput(
function getBuildOutputChunks(
result: Awaited<ReturnType<typeof vite.build>>,
): BuildOutput {
): BuildStepOutput['chunks'] {
if ('on' in result) throw Error('wxt does not support vite watch mode.');
if (Array.isArray(result)) return result.flatMap(({ output }) => output);
return result.output;
@@ -113,19 +131,19 @@ function getBuildOutput(
async function copyPublicDirectory(
config: InternalConfig,
): Promise<BuildOutput> {
if (!(await fs.exists(config.publicDir))) return [];
): Promise<BuildOutput['publicAssets']> {
const publicAssets: BuildOutput['publicAssets'] = [];
if (!(await fs.exists(config.publicDir))) return publicAssets;
const files = await glob('**/*', { cwd: config.publicDir });
const outputs: BuildOutput = [];
for (const file of files) {
const srcPath = resolve(config.publicDir, file);
const outPath = resolve(config.outDir, file);
await fs.ensureDir(dirname(outPath));
await fs.copyFile(srcPath, outPath);
outputs.push({
publicAssets.push({
type: 'asset',
fileName: file,
name: file,
@@ -133,5 +151,6 @@ async function copyPublicDirectory(
source: await fs.readFile(srcPath),
});
}
return outputs;
return publicAssets;
}
+13 -16
View File
@@ -21,7 +21,7 @@ import { getEntrypointName } from '../utils/entrypoints';
* Return entrypoints and their configuration by looking through the
*/
export async function findEntrypoints(
config: FindEntrypointsConfig,
config: InternalConfig,
): Promise<Entrypoint[]> {
const relativePaths = await glob('**/*', {
cwd: config.entrypointsDir,
@@ -67,7 +67,7 @@ export async function findEntrypoints(
case 'content-script':
entrypoint = await getContentScriptEntrypoint(
config,
relativePath.split('.', 2)[0],
getEntrypointName(config.entrypointsDir, path),
path,
);
break;
@@ -103,7 +103,7 @@ export async function findEntrypoints(
* @param content String contents of the file at the path.
*/
async function getPopupEntrypoint(
config: FindEntrypointsConfig,
config: InternalConfig,
path: string,
): Promise<PopupEntrypoint> {
const options: PopupEntrypoint['options'] = {};
@@ -150,7 +150,7 @@ async function getPopupEntrypoint(
* @param content String contents of the file at the path.
*/
async function getOptionsEntrypoint(
config: FindEntrypointsConfig,
config: InternalConfig,
path: string,
): Promise<OptionsEntrypoint> {
const options: OptionsEntrypoint['options'] = {};
@@ -162,21 +162,21 @@ async function getOptionsEntrypoint(
.querySelector("meta[name='manifest.open_in_tab']")
?.getAttribute('content');
if (openInTabContent) {
options.openInTab = Boolean(openInTabContent);
options.openInTab = openInTabContent === 'true';
}
const chromeStyleContent = document
.querySelector("meta[name='manifest.chrome_style']")
?.getAttribute('content');
if (chromeStyleContent) {
options.chromeStyle = Boolean(chromeStyleContent);
options.chromeStyle = chromeStyleContent === 'true';
}
const browserStyleContent = document
.querySelector("meta[name='manifest.browser_style']")
?.getAttribute('content');
if (browserStyleContent) {
options.browserStyle = Boolean(browserStyleContent);
options.browserStyle = browserStyleContent === 'true';
}
return {
@@ -192,11 +192,11 @@ async function getOptionsEntrypoint(
* @param path Absolute path to the background's TS file.
*/
async function getBackgroundEntrypoint(
config: FindEntrypointsConfig,
config: InternalConfig,
path: string,
): Promise<BackgroundEntrypoint> {
const { main: _, ...options } =
await importTsFile<BackgroundScriptDefintition>(config.root, path);
await importTsFile<BackgroundScriptDefintition>(path, config);
if (options == null) {
throw Error('Background script does not have a default export');
}
@@ -213,13 +213,13 @@ async function getBackgroundEntrypoint(
* @param path Absolute path to the content script's TS file.
*/
async function getContentScriptEntrypoint(
config: FindEntrypointsConfig,
config: InternalConfig,
name: string,
path: string,
): Promise<ContentScriptEntrypoint> {
const { main: _, ...options } = await importTsFile<ContentScriptDefinition>(
config.root,
path,
config,
);
if (options == null) {
throw Error(`Content script ${name} does not have a default export`);
@@ -258,6 +258,8 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
'background.ts': 'background',
'content.ts?(x)': 'content-script',
'content/index.ts?(x)': 'content-script',
'*.content.ts?(x)': 'content-script',
'*.content/index.ts?(x)': 'content-script',
@@ -274,8 +276,3 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files
'*/*': 'ignored',
};
export type FindEntrypointsConfig = Pick<
InternalConfig,
'root' | 'entrypointsDir' | 'outDir' | 'logger' | 'mode' | 'command'
>;
+26 -11
View File
@@ -48,22 +48,23 @@ async function writePathsDeclarationFile(
config: InternalConfig,
): Promise<string> {
const filePath = resolve(config.typesDir, 'paths.d.ts');
const unions = entrypoints
.map((entry) => {
const path = getEntrypointBundlePath(
entry,
config.outDir,
entry.inputPath.endsWith('.html') ? '.html' : '.js',
);
return ` | "/${path}"`;
})
.sort();
await fs.writeFile(
filePath,
[
'// Generated by wxt',
'type EntrypointPath =',
...entrypoints
.map((entry) => {
const path = getEntrypointBundlePath(
entry,
config.outDir,
entry.inputPath.endsWith('.html') ? '.html' : '.js',
);
return ` | "/${path}"`;
})
.sort(),
...(unions.length === 0 ? [' never'] : unions),
].join('\n') + '\n',
);
@@ -99,6 +100,7 @@ async function writeMainDeclarationFile(
filePath,
[
'// Generated by wxt',
`/// <reference types="vite/client" />`,
...references.map(
(ref) => `/// <reference types="./${relative(dir, ref)}" />`,
),
@@ -128,7 +130,20 @@ async function writeTsConfigFile(
"strict": true,
/* Completeness */
"skipLibCheck": true
"skipLibCheck": true,
/* Aliases */
"baseUrl": "${relative(dir, config.root)}",
"paths": {
"@@": ["."],
"@@/*": ["./*"],
"~~": ["."],
"~~/*": ["./*"],
"@": ["${relative(config.root, config.srcDir)}"],
"@/*": ["${relative(config.root, config.srcDir)}/*"],
"~": ["${relative(config.root, config.srcDir)}"],
"~/*": ["${relative(config.root, config.srcDir)}/*"]
}
},
"include": [
"${relative(dir, config.root)}/**/*",
+18 -47
View File
@@ -1,67 +1,38 @@
import path, { extname, relative, resolve } from 'path';
import { resolve } from 'path';
import { BuildOutput, InternalConfig } from '../types';
import { printTable } from './printTable';
import pc from 'picocolors';
import fs from 'fs-extra';
import { filesize } from 'filesize';
import { printFileList } from './printFileList';
export async function printBuildSummary(
output: BuildOutput,
config: InternalConfig,
) {
const chunks = output.sort((l, r) => {
const lWeight =
CHUNK_SORT_WEIGHTS[l.fileName] ??
CHUNK_SORT_WEIGHTS[extname(l.fileName)] ??
DEFAULT_SORT_WEIGHT;
const rWeight =
CHUNK_SORT_WEIGHTS[r.fileName] ??
CHUNK_SORT_WEIGHTS[extname(r.fileName)] ??
DEFAULT_SORT_WEIGHT;
const chunks = [
...output.steps.flatMap((step) => step.chunks),
...output.publicAssets,
].sort((l, r) => {
const lWeight = getChunkSortWeight(l.fileName);
const rWeight = getChunkSortWeight(r.fileName);
const diff = lWeight - rWeight;
if (diff !== 0) return diff;
return l.fileName.localeCompare(r.fileName);
});
let totalSize = 0;
const chunkRows: string[][] = await Promise.all(
chunks.map(async (chunk, i) => {
const file = [
relative(process.cwd(), config.outDir) + path.sep,
chunk.fileName,
];
const ext = extname(chunk.fileName);
const prefix = i === chunks.length - 1 ? ' └─' : ' ├─';
const color = CHUNK_COLORS[ext] ?? DEFAULT_COLOR;
const stats = await fs.lstat(resolve(config.outDir, chunk.fileName));
totalSize += stats.size;
const size = String(filesize(stats.size));
return [
`${pc.gray(prefix)} ${pc.dim(file[0])}${color(file[1])}`,
pc.dim(size),
];
}),
);
printTable(config.logger.log, chunkRows);
config.logger.log(
`${pc.cyan('Σ Total size:')} ${String(filesize(totalSize))}`,
);
const files = chunks.map((chunk) => resolve(config.outDir, chunk.fileName));
await printFileList(config.logger.log, config.outDir, files);
}
const DEFAULT_SORT_WEIGHT = 100;
const CHUNK_SORT_WEIGHTS: Record<string, number> = {
'manifest.json': 0,
'.html': 1,
'.js.map': 2,
'.js': 2,
'.css': 3,
};
const DEFAULT_COLOR = pc.blue;
const CHUNK_COLORS: Record<string, (text: string) => string> = {
'.html': pc.green,
'.css': pc.magenta,
'.js': pc.cyan,
};
function getChunkSortWeight(filename: string) {
return (
Object.entries(CHUNK_SORT_WEIGHTS).find(([key]) =>
filename.endsWith(key),
)?.[1] ?? DEFAULT_SORT_WEIGHT
);
}
+50
View File
@@ -0,0 +1,50 @@
import path from 'node:path';
import pc from 'picocolors';
import fs from 'fs-extra';
import { filesize } from 'filesize';
import { printTable } from './printTable';
export async function printFileList(
log: (message: string) => void,
baseDir: string,
files: string[],
): Promise<void> {
let totalSize = 0;
const fileRows: string[][] = await Promise.all(
files.map(async (file, i) => {
const parts = [
path.relative(process.cwd(), baseDir) + path.sep,
path.relative(baseDir, file),
];
const prefix = i === files.length - 1 ? ' └─' : ' ├─';
const color = getChunkColor(file);
const stats = await fs.lstat(file);
totalSize += stats.size;
const size = String(filesize(stats.size));
return [
`${pc.gray(prefix)} ${pc.dim(parts[0])}${color(parts[1])}`,
pc.dim(size),
];
}),
);
printTable(log, fileRows);
log(`${pc.cyan('Σ Total size:')} ${String(filesize(totalSize))}`);
}
const DEFAULT_COLOR = pc.blue;
const CHUNK_COLORS: Record<string, (text: string) => string> = {
'.js.map': pc.gray,
'.html': pc.green,
'.css': pc.magenta,
'.js': pc.cyan,
'.zip': pc.yellow,
};
function getChunkColor(filename: string) {
return (
Object.entries(CHUNK_COLORS).find(([key]) => filename.endsWith(key))?.[1] ??
DEFAULT_COLOR
);
}
+133
View File
@@ -0,0 +1,133 @@
import {
BuildStepOutput,
EntrypointGroup,
InternalConfig,
WxtDevServer,
} from './types';
import * as vite from 'vite';
import { Scripting } from 'webextension-polyfill';
import { getEntrypointBundlePath } from './utils/entrypoints';
import { getContentScriptCssFiles } from './utils/manifest';
import { createWebExtRunner } from './runners/createWebExtRunner';
import { buildInternal } from './build';
export async function getServerInfo(): Promise<ServerInfo> {
const { default: getPort, portNumbers } = await import('get-port');
const port = await getPort({ port: portNumbers(3000, 3010) });
const hostname = 'localhost';
const origin = `http://${hostname}:${port}`;
const serverConfig: vite.InlineConfig = {
server: {
origin,
},
};
return {
port,
hostname,
origin,
viteServerConfig: serverConfig,
};
}
export async function setupServer(
serverInfo: ServerInfo,
config: InternalConfig,
): Promise<WxtDevServer> {
const runner = createWebExtRunner();
const viteServer = await vite.createServer(
vite.mergeConfig(serverInfo, config.vite),
);
const start = async () => {
await viteServer.listen(server.port);
config.logger.success(`Started dev server @ ${serverInfo.origin}`);
server.currentOutput = await buildInternal(config);
config.logger.info('Opening browser...');
await runner.openBrowser(config);
config.logger.success('Opened!');
};
const reloadExtension = () => {
viteServer.ws.send('wxt:reload-extension');
};
const reloadPage = (path: string) => {
// Can't use Vite's built-in "full-reload" event because it doesn't like our paths, it expects
// paths ending in "/index.html"
viteServer.ws.send('wxt:reload-page', path);
};
const reloadContentScript = (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => {
viteServer.ws.send('wxt:reload-content-script', contentScript);
};
const server: WxtDevServer = {
...viteServer,
start,
currentOutput: {
manifest: {
manifest_version: 3,
name: '',
version: '',
},
publicAssets: [],
steps: [],
},
port: serverInfo.port,
hostname: serverInfo.hostname,
origin: serverInfo.origin,
reloadExtension,
reloadPage,
reloadContentScript,
};
return server;
}
/**
* From the server, tell the client to reload content scripts from the provided build step outputs.
*/
export function reloadContentScripts(
steps: BuildStepOutput[],
config: InternalConfig,
server: WxtDevServer,
) {
if (config.manifestVersion === 3) {
steps.forEach((step) => {
const entry = step.entrypoints;
if (Array.isArray(entry) || entry.type !== 'content-script') return;
const js = [getEntrypointBundlePath(entry, config.outDir, '.js')];
const css = getContentScriptCssFiles([entry], server.currentOutput);
server.reloadContentScript({
...entry.options,
js,
css,
});
});
} else {
server.reloadExtension();
}
}
export function reloadHtmlPages(
groups: EntrypointGroup[],
server: WxtDevServer,
config: InternalConfig,
) {
groups.flat().forEach((entry) => {
const path = getEntrypointBundlePath(entry, config.outDir, '.html');
server.reloadPage(path);
});
}
interface ServerInfo {
port: number;
hostname: string;
origin: string;
viteServerConfig: vite.InlineConfig;
}
+255 -33
View File
@@ -1,27 +1,148 @@
import * as vite from 'vite';
import { Manifest } from 'webextension-polyfill';
import { Manifest, Scripting } from 'webextension-polyfill';
import { UnimportOptions } from 'unimport';
import { EntrypointGroup } from '.';
export interface InlineConfig {
/**
* Project root directory.
*
* @default
* process.cwd()
*/
root?: string;
/**
* Directory containing all source code. Set to `"src"` to move all source code to a `src/`
* directory.
*
* @default
* "<root>"
*/
srcDir?: string;
/**
* Directory containing files that will be copied to the output directory as-is.
*
* @default
* "<srcDir>/publicDir"
*/
publicDir?: string;
/**
* @default
* "<srcDir>/entrypoints"
*/
entrypointsDir?: string;
/**
* Path to `"wxt.config.ts"` file or false to disable config file discovery.
*
* @default
* "wxt.config.ts"
*/
configFile?: string | false;
/**
* ID of the extension for each store. Used for publishing.
*/
storeIds?: {
chrome?: string;
firefox?: string;
edge?: string;
};
/**
* Explicitly set a mode to run in. This will override the default mode for each command, and can
* be overridden by the command line `--mode` option.
*/
mode?: string;
/**
* Customize auto-import options.
*/
imports?: Partial<UnimportOptions>;
/**
* Explicitly set a browser to target. This will override the default browser for each command,
* and can be overridden by the command line `--browser` option.
*
* @default
* "chrome"
*/
browser?: TargetBrowser;
/**
* Explicitly set a manifest version to target. This will override the default manifest version
* for each command, and can be overridden by the command line `--mv2` or `--mv3` option.
*/
manifestVersion?: TargetManifestVersion;
/**
* Override the logger used.
*
* @default
* consola
*/
logger?: Logger;
vite?: Omit<vite.InlineConfig, 'root' | 'configFile' | 'mode'>;
manifest?: UserManifest;
/**
* Custom Vite options.
*/
vite?: Omit<vite.UserConfig, 'root' | 'configFile' | 'mode'>;
/**
* Customize the `manifest.json` output. Can be an object, promise, or function that returns an
* object or promise.
*/
manifest?: UserManifest | Promise<UserManifest> | UserManifestFn;
/**
* Custom server options.
*/
server?: WxtDevServer;
/**
* Custom runner options. Options set here can be overridden in a `web-ext.config.ts` file.
*/
runner?: ExtensionRunnerConfig;
zip?: {
/**
* Configure the filename output when zipping files.
*
* Available template variables:
*
* - `{{name}}` - The project's name converted to kebab-case
* - `{{version}} - The version_name or version from the manifest
* - `{{browser}} - The target browser from the `--browser` CLI flag
* - `{{manifestVersion}}` - Either "2" or "3"
*
* @default "{{name}}-{{version}}-{{browser}}.zip"
*/
artifactTemplate?: string;
/**
* Configure the filename output when zipping files.
*
* Available template variables:
*
* - `{{name}}` - The project's name converted to kebab-case
* - `{{version}} - The version_name or version from the manifest
* - `{{browser}} - The target browser from the `--browser` CLI flag
* - `{{manifestVersion}}` - Either "2" or "3"
*
* @default "{{name}}-{{version}}-sources.zip"
*/
sourcesTemplate?: string;
/**
* Override the artifactTemplate's `{{name}}` template variable. Defaults to the package.json's
* name, or if that doesn't exist, the current working directories name.
*/
name?: string;
/**
* Root directory to ZIP. The ZIP can be uploaded to the Firefox Addon Store as your source
* code. Defaults to the `config.root` directory.
*/
sourcesRoot?: string;
/**
* [Minimatch](https://www.npmjs.com/package/minimatch) patterns of files to exclude when
* creating a ZIP of all your source code for Firfox. Patterns are relative to your
* `config.zip.sourcesRoot`.
*
* Hidden files, node_modules, and tests are ignored by default.
*
* @example
* [
* "coverage", // Ignore the coverage directory in the `sourcesRoot`
* ]
*/
ignoredSources?: string[];
};
}
export interface WxtInlineViteConfig
@@ -29,13 +150,62 @@ export interface WxtInlineViteConfig
build?: Omit<vite.BuildOptions, 'outDir'>;
}
export type BuildOutput = (vite.Rollup.OutputChunk | vite.Rollup.OutputAsset)[];
export interface BuildOutput {
manifest: Manifest.WebExtensionManifest;
publicAssets: vite.Rollup.OutputAsset[];
steps: BuildStepOutput[];
}
export interface BuildStepOutput {
entrypoints: EntrypointGroup;
chunks: (vite.Rollup.OutputChunk | vite.Rollup.OutputAsset)[];
}
export interface WxtDevServer extends vite.ViteDevServer {
logger: Logger;
/**
* Ex: `3000`
*/
port: number;
/**
* Ex: `"localhost"`
*/
hostname: string;
/**
* Ex: `"http://localhost:3000"`
*/
origin: string;
/**
* Stores the current build output of the server.
*/
currentOutput: BuildOutput;
/**
* Start the server on the first open port.
*/
start(): Promise<void>;
/**
* Tell the extension to reload by running `browser.runtime.reload`.
*/
reloadExtension: () => void;
/**
* Tell an extension page to reload.
*
* The path is the bundle path, not the input paths, so if the input paths is
* "src/options/index.html", you would pass "options.html" because that's where it is written to
* in the dist directory, and where it's available at in the actual extension.
*
* @example
* server.reloadPage("popup.html")
* server.reloadPage("sandbox.html")
*/
reloadPage: (path: string) => void;
/**
* Tell the extension to restart a content script.
*
* @param contentScript The manifest definition for a content script
*/
reloadContentScript: (
contentScript: Omit<Scripting.RegisteredContentScript, 'id'>,
) => void;
}
export type TargetBrowser = 'chrome' | 'firefox' | 'safari' | 'edge' | 'opera';
@@ -91,7 +261,6 @@ export interface GenericEntrypoint extends BaseEntrypoint {
| 'sidepanel'
| 'devtools'
| 'unlisted-page'
| 'unlisted-page'
| 'unlisted-script';
}
@@ -139,12 +308,51 @@ export type Entrypoint =
export type OnContentScriptStopped = (cb: () => void) => void;
export interface ContentScriptDefinition {
matches: string[];
runAt?: 'document_start' | 'document_end' | 'document_idle';
matchAboutBlank?: boolean;
matches: Manifest.ContentScript['matches'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default "documentIdle"
*/
runAt?: Manifest.ContentScript['run_at'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default false
*/
matchAboutBlank?: Manifest.ContentScript['match_about_blank'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default []
*/
excludeMatches?: Manifest.ContentScript['exclude_matches'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default []
*/
includeGlobs?: Manifest.ContentScript['include_globs'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default []
*/
excludeGlobs?: Manifest.ContentScript['exclude_globs'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default false
*/
allFrames?: Manifest.ContentScript['all_frames'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default false
*/
matchOriginAsFallback?: boolean;
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
* @default "ISOLATED"
*/
world?: 'ISOLATED' | 'MAIN';
main(onStopped: OnContentScriptStopped): void | Promise<void>;
/**
* Main function executed when the content script is loaded.
*/
main(): void | Promise<void>;
}
export interface BackgroundScriptDefintition {
@@ -153,32 +361,46 @@ export interface BackgroundScriptDefintition {
}
/**
* Manifest customization available in the `wxt.config.ts` file. Any missing fields like "name"
* and "version" are managed automatically, and don't need to be listed here.
* Manifest customization available in the `wxt.config.ts` file. You cannot configure entrypoints
* here, they are configured inline.
*/
export type UserManifest = Omit<
Manifest.WebExtensionManifest,
| 'action'
| 'background'
| 'browser_action'
| 'chrome_url_overrides'
| 'content_scripts'
| 'description'
| 'devtools_page'
| 'manifest_version'
| 'name'
| 'options_page'
| 'options_ui'
| 'sandbox'
| 'page_action'
| 'popup'
| 'short_name'
| 'sidepanel'
| 'sidebar_action'
| 'version'
| 'version_name'
export type UserManifest = Partial<
Omit<
Manifest.WebExtensionManifest,
| 'action'
| 'background'
| 'browser_action'
| 'chrome_url_overrides'
| 'content_scripts'
| 'devtools_page'
| 'manifest_version'
| 'options_page'
| 'options_ui'
| 'sandbox'
| 'page_action'
| 'popup'
| 'sidepanel'
| 'sidebar_action'
>
>;
export type UserManifestFn = (
env: ConfigEnv,
) => UserManifest | Promise<UserManifest>;
export interface ConfigEnv {
mode: string;
command: 'build' | 'serve';
/**
* Browser passed in from the CLI
*/
browser: TargetBrowser;
/**
* Manifest version passed in from the CLI
*/
manifestVersion: 2 | 3;
}
/**
* Configure how the browser starts up.
*/
+7
View File
@@ -36,6 +36,13 @@ export interface InternalConfig {
fsCache: FsCache;
server?: WxtDevServer;
runnerConfig: ResolvedConfig<ExtensionRunnerConfig>;
zip: {
name?: string;
artifactTemplate: string;
sourcesTemplate: string;
ignoredSources: string[];
sourcesRoot: string;
};
}
export type EntrypointGroup = Entrypoint | Entrypoint[];
+18
View File
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { every } from '../arrays';
describe('Array Utils', () => {
describe('every', () => {
it('should return true when the array is empty', () => {
expect(every([], () => false)).toBe(true);
});
it("should return true when all item predicate's return true", () => {
expect(every([1, 1, 1], (item) => item === 1)).toBe(true);
});
it("should return false when a single item predicate's return false", () => {
expect(every([1, 2, 1], (item) => item === 1)).toBe(false);
});
});
});
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { hashContentScriptOptions } from '../content-scripts';
describe('Content Script Utils', () => {
describe('hashContentScriptOptions', () => {
it('should return a string containing all the options with defaults applied', () => {
const hash = hashContentScriptOptions({ matches: [] });
expect(hash).toMatchInlineSnapshot(
'"[[\\"allFrames\\",false],[\\"excludeGlobs\\",[]],[\\"excludeMatches\\",[]],[\\"includeGlobs\\",[]],[\\"matchAboutBlank\\",false],[\\"matches\\",[]],[\\"matchOriginAsFallback\\",false],[\\"runAt\\",\\"document_idle\\"],[\\"world\\",\\"ISOLATED\\"]]"',
);
});
it('should be consistent regardless of the object ordering and default values', () => {
const hash1 = hashContentScriptOptions({
allFrames: true,
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
matchAboutBlank: false,
});
const hash2 = hashContentScriptOptions({
matches: ['*://duckduckgo.com/*', '*://google.com/*'],
allFrames: true,
});
expect(hash1).toBe(hash2);
});
});
});
@@ -0,0 +1,251 @@
import { describe, expect, it } from 'vitest';
import { DevModeChange, detectDevChanges } from '../detectDevChanges';
import {
fakeBackgroundEntrypoint,
fakeContentScriptEntrypoint,
fakeFile,
fakeGenericEntrypoint,
fakeManifest,
fakeOptionsEntrypoint,
fakePopupEntrypoint,
fakeRollupOutputAsset,
fakeRollupOutputChunk,
} from '../../../testing/fake-objects';
import { BuildOutput, BuildStepOutput } from '../../types';
describe('Detect Dev Changes', () => {
describe('No changes', () => {
it("should return 'no-change' when a build hasn't finished", () => {
const actual = detectDevChanges(
[['unknown', '/path/to/file.ts']],
undefined,
);
expect(actual).toEqual({ type: 'no-change' });
});
it("should return 'no-change' when the changed file isn't used by any of the entrypoints", () => {
const change: [string, string] = ['unknown', '/some/path.ts'];
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [
{
entrypoints: fakeContentScriptEntrypoint(),
chunks: [fakeRollupOutputChunk(), fakeRollupOutputChunk()],
},
{
entrypoints: fakeContentScriptEntrypoint(),
chunks: [
fakeRollupOutputChunk(),
fakeRollupOutputChunk(),
fakeRollupOutputChunk(),
],
},
],
};
const actual = detectDevChanges([change], currentOutput);
expect(actual).toEqual({ type: 'no-change' });
});
});
describe('Public Assets', () => {
it("should return 'extension-reload' without any groups to rebuild when the changed file is a public asset", () => {
const change: [string, string] = [
'unknown',
'/root/src/public/image.svg',
];
const asset1 = fakeRollupOutputAsset({
fileName: 'image.svg',
});
const asset2 = fakeRollupOutputAsset({
fileName: 'some-other-image.svg',
});
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [asset1, asset2],
steps: [],
};
const expected: DevModeChange = {
type: 'extension-reload',
rebuildGroups: [],
cachedOutput: {
...currentOutput,
publicAssets: [asset2],
},
};
const actual = detectDevChanges([change], currentOutput);
expect(actual).toEqual(expected);
});
});
describe('Background', () => {
it("should rebuild the background and reload the extension when the changed file in it's chunks' `moduleIds` field", () => {
const changedPath = '/root/utils/shared.ts';
const contentScript = fakeContentScriptEntrypoint({
inputPath: '/root/overlay.content.ts',
});
const background = fakeBackgroundEntrypoint({
inputPath: '/root/background.ts',
});
const step1: BuildStepOutput = {
entrypoints: contentScript,
chunks: [
fakeRollupOutputChunk({
moduleIds: [fakeFile(), fakeFile()],
}),
],
};
const step2: BuildStepOutput = {
entrypoints: background,
chunks: [
fakeRollupOutputChunk({
moduleIds: [fakeFile(), changedPath, fakeFile()],
}),
],
};
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [step1, step2],
};
const expected: DevModeChange = {
type: 'extension-reload',
cachedOutput: {
...currentOutput,
steps: [step1],
},
rebuildGroups: [background],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
expect(actual).toEqual(expected);
});
});
describe('HTML Pages', () => {
it('should rebuild then reload only the effected pages', async () => {
const changedPath = '/root/page1/index.html';
const htmlPage1 = fakePopupEntrypoint({
inputPath: changedPath,
});
const htmlPage2 = fakeOptionsEntrypoint({
inputPath: '/root/page2.html',
});
const htmlPage3 = fakeGenericEntrypoint({
type: 'sandbox',
inputPath: '/root/page3.html',
});
const step1: BuildStepOutput = {
entrypoints: [htmlPage1, htmlPage2],
chunks: [
fakeRollupOutputChunk({
moduleIds: [fakeFile(), changedPath],
}),
],
};
const step2: BuildStepOutput = {
entrypoints: [htmlPage3],
chunks: [
fakeRollupOutputChunk({
moduleIds: [fakeFile(), fakeFile(), fakeFile()],
}),
],
};
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [step1, step2],
};
const expected: DevModeChange = {
type: 'html-reload',
cachedOutput: {
...currentOutput,
steps: [step2],
},
rebuildGroups: [[htmlPage1, htmlPage2]],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
expect(actual).toEqual(expected);
});
});
describe('Content Scripts', () => {
it('should rebuild then reload only the effected content scripts', async () => {
const changedPath = '/root/utils/shared.ts';
const script1 = fakeContentScriptEntrypoint({
inputPath: '/root/overlay1.content/index.ts',
});
const script2 = fakeContentScriptEntrypoint({
inputPath: '/root/overlay2.ts',
});
const script3 = fakeContentScriptEntrypoint({
inputPath: '/root/overlay3.content/index.ts',
});
const step1: BuildStepOutput = {
entrypoints: script1,
chunks: [
fakeRollupOutputChunk({
moduleIds: [fakeFile(), changedPath],
}),
],
};
const step2: BuildStepOutput = {
entrypoints: script2,
chunks: [
fakeRollupOutputChunk({
moduleIds: [fakeFile(), fakeFile(), fakeFile()],
}),
],
};
const step3: BuildStepOutput = {
entrypoints: script3,
chunks: [
fakeRollupOutputChunk({
moduleIds: [changedPath, fakeFile(), fakeFile()],
}),
],
};
const currentOutput: BuildOutput = {
manifest: fakeManifest(),
publicAssets: [],
steps: [step1, step2, step3],
};
const expected: DevModeChange = {
type: 'content-script-reload',
cachedOutput: {
...currentOutput,
steps: [step2],
},
changedSteps: [step1, step3],
rebuildGroups: [script1, script3],
};
const actual = detectDevChanges(
[['unknown', changedPath]],
currentOutput,
);
expect(actual).toEqual(expected);
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { kebabCaseAlphanumeric } from '../strings';
describe('String utils', () => {
describe('kebabCaseAlphanumeric', () => {
it.each([
['HELLO', 'hello'],
['Hello, World!', 'hello-world'],
['hello123', 'hello123'],
['Hello World This Is A Test', 'hello-world-this-is-a-test'],
['Hello World', 'hello-world'],
['hello-world', 'hello-world'], // Ensure hyphens are preserved
])('should convert "%s" to "%s"', (input, expected) => {
expect(kebabCaseAlphanumeric(input)).toBe(expected);
});
});
});
+11
View File
@@ -0,0 +1,11 @@
/**
* Checks if `predicate` returns truthy for all elements of the array.
*/
export function every<T>(
array: T[],
predicate: (item: T, index: number) => boolean,
): boolean {
for (let i = 0; i < array.length; i++)
if (!predicate(array[i], i)) return false;
return true;
}
+52
View File
@@ -0,0 +1,52 @@
import { Manifest } from 'webextension-polyfill';
import { ContentScriptEntrypoint } from '../types';
/**
* Returns a unique and consistent string hash based on a content scripts options.
*
* It is able to recognize default values,
*/
export function hashContentScriptOptions(
options: ContentScriptEntrypoint['options'],
): string {
const withDefaults: ContentScriptEntrypoint['options'] = {
excludeGlobs: [],
excludeMatches: [],
includeGlobs: [],
matchAboutBlank: false,
matchOriginAsFallback: false,
runAt: 'document_idle',
allFrames: false,
world: 'ISOLATED',
// TODO: strip undefined fields from options object to improve content script grouping.
...options,
};
return JSON.stringify(
Object.entries(withDefaults)
// Sort any arrays so their values are consistent
.map<[string, unknown]>(([key, value]) => {
if (Array.isArray(value)) return [key, value.sort()];
else return [key, value];
})
// Sort all the fields alphabetically
.sort((l, r) => l[0].localeCompare(r[0])),
);
}
export function mapWxtOptionsToContentScript(
options: ContentScriptEntrypoint['options'],
): Omit<Manifest.ContentScript, 'js' | 'css'> {
return {
matches: options.matches,
all_frames: options.allFrames,
match_about_blank: options.matchAboutBlank,
exclude_globs: options.excludeGlobs,
exclude_matches: options.excludeMatches,
include_globs: options.includeGlobs,
run_at: options.runAt,
// @ts-expect-error: untyped chrome options
match_origin_as_fallback: options.matchOriginAsFallback,
world: options.world,
};
}
+177
View File
@@ -0,0 +1,177 @@
import { BuildOutput, BuildStepOutput, EntrypointGroup } from '../types';
import * as vite from 'vite';
import { every } from './arrays';
/**
* Compare the changed files vs the build output and determine what kind of reload needs to happen:
*
* - Do nothing
* - CSS or JS file associated with an HTML page is changed - this is handled automatically by the
* dev server
* - Change isn't used by any of the entrypoints
* - Reload Content script
* - CSS or JS file associated with a content script
* - Background script will be told to reload the content script
* - Reload HTML file
* - HTML file itself is saved - HMR doesn't handle this because the HTML pages are pre-rendered
* - Chrome is OK reloading the page when the HTML file is changed without reloading the whole
* extension. Not sure about firefox, this might need to change to an extension reload
* - Reload extension
* - Background script is changed
* - Manifest is different
* - Restart browser
* - Config file changed (wxt.config.ts, .env, web-ext.config.ts, etc)
*/
export function detectDevChanges(
changedFiles: [event: string, path: string][],
currentOutput: BuildOutput | undefined,
): DevModeChange {
if (currentOutput == null) return { type: 'no-change' };
const changedSteps = new Set(
changedFiles.flatMap((changedFile) =>
findEffectedSteps(changedFile, currentOutput),
),
);
if (changedSteps.size === 0) return { type: 'no-change' };
const unchangedOutput: BuildOutput = {
manifest: currentOutput.manifest,
steps: [],
publicAssets: [],
};
const changedOutput: BuildOutput = {
manifest: currentOutput.manifest,
steps: [],
publicAssets: [],
};
for (const step of currentOutput.steps) {
if (changedSteps.has(step)) {
changedOutput.steps.push(step);
} else {
unchangedOutput.steps.push(step);
}
}
for (const asset of currentOutput.publicAssets) {
if (changedSteps.has(asset)) {
changedOutput.publicAssets.push(asset);
} else {
unchangedOutput.publicAssets.push(asset);
}
}
const isOnlyHtmlChanges =
changedFiles.length > 0 &&
every(changedFiles, ([_, file]) => file.endsWith('.html'));
if (isOnlyHtmlChanges) {
return {
type: 'html-reload',
cachedOutput: unchangedOutput,
rebuildGroups: changedOutput.steps.map((step) => step.entrypoints),
};
}
const isOnlyContentScripts =
changedOutput.steps.length > 0 &&
every(
changedOutput.steps.flatMap((step) => step.entrypoints),
(entry) => entry.type === 'content-script',
);
if (isOnlyContentScripts) {
return {
type: 'content-script-reload',
cachedOutput: unchangedOutput,
changedSteps: changedOutput.steps,
rebuildGroups: changedOutput.steps.map((step) => step.entrypoints),
};
}
return {
type: 'extension-reload',
cachedOutput: unchangedOutput,
rebuildGroups: changedOutput.steps.map((step) => step.entrypoints),
};
}
/**
* For a single change, return all the step of the build output that were effected by it.
*/
function findEffectedSteps(
changedFile: [event: string, path: string],
currentOutput: BuildOutput,
): DetectedChange[] {
const changes: DetectedChange[] = [];
const changedPath = changedFile[1];
const isChunkEffected = (
chunk: vite.Rollup.OutputChunk | vite.Rollup.OutputAsset,
): boolean =>
// If it's an HTML file with the same path, is is effected because HTML files need to be pre-rendered
// TODO: use bundle path to support `<name>/index.html`?
(chunk.type === 'asset' && changedPath.endsWith(chunk.fileName)) ||
// If it's a chunk that depends on the changed file, it is effected
(chunk.type === 'chunk' && chunk.moduleIds.includes(changedPath));
for (const step of currentOutput.steps) {
const effectedChunk = step.chunks.find((chunk) => isChunkEffected(chunk));
if (effectedChunk) changes.push(step);
}
const effectedAsset = currentOutput.publicAssets.find((chunk) =>
isChunkEffected(chunk),
);
if (effectedAsset) changes.push(effectedAsset);
return changes;
}
/**
* Contains information about what files changed, what needs rebuilt, and the type of reload that is
* required.
*/
export type DevModeChange =
| NoChange
| HtmlReload
| ExtensionReload
| ContentScriptReload;
// | BrowserRestart
interface NoChange {
type: 'no-change';
}
interface RebuildChange {
/**
* The list of entrypoints that need rebuilt.
*/
rebuildGroups: EntrypointGroup[];
/**
* The previous output stripped of any files are going to change.
*/
cachedOutput: BuildOutput;
}
interface HtmlReload extends RebuildChange {
type: 'html-reload';
}
interface ExtensionReload extends RebuildChange {
type: 'extension-reload';
}
// interface BrowserRestart extends RebuildChange {
// type: 'browser-restart';
// }
interface ContentScriptReload extends RebuildChange {
type: 'content-script-reload';
changedSteps: BuildStepOutput[];
}
/**
* When figuring out what needs reloaded, this stores the step that was changed, or the public
* directory asset that was changed. It doesn't know what type of change is required yet. Just an
* intermediate type.
*/
type DetectedChange = BuildStepOutput | vite.Rollup.OutputAsset;
-33
View File
@@ -1,33 +0,0 @@
import net from 'node:net';
/**
* Finds the first open port in a range of ports.
*/
export function findOpenPort(
startPort: number,
endPort: number,
): Promise<number> {
return findOpenPortRecursive(startPort, startPort, endPort);
}
function findOpenPortRecursive(
port: number,
startPort: number,
endPort: number,
): Promise<number> {
return new Promise((resolve, reject) => {
if (port > endPort)
return reject(
Error(`Could not find open port between ${startPort}-${endPort}`),
);
const server = net.createServer();
server.listen(port, () => {
server.once('close', () => resolve(port));
server.close();
});
server.on('error', () =>
resolve(findOpenPortRecursive(port + 1, startPort, endPort)),
);
});
}
+56 -8
View File
@@ -1,13 +1,15 @@
import {
ConfigEnv,
ExtensionRunnerConfig,
InlineConfig,
InternalConfig,
UserConfig,
UserManifest,
UserManifestFn,
} from '../types';
import path, { resolve } from 'node:path';
import * as vite from 'vite';
import { consola } from 'consola';
import { importTsFile } from './importTsFile';
import * as plugins from '../vite-plugins';
import { createFsCache } from './createFsCache';
import { getGlobals } from './globals';
@@ -43,7 +45,6 @@ export async function getInternalConfig(
command,
logger,
vite: config.vite ?? {},
manifest: config.manifest ?? {},
imports: config.imports ?? {},
runnerConfig: await loadConfig<ExtensionRunnerConfig>({
name: 'web-ext',
@@ -59,10 +60,12 @@ export async function getInternalConfig(
mode,
};
if (config.configFile !== false) {
userConfig = await importTsFile<UserConfig>(
root,
path.resolve(root, config.configFile ?? 'wxt.config.ts'),
);
const loaded = await loadConfig<UserConfig>({
name: 'wxt',
cwd: root,
rcFile: false,
});
userConfig = loaded.config ?? {};
}
// Merge inline and user configs
@@ -81,6 +84,12 @@ export async function getInternalConfig(
const wxtDir = resolve(srcDir, '.wxt');
const typesDir = resolve(wxtDir, 'types');
// Merge manifest sources
const env: ConfigEnv = { mode, browser, manifestVersion, command };
const userManifest = await resolveManifestConfig(env, userConfig.manifest);
const inlineManifest = await resolveManifestConfig(env, config.manifest);
const manifest = vite.mergeConfig(userManifest, inlineManifest);
const finalConfig: InternalConfig = {
...merged,
srcDir,
@@ -89,12 +98,33 @@ export async function getInternalConfig(
wxtDir: wxtDir,
typesDir,
fsCache: createFsCache(wxtDir),
manifest,
zip: {
sourcesTemplate: '{{name}}-{{version}}-sources.zip',
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
sourcesRoot: root,
...userConfig.zip,
...config.zip,
ignoredSources: [
'**/node_modules',
// WXT files
'**/web-ext.config.ts',
// Hidden files
'**/.*',
// Tests
'**/__tests__/**',
'**/*.+(test|spec).?(c|m)+(j|t)s?(x)',
// User config
...(userConfig.zip?.ignoredSources ?? []),
...(config.zip?.ignoredSources ?? []),
],
},
};
// Customize the default vite config
finalConfig.vite.root = root;
finalConfig.vite.configFile = false;
finalConfig.vite.logLevel = 'silent';
finalConfig.vite.logLevel = 'warn';
finalConfig.vite.build ??= {};
finalConfig.vite.build.outDir = outDir;
@@ -110,6 +140,8 @@ export async function getInternalConfig(
finalConfig.vite.plugins.push(
plugins.virtualEntrypoin('content-script', finalConfig),
);
finalConfig.vite.plugins.push(plugins.devServerGlobals(finalConfig));
finalConfig.vite.plugins.push(plugins.tsconfigPaths(finalConfig));
finalConfig.vite.define ??= {};
getGlobals(finalConfig).forEach((global) => {
@@ -125,5 +157,21 @@ export async function getInternalConfig(
*/
type InternalConfigNoUserDirs = Omit<
InternalConfig,
'srcDir' | 'publicDir' | 'entrypointsDir' | 'wxtDir' | 'typesDir' | 'fsCache'
| 'srcDir'
| 'publicDir'
| 'entrypointsDir'
| 'wxtDir'
| 'typesDir'
| 'fsCache'
| 'manifest'
| 'zip'
>;
async function resolveManifestConfig(
env: ConfigEnv,
manifest: UserManifest | Promise<UserManifest> | UserManifestFn | undefined,
): Promise<UserManifest> {
return await (typeof manifest === 'function'
? manifest(env)
: manifest ?? {});
}
+5
View File
@@ -39,5 +39,10 @@ export function getGlobals(
value: config.browser === 'opera',
type: `boolean`,
},
{
name: '__COMMAND__',
value: config.command,
type: `"build" | "serve"`,
},
];
}
+50 -27
View File
@@ -1,44 +1,67 @@
import { consola } from 'consola';
import createJITI from 'jiti';
import transform from 'jiti/dist/babel';
import { InternalConfig } from '../types';
import { createUnimport } from 'unimport';
import fs from 'fs-extra';
import { resolve } from 'path';
import { scanExports } from 'unimport';
import transform from 'jiti/dist/babel';
import { getUnimportOptions } from './auto-imports';
export async function importTsFile<T>(root: string, path: string): Promise<T> {
const clientImports = await scanExports(
resolve(root, 'node_modules/wxt/dist/client/index.js'),
/**
* Get the value from the default export of a `path`.
*
* It works by:
*
* 1. Reading the file text
* 2. Stripping all imports from it via regex
* 3. Auto-import only the client helper functions
*
* This prevents resolving imports of imports, speeding things up and preventing "xxx is not
* defined" errors.
*
* Downside is that code cannot be executed outside of the main fucntion for the entrypoint,
* otherwise you will see "xxx is not defined" errors for any imports used outside of main function.
*/
export async function importTsFile<T>(
path: string,
config: InternalConfig,
): Promise<T> {
config.logger.debug('Loading file metadata:', path);
const unimport = createUnimport({
...getUnimportOptions(config),
// Only allow specific imports, not all from the project
imports: [{ name: '*', as: 'browser', from: 'webextension-polyfill' }],
dirs: [],
});
await unimport.init();
const text = await fs.readFile(path, 'utf-8');
const textNoImports = text.replace(/import.*[\n;]/gm, '');
const { code } = await unimport.injectImports(textNoImports);
config.logger.debug(
['Text:', text, 'No imports:', textNoImports, 'Code:', code].join('\n'),
);
const jiti = createJITI(__filename, {
cache: false,
esmResolve: true,
interopDefault: true,
alias: {
'webextension-polyfill': resolve(
config.root,
'node_modules/wxt/dist/virtual-modules/fake-browser.js',
),
},
transform(opts) {
// Remove CSS imports from the source code - Jiti can't handle them.
opts.source = opts.source.replace(/^import ['"].*\.css['"];?$/gm, '');
opts.source = opts.source.replace(
/^import\s+.*\s+from ['"]webextension-polyfill['"];?$/gm,
'',
);
// Append any wxt/client functions so babel doesn't complain about undefined variables
if (opts.filename === path) {
// TODO: Only append import if it isn't already imported
const imports =
clientImports
.map((i) => `import { ${i.name} } from "${i.from}";`)
.join('\n') + '\n';
opts.source = imports + opts.source;
}
// Call the default babel transformer with our modified source code
return transform(opts);
if (opts.filename === path) return transform({ ...opts, source: code });
else return transform(opts);
},
});
try {
return await jiti(path);
} catch (err) {
consola.error(`Failed to import file: ${path}`);
config.logger.error(err);
throw err;
}
}
+85 -49
View File
@@ -12,6 +12,11 @@ import fs from 'fs-extra';
import { resolve } from 'path';
import { getEntrypointBundlePath } from './entrypoints';
import { ContentSecurityPolicy } from './ContentSecurityPolicy';
import {
hashContentScriptOptions,
mapWxtOptionsToContentScript,
} from './content-scripts';
import { getPackageJson } from './package';
/**
* Writes the manifest to the output directory and the build output.
@@ -29,7 +34,7 @@ export async function writeManifest(
await fs.ensureDir(config.outDir);
await fs.writeFile(resolve(config.outDir, 'manifest.json'), str, 'utf-8');
output.unshift({
output.publicAssets.unshift({
type: 'asset',
fileName: 'manifest.json',
name: 'manifest',
@@ -43,48 +48,53 @@ export async function writeManifest(
*/
export async function generateMainfest(
entrypoints: Entrypoint[],
buildOutput: BuildOutput,
buildOutput: Omit<BuildOutput, 'manifest'>,
config: InternalConfig,
): Promise<Manifest.WebExtensionManifest> {
const pkg = await getPackageJson(config);
if (pkg.version == null)
throw Error('package.json does not include a version');
if (pkg.name == null) throw Error('package.json does not include a name');
if (pkg.description == null)
throw Error('package.json does not include a description');
const manifest: Manifest.WebExtensionManifest = {
manifest_version: config.manifestVersion,
name: pkg.name,
short_name: pkg.shortName,
version: simplifyVersion(pkg.version),
version_name: config.browser === 'firefox' ? undefined : pkg.version,
...config.manifest,
};
const manifest: Manifest.WebExtensionManifest = Object.assign(
{
manifest_version: config.manifestVersion,
name: pkg?.name,
description: pkg?.description,
version: pkg?.version && simplifyVersion(pkg.version),
// Only add the version name to chromium and if the user hasn't specified a custom version.
version_name:
config.browser !== 'firefox' && !config.manifest.version
? pkg?.version
: undefined,
short_name: pkg?.shortName,
},
config.manifest,
);
addEntrypoints(manifest, entrypoints, buildOutput, config);
if (config.command === 'serve') addDevModeCsp(manifest, config);
if (config.command === 'serve') addDevModePermissions(manifest, config);
// TODO: transform manifest here.
if (manifest.name == null)
throw Error(
"Manifest 'name' is missing. Either:\n1. Set the name in your <root>/package.json\n2. Set a name via the manifest option in your wxt.config.ts",
);
if (manifest.version == null) {
throw Error(
"Manifest 'version' is missing. Either:\n1. Add a version in your <root>/package.json\n2. Pass the version via the manifest option in your wxt.config.ts",
);
}
return manifest;
}
/**
* Read the package.json from the current directory.
*
* TODO: look in root and up directories until it's found
*/
async function getPackageJson(config: InternalConfig): Promise<any> {
return await fs.readJson(resolve(config.root, 'package.json'));
}
/**
* Removes suffixes from the version, like X.Y.Z-alpha1 (which brosers don't allow), so it's a
* simple version number, like X or X.Y or X.Y.Z, which browsers allow.
*/
function simplifyVersion(versionName: string): string {
// Regex adapted from here: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version#version_format
const version = /^((0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}).*$/.exec(
versionName,
)?.[1];
@@ -100,7 +110,7 @@ function simplifyVersion(versionName: string): string {
function addEntrypoints(
manifest: Manifest.WebExtensionManifest,
entrypoints: Entrypoint[],
buildOutput: BuildOutput,
buildOutput: Omit<BuildOutput, 'manifest'>,
config: InternalConfig,
): void {
const entriesByType = entrypoints.reduce<
@@ -270,30 +280,29 @@ function addEntrypoints(
}
if (contentScripts?.length) {
if (config.command === 'serve') {
const permissionsKey =
config.manifestVersion === 2 ? 'permissions' : 'host_permissions';
const hostPermissions = new Set<string>(manifest[permissionsKey] ?? []);
// Don't add content scripts to the manifest in dev mode for MV3 - they're managed and reloaded
// at runtime
if (config.command === 'serve' && config.manifestVersion === 3) {
const hostPermissions = new Set<string>(manifest.host_permissions ?? []);
contentScripts.forEach((script) => {
script.options.matches.forEach((matchPattern) => {
hostPermissions.add(matchPattern);
});
});
manifest[permissionsKey] = Array.from(hostPermissions).sort();
hostPermissions.forEach((permission) =>
addHostPermission(manifest, permission),
);
} else {
const hashToEntrypointsMap = contentScripts.reduce((map, script) => {
const hash = JSON.stringify(script.options);
if (!map.has(hash)) {
map.set(hash, [script]);
} else {
map.get(hash)?.push(script);
}
const hash = hashContentScriptOptions(script.options);
if (map.has(hash)) map.get(hash)?.push(script);
else map.set(hash, [script]);
return map;
}, new Map<string, ContentScriptEntrypoint[]>());
manifest.content_scripts = Array.from(hashToEntrypointsMap.entries()).map(
([, scripts]) => ({
...scripts[0].options,
...mapWxtOptionsToContentScript(scripts[0].options),
// TOOD: Sorting css and js arrays here so we get consistent test results... but we
// shouldn't have to. Where is the inconsistency coming from?
css: getContentScriptCssFiles(scripts, buildOutput)?.sort(),
@@ -316,13 +325,9 @@ function addDevModeCsp(
const allowedCsp = config.server?.origin ?? 'http://localhost:*';
if (manifest.manifest_version === 3) {
manifest.host_permissions ??= [];
if (!manifest.host_permissions.includes(permission))
manifest.host_permissions.push(permission);
addHostPermission(manifest, permission);
} else {
manifest.permissions ??= [];
if (!manifest.permissions.includes(permission))
manifest.permissions.push(permission);
addPermission(manifest, permission);
}
const csp = new ContentSecurityPolicy(
@@ -345,20 +350,33 @@ function addDevModeCsp(
}
}
function addDevModePermissions(
manifest: Manifest.WebExtensionManifest,
config: InternalConfig,
) {
// For reloading the page
addPermission(manifest, 'tabs');
// For registering content scripts
if (config.manifestVersion === 3) addPermission(manifest, 'scripting');
}
/**
* Returns the bundle paths to CSS files associated with a list of content scripts, or undefined if
* there is no associated CSS.
*/
function getContentScriptCssFiles(
export function getContentScriptCssFiles(
contentScripts: ContentScriptEntrypoint[],
buildOutput: BuildOutput,
buildOutput: Omit<BuildOutput, 'manifest'>,
): string[] | undefined {
const css: string[] = [];
const allChunks = buildOutput.steps.flatMap((step) => step.chunks);
contentScripts.forEach((script) => {
const cssRegex = new RegExp(`^assets/${script.name}-[a-f0-9]{8}.css$`);
const relatedCss = buildOutput.find((chunk) =>
chunk.fileName.match(cssRegex),
// TODO: optimize and remove loop with a map
const relatedCss = allChunks.find(
(chunk) => chunk.fileName === `assets/${script.name}.css`,
);
if (relatedCss) css.push(relatedCss.fileName);
});
@@ -366,3 +384,21 @@ function getContentScriptCssFiles(
if (css.length > 0) return css;
return undefined;
}
function addPermission(
manifest: Manifest.WebExtensionManifest,
permission: string,
): void {
manifest.permissions ??= [];
if (manifest.permissions.includes(permission)) return;
manifest.permissions.push(permission);
}
function addHostPermission(
manifest: Manifest.WebExtensionManifest,
hostPermission: string,
): void {
manifest.host_permissions ??= [];
if (manifest.host_permissions.includes(hostPermission)) return;
manifest.host_permissions.push(hostPermission);
}
+22
View File
@@ -0,0 +1,22 @@
import { resolve } from 'node:path';
import { InternalConfig } from '../types';
import fs from 'fs-extra';
/**
* Read the project's package.json.
*
* TODO: look in root and up directories until it's found
*/
export async function getPackageJson(
config: InternalConfig,
): Promise<Partial<Record<string, any>> | undefined> {
const file = resolve(config.root, 'package.json');
try {
return await fs.readJson(file);
} catch (err) {
config.logger.debug(
`Failed to read package.json at: ${file}. Returning undefined.`,
);
return {};
}
}
+6
View File
@@ -0,0 +1,6 @@
export function kebabCaseAlphanumeric(str: string): string {
return str
.toLowerCase()
.replace(/[^a-z0-9-\s]/g, '') // Remove all non-alphanumeric, non-hyphen characters
.replace(/\s+/g, '-'); // Replace spaces with hyphens
}
+18
View File
@@ -11,6 +11,18 @@ export function devHtmlPrerender(config: InternalConfig): vite.Plugin {
return {
apply: 'build',
name: 'wxt:dev-html-prerender',
config() {
return {
resolve: {
alias: {
'@wxt/reload-html': resolve(
config.root,
'node_modules/wxt/dist/virtual-modules/reload-html.js',
),
},
},
};
},
async transform(html, id) {
const server = config.server;
if (config.command !== 'serve' || server == null || !id.endsWith('.html'))
@@ -43,6 +55,12 @@ export function devHtmlPrerender(config: InternalConfig): vite.Plugin {
pointToDevServer('script[type=module]', 'src');
pointToDevServer('link[rel=stylesheet]', 'href');
// Add a script to add page reloading
const reloader = document.createElement('script');
reloader.src = '@wxt/reload-html';
reloader.type = 'module';
document.head.appendChild(reloader);
const newHtml = document.toString();
config.logger.debug('Transformed ' + id);
config.logger.debug('Old HTML:\n' + html);
+25
View File
@@ -0,0 +1,25 @@
import { Plugin } from 'vite';
import { InternalConfig } from '../types';
/**
* Defines global constants about the dev server. Helps scripts connect to the server's web socket.
*/
export function devServerGlobals(internalConfig: InternalConfig): Plugin {
return {
name: 'wxt:dev-server-globals',
config() {
if (internalConfig.server == null || internalConfig.command == 'build')
return;
return {
define: {
__DEV_SERVER_PROTOCOL__: JSON.stringify('ws:'),
__DEV_SERVER_HOSTNAME__: JSON.stringify(
internalConfig.server.hostname,
),
__DEV_SERVER_PORT__: JSON.stringify(internalConfig.server.port),
},
};
},
};
}
+5 -3
View File
@@ -1,5 +1,7 @@
export * from './download';
export * from './unimport';
export * from './multipageMove';
export * from './devHtmlPrerender';
export * from './devServerGlobals';
export * from './download';
export * from './multipageMove';
export * from './unimport';
export * from './virtualEntrypoint';
export * from './tsconfigPaths';
+1 -1
View File
@@ -53,7 +53,7 @@ export function multipageMove(
const oldAbsPath = resolve(config.outDir, oldBundlePath);
const newAbsPath = resolve(config.outDir, newBundlePath);
await ensureDir(dirname(newAbsPath));
await fs.move(oldAbsPath, newAbsPath);
await fs.move(oldAbsPath, newAbsPath, { overwrite: true });
const renamedChunk = {
...bundle[oldBundlePath],
+10
View File
@@ -0,0 +1,10 @@
import { InternalConfig } from '../types';
import paths from 'vite-tsconfig-paths';
export function tsconfigPaths(config: InternalConfig) {
const fn: typeof paths =
typeof paths === 'function' ? paths : (paths as any).default;
return fn({
root: config.root,
});
}
+12 -1
View File
@@ -2,6 +2,16 @@ import { createUnimport } from 'unimport';
import { InternalConfig } from '../types';
import { getUnimportOptions } from '../utils/auto-imports';
import { Plugin } from 'vite';
import { extname } from 'path';
const ENABLED_EXTENSIONS: Record<string, boolean | undefined> = {
'.js': true,
'.jsx': true,
'.ts': true,
'.tsx': true,
'.vue': true,
'.svelte': true,
};
/**
* Inject any global imports defined by unimport
@@ -16,7 +26,8 @@ export function unimport(config: InternalConfig): Plugin {
await unimport.scanImportsFromDir(undefined, { cwd: config.srcDir });
},
async transform(code, id) {
return unimport.injectImports(code, id);
const ext = extname(id);
if (ENABLED_EXTENSIONS[ext]) return unimport.injectImports(code, id);
},
};
}
+5 -2
View File
@@ -29,10 +29,13 @@ export function virtualEntrypoin(
const inputPath = id.replace(resolvedVirtualId, '');
const template = await fs.readFile(
resolve(config.root, `node_modules/wxt/templates/virtual-${type}.ts`),
resolve(
config.root,
`node_modules/wxt/dist/virtual-modules/${type}-entrypoint.js`,
),
'utf-8',
);
return template.replaceAll('{{moduleId}}', inputPath);
return template.replace(`virtual:user-${type}`, inputPath);
},
};
}
+75
View File
@@ -0,0 +1,75 @@
import { BuildOutput, InternalConfig } from './types';
import zipdir from 'zip-dir';
import { dirname, relative, resolve } from 'node:path';
import fs from 'fs-extra';
import { kebabCaseAlphanumeric } from './utils/strings';
import { getPackageJson } from './utils/package';
import { minimatch } from 'minimatch';
import { formatDuration } from './utils/formatDuration';
import { printFileList } from './log/printFileList';
/**
* Zip the extension for distribution. Does not build, just zips the output that should exist for
* the given config.
*
* Returns a list of ZIP files that were created.
*/
export async function zipExtension(
config: InternalConfig,
buildOutput: BuildOutput,
): Promise<string[]> {
const start = Date.now();
config.logger.info('Zipping extension...');
const zipFiles: string[] = [];
const projectName =
config.zip.name ??
kebabCaseAlphanumeric(
(await getPackageJson(config))?.name || dirname(process.cwd()),
);
const applyTemplate = (template: string): string =>
template
.replaceAll('{{name}}', projectName)
.replaceAll('{{browser}}', config.browser)
.replaceAll(
'{{version}}',
buildOutput.manifest.version_name ?? buildOutput.manifest.version,
)
.replaceAll('{{manifestVersion}}', `mv${config.manifestVersion}`);
await fs.ensureDir(config.outBaseDir);
// ZIP output directory
const outZipFilename = applyTemplate(config.zip.artifactTemplate);
const outZipPath = resolve(config.outBaseDir, outZipFilename);
await zipdir(config.outDir, {
saveTo: outZipPath,
});
zipFiles.push(outZipPath);
// ZIP sources for Firefox
if (config.browser === 'firefox') {
const sourcesZipFilename = applyTemplate(config.zip.sourcesTemplate);
const sourcesZipPath = resolve(config.outBaseDir, sourcesZipFilename);
await zipdir(config.zip.sourcesRoot, {
saveTo: sourcesZipPath,
filter(path) {
const relativePath = relative(config.zip.sourcesRoot, path);
const matchedPattern = config.zip.ignoredSources.find((pattern) =>
minimatch(relativePath, pattern),
);
return matchedPattern == null;
},
});
zipFiles.push(sourcesZipPath);
}
config.logger.success(
`Zipped extension in ${formatDuration(Date.now() - start)}`,
);
await printFileList(config.logger.log, config.outBaseDir, zipFiles);
return zipFiles;
}
+83 -83
View File
@@ -1,21 +1,19 @@
import {
BuildOutput,
WxtDevServer,
InlineConfig,
InternalConfig,
} from './core/types';
import { BuildOutput, WxtDevServer, InlineConfig } from './core/types';
import { getInternalConfig } from './core/utils/getInternalConfig';
import { findEntrypoints } from './core/build/findEntrypoints';
import { buildEntrypoints } from './core/build/buildEntrypoints';
import { generateMainfest, writeManifest } from './core/utils/manifest';
import { printBuildSummary } from './core/log/printBuildSummary';
import fs from 'fs-extra';
import { generateTypesDir } from './core/build/generateTypesDir';
import pc from 'picocolors';
import * as vite from 'vite';
import { findOpenPort } from './core/utils/findOpenPort';
import { formatDuration } from './core/utils/formatDuration';
import { createWebExtRunner } from './core/runners/createWebExtRunner';
import { detectDevChanges } from './core/utils/detectDevChanges';
import { Mutex } from 'async-mutex';
import { consola } from 'consola';
import { relative } from 'node:path';
import { getEntrypointOutputFile } from './core/utils/entrypoints';
import { buildInternal, rebuild } from './core/build';
import {
getServerInfo,
reloadContentScripts,
reloadHtmlPages,
setupServer,
} from './core/server';
export { version } from '../package.json';
export * from './core/types/external';
@@ -33,77 +31,79 @@ export async function build(config: InlineConfig): Promise<BuildOutput> {
export async function createServer(
config?: InlineConfig,
): Promise<WxtDevServer> {
const port = await findOpenPort(3000, 3010);
const hostname = 'localhost';
const origin = `http://${hostname}:${port}`;
const serverConfig: vite.InlineConfig = {
server: {
origin,
},
const serverInfo = await getServerInfo();
const getLatestInternalConfig = () => {
const viteConfig: vite.InlineConfig = vite.mergeConfig(
serverInfo.viteServerConfig,
config?.vite ?? {},
);
return getInternalConfig({ ...config, vite: viteConfig }, 'serve');
};
const internalConfig = await getInternalConfig(
vite.mergeConfig(serverConfig, config ?? {}),
'serve',
);
const runner = createWebExtRunner();
const viteServer = await vite.createServer(internalConfig.vite);
const server: WxtDevServer = {
...viteServer,
async listen(port, isRestart) {
const res = await viteServer.listen(port, isRestart);
if (!isRestart) {
internalConfig.logger.success(`Started dev server @ ${origin}`);
internalConfig.logger.info('Opening browser...');
await runner.openBrowser(internalConfig);
internalConfig.logger.success('Opened!');
}
return res;
},
logger: internalConfig.logger,
port,
hostname,
origin,
};
internalConfig.logger.info('Created dev server');
let internalConfig = await getLatestInternalConfig();
const server = await setupServer(serverInfo, internalConfig);
internalConfig.server = server;
await buildInternal(internalConfig);
const fileChangedMutex = new Mutex();
const changeQueue: Array<[string, string]> = [];
server.ws.on('wxt:background-initialized', () => {
// Register content scripts for the first time since they're not listed in the manifest
reloadContentScripts(server.currentOutput.steps, internalConfig, server);
});
server.watcher.on('all', async (event, path, _stats) => {
if (path.startsWith(internalConfig.outBaseDir)) return;
changeQueue.push([event, path]);
await fileChangedMutex.runExclusive(async () => {
const fileChanges = changeQueue.splice(0, changeQueue.length);
const changes = detectDevChanges(fileChanges, server.currentOutput);
if (changes.type === 'no-change') return;
// Log the entrypoints that were effected
internalConfig.logger.info(
`Changed: ${Array.from(new Set(fileChanges.map((change) => change[1])))
.map((file) => pc.dim(relative(internalConfig.root, file)))
.join(', ')}`,
);
const rebuiltNames = changes.rebuildGroups
.flat()
.map((entry) => {
return pc.cyan(
relative(internalConfig.outDir, getEntrypointOutputFile(entry, '')),
);
})
.join(pc.dim(', '));
// Get latest config and Rebuild groups with changes
internalConfig = await getLatestInternalConfig();
internalConfig.server = server;
const { output: newOutput } = await rebuild(
internalConfig,
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
changes.rebuildGroups,
changes.cachedOutput,
);
server.currentOutput = newOutput;
// Perform reloads
switch (changes.type) {
case 'extension-reload':
server.reloadExtension();
break;
case 'html-reload':
reloadHtmlPages(changes.rebuildGroups, server, internalConfig);
break;
case 'content-script-reload':
reloadContentScripts(changes.changedSteps, internalConfig, server);
break;
}
consola.success(`Reloaded: ${rebuiltNames}`);
});
});
return server;
}
async function buildInternal(config: InternalConfig): Promise<BuildOutput> {
const verb = config.command === 'serve' ? 'Pre-rendering' : 'Building';
const target = `${config.browser}-mv${config.manifestVersion}`;
config.logger.info(
`${verb} ${pc.cyan(target)} for ${pc.cyan(config.mode)} with ${pc.green(
`Vite ${vite.version}`,
)}`,
);
const startTime = Date.now();
// Cleanup
await fs.rm(config.outDir, { recursive: true, force: true });
await fs.ensureDir(config.outDir);
// Build
const entrypoints = await findEntrypoints(config);
await generateTypesDir(entrypoints, config);
const output = await buildEntrypoints(entrypoints, config);
// Write manifest
const manifest = await generateMainfest(entrypoints, output, config);
await writeManifest(manifest, output, config);
// Post-build
config.logger.success(
`Built extension in ${formatDuration(Date.now() - startTime)}`,
);
await printBuildSummary(output, config);
return output;
}
+228
View File
@@ -0,0 +1,228 @@
/**
* SHOULD ONLY BE IMPORTED IN TESTS.
*/
import { resolve } from 'path';
import {
BackgroundEntrypoint,
ContentScriptEntrypoint,
GenericEntrypoint,
OptionsEntrypoint,
PopupEntrypoint,
} from '..';
import { faker } from '@faker-js/faker';
import merge from 'lodash.merge';
import { Rollup } from 'vite';
import { Manifest } from 'webextension-polyfill';
import { FsCache, InternalConfig, WxtDevServer } from '../core/types';
import { mock } from 'vitest-mock-extended';
faker.seed(__TEST_SEED__);
type DeepPartial<T> = { [key in keyof T]+?: Partial<T[key]> };
function fakeObjectCreator<T>(base: () => T) {
return (overrides?: DeepPartial<T>): T => merge(base(), overrides);
}
export function fakeFileName(): string {
return faker.string.alphanumeric() + '.' + faker.string.alpha({ length: 3 });
}
export function fakeFile(root = process.cwd()): string {
return resolve(root, fakeFileName());
}
export function fakeDir(root = process.cwd()): string {
return resolve(root, faker.string.alphanumeric());
}
export const fakeContentScriptEntrypoint =
fakeObjectCreator<ContentScriptEntrypoint>(() => ({
type: 'content-script',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
options: {
matches: [],
matchAboutBlank: faker.helpers.arrayElement([true, false, undefined]),
matchOriginAsFallback: faker.helpers.arrayElement([
true,
false,
undefined,
]),
runAt: faker.helpers.arrayElement([
'document_start',
'document_end',
'document_idle',
undefined,
]),
},
outputDir: fakeDir('.output'),
}));
export const fakeBackgroundEntrypoint = fakeObjectCreator<BackgroundEntrypoint>(
() => ({
type: 'background',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
options: {
persistent: faker.helpers.arrayElement([true, false, undefined]),
type: faker.helpers.maybe(() => 'module'),
},
outputDir: fakeDir('.output'),
}),
);
export const fakeUnlistedScriptEntrypoint =
fakeObjectCreator<GenericEntrypoint>(() => ({
type: 'unlisted-script',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
}));
export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
() => ({
type: 'options',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
options: {
browserStyle: faker.helpers.arrayElement([true, false, undefined]),
chromeStyle: faker.helpers.arrayElement([true, false, undefined]),
openInTab: faker.helpers.arrayElement([true, false, undefined]),
},
}),
);
export const fakePopupEntrypoint = fakeObjectCreator<PopupEntrypoint>(() => ({
type: 'popup',
inputPath: fakeFile('src'),
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
options: {
defaultTitle: faker.helpers.arrayElement([
faker.person.fullName(),
undefined,
]),
defaultIcon: faker.helpers.arrayElement([
{
'16': 'icon/16.png',
'24': 'icon/24.png',
'64': 'icon/64.png',
},
]),
mv2Key: faker.helpers.arrayElement([
'browser_action',
'page_action',
undefined,
]),
},
}));
export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
() => ({
type: faker.helpers.arrayElement([
'sandbox',
'bookmarks',
'history',
'newtab',
'sidepanel',
'devtools',
'unlisted-page',
'unlisted-script',
]),
inputPath: fakeFile('src'),
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
}),
);
export const fakeRollupOutputChunk = fakeObjectCreator<Rollup.OutputChunk>(
() => ({
type: 'chunk',
code: '',
dynamicImports: [],
exports: [],
facadeModuleId: faker.helpers.arrayElement([null, fakeFile()]),
fileName: faker.string.alphanumeric(),
implicitlyLoadedBefore: [],
importedBindings: {},
imports: [],
isDynamicEntry: faker.datatype.boolean(),
isEntry: faker.datatype.boolean(),
isImplicitEntry: faker.datatype.boolean(),
map: null,
moduleIds: [],
modules: {},
name: faker.string.alpha(),
referencedFiles: [],
viteMetadata: {
importedAssets: new Set(),
importedCss: new Set(),
},
}),
);
export const fakeRollupOutputAsset = fakeObjectCreator<Rollup.OutputAsset>(
() => ({
type: 'asset',
fileName: fakeFileName(),
name: faker.string.alpha(),
needsCodeReference: faker.datatype.boolean(),
source: '',
}),
);
export function fakeRollupOutput(): Rollup.OutputAsset | Rollup.OutputChunk {
return faker.helpers.arrayElement([
fakeRollupOutputAsset(),
fakeRollupOutputChunk(),
]);
}
export const fakeManifest = fakeObjectCreator<Manifest.WebExtensionManifest>(
() => ({
manifest_version: faker.helpers.arrayElement([2, 3]),
name: faker.string.alphanumeric(),
version: `${faker.number.int()}.${faker.number.int()}.${faker.number.int()}`,
}),
);
export function fakeArray<T>(createItem: () => T, count = 3): T[] {
const array: T[] = [];
for (let i = 0; i < count; i++) {
array.push(createItem());
}
return array;
}
export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => ({
browser: faker.helpers.arrayElement(['chrome', 'firefox']),
command: faker.helpers.arrayElement(['build', 'serve']),
entrypointsDir: fakeDir(),
fsCache: mock<FsCache>(),
imports: {},
logger: mock(),
manifest: fakeManifest(),
manifestVersion: faker.helpers.arrayElement([2, 3]),
mode: faker.helpers.arrayElement(['development', 'production']),
outBaseDir: fakeDir(),
outDir: fakeDir(),
publicDir: fakeDir(),
root: fakeDir(),
runnerConfig: {
config: {},
},
srcDir: fakeDir(),
storeIds: {},
typesDir: fakeDir(),
vite: {},
wxtDir: fakeDir(),
server: mock<WxtDevServer>(),
zip: {
artifactTemplate: '{{name}}-{{version}}.zip',
ignoredSources: [],
sourcesRoot: fakeDir(),
sourcesTemplate: '{{name}}-sources.zip',
name: faker.person.firstName().toLowerCase(),
},
}));

Some files were not shown because too many files have changed in this diff Show More