Compare commits

...

74 Commits

Author SHA1 Message Date
GitHub Actions 474de83e28 chore(release): v0.7.1 2023-10-01 04:18:14 +00:00
Aaron Klinker 65fcfc0064 docs: Add docs for createContentScriptUi 2023-09-30 23:05:07 -05:00
Aaron Klinker 2d4983e88b chore(release): v0.7.1-alpha1 2023-09-30 21:59:20 -05:00
Aaron 4150e42c05 feat: createContentScriptUi helper (#143) 2023-09-30 21:57:57 -05:00
Aaron 8d7150653a Tweak badge colors for readability 2023-09-30 21:57:16 -05:00
Aaron Klinker 61e57b7b7c Remove all from sync-releases script 2023-09-30 12:49:04 -05:00
Aaron Klinker 83add72312 Update breaking change docs 2023-09-30 12:48:22 -05:00
GitHub Actions 3c723d2c92 chore(release): v0.7.0 2023-09-30 17:45:03 +00:00
Aaron Klinker 1eb35c7207 Reduce codecov requirement to 80% 2023-09-30 12:38:14 -05:00
Aaron 725ecf7c64 fix: Reorder typescript paths to give priority to @ and ~ over @@ and ~~ (#142) 2023-09-30 12:33:33 -05:00
Aaron 3847092df4 feat: Content script cssInjectionMode (#141) 2023-09-30 12:32:00 -05:00
Aaron 19b11c236f fix!: Output content script CSS to content-scripts/<name>.css (#140)
BREAKING CHANGE: Content script CSS used to be output to `assets/<name>.css`, but is now `content-scripts/<name>.css` to match the docs.
2023-09-30 12:20:41 -05:00
Aaron Klinker 4b2012c489 fix: Validate transformed manifest correctly 2023-09-30 10:32:57 -05:00
Aaron Klinker 0591050f31 chore: Store user config metadata in memory
This will be used in #16
2023-09-30 10:30:25 -05:00
GitHub Actions b15dc7fc11 chore(release): v0.6.6 2023-09-30 15:05:16 +00:00
Aaron 89d15babc2 feat: Disable opening browser automatically during dev mode (#136) 2023-09-30 08:45:24 -05:00
GitHub Actions c616125689 chore(release): v0.6.5 2023-09-30 03:15:10 +00:00
Aaron e97071b7d7 fix: Support loading tsx entrypoints (#134) 2023-09-29 22:08:20 -05:00
Aaron 1fa049c5a7 Update badge colors 2023-09-29 12:43:28 -05:00
Aaron Klinker b48cee9715 fix: Don't crash when <all_urls> matches is used in dev mode 2023-09-29 08:18:56 -05:00
Aaron Klinker 24e69fe1bf docs: Add link to update extensions using WXT 2023-09-28 12:26:29 -05:00
Aaron Klinker 41e154992a docs: Fix DX section sizing 2023-09-28 12:22:56 -05:00
Aaron Klinker 55707932a9 docs: Add more content to the homepage 2023-09-28 12:08:21 -05:00
Aaron Klinker 08115a40ae Add explicit title to homepage 2023-09-28 10:22:20 -05:00
Aaron Klinker 96be879918 docs: Add tags for SEO and socials 2023-09-28 10:13:18 -05:00
Aaron 1fc4ada66d Add badges to README 2023-09-27 12:41:37 -05:00
Aaron Klinker d27f299641 Remove files from coverage 2023-09-27 12:27:58 -05:00
Aaron 7183114370 chore: Code coverage improvements (#131) 2023-09-27 12:21:50 -05:00
GitHub Actions 7bd940450f chore(release): v0.6.4 2023-09-27 17:12:03 +00:00
Aaron cf5a7d1411 fix: Use execaCommand instead of node:child_process (#130) 2023-09-27 12:06:44 -05:00
Aaron Klinker 5482b2f934 chore: Increase E2E test timeout
GitHub actions windows fails every once in a while due to a timeout, this should reduce those errors
2023-09-27 11:36:37 -05:00
Aaron Klinker c4a6ff928d Remove Aaron from release contributors 2023-09-27 11:34:18 -05:00
Aaron Klinker 455e7f3765 fix(content-scripts): Don't throw an error when including include or exclude options on a content script 2023-09-27 11:29:50 -05:00
Aaron 25b6ab92f5 chore(templates): Add .wxt directory to gitignore (#129) 2023-09-27 11:24:04 -05:00
GitHub Actions 50f6289ac5 chore(release): v0.6.3 2023-09-26 22:28:00 +00:00
Aaron a0e1b4741e feat(client): Add block and addEventListener utils to ContentScriptContext (#128) 2023-09-26 17:19:56 -05:00
GitHub Actions ab672e9dbd chore(release): v0.6.2 2023-09-25 19:31:40 +00:00
Aaron 7b3ea52e02 feat: Show spinner when building entrypoints (#126) 2023-09-25 14:23:19 -05:00
Aaron e0929a68ba docs: Update vite docs to use function 2023-09-25 00:14:38 -05:00
Aaron 4c430725a5 docs: Fix import typo 2023-09-25 00:10:35 -05:00
Aaron 7d745f6ec7 Remove vercel config
Using netlify
2023-09-23 16:17:10 -05:00
Aaron Klinker 10091d7769 Update feature list 2023-09-23 16:05:43 -05:00
Aaron 2e142b3038 feat: --analyze build flag (#125) 2023-09-23 16:00:35 -05:00
GitHub Actions 361bb2dcc8 chore(release): v0.6.1 2023-09-23 02:13:38 +00:00
Aaron 26fca5c0a2 feat: Add transformManifest option (#124) 2023-09-22 21:08:03 -05:00
Aaron 30a61f4384 fix: Don't open browser during development when using WSL (#123) 2023-09-22 20:32:31 -05:00
Aaron Klinker 8e0a189d77 docs: Load extension details from CWS 2023-09-22 14:37:20 -05:00
Aaron Klinker cae44c9732 Update changelog 2023-09-22 12:47:34 -05:00
GitHub Actions fba8f0d017 chore(release): v0.6.0 2023-09-22 17:45:55 +00:00
Aaron Klinker 3f260ee777 fix: Disable dev logs in production
This closes #119
2023-09-22 12:05:05 -05:00
Aaron Klinker 1f6a931b02 fix: Use the same mode for each build step 2023-09-22 11:57:36 -05:00
Aaron 07891d028b feat!: Require a function for vite configuration (#121)
BREAKING CHANGE: The `vite` config option must now be a function. If you were using an object before, change it from `vite: { ... }` to `vite: () => ({ ... })`.
2023-09-22 11:51:55 -05:00
Aaron Klinker 1f448d1bf0 feat: Export ContentScriptContext from wxt/client 2023-09-22 11:00:28 -05:00
Aaron Klinker 7722986537 Update changelog authors 2023-09-22 10:56:22 -05:00
GitHub Actions 4b73168618 chore(release): v0.5.6 2023-09-22 15:48:35 +00:00
Aaron 16cebc538f feat: Add ContentScriptContext util for stopping invalidated content scripts (#120) 2023-09-21 18:48:07 -05:00
Aaron e4aaba98ab Remove defer from umami script 2023-09-20 21:09:30 -05:00
Aaron c190d8c44c Add umami to docs website 2023-09-20 21:08:51 -05:00
GitHub Actions 5801f96f79 chore(release): v0.5.5 2023-09-19 23:00:53 +00:00
Aaron Klinker 92039b855e fix: Automatically replace vite's process.env.NODE_ENV output in lib mode with the mode 2023-09-19 17:56:10 -05:00
GitHub Actions f145e00cc9 chore(release): v0.5.4 2023-09-19 22:39:57 +00:00
Aaron Klinker 87e8df90f5 fix: Don't warn about deep entrypoint subdirectories not being recognized 2023-09-19 17:32:25 -05:00
Aaron Klinker 419fab8193 fix: Recognize background/index.ts as an entrypoint 2023-09-19 17:32:25 -05:00
GitHub Actions 71743b3bf3 chore(release): v0.5.3 2023-09-19 21:40:41 +00:00
Aaron 8e3169eb1e chore: Refactor how config is resolved (#118) 2023-09-19 16:36:00 -05:00
Aaron Klinker 4ec904ea3c fix: Allow function for vite config 2023-09-19 12:53:35 -05:00
GitHub Actions b284305605 chore(release): v0.5.2 2023-09-19 15:55:16 +00:00
Aaron 234bd39a11 fix: Import client utils when getting entrypoint config (#117) 2023-09-19 10:50:41 -05:00
Aaron b5acea106d Bump templates to ^0.5.0 (#116) 2023-09-15 13:57:10 -05:00
GitHub Actions 33ed04ff6b chore(release): v0.5.1 2023-09-15 18:56:09 +00:00
Aaron f51bbc0a68 feat: Include/exclude entrypoints based on target browser (#115) 2023-09-15 13:51:53 -05:00
Aaron 6ee5b22e6c feat: Allow disabling auto-imports (#114) 2023-09-15 12:25:22 -05:00
Aaron Klinker b4de93d2e6 fix: Allow any string for target browser 2023-09-15 12:24:52 -05:00
Aaron Klinker 2570debc5c Update changelog 2023-09-14 15:19:32 -05:00
107 changed files with 3987 additions and 917 deletions
+6
View File
@@ -0,0 +1,6 @@
coverage:
status:
project:
default:
target: 80%
threshold: 1%
+5
View File
@@ -51,6 +51,11 @@ jobs:
- name: Tests
run: pnpm test:coverage --reporter=default --reporter=hanging-process
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
project-templates:
name: Project Templates
runs-on: ubuntu-22.04
+1
View File
@@ -18,3 +18,4 @@ templates/*/pnpm-lock.yaml
templates/*/yarn.lock
templates/*/package-lock.json
docs/config.md
stats.html
+2
View File
@@ -10,3 +10,5 @@ CHANGELOG.md
.prettierignore
*.png
*.svg
*.txt
_gitignore
+195 -83
View File
@@ -1,5 +1,199 @@
# Changelog
## v0.7.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.7.0...v0.7.1)
### 🚀 Enhancements
- `createContentScriptUi` helper ([#143](https://github.com/wxt-dev/wxt/pull/143))
### 📖 Documentation
- Add docs for `createContentScriptUi` ([65fcfc0](https://github.com/wxt-dev/wxt/commit/65fcfc0))
### 🏡 Chore
- **release:** V0.7.1-alpha1 ([2d4983e](https://github.com/wxt-dev/wxt/commit/2d4983e))
## v0.7.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.6...v0.7.0)
### 🚀 Enhancements
- Content script `cssInjectionMode` ([#141](https://github.com/wxt-dev/wxt/pull/141))
### 🩹 Fixes
- Validate transformed manifest correctly ([4b2012c](https://github.com/wxt-dev/wxt/commit/4b2012c))
- ⚠️ Output content script CSS to `content-scripts/<name>.css` ([#140](https://github.com/wxt-dev/wxt/pull/140))
- Reorder typescript paths to give priority to `@` and `~` over `@@` and `~~` ([#142](https://github.com/wxt-dev/wxt/pull/142))
### 🏡 Chore
- Store user config metadata in memory ([0591050](https://github.com/wxt-dev/wxt/commit/0591050))
#### ⚠️ Breaking Changes
- ⚠️ Content script CSS used to be output to `assets/<name>.css`, but is now `content-scripts/<name>.css` to match the docs. ([#140](https://github.com/wxt-dev/wxt/pull/140))
## v0.6.6
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.5...v0.6.6)
### 🚀 Enhancements
- Disable opening browser automatically during dev mode ([#136](https://github.com/wxt-dev/wxt/pull/136))
## v0.6.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.4...v0.6.5)
### 🩹 Fixes
- Don't crash when `<all_urls>` matches is used in dev mode ([b48cee9](https://github.com/wxt-dev/wxt/commit/b48cee9))
- Support loading `tsx` entrypoints ([#134](https://github.com/wxt-dev/wxt/pull/134))
### 📖 Documentation
- Add tags for SEO and socials ([96be879](https://github.com/wxt-dev/wxt/commit/96be879))
- Add more content to the homepage ([5570793](https://github.com/wxt-dev/wxt/commit/5570793))
- Fix DX section sizing ([41e1549](https://github.com/wxt-dev/wxt/commit/41e1549))
- Add link to update extensions using WXT ([24e69fe](https://github.com/wxt-dev/wxt/commit/24e69fe))
### 🏡 Chore
- Code coverage improvements ([#131](https://github.com/wxt-dev/wxt/pull/131))
## v0.6.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.3...v0.6.4)
### 🩹 Fixes
- **content-scripts:** Don't throw an error when including `include` or `exclude` options on a content script ([455e7f3](https://github.com/wxt-dev/wxt/commit/455e7f3))
- Use `execaCommand` instead of `node:child_process` ([#130](https://github.com/wxt-dev/wxt/pull/130))
### 🏡 Chore
- **templates:** Add `.wxt` directory to gitignore ([#129](https://github.com/wxt-dev/wxt/pull/129))
- Increase E2E test timeout ([5482b2f](https://github.com/wxt-dev/wxt/commit/5482b2f))
## v0.6.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.2...v0.6.3)
### 🚀 Enhancements
- **client:** Add `block` and `addEventListener` utils to `ContentScriptContext` ([#128](https://github.com/wxt-dev/wxt/pull/128))
## v0.6.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.1...v0.6.2)
### 🚀 Enhancements
- `--analyze` build flag ([#125](https://github.com/wxt-dev/wxt/pull/125))
- Show spinner when building entrypoints ([#126](https://github.com/wxt-dev/wxt/pull/126))
### 📖 Documentation
- Fix import typo ([4c43072](https://github.com/wxt-dev/wxt/commit/4c43072))
- Update vite docs to use function ([e0929a6](https://github.com/wxt-dev/wxt/commit/e0929a6))
## v0.6.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.6.0...v0.6.1)
### 🚀 Enhancements
- Add `transformManifest` option ([#124](https://github.com/wxt-dev/wxt/pull/124))
### 🩹 Fixes
- Don't open browser during development when using WSL ([#123](https://github.com/wxt-dev/wxt/pull/123))
### 📖 Documentation
- Load extension details from CWS ([8e0a189](https://github.com/wxt-dev/wxt/commit/8e0a189))
## v0.6.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.6...v0.6.0)
### 🚀 Enhancements
- Export `ContentScriptContext` from `wxt/client` ([1f448d1](https://github.com/wxt-dev/wxt/commit/1f448d1))
- ⚠️ Require a function for `vite` configuration ([#121](https://github.com/wxt-dev/wxt/pull/121))
### 🩹 Fixes
- Use the same mode for each build step ([1f6a931](https://github.com/wxt-dev/wxt/commit/1f6a931))
- Disable dev logs in production ([3f260ee](https://github.com/wxt-dev/wxt/commit/3f260ee))
#### ⚠️ Breaking Changes
- ⚠️ The `vite` config option must now be a function. If you were using an object before, change it from `vite: { ... }` to `vite: () => ({ ... })`. ([#121](https://github.com/wxt-dev/wxt/pull/121))
## v0.5.6
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.5...v0.5.6)
### 🚀 Enhancements
- Add `ContentScriptContext` util for stopping invalidated content scripts ([#120](https://github.com/wxt-dev/wxt/pull/120))
## v0.5.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.4...v0.5.5)
### 🩹 Fixes
- Automatically replace vite's `process.env.NODE_ENV` output in lib mode with the mode ([92039b8](https://github.com/wxt-dev/wxt/commit/92039b8))
## v0.5.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.3...v0.5.4)
### 🩹 Fixes
- Recognize `background/index.ts` as an entrypoint ([419fab8](https://github.com/wxt-dev/wxt/commit/419fab8))
- Don't warn about deep entrypoint subdirectories not being recognized ([87e8df9](https://github.com/wxt-dev/wxt/commit/87e8df9))
## v0.5.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.2...v0.5.3)
### 🩹 Fixes
- Allow function for vite config ([4ec904e](https://github.com/wxt-dev/wxt/commit/4ec904e))
### 🏡 Chore
- Refactor how config is resolved ([#118](https://github.com/wxt-dev/wxt/pull/118))
## v0.5.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.1...v0.5.2)
### 🩹 Fixes
- Import client utils when getting entrypoint config ([#117](https://github.com/wxt-dev/wxt/pull/117))
## v0.5.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.5.0...v0.5.1)
### 🚀 Enhancements
- Allow disabling auto-imports ([#114](https://github.com/wxt-dev/wxt/pull/114))
- Include/exclude entrypoints based on target browser ([#115](https://github.com/wxt-dev/wxt/pull/115))
### 🩹 Fixes
- Allow any string for target browser ([b4de93d](https://github.com/wxt-dev/wxt/commit/b4de93d))
## v0.5.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.4.1...v0.5.0)
@@ -29,11 +223,6 @@
- ⚠️ Change default `publicDir` to `<srcDir>/public` ([5f15f9c](https://github.com/wxt-dev/wxt/commit/5f15f9c))
### ❤️ Contributors
- Aaron Klinker <aaronklinker1@gmail.com>
- Aaron <aaronklinker1@gmail.com>
## v0.4.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.4.0...v0.4.1)
@@ -46,10 +235,6 @@
- **init:** Don't show `cd .` when initializing the current directory ([e086374](https://github.com/wxt-dev/wxt/commit/e086374))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.4.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.3.2...v0.4.0)
@@ -99,10 +284,6 @@
- ⚠️ Use relative path aliases inside `.wxt/tsconfig.json` ([#102](https://github.com/wxt-dev/wxt/pull/102))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.3.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.3.1...v0.3.2)
@@ -132,10 +313,6 @@
- List vite version when validating project templates ([ef140dc](https://github.com/wxt-dev/wxt/commit/ef140dc))
- Validate templates using tarball to avoid version conflicts within the `wxt/node_modules` directory ([edfa075](https://github.com/wxt-dev/wxt/commit/edfa075))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.3.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.3.0...v0.3.1)
@@ -167,9 +344,8 @@
- Speed up demo validation ([3a9fd39](https://github.com/wxt-dev/wxt/commit/3a9fd39))
- Fix flakey failure when validating templates ([25677ba](https://github.com/wxt-dev/wxt/commit/25677ba))
### ❤️ Contributors
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
- BeanWei ([@BeanWei](https://github.com/BeanWei))
## v0.3.0
@@ -203,10 +379,6 @@
- ⚠️ Add type safety to `browser.runtime.getURL` ([58a84ec](https://github.com/wxt-dev/wxt/commit/58a84ec))
- ⚠️ Change default `publicDir` to `<rootDir>/public` ([19c0948](https://github.com/wxt-dev/wxt/commit/19c0948))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.2.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.2.4...v0.2.5)
@@ -229,10 +401,6 @@
- Validate project templates ([9ac756f](https://github.com/wxt-dev/wxt/commit/9ac756f))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.2.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.2.3...v0.2.4)
@@ -241,10 +409,6 @@
- Add `wxt zip` command ([#47](https://github.com/wxt-dev/wxt/pull/47))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.2.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.2.2...v0.2.3)
@@ -255,10 +419,6 @@
- Read boolean maniest options from meta tags correctly ([495c5c8](https://github.com/wxt-dev/wxt/commit/495c5c8))
- Some fields cannot be overridden from `config.manifest` ([#46](https://github.com/wxt-dev/wxt/pull/46))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.2.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.2.1...v0.2.2)
@@ -267,10 +427,6 @@
- Register content scripts correctly in dev mode ([2fb5a54](https://github.com/wxt-dev/wxt/commit/2fb5a54))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.2.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.2.0...v0.2.1)
@@ -283,10 +439,6 @@
- Remove HMR log ([90fa6bf](https://github.com/wxt-dev/wxt/commit/90fa6bf))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.2.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.6...v0.2.0)
@@ -311,10 +463,6 @@
- ⚠️ Rename `defineBackgroundScript` to `defineBackground` ([5b48ae9](https://github.com/wxt-dev/wxt/commit/5b48ae9))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.6
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.5...v0.1.6)
@@ -329,10 +477,6 @@
- React and Vue starter templates ([#33](https://github.com/wxt-dev/wxt/pull/33))
- Svelte template ([#34](https://github.com/wxt-dev/wxt/pull/34))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.5
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.4...v0.1.5)
@@ -341,10 +485,6 @@
- Include `vite/client` types ([371be99](https://github.com/wxt-dev/wxt/commit/371be99))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.4
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.3...v0.1.4)
@@ -362,10 +502,6 @@
- Update prettier ignore ([68611ae](https://github.com/wxt-dev/wxt/commit/68611ae))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.3
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.2...v0.1.3)
@@ -387,10 +523,6 @@
- Upgrade to pnpm 8 ([0ce7c9d](https://github.com/wxt-dev/wxt/commit/0ce7c9d))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.1...v0.1.2)
@@ -409,10 +541,6 @@
- Change `config.vite` to `UserConfig` ([ef6001e](https://github.com/wxt-dev/wxt/commit/ef6001e))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.1.0...v0.1.1)
@@ -422,10 +550,6 @@
- Allow dashes in entrypoint names ([2e51e73](https://github.com/wxt-dev/wxt/commit/2e51e73))
- Unable to read entrypoint options ([#28](https://github.com/wxt-dev/wxt/pull/28))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.1.0
Initial release of WXT. Full support for production builds and initial toolkit for development:
@@ -449,10 +573,6 @@ Initial release of WXT. Full support for production builds and initial toolkit f
- Create github release ([b7c078f](https://github.com/wxt-dev/wxt/commit/b7c078f))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.0.2
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.0.1...v0.0.2)
@@ -483,10 +603,6 @@ Initial release of WXT. Full support for production builds and initial toolkit f
- Rename `templates` to `virtual-modules` ([#24](https://github.com/wxt-dev/wxt/pull/24))
- Update cli screenshot ([54eb118](https://github.com/wxt-dev/wxt/commit/54eb118))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
## v0.0.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.0.0...v0.0.1)
@@ -556,7 +672,3 @@ Initial release of WXT. Full support for production builds and initial toolkit f
- Create validation workflow ([#12](https://github.com/wxt-dev/wxt/pull/12))
- Create release workflow ([#13](https://github.com/wxt-dev/wxt/pull/13))
### ❤️ Contributors
- Aaron Klinker ([@aklinker1](https://github.com/aklinker1))
+15 -1
View File
@@ -3,6 +3,20 @@
<span>WXT</span>
</h1>
<p align="center">
<a href="https://www.npmjs.com/package/wxt" target="_blank">
<img alt="npm" src="https://img.shields.io/npm/v/wxt?labelColor=black&color=%234fa048">
</a>
<span> </span>
<img alt="npm" src="https://img.shields.io/npm/dm/wxt?labelColor=black&color=%234fa048">
<span> </span>
<img alt="NPM" src="https://img.shields.io/npm/l/wxt?labelColor=black&color=%234fa048">
<span> </span>
<a href="https://codecov.io/github/wxt-dev/wxt" target="_blank">
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/wxt-dev/wxt?labelColor=black&color=%234fa048">
</a>
</p>
<p align="center">
<span>Next-gen framework for developing web extensions.</span>
<br/>
@@ -44,10 +58,10 @@ Or see the [installation guide](https://wxt.dev/get-started/installation.html) t
- ⬇️ Download and bundle remote URL imports
- 🎨 Frontend framework agnostic: works with Vue, React, Svelte, etc
- 🖍️ Quickly bootstrap a new project
- 📏 Bundle analysis
### Coming Soon
- 📏 Bundle analysis
- 🤖 Automated publishing
## Contributors
+5 -1
View File
@@ -16,9 +16,13 @@
"postinstall": "pnpm -w build && wxt prepare"
},
"dependencies": {
"webextension-polyfill": "^0.10.0"
"webextension-polyfill": "^0.10.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.14",
"@types/react-dom": "^18.2.6",
"@types/webextension-polyfill": "^0.10.0",
"sass": "^1.64.0",
"wxt": "workspace:*"
+2
View File
@@ -19,4 +19,6 @@ export default defineBackground(() => {
browser.i18n.getMessage('hello', 'Aaron');
browser.i18n.getMessage('bye', ['Aaron']);
browser.i18n.getMessage('@@extension_id');
console.log('WXT MODE:', import.meta.env.MODE);
});
@@ -0,0 +1,25 @@
import ReactDOM from 'react-dom/client';
export default defineContentScript({
matches: ['<all_urls>'],
async main(ctx) {
console.log(browser.runtime.id);
logId();
console.log('WXT MODE:', import.meta.env.MODE);
const n = (Math.random() * 100).toFixed(1);
ctx.setInterval(() => {
console.log(n, browser.runtime.id);
}, 1e3);
const container = document.createElement('div');
document.body.append(container);
ReactDOM.createRoot(container).render(<SomeComponent />);
},
});
function SomeComponent() {
return <div>Some component</div>;
}
+2
View File
@@ -3,3 +3,5 @@ import 'url:https://code.jquery.com/jquery-3.7.1.slim.min.js';
console.log(browser.runtime.id);
logId();
console.log(2);
console.log('WXT MODE:', import.meta.env.MODE);
@@ -1,10 +0,0 @@
import '../../common/style.css';
export default defineContentScript({
matches: ['*://*/*'],
async main() {
console.log(browser.runtime.id);
logId();
mountContentScriptUi();
},
});
+24
View File
@@ -0,0 +1,24 @@
import '../../common/style.css';
import './style.css';
export default defineContentScript({
matches: ['https://*.duckduckgo.com/*'],
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createContentScriptUi(ctx, {
name: 'demo-ui',
type: 'inline',
append: 'before',
anchor: 'form[role=search]',
mount: (container) => {
const app = document.createElement('div');
app.textContent = 'Custom content script UI';
container.append(app);
},
});
ui.mount();
setTimeout(ui.remove, 5000);
},
});
+11
View File
@@ -0,0 +1,11 @@
:root {
color-scheme: dark;
color: indianred;
}
html {
background-color: black;
}
div {
padding: 16px;
}
+5 -1
View File
@@ -1,3 +1,7 @@
{
"extends": ["../tsconfig.base.json", "./.wxt/tsconfig.json"]
"extends": ["../tsconfig.base.json", "./.wxt/tsconfig.json"],
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx"
}
}
-3
View File
@@ -2,9 +2,6 @@ import { defineConfig } from 'wxt';
export default defineConfig({
srcDir: 'src',
storeIds: {
chrome: '123',
},
manifest: {
default_locale: 'en',
},
+110 -43
View File
@@ -1,26 +1,68 @@
<script lang="ts" setup>
const extensions = [
{
name: 'GitHub: Better Line Counts',
description: 'Remove generated files from GitHub line counts.',
icon: 'https://lh3.googleusercontent.com/GcffNyCJaxT2G9dsQCJHhUEMlu_E0vEzph5cLPrQj7UHKat7QyCzGu69Dmp_DDUL8rY-bPMFJceQarS1wcqdwTalTg=s256',
link: 'https://chrome.google.com/webstore/detail/github-better-line-counts/ocfdgncpifmegplaglcnglhioflaimkd',
},
import { computed } from 'vue';
import useListExtensionDetails, {
ChromeExtension,
} from '../composables/useListExtensionDetails';
// Add extension IDs here. Order doesn't matter, will be sorted by weekly active users
const chromeExtensionIds = [
'ocfdgncpifmegplaglcnglhioflaimkd', // GitHub: Better Line Counts
'mgmdkjcljneegjfajchedjpdhbadklcf', // Anime Skip Player
];
const { data } = useListExtensionDetails(chromeExtensionIds);
const sortedExtensions = computed(() =>
!data.value
? undefined
: [...data.value].sort((l, r) => r.weeklyActiveUsers - l.weeklyActiveUsers),
);
function getStoreUrl(extension: ChromeExtension) {
const url = new URL(extension.storeUrl);
url.searchParams.set('utm_source', 'wxt.dev');
return url.href;
}
</script>
<template>
<section class="vp-doc">
<div class="container">
<h2>Who's Using WXT?</h2>
<p>
Battle tested and ready for production. Explore chrome extensions made
with WXT.
</p>
<ul>
<li v-for="extension of extensions">
<img :src="extension.icon" :alt="`${extension.name} icon`" />
<a :href="extension.link" target="_blank">{{ extension.name }}</a>
<small>{{ extension.description }}</small>
<li v-for="extension of sortedExtensions">
<img
:src="extension.iconUrl"
:alt="`${extension.name} icon`"
referrerpolicy="no-referrer"
/>
<div>
<a
:href="getStoreUrl(extension)"
target="_blank"
:title="extension.name"
>{{ extension.name }}</a
>
<p class="description" :title="extension.shortDescription">
{{ extension.shortDescription }}
</p>
<p class="user-count">
{{ extension.weeklyActiveUsers.toLocaleString() }} users
</p>
</div>
</li>
</ul>
<p>Open a PR to add your extension to the list!</p>
<p class="centered pr">
<a
href="https://github.com/wxt-dev/wxt/edit/main/docs/.vitepress/components/UsingWxtSection.vue"
target="_blank"
>Open a PR</a
>
to add your extension to the list!
</p>
</div>
</section>
</template>
@@ -30,10 +72,6 @@ const extensions = [
padding: 0 24px;
}
h2 {
margin-bottom: 32px;
}
@media (min-width: 640px) {
.vp-doc {
padding: 0 48px;
@@ -53,57 +91,86 @@ h2 {
flex-direction: column;
}
img {
width: 96px;
height: 96px;
margin-bottom: 16px;
li img {
width: 116px;
height: 116px;
padding: 16px;
border-radius: 8px;
background-color: var(--vp-c-default-soft);
}
ul {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(1, 1fr);
align-items: stretch;
gap: 16px;
list-style: none;
margin: 0;
margin: 16px 0;
padding: 0;
}
@media (min-width: 640px) {
ul {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 960px) {
ul {
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(2, 1fr);
}
}
li {
margin: 0 !important;
padding: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--vp-c-bg-soft);
border-radius: 12px;
flex: 1;
}
a,
small {
text-align: center;
}
small {
opacity: 50%;
gap: 16px;
}
p {
.centered {
text-align: center;
opacity: 50%;
}
a {
color: var(--vp-c-text-1);
li a,
li .user-count,
li .description {
padding: 0;
margin: 0;
}
li .user-count {
opacity: 70%;
text-align: right;
width: 100%;
font-size: small;
}
li a {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
cursor: pointer;
padding: 0;
margin: 0;
text-decoration: none;
}
li a:hover {
text-decoration: underline;
}
li div {
flex: 1;
display: flex;
flex-direction: column;
}
li .description {
opacity: 90%;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
flex-grow: 1;
}
.pr {
opacity: 70%;
}
</style>
@@ -0,0 +1,53 @@
import { ref } from 'vue';
export interface ChromeExtension {
id: string;
name: string;
iconUrl: string;
weeklyActiveUsers: number;
shortDescription: string;
storeUrl: string;
}
const operationName = 'WxtDocsUsedBy';
const query = `query ${operationName}($ids:[String!]!) {
chromeExtensions(ids: $ids) {
id
name
iconUrl
weeklyActiveUsers
shortDescription
storeUrl
}
}`;
export default function (ids: string[]) {
const data = ref<ChromeExtension[]>();
const err = ref<unknown>();
fetch('https://queue.wxt.dev/api', {
method: 'POST',
body: JSON.stringify({
operationName,
query,
variables: { ids },
}),
})
.then(async (res) => {
const {
data: { chromeExtensions },
} = await res.json();
data.value = chromeExtensions;
err.value = undefined;
})
.catch((error) => {
console.error(error);
data.value = undefined;
err.value = error;
});
return {
data,
err,
};
}
+35 -2
View File
@@ -1,19 +1,52 @@
import { defineConfig } from 'vitepress';
import { generateConfigDocs } from './plugins/generate-config-docs';
const title = 'Next-gen Web Extension Framework';
const titleSuffix = ' WXT';
const description =
"WXT provides the best developer experience, making it quick, easy, and fun to develop chrome extensions for all browsers. With built-in utilties for building, zipping, and publishing your extension, it's easy to get started.";
const ogTitle = `${title}${titleSuffix}`;
const ogUrl = 'https://wxt.dev';
const ogImage = 'https://wxt.dev/social-preview.png';
// https://vitepress.dev/reference/site-config
export default defineConfig({
titleTemplate: `:title${titleSuffix}`,
title: 'WXT',
description,
vite: {
clearScreen: false,
plugins: [generateConfigDocs()],
},
description: 'Next gen framework for developing web extensions',
lastUpdated: true,
sitemap: {
hostname: 'https://wxt.dev',
},
head: [
['meta', { property: 'og:type', content: 'website' }],
['meta', { property: 'og:title', content: ogTitle }],
['meta', { property: 'og:image', content: ogImage }],
['meta', { property: 'og:url', content: ogUrl }],
['meta', { property: 'og:description', content: description }],
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
[
'script',
{
async: '',
'data-website-id': 'c1840c18-a12c-4a45-a848-55ae85ef7915',
src: 'https://umami.aklinker1.io/script.js',
},
],
],
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
logo: '/logo.svg',
logo: {
src: '/logo.svg',
alt: 'WXT logo',
},
editLink: {
pattern: 'https://github.com/wxt-dev/wxt/edit/main/docs/:path',
},
+11 -12
View File
@@ -23,11 +23,16 @@ const LEAF_PATHS = ['imports', 'vite', 'server'];
/**
* Override any types that resolve to `import(...)` instead of their type names when calling
* `type.getText()`
* `type.getText()`.
*
* This also stops any further type inspection for objects, meaning the object will be documented,
* not it's properties.
*/
const CUSTOM_TYPES = {
manifest:
'Manifest | Promise<Manifest> | () => Manifest | () => Promise<Manifest>',
imports: "false | Partial<import('unimport').UnimportOptions>",
vite: "Omit<import('vite').UserConfig, 'root' | 'configFile' | 'mode'>",
};
export function generateConfigDocs() {
@@ -61,18 +66,12 @@ export function generateConfigDocs() {
throw Error('Unsupported type node: ' + node.getKindName());
}
if (type.isObject() && !type.isArray()) {
return (
type
.getProperties()
// .sort((l, r) => l.getName().localeCompare(r.getName()))
.flatMap((property) => {
const childPath = [...path, property.getName()];
if (LEAF_PATHS.includes(childPath.join('.'))) return [];
if (type.isObject() && !type.isArray() && !CUSTOM_TYPES[pathStr]) {
return type.getProperties().flatMap((property) => {
const childPath = [...path, property.getName()];
return getDocsFor(childPath, property.getDeclarations()[0]);
})
);
return getDocsFor(childPath, property.getDeclarations()[0]);
});
}
if ('getJsDocs' in node) {
+1 -1
View File
@@ -1,5 +1,5 @@
# API Reference
:::warning 🚧&ensp;Under construction
This documentation does not exist yet.
This documentation does not exist yet. All APIs are documented with JSDoc, so for now, you can view the documentation in your editor.
:::
-6
View File
@@ -2,12 +2,6 @@
Bootstrap a new project, start from scratch, or migrate an existing project.
:::warning 🚧&ensp;WSL Support
**_WXT does not support [Windows Subsystem for Linux](https://learn.microsoft.com/en-us/windows/wsl/) yet_**. See [Issue #55](https://github.com/wxt-dev/wxt/issues/55) to track progress.
In the meantime, you can use `cmd` instead.
:::
## Bootstrap Project
:::code-group
+7
View File
@@ -1,3 +1,10 @@
---
head:
- - link
- rel: canonical
href: https://wxt.dev
---
# Introduction
WXT is a free and open source framework for building web extensions in an conventional, intuative, and safe way **_for all browsers_**.
+1
View File
@@ -13,6 +13,7 @@ Some WXT APIs can be used without importing them:
- [`browser`](/config.md#browser) from `wxt/browser`, a small wrapper around `webextension-polyfill`
- [`defineContentScript`](/config.md#defiencontentscript) from `wxt/client`
- [`defineBackground`](/config.md#definebackgroundscript) from `wxt/client`
- [`createContentScriptUi`](/config.md#createcontentscriptui) from `wxt/client`
And more. All [`wxt/client`](/config.md#wxtclient) APIs can be used without imports.
+5
View File
@@ -9,6 +9,7 @@ For MV2, the background is added as a script to the background page. For MV3, th
<EntrypointPatterns
:patterns="[
['background.ts', 'background.js'],
['background/index.ts', 'background.js'],
]"
/>
@@ -32,6 +33,10 @@ export default defineBackground({
persistent: undefined | true | false,
type: undefined | 'module',
// Set include/exclude if the background should be removed from some builds
include: undefined | string[],
exclude: undefined | string[],
// Executed when background is loaded
main() {
// ...
+3 -2
View File
@@ -13,8 +13,6 @@
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
@@ -22,6 +20,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+213 -2
View File
@@ -30,7 +30,14 @@ export default defineContentScript({
matchOriginAsFallback: undefined | true | false,
world: undefined | 'ISOLATED' | 'MAIN',
main() {
// Set include/exclude if the background should be removed from some builds
include: undefined | string[],
exclude: undefined | string[],
// Configure how CSS is injected onto the page
cssInjectionMode: undefined | "manifest" | "manual" | "ui",
main(ctx) {
// Executed when content script is loaded
},
});
@@ -40,6 +47,43 @@ export default defineContentScript({
When defining multiple content scripts, content script entrypoints that have the same set of options will be merged into a single `content_script` item in the manifest.
## Context
Old content scripts are not automatically stopped when an extension updates and reloads. Often, this leads to "Invalidated context" errors in production when a content script from an old version of your extension tries to use a extension API.
WXT provides a utility for managing this process: `ContentScriptContext`. An instance of this class is provided to you automatically inside the `main` function of your content script.
```ts
export default defineContentScript({
// ...
main(ctx: ContentScriptContext) {
// Add custom listeners for stopping work
ctx.onInvalidated(() => {
// ...
});
// Stop fetch requests
fetch('...url', { signal: ctx.signal });
// Timeout utilities
ctx.setTimeout(() => {
// ...
}, 5e3);
ctx.setInterval(() => {
// ...
}, 60e3);
},
});
```
The class extends [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) and provides other utilities for stopping a content script's logic once it becomes invalidated.
:::tip
When working with content scripts, **you should always use the `ctx` object to stop any async or future work.**
This prevents old content scripts from interfering with new content scripts, and prevents error messages from the console in production.
:::
## CSS
To include CSS with your content script, import the CSS file at the top of your entrypoint.
@@ -59,7 +103,8 @@ import './style.css';
export default defineContentScript({
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
main() {
main(ctx) {
// ...
},
});
@@ -79,3 +124,169 @@ Any styles imported in your content script will be added to that content script'
]
}
```
To disable this behavior, set `cssInjectionMode` to `"manual"` or `"ui"`.
```ts
export default defineContentScript({
matches: ['*://google.com/*', '*://duckduckgo.com/*'],
cssInjectionMode: 'manual',
main(ctx) {
// ...
},
});
```
## UI
WXT provides a utility function, `createContentScriptUi` to simplify mounting a UI from a content script. Internally, it uses the `ShadowRoot` API to isolate your CSS from the webpages.
`createContentScriptUi` requires a `ContentScriptContext` so that when the context is invalidated, the UI is automatically removed from the webpage.
:::details When to use `createContentScriptUi`
You should only use `createContentScriptUi` if you want your UI's styles isolated from the webpages. If you want to create a more "integrated" UI that uses the page's styles, you can just use the regular JS API's to append your UI to the page.
```ts
const ui = document.createElement('div');
const anchor = document.querySelector('#anchor-selector');
anchor.append(ui);
```
:::
### Usage
To use `createContentScriptUi`, follow these steps:
1. Import your CSS file at the top of your content script
2. Set `cssInjectionMode: "ui"` inside `defineContentScript`
3. Call `createContentScriptUi`
4. Call `ui.mount()` to add the UI to the webpage
Here's a basic example:
```ts
// entrypoints/ui.content/index.ts
import './style.css';
export default defineContentScript({
// ...
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createContentScriptUi(ctx, {
name: 'example-ui',
type: 'inline',
anchor: '#some-element',
append: 'after',
mount(container) {
// Mount UI inside `container`...
},
});
// Yoy must call `mount` to add the UI to the page.
ui.mount();
},
});
```
If you're using a frontend framework, you'll also need to include an `onRemoved` callback:
:::code-group
```ts [Vue]
import { createApp } from 'vue';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Create a new app and mount it inside the container
const app = createApp(...);
app.mount(container);
return app;
},
onRemove(app) {
// When the UI is removed from the DOM, call unmount to stop the app
app.unmount();
},
});
```
```ts [React]
import ReactDOM from 'react-dom/client';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Create a root using the container and render your app
const root = ReactDOM.createRoot(container);
root.render(...);
return root;
},
onRemove(root) {
// When the UI is removed from the DOM, call unmount to stop the app
root.unmount();
},
});
```
```ts [Svelte]
import App from './App.svelte';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Mount your app component inside the container
return new App({
target: container,
});
},
onRemove(app) {
// When the UI is removed from the DOM, call $destroy to stop the app
app.$destroy();
},
});
```
```ts [Solid]
import { render } from 'solid-js/web';
createContentScriptUi(ctx, {
// ...
mount(container) {
// Render your app component into the container
return render(() => ..., container)
},
onRemove(unmount) {
// When the UI is removed from the DOM, call unmount to stop the app
unmount();
},
});
```
:::
### `anchor`
The anchor dictates where the UI will be mounted.
### `append`
Customize where the UI get's appended to the DOM, relative to the `anchor` element.
### `type`
There are 3 types of UI's you can mount.
- `inline`: Shows up inline based on the `anchor` and `append` options
- `overlay`: Shows up inline, but styled to be 0px by 0px, with overflow visible. This causes the UI to overlay on top of the webpage's content
- `modal`: A fullscreen overlay that covers the entire screen, regardless of where it's anchored.
> TODO: Add visualization of the different UI types.
### Overlay `alignment`
Because the overlay UI type results in a 0px by 0px container being added to the webpage, the `alignment` option allows you to configure which corner of your UI is aligned with the 0x0 element.
> TODO: Add visualization of the different alignments.
+3
View File
@@ -22,6 +22,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+1 -1
View File
@@ -14,7 +14,7 @@ And that's it! Your extension now supports Chrome, Firefox, Safari, Edge, and ot
The `browser` variable is available globally via [auto-imports](/guide/auto-imports.md), or it can be imported manually.
```ts
import browser from 'wxt/browser';
import { browser } from 'wxt/browser';
```
The `wxt/browser` module exports a customized version of `webextension-polyfill`'s browser with improved typing.
+3 -2
View File
@@ -13,8 +13,6 @@
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
@@ -22,6 +20,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+3 -2
View File
@@ -13,8 +13,6 @@
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
@@ -22,6 +20,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+3
View File
@@ -23,6 +23,9 @@
<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" />
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+3
View File
@@ -29,6 +29,9 @@
}"
/>
<meta name="manifest.type" content="page_action|browser_action" />
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+3 -2
View File
@@ -19,8 +19,6 @@ Firefox does not support sandboxed pages.
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
@@ -28,6 +26,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+3 -2
View File
@@ -19,8 +19,6 @@ Firefox does not support sidepanel pages.
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
@@ -28,6 +26,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+3 -2
View File
@@ -29,8 +29,6 @@ console.log(url); // "chrome-extension://<id>/<name>.html"
## Definition
Plain old HTML file.
```html
<!doctype html>
<html lang="en">
@@ -38,6 +36,9 @@ Plain old HTML file.
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Title</title>
<!-- Set include/exclude if the page should be removed from some builds -->
<meta name="manifest.include" content="['chrome', ...]" />
<meta name="manifest.exclude" content="['chrome', ...]" />
</head>
<body>
<!-- ... -->
+4 -4
View File
@@ -10,9 +10,9 @@ All of Vite's config can be customized by setting the `vite` configuration in yo
import { defineConfig } from 'wxt';
export default defineConfig({
vite: {
vite: () => ({
// Same as `defineConfig({ ... })` inside vite.config.ts
},
}),
});
```
@@ -26,10 +26,10 @@ All plugins should work in WXT, but it is worth pointing out that since WXT orch
import { defineConfig } from 'wxt';
export default defineConfig({
vite: {
vite: () => ({
plugins: [
// ...
],
},
}),
});
```
+53 -13
View File
@@ -1,12 +1,12 @@
---
# https://vitepress.dev/reference/default-theme-home-page
layout: home
titleTemplate: 'Next Generation Web Extension Framework'
title: Next-gen Web Extension Framework
hero:
name: WXT
text: Next-gen Web Extension Framework
tagline: Powered by Vite, inspired by Nuxt.
tagline: An open source tool that makes Chrome Extension devlopment faster than ever before.
image:
src: /hero-logo.svg
alt: WXT
@@ -21,37 +21,77 @@ hero:
features:
- icon: 🌐
title: Supported Browsers
details: Chrome, Firefox, Edge, Safari, and any Chromium based browser.
details: WXT will build extensions for Chrome, Firefox, Edge, Safari, and any Chromium based browser.
link: /get-started/build-targets
linkText: Read docs
- icon: ✅
title: MV2 and MV3
details: Supports both manifest versions for each browser.
details: Build Manifest V2 or V3 extensions for any browser using the same codebase.
link: /get-started/build-targets#target-manifest-version
linkText: Read docs
- icon: ⚡
title: Fast Dev Mode
details: HMR for UIs and fast reload for background and content scripts.
details: Lighting fast HMR for UI development and fast reloads for content/background scripts enables faster iterations.
link: /get-started/introduction.html#development
linkText: Learn more
- icon: 📂
title: File Based Entrypoints
details: Manifest is generated based on files inside the project.
details: Manifest is generated based on files in the project with inline configuration.
link: /get-started/entrypoints
linkText: See project structure
- icon: 🚔
title: TypeScript
details: Scale projects with full TS support.
details: Create large projects with confidence using TS by default.
- icon: 🦾
title: Auto-imports
details: Nuxt-like auto-imports to speed up development.
link: /guide/auto-imports
linkText: Read docs
- icon: ⬇️
title: Bundle Remote Code
details: Downloads and bundles remote code imported from URLs.
link: guide/remote-code
linkText: Read docs
- icon: 🎨
title: Frontend Framework Agnostic
details: Works with any front-end framework with a Vite plugin.
link: /get-started/configuration#frontend-frameworks
linkText: Add a framework
- icon: 🖍️
title: Bootstrap a New Project
details: Comes with starter templates for all major frontend frameworks.
- icon: 🤖
title: Automated Publishing
details: 'TODO: Automatically zip, upload, and release extensions.'
details: Get started quickly with several awesome project templates.
link: /get-started/installation#bootstrap-project
linkText: See templates
- icon: 📏
title: Bundle Analysis
details: 'TODO: Tools for analyizing the final extension bundle.'
details: Tools for analyizing the final extension bundle and minimizing your extension's size.
- icon: 🤖
title: Automated Publishing
details: 'Coming soon. Automatically zip, upload, and release extensions.'
---
<UsingWxtSection />
<section class="vp-doc">
<div class="container">
<h2>Put <span style="color: var(--vp-c-brand-1)">Developer Experience</span> First</h2>
<p>
WXT's simplifies the chrome extension development process by providing tools for zipping and publishing, the best-in-class dev mode, an opinionated project structure, and more. Iterate faster, develop features not build scripts, and use everything the JS ecosystem has to offer.
</p>
<div style="margin: auto; width: 100%; max-width: 700px; text-align: center">
<img src="./assets/cli-output.png" alt="Screenshot of WXT's build output" />
<small>
And who doesn't appreciate a beautiful CLI?
</small>
</div>
</div>
</section>
<ClientOnly>
<UsingWxtSection />
</ClientOnly>
<style scoped>
.container {
margin: 0 auto;
max-width: 1152px;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /api.html
Disallow: /config.html
Sitemap: https://wxt.dev/sitemap.xml
+70 -42
View File
@@ -2,60 +2,88 @@ import { describe, it, expect } from 'vitest';
import { TestProject } from '../utils';
describe('Auto Imports', () => {
it('should output types for entrypoint paths', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/background.ts',
'export default defineBackground(() => {})',
);
project.addFile(
'entrypoints/overlay.content.ts',
'export default defineContentScript(() => {})',
);
project.addFile('entrypoints/popup.html', '<html></html>');
describe('imports: { ... }', () => {
it('should generate a declaration file, imports.d.ts, for auto-imports', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
await project.build();
expect(await project.serializeFile('.wxt/types/paths.d.ts'))
.toMatchInlineSnapshot(`
".wxt/types/paths.d.ts
expect(await project.serializeFile('.wxt/types/imports.d.ts'))
.toMatchInlineSnapshot(`
".wxt/types/imports.d.ts
----------------------------------------
// Generated by wxt
export {}
declare global {
const ContentScriptContext: typeof import('wxt/client')['ContentScriptContext']
const browser: typeof import('wxt/browser')['browser']
const createContentScriptUi: typeof import('wxt/client')['createContentScriptUi']
const defineBackground: typeof import('wxt/client')['defineBackground']
const defineConfig: typeof import('wxt')['defineConfig']
const defineContentScript: typeof import('wxt/client')['defineContentScript']
}
"
`);
});
it('should include auto-imports in the project', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
expect(await project.serializeFile('.wxt/wxt.d.ts'))
.toMatchInlineSnapshot(`
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
import \\"wxt/browser\\";
declare module \\"wxt/browser\\" {
type PublicPath =
| \\"/background.js\\"
| \\"/content-scripts/overlay.js\\"
| \\"/popup.html\\"
export interface WxtRuntime extends Runtime.Static {
getURL(path: PublicPath): string;
}
}
/// <reference types=\\"vite/client\\" />
/// <reference types=\\"./types/imports.d.ts\\" />
/// <reference types=\\"./types/paths.d.ts\\" />
/// <reference types=\\"./types/i18n.d.ts\\" />
/// <reference types=\\"./types/globals.d.ts\\" />
"
`);
});
});
it('should make some client utils auto-importable', async () => {
const project = new TestProject();
project.addFile('entrypoints/popup.html', `<html></html>`);
describe('imports: false', () => {
it('should not generate a imports.d.ts file', async () => {
const project = new TestProject();
project.setConfigFileConfig({
imports: false,
});
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
await project.build();
expect(await project.serializeFile('.wxt/types/imports.d.ts'))
.toMatchInlineSnapshot(`
".wxt/types/imports.d.ts
expect(await project.fileExists('.wxt/types/imports.d.ts')).toBe(false);
});
it('should not include imports.d.ts in the type references', async () => {
const project = new TestProject();
project.setConfigFileConfig({
imports: false,
});
project.addFile('entrypoints/popup.html', `<html></html>`);
await project.build();
expect(
await project.serializeFile('.wxt/wxt.d.ts'),
).toMatchInlineSnapshot(
`
".wxt/wxt.d.ts
----------------------------------------
// Generated by wxt
export {}
declare global {
const browser: typeof import('wxt/browser')['browser']
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']
}
/// <reference types=\\"vite/client\\" />
/// <reference types=\\"./types/paths.d.ts\\" />
/// <reference types=\\"./types/i18n.d.ts\\" />
/// <reference types=\\"./types/globals.d.ts\\" />
"
`);
`,
);
});
});
});
+1 -1
View File
@@ -40,5 +40,5 @@ describe('Init command', () => {
"wxt.config.ts",
]
`);
}, 30e3);
});
});
+276 -74
View File
@@ -193,117 +193,319 @@ describe('Manifest Content', () => {
});
});
it('should group content scripts and styles together based on their matches and run_at', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/one.content/index.ts',
`import "./style.css";
describe('content_scripts', () => {
it('should group content scripts and styles together based on their matches and run_at', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/one.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
main: () => {},
})`,
);
project.addFile('entrypoints/one.content/style.css', `body { color: red }`);
project.addFile(
'entrypoints/two.content/index.ts',
`import "./style.css";
);
project.addFile(
'entrypoints/one.content/style.css',
`body { color: red }`,
);
project.addFile(
'entrypoints/two.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
runAt: "document_end",
main: () => {},
})`,
);
project.addFile(
'entrypoints/two.content/style.css',
`body { color: green }`,
);
project.addFile(
'entrypoints/three.content/index.ts',
`import "./style.css";
);
project.addFile(
'entrypoints/two.content/style.css',
`body { color: green }`,
);
project.addFile(
'entrypoints/three.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://google.com/*"],
runAt: "document_end",
main: () => {},
})`,
);
project.addFile(
'entrypoints/three.content/style.css',
`body { color: blue }`,
);
project.addFile(
'entrypoints/four.content/index.ts',
`import "./style.css";
);
project.addFile(
'entrypoints/three.content/style.css',
`body { color: blue }`,
);
project.addFile(
'entrypoints/four.content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["*://duckduckgo.com/*"],
runAt: "document_end",
main: () => {},
})`,
);
project.addFile(
'entrypoints/four.content/style.css',
`body { color: yellow }`,
);
);
project.addFile(
'entrypoints/four.content/style.css',
`body { color: yellow }`,
);
await project.build();
await project.build();
const manifest = await project.getOutputManifest();
const manifest = await project.getOutputManifest();
expect(manifest.content_scripts).toContainEqual({
matches: ['*://duckduckgo.com/*'],
run_at: 'document_end',
css: ['assets/four.css'],
js: ['content-scripts/four.js'],
expect(manifest.content_scripts).toContainEqual({
matches: ['*://duckduckgo.com/*'],
run_at: 'document_end',
css: ['content-scripts/four.css'],
js: ['content-scripts/four.js'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
run_at: 'document_end',
css: ['content-scripts/three.css', 'content-scripts/two.css'],
js: ['content-scripts/three.js', 'content-scripts/two.js'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
css: ['content-scripts/one.css'],
js: ['content-scripts/one.js'],
});
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
run_at: 'document_end',
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: ['assets/one.css'],
js: ['content-scripts/one.js'],
});
});
it('should add to any content scripts declared in wxt.config.ts', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/one.content/index.ts',
`export default defineContentScript({
it('should add to any content scripts declared in wxt.config.ts', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/one.content/index.ts',
`export default defineContentScript({
matches: ["*://google.com/*"],
main: () => {},
})`,
);
project.addFile(
'entrypoints/two.content/style.css',
`body {
);
project.addFile(
'entrypoints/two.content/style.css',
`body {
background-color: red;
}`,
);
project.setConfigFileConfig({
manifest: {
content_scripts: [
{
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
},
],
},
});
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.content_scripts).toContainEqual({
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
js: ['content-scripts/one.js'],
});
});
it('should add a CSS entry when cssInjectionMode is undefined', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
main() {},
});`,
);
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".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\\":[\\"https://*.google.com/*\\"],\\"css\\":[\\"content-scripts/content.css\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
`);
});
it('should add a CSS entry when cssInjectionMode is "manifest"', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "manifest",
main() {},
});`,
);
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".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\\":[\\"https://*.google.com/*\\"],\\"css\\":[\\"content-scripts/content.css\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
`);
});
it('should not add an entry for CSS when cssInjectionMode is "manual"', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "manual",
main() {},
});`,
);
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".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\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
`);
});
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV2', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "ui",
main() {},
});`,
);
await project.build({
manifestVersion: 2,
});
expect(await project.serializeFile('.output/chrome-mv2/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv2/manifest.json
----------------------------------------
{\\"manifest_version\\":2,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[\\"content-scripts/content.css\\"]}"
`);
});
it('should not add a content script entry for CSS when cssInjectionMode is "ui", but add a web_accessible_resources entry for MV3', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "ui",
main() {},
});`,
);
await project.build({
manifestVersion: 3,
});
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".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\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}],\\"web_accessible_resources\\":[{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}]}"
`);
});
});
it('should combine web accessible resources', async () => {
const project = new TestProject();
project.addFile(
'entrypoints/content/style.css',
'body { background-color: red; }',
);
project.addFile(
'entrypoints/content/index.ts',
`import "./style.css";
export default defineContentScript({
matches: ["https://*.google.com/*"],
cssInjectionMode: "ui",
main() {},
});`,
);
project.setConfigFileConfig({
manifest: {
content_scripts: [
{
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
},
web_accessible_resources: [
{ resources: ['one.png'], matches: ['https://one.com/*'] },
],
},
});
await project.build();
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"web_accessible_resources\\":[{\\"resources\\":[\\"one.png\\"],\\"matches\\":[\\"https://one.com/*\\"]},{\\"resources\\":[\\"content-scripts/content.css\\"],\\"matches\\":[\\"https://*.google.com/*\\"]}],\\"content_scripts\\":[{\\"matches\\":[\\"https://*.google.com/*\\"],\\"js\\":[\\"content-scripts/content.js\\"]}]}"
`);
});
it('should respect the transformManifest option', async () => {
const project = new TestProject();
project.addFile(
'wxt.config.ts',
`import { defineConfig } from 'wxt';
export default defineConfig({
transformManifest(manifest) {
manifest.author = "Custom Author"
}
})`,
);
await project.build();
const manifest = await project.getOutputManifest();
expect(manifest.content_scripts).toContainEqual({
css: ['content-scripts/two.css'],
matches: ['*://*.google.com/*'],
});
expect(manifest.content_scripts).toContainEqual({
matches: ['*://google.com/*'],
js: ['content-scripts/one.js'],
});
const output = await project.serializeFile(
'.output/chrome-mv3/manifest.json',
);
expect(output).toMatchInlineSnapshot(`
".output/chrome-mv3/manifest.json
----------------------------------------
{\\"manifest_version\\":3,\\"name\\":\\"E2E Extension\\",\\"description\\":\\"Example description\\",\\"version\\":\\"0.0.0\\",\\"version_name\\":\\"0.0.0-test\\",\\"author\\":\\"Custom Author\\"}"
`);
});
});
+86 -14
View File
@@ -43,30 +43,33 @@ describe('Output Directory Structure', () => {
await project.build();
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
".output/chrome-mv3/assets/one.css
expect(
await project.serializeOutput([
'.output/chrome-mv3/content-scripts/one.js',
'.output/chrome-mv3/content-scripts/two.js',
]),
).toMatchInlineSnapshot(`
".output/chrome-mv3/content-scripts/one.css
----------------------------------------
body{color:#00f}
================================================================================
.output/chrome-mv3/assets/two.css
.output/chrome-mv3/content-scripts/one.js
----------------------------------------
<contents-ignored>
================================================================================
.output/chrome-mv3/content-scripts/two.css
----------------------------------------
body{color:red}
================================================================================
.output/chrome-mv3/content-scripts/one.js
----------------------------------------
(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 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)}})()})();
<contents-ignored>
================================================================================
.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\\":[\\"*://*/*\\"],\\"css\\":[\\"assets/one.css\\",\\"assets/two.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\\":[\\"content-scripts/one.css\\",\\"content-scripts/two.css\\"],\\"js\\":[\\"content-scripts/one.js\\",\\"content-scripts/two.js\\"]}]}"
`);
});
@@ -82,15 +85,84 @@ describe('Output Directory Structure', () => {
await project.build();
expect(await project.serializeOutput()).toMatchInlineSnapshot(`
expect(
await project.serializeOutput([
'.output/chrome-mv3/content-scripts/overlay-one.js',
]),
).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)}})()})();
<contents-ignored>
================================================================================
.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\\"]}]}"
`);
});
it('should not include an entrypoint if the target browser is not in the list of included targets', async () => {
const project = new TestProject();
project.addFile('entrypoints/options.html', '<html></html>');
project.addFile(
'entrypoints/background.ts',
`
export default defineBackground({
include: ["chrome"],
main() {},
})
`,
);
await project.build({ browser: 'firefox' });
expect(await project.fileExists('.output/firefox-mv2/background.js')).toBe(
false,
);
});
it('should not include an entrypoint if the target browser is in the list of excluded targets', async () => {
const project = new TestProject();
project.addFile('entrypoints/options.html', '<html></html>');
project.addFile(
'entrypoints/background.ts',
`
export default defineBackground({
exclude: ["chrome"],
main() {},
})
`,
);
await project.build({ browser: 'chrome' });
expect(await project.fileExists('.output/firefox-mv2/background.js')).toBe(
false,
);
});
it('should generate a stats file when analyzing the bundle', async () => {
const project = new TestProject();
project.setConfigFileConfig({
analysis: {
enabled: true,
template: 'sunburst',
},
});
project.addFile(
'entrypoints/background.ts',
`export default defineBackground(() => {});`,
);
project.addFile('entrypoints/popup.html', '<html></html>');
project.addFile(
'entrypoints/overlay.content.html',
`export default defineContentScript({
matches: [],
main() {},
});`,
);
await project.build();
expect(await project.fileExists('stats.html')).toBe(true);
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { TestProject } from '../utils';
describe('React', () => {
it('should prepare and build an project with a tsx entrypoint', async () => {
const project = new TestProject({
dependencies: {
react: '^18.2.0',
'react-dom': '^18.2.0',
},
devDependencies: {
'@types/react': '^18.2.14',
'@types/react-dom': '^18.2.6',
},
});
project.addFile(
'entrypoints/demo.content.tsx',
`import ReactDOM from 'react-dom/client';
export default defineContentScript({
matches: "<all_urls>",
main() {
const container = document.createElement("div");
document.body.append(container)
const root = ReactDOM.createRoot(container);
root.render(<h1>Hello, world!</h1>);
}
})`,
);
await project.build();
expect(
await project.fileExists('.output/chrome-mv3/content-scripts/demo.js'),
).toBe(true);
expect(await project.serializeFile('.output/chrome-mv3/manifest.json'))
.toMatchInlineSnapshot(`
".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\\":\\"<all_urls>\\",\\"js\\":[\\"content-scripts/demo.js\\"]}]}"
`);
});
});
+12 -33
View File
@@ -22,28 +22,7 @@ describe('TypeScript Project', () => {
const __IS_EDGE__: boolean;
const __IS_OPERA__: boolean;
const __COMMAND__: \\"build\\" | \\"serve\\";
}
"
`);
});
it('should define auto-import globals', async () => {
const project = new TestProject();
await project.build();
const output = await project.serializeFile('.wxt/types/imports.d.ts');
expect(output).toMatchInlineSnapshot(`
".wxt/types/imports.d.ts
----------------------------------------
// Generated by wxt
export {}
declare global {
const browser: typeof import('wxt/browser')['browser']
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']
const __ENTRYPOINT__: string;
}
"
`);
@@ -276,14 +255,14 @@ describe('TypeScript Project', () => {
\\"lib\\": [\\"DOM\\", \\"WebWorker\\"],
\\"skipLibCheck\\": true,
\\"paths\\": {
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"],
\\"@\\": [\\"..\\"],
\\"@/*\\": [\\"../*\\"],
\\"~\\": [\\"..\\"],
\\"~/*\\": [\\"../*\\"]
\\"~/*\\": [\\"../*\\"],
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"]
}
},
\\"include\\": [
@@ -295,7 +274,7 @@ describe('TypeScript Project', () => {
`);
});
it('should correct path aliases for a custom srcDir', async () => {
it('should generate correct path aliases for a custom srcDir', async () => {
const project = new TestProject();
project.setConfigFileConfig({
srcDir: 'src',
@@ -320,14 +299,14 @@ describe('TypeScript Project', () => {
\\"lib\\": [\\"DOM\\", \\"WebWorker\\"],
\\"skipLibCheck\\": true,
\\"paths\\": {
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"],
\\"@\\": [\\"../src\\"],
\\"@/*\\": [\\"../src/*\\"],
\\"~\\": [\\"../src\\"],
\\"~/*\\": [\\"../src/*\\"]
\\"~/*\\": [\\"../src/*\\"],
\\"@@\\": [\\"..\\"],
\\"@@/*\\": [\\"../*\\"],
\\"~~\\": [\\"..\\"],
\\"~~/*\\": [\\"../*\\"]
}
},
\\"include\\": [
File diff suppressed because one or more lines are too long
+38 -17
View File
@@ -1,32 +1,36 @@
import { dirname, join, relative, resolve } from 'path';
import fs from 'fs-extra';
import glob from 'fast-glob';
import { execSync } from 'child_process';
import { execaCommand } from 'execa';
import { InlineConfig, UserConfig, build } from '../src';
import { normalizePath } from '../src/core/utils/paths';
import merge from 'lodash.merge';
export class TestProject {
files: Array<[string, string]> = [];
config: UserConfig | undefined;
readonly root: string;
constructor(root = 'e2e/dist') {
constructor(packageJson: any = {}) {
// We can't put each test's project inside e2e/dist directly, otherwise the wxt.config.ts
// file is cached and cannot be different between each test. Instead, we add a random ID to the
// end to make each test's path unique.
const id = Math.random().toString(32).substring(3);
this.root = join(root, id);
this.root = join('e2e/dist', id);
this.files.push([
'package.json',
JSON.stringify(
{
name: 'E2E Extension',
description: 'Example description',
version: '0.0.0-test',
dependencies: {
wxt: '../../..',
merge(
{
name: 'E2E Extension',
description: 'Example description',
version: '0.0.0-test',
dependencies: {
wxt: '../../..',
},
},
},
packageJson,
),
null,
2,
),
@@ -72,17 +76,20 @@ export class TestProject {
await fs.ensureDir(fileDir);
await fs.writeFile(filePath, content ?? '', 'utf-8');
}
execSync('npm i --ignore-scripts', { cwd: this.root });
await execaCommand('npm i --ignore-scripts', { cwd: this.root });
await build({ ...config, root: this.root });
}
/**
* Read all the files from the test project's `.output` directory and combine them into a string
* that can be used in a snapshot.
*
* Optionally, provide a list of filenames whose content is not printed (because it's inconsistent
* or not relevant to a test).
*/
serializeOutput(): Promise<string> {
return this.serializeDir('.output');
serializeOutput(ignoreContentsOfFilenames?: string[]): Promise<string> {
return this.serializeDir('.output', ignoreContentsOfFilenames);
}
/**
@@ -95,7 +102,16 @@ export class TestProject {
);
}
private async serializeDir(dir: string): Promise<string> {
/**
* Deeply print the filename and contents of all files in a directory.
*
* Optionally, provide a list of filenames whose content is not printed (because it's inconsistent
* or not relevant to a test).
*/
private async serializeDir(
dir: string,
ignoreContentsOfFilenames?: string[],
): Promise<string> {
const outputFiles = await glob('**/*', {
cwd: resolve(this.root, dir),
ignore: ['**/node_modules', '**/.output'],
@@ -104,19 +120,24 @@ export class TestProject {
const fileContents = [];
for (const file of outputFiles) {
const path = resolve(this.root, dir, file);
fileContents.push(await this.serializeFile(path));
const isContentIgnored = !!ignoreContentsOfFilenames?.find(
(ignoredFile) => normalizePath(path).endsWith(ignoredFile),
);
fileContents.push(await this.serializeFile(path, isContentIgnored));
}
return fileContents.join(`\n${''.padEnd(80, '=')}\n`);
}
/**
* @param path An abosolute path to a file or a path relative to the root.
* @param ignoreContents An optional boolean that, when true, causes this function to not print
* the file contents.
*/
async serializeFile(path: string): Promise<string> {
async serializeFile(path: string, ignoreContents?: boolean): Promise<string> {
const absolutePath = resolve(this.root, path);
return [
normalizePath(relative(this.root, absolutePath)),
await fs.readFile(absolutePath),
ignoreContents ? '<contents-ignored>' : await fs.readFile(absolutePath),
].join(`\n${''.padEnd(40, '-')}\n`);
}
+17 -6
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.5.0",
"version": "0.7.1",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18.16.0",
@@ -56,33 +56,39 @@
"format:check": "prettier --write .",
"compile": "tsc --noEmit",
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:coverage": "vitest run --coverage.enabled --coverage.exclude=e2e --coverage.exclude=src/testing",
"prepare": "simple-git-hooks",
"prepublish": "pnpm -s build",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"sync-releases": "pnpx changelogen@latest gh release all"
"sync-releases": "pnpx changelogen@latest gh release"
},
"dependencies": {
"@types/webextension-polyfill": "^0.10.1",
"@webext-core/fake-browser": "^1.2.2",
"@webext-core/match-patterns": "^1.0.1",
"@webext-core/isolated-element": "^1.0.4",
"@webext-core/match-patterns": "^1.0.2",
"async-mutex": "^0.4.0",
"c12": "^1.4.2",
"cac": "^6.7.14",
"consola": "^3.2.3",
"esbuild": "^0.19.4",
"fast-glob": "^3.3.1",
"filesize": "^10.0.8",
"fs-extra": "^11.1.1",
"get-port": "^7.0.0",
"giget": "^1.1.2",
"immer": "^10.0.2",
"is-wsl": "^3.0.0",
"jiti": "^1.19.1",
"json5": "^2.2.3",
"linkedom": "^0.15.1",
"minimatch": "^9.0.3",
"ora": "^7.0.1",
"picocolors": "^1.0.0",
"prompts": "^2.4.2",
"rollup-plugin-visualizer": "^5.9.2",
"unimport": "^3.1.0",
"vite": "^4.4.7",
"web-ext-run": "^0.1.0",
@@ -94,14 +100,13 @@
"@types/fs-extra": "^11.0.1",
"@types/lodash.merge": "^4.6.7",
"@types/node": "^20.4.5",
"@types/prettier": "^3.0.0",
"@types/prompts": "^2.4.4",
"@vitest/coverage-v8": "^0.34.1",
"execa": "^7.2.0",
"jsdom": "^22.1.0",
"lint-staged": "^14.0.0",
"lodash.merge": "^4.6.2",
"npm-run-all": "^4.1.5",
"ora": "^7.0.1",
"prettier": "^3.0.1",
"simple-git-hooks": "^2.9.0",
"ts-morph": "^19.0.0",
@@ -111,6 +116,7 @@
"vitepress": "1.0.0-rc.10",
"vitest": "^0.34.1",
"vitest-mock-extended": "^1.1.4",
"vue": "^3.3.4",
"webextension-polyfill": "^0.10.0"
},
"peerDependencies": {
@@ -123,6 +129,11 @@
"lint-staged": {
"*": "prettier --write"
},
"changelog": {
"excludeAuthors": [
"aaronklinker1@gmail.com"
]
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
+626 -174
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -11,17 +11,20 @@ export const build = defineCommand<
mv3?: boolean;
mv2?: boolean;
debug?: boolean;
analyze?: boolean;
},
]
>(async (root, flags) => {
const mode = flags.mode ?? 'production';
const cliConfig: wxt.InlineConfig = {
root,
mode,
mode: flags.mode,
browser: flags.browser,
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
configFile: flags.config,
debug: flags.debug,
analysis: {
enabled: flags.analyze,
},
};
await wxt.build(cliConfig);
+1 -2
View File
@@ -14,10 +14,9 @@ export const dev = defineCommand<
},
]
>(async (root, flags) => {
const mode = flags.mode ?? 'development';
const cliConfig: wxt.InlineConfig = {
root,
mode,
mode: flags.mode,
browser: flags.browser,
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
configFile: flags.config,
+1 -1
View File
@@ -1,6 +1,5 @@
import { defineCommand } from '../utils/defineCommand';
import prompts from 'prompts';
import ora from 'ora';
import { consola } from 'consola';
import { downloadTemplate } from 'giget';
import fs from 'fs-extra';
@@ -133,6 +132,7 @@ async function cloneProject({
template: Template;
packageManager: string;
}) {
const { default: ora } = await import('ora');
const spinner = ora('Downloading template').start();
try {
// 1. Clone repo
+1 -2
View File
@@ -17,10 +17,9 @@ export const zip = defineCommand<
},
]
>(async (root, flags) => {
const mode = flags.mode ?? 'production';
const cliConfig: wxt.InlineConfig = {
root,
mode,
mode: flags.mode,
browser: flags.browser,
manifestVersion: flags.mv3 ? 3 : flags.mv2 ? 2 : undefined,
configFile: flags.config,
+1
View File
@@ -26,6 +26,7 @@ cli
.option('-b, --browser <browser>', 'specify a browser')
.option('--mv3', 'target manifest v3')
.option('--mv2', 'target manifest v2')
.option('--analyze', 'visualize extension bundle')
.action(commands.build);
// ZIP
@@ -0,0 +1,336 @@
/** @vitest-environment jsdom */
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContentScriptContext, createContentScriptUi } from '..';
import { createIsolatedElement } from '@webext-core/isolated-element';
import { mock } from 'vitest-mock-extended';
import { browser } from '../browser';
vi.mock('webextension-polyfill', () =>
vi.importActual('../virtual-modules/fake-browser'),
);
vi.mock('@webext-core/isolated-element', async () => {
const { vi } = await import('vitest');
return {
createIsolatedElement: vi.fn(),
};
});
const createIsolatedElementMock = vi.mocked(createIsolatedElement);
const testApp = (container: Element) => {
const app = document.createElement('div');
app.textContent = 'App';
container.append(app);
};
const createCtx = () => new ContentScriptContext('test');
const fetch = vi.fn();
describe('createContentScriptUi', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="parent">
<p id="one">one</p>
<p id="two">two</p>
<p id="three"></p>
</div>
`;
window.fetch = fetch;
fetch.mockResolvedValue({ text: () => Promise.resolve('') });
createIsolatedElementMock.mockImplementation(async (config) => {
const parentElement = document.createElement(config.name);
const isolatedElement = document.createElement('html');
parentElement.append(isolatedElement);
return {
isolatedElement,
parentElement: parentElement,
shadow: mock<ShadowRoot>({
querySelector: (selector: string) => {
if (selector === 'html') return isolatedElement;
},
}),
};
});
});
describe('css', () => {
it('should load the CSS for the current entrypoint when cssInjectionMode=ui', async () => {
fetch.mockResolvedValue({ text: () => Promise.resolve('body {}') });
const ctx = new ContentScriptContext('test', {
matches: [],
cssInjectionMode: 'ui',
});
await createContentScriptUi(ctx, {
name: 'test',
type: 'inline',
mount: testApp,
});
expect(fetch).toBeCalledTimes(1);
expect(fetch).toBeCalledWith(
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
);
expect(createIsolatedElementMock).toBeCalledTimes(1);
expect(createIsolatedElementMock).toBeCalledWith(
expect.objectContaining({
css: {
textContent: 'body {}',
},
}),
);
});
it('should still load the UI when fetch fails to load CSS file when cssInjectionMode=ui', async () => {
const error = Error('Test fetch error');
fetch.mockRejectedValue(error);
const ctx = new ContentScriptContext('test', {
matches: [],
cssInjectionMode: 'ui',
});
await createContentScriptUi(ctx, {
name: 'test',
type: 'inline',
mount: testApp,
});
expect(fetch).toBeCalledTimes(1);
expect(fetch).toBeCalledWith(
`chrome-extension://${browser.runtime.id}/content-scripts/${__ENTRYPOINT__}.css`,
);
expect(createIsolatedElementMock).toBeCalledTimes(1);
expect(createIsolatedElementMock).toBeCalledWith(
expect.objectContaining({
css: {
textContent: '',
},
}),
);
});
it.each(['manifest', 'manual'] as const)(
'should not fetch CSS when cssInjectionMode=%s',
async (cssInjectionMode) => {
const ctx = new ContentScriptContext('test', {
matches: [],
cssInjectionMode,
});
await createContentScriptUi(ctx, {
name: 'test',
type: 'inline',
mount: testApp,
});
expect(fetch).not.toBeCalled();
},
);
});
describe('mount', () => {
describe('append option', () => {
it.each([undefined, 'last' as const])(
'should append the element as the last child to the anchor when append=%s',
async (append) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#parent',
append,
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(4)')?.tagName,
).toEqual('TEST-APP');
},
);
it('should append the element as the first child to the anchor when append=first', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#parent',
append: 'first',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(1)')?.tagName,
).toEqual('TEST-APP');
});
it('should append the element normally when append=first but there are no other children', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#three',
append: 'first',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#three > :nth-child(1)')?.tagName,
).toEqual('TEST-APP');
});
it('should replace the anchor with the element when append=replace', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#two',
append: 'replace',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(2)')?.tagName,
).toEqual('TEST-APP');
});
it('should append the element after the anchor when append=before', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#two',
append: 'before',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(2)')?.tagName,
).toEqual('TEST-APP');
});
it('should append the element after the anchor when append=after', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#two',
append: 'after',
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(3)')?.tagName,
).toEqual('TEST-APP');
});
it('should apply a custom function', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: '#three',
append: (anchor, ui) => anchor.replaceWith(ui),
mount: testApp,
});
ui.mount();
expect(
document.querySelector('#parent > :nth-child(3)')?.tagName,
).toEqual('TEST-APP');
});
});
it('should default the anchor to the body when unset', async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
mount: testApp,
});
ui.mount();
expect(document.querySelector('body > :nth-child(2)')?.tagName).toEqual(
'TEST-APP',
);
});
it.each(['#four', () => document.querySelector('#four')])(
"should throw an error if the anchor doesn't exist",
async (anchor) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test',
type: 'inline',
anchor,
mount: testApp,
});
expect(ui.mount).toThrow(
'Failed to mount content script ui: could not find anchor element',
);
},
);
});
describe('remove', () => {
it("should not fail if the ui hasn't been mounted", async () => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
mount: testApp,
});
ui.remove();
});
it('should automatically remove the UI when the context is invalidated', async () => {
const ctx = createCtx();
const ui = await createContentScriptUi(ctx, {
name: 'test-app',
type: 'inline',
mount: testApp,
});
ui.mount();
expect(document.querySelector('test-app')).toBeDefined();
ctx.abort();
expect(document.querySelector('test-app')).toBeNull();
});
});
describe('type', () => {
it.each(['inline', 'overlay', 'modal'] as const)(
'should render type=%s',
async (type) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type,
mount: testApp,
});
ui.mount();
expect(document.querySelector('test-app')).toBeDefined();
},
);
});
describe('anchor', () => {
it.each([
() => '#two',
() => () => '#two',
() => document.querySelector('#two'),
() => () => document.querySelector('#two'),
])('should render anchor=%s', async (getAnchor) => {
const ui = await createContentScriptUi(createCtx(), {
name: 'test-app',
type: 'inline',
anchor: getAnchor(),
mount: testApp,
});
ui.mount();
expect(document.querySelector('test-app')).toBeDefined();
});
});
});
+298
View File
@@ -0,0 +1,298 @@
import { createIsolatedElement } from '@webext-core/isolated-element';
import { browser } from './browser';
import { logger } from './utils/logger';
import { ContentScriptContext } from '.';
/**
* Utility for mounting content script UI's with isolated styles. Automatically removed from the DOM
* when the content script's context is invalidated.
*
* See <https://wxt.dev/guide/content-scripts.html#ui> for full documentation.
*
* @example
* // entrypoints/example-ui.content/index.ts
* import "./style.css"
*
* export default defineContentScript({
* matches: ["*://*.google.com/*"],
* cssInjectionMode: "ui",
*
* async main(ctx) {
* const ui = await createContentScriptUi(ctx, {
* name: "example-overlay",
* type: "modal",
* mount(container) {
* const app = document.createElement("div");
* app.textContent = "Content Script UI";
* container.append(app);
* }
* })
* ui.mount();
* }
* })
*/
export async function createContentScriptUi<T>(
ctx: ContentScriptContext,
options: ContentScriptUiOptions<T>,
): Promise<ContentScriptUi<T>> {
const css = [options.css ?? ''];
if (ctx.options?.cssInjectionMode === 'ui') {
css.push(await loadCss());
}
const {
isolatedElement: uiContainer,
parentElement: shadowHost,
shadow,
} = await createIsolatedElement({
name: options.name,
css: {
textContent: css.join('\n').trim(),
},
mode: 'open',
});
const getAnchor = (): Element | undefined => {
if (options.anchor == null) return document.body;
let resolved =
typeof options.anchor === 'function' ? options.anchor() : options.anchor;
if (typeof resolved === 'string')
return document.querySelector<Element>(resolved) ?? undefined;
return resolved ?? undefined;
};
let mounted: T;
const mount = () => {
const anchor = getAnchor();
if (anchor == null)
throw Error(
'Failed to mount content script ui: could not find anchor element',
);
// Mount UI inside shadow root
mounted = options.mount(uiContainer);
// Add shadow root element to DOM
switch (options.append) {
case undefined:
case 'last':
anchor.append(shadowHost);
break;
case 'first':
if (anchor.firstChild) {
anchor.insertBefore(shadowHost, anchor.firstChild);
} else {
anchor.append(shadowHost);
}
break;
case 'replace':
anchor.replaceWith(shadowHost);
break;
case 'after':
anchor.replaceWith(anchor, shadowHost);
break;
case 'before':
anchor.replaceWith(shadowHost, anchor);
break;
default:
options.append(anchor, shadowHost);
break;
}
// Apply types
if (options.type !== 'inline') {
if (options.zIndex != null)
shadowHost.style.zIndex = String(options.zIndex);
shadowHost.style.overflow = 'visible';
shadowHost.style.position = 'relative';
shadowHost.style.width = '0';
shadowHost.style.height = '0';
shadowHost.style.display = 'block';
const html = shadow.querySelector('html')!;
// HTML doesn't exist in tests
if (options.type === 'overlay') {
html.style.position = 'absolute';
if (options.alignment?.startsWith('bottom-')) html.style.bottom = '0';
else html.style.top = '0';
if (options.alignment?.endsWith('-right')) html.style.right = '0';
else html.style.left = '0';
} else {
html.style.position = 'fixed';
html.style.top = '0';
html.style.bottom = '0';
html.style.left = '0';
html.style.right = '0';
}
}
};
const remove = () => {
// Detatch shadow root from DOM
shadowHost.remove();
// Cleanup mounted state
options.onRemove?.(mounted);
// Remove children from uiContainer
while (uiContainer.lastChild)
uiContainer.removeChild(uiContainer.lastChild);
};
ctx.onInvalidated(remove);
return {
shadow,
shadowHost,
uiContainer,
mount,
remove,
mounted: mounted!,
};
}
/**
* Load the CSS for the current entrypoint.
*/
async function loadCss(): Promise<string> {
const url = browser.runtime.getURL(`/content-scripts/${__ENTRYPOINT__}.css`);
try {
const res = await fetch(url);
const css = await res.text();
// Replace :root selectors with :host since we're in a shadow root
return css.replaceAll(':root', ':host');
} catch (err) {
logger.warn(
`Failed to load styles @ ${url}. Did you forget to import the stylesheet in your entrypoint?`,
err,
);
return '';
}
}
export interface ContentScriptUi<T> {
/**
* The `HTMLElement` hosting the shadow root used to isolate the UI's styles. This is the element
* that get's added to the DOM. This element's style is not isolated from the webpage.
*/
shadowHost: HTMLElement;
/**
* The container element inside the `ShadowRoot` whose styles are isolated. The UI is mounted
* inside this `HTMLElement`.
*/
uiContainer: HTMLElement;
/**
* The shadow root performing the isolation.
*/
shadow: ShadowRoot;
/**
* Custom data returned from the `options.mount` function.
*/
mounted: T;
/**
* Function that mounts or remounts the UI on the page.
*/
mount: () => void;
/**
* Function that removes the UI from the webpage.
*/
remove: () => void;
}
interface BaseContentScriptUiOptions<T> {
/**
* The name of the custom component used to host the ShadowRoot. Must be kebab-case.
*/
name: string;
/**
* In combination with `anchor`, decide how to add the UI to the DOM.
*
* - `"last"` (default) - Add the UI as the last child of the `anchor` element
* - `"first"` - Add the UI as the last child of the `anchor` element
* - `"replace"` - Replace the `anchor` element with the UI.
* - `"before"` - Add the UI as the sibling before the `anchor` element
* - `"after"` - Add the UI as the sibling after the `anchor` element
* - `(anchor, ui) => void` - Customizable function that let's you add the UI to the DOM
*/
append?: ContentScriptAppendMode | ((anchor: Element, ui: Element) => void);
/**
* A CSS selector, element, or function that returns one of the two. Along with `append`, the
* `anchor` dictates where in the page the UI will be added.
*/
anchor?:
| string
| Element
| null
| undefined
| (() => string | Element | null | undefined);
/**
* Callback executed when mounting the UI. This function should create and append the UI to the
* `container` element. It is called every time `ui.mount()` is called
*
* Optionally return a value that can be accessed at `ui.mounted` or in the `onRemove` callback.
*/
mount: (container: Element) => T;
/**
* Callback called when the UI is removed from the webpage. Use to cleanup your UI, like
* unmounting your vue or react apps.
*/
onRemove?: (mounted: T) => void;
/**
* Custom CSS text to apply to the UI. If your content script imports/generates CSS and you've
* set `cssInjectionMode: "ui"`, the imported CSS will be included automatically. You do not need
* to pass those styles in here. This is for any additional styles not in the imported CSS.
*
* See <https://wxt.dev/guide/content-scripts.html#ui> for more info.
*/
css?: string;
}
export type OverlayContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
type: 'overlay';
/**
* When using `type: "overlay"`, the mounted element is 0px by 0px in size. Alignment specifies
* which corner is aligned with that 0x0 pixel space.
*
* @default "top-left"
*/
alignment?: ContentScriptUiOverlayAlignment;
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
};
export type ModalContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
type: 'modal';
/**
* The `z-index` used on the `shadowHost`. Set to a positive number to show your UI over website
* content.
*/
zIndex?: number;
};
export type InlineContentScriptUiOptions<T> = BaseContentScriptUiOptions<T> & {
type: 'inline';
};
export type ContentScriptUiOverlayAlignment =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right';
export type ContentScriptAppendMode =
| 'last'
| 'first'
| 'replace'
| 'before'
| 'after';
export type ContentScriptUiOptions<T> =
| OverlayContentScriptUiOptions<T>
| ModalContentScriptUiOptions<T>
| InlineContentScriptUiOptions<T>;
+2 -1
View File
@@ -1,3 +1,4 @@
export * from './defineContentScript';
export * from './defineBackground';
export * from './mountContentScriptUi';
export * from './createContentScriptUi';
export * from './utils/ContentScriptContext';
-3
View File
@@ -1,3 +0,0 @@
export function mountContentScriptUi() {
throw Error('Not implemented: mountContentScriptUi');
}
+186
View File
@@ -0,0 +1,186 @@
import { ContentScriptDefinition } from '../../core/types';
import { browser } from '../browser';
import { logger } from './logger';
/**
* Extends [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
* Used to detect and stop content script code when the script is invalidated.
*
* It also provides several utilities like `ctx.setTimeout` and `ctx.setInterval` that should be used in
* content scripts instead of `window.setTimeout` or `window.setInterval`.
*/
export class ContentScriptContext extends AbortController {
static SCRIPT_STARTED_MESSAGE_TYPE = 'wxt:content-script-started';
#isTopFrame = window.self === window.top;
constructor(
private readonly contentScriptName: string,
public readonly options?: Omit<ContentScriptDefinition, 'main'>,
) {
super();
if (this.#isTopFrame) {
this.#stopOldScripts();
}
this.setTimeout(() => {
// Run on next tick so the listener it adds isn't triggered by stopOldScript
this.#listenForNewerScripts();
});
}
get isInvalid(): boolean {
if (browser.runtime.id == null) {
this.notifyInvalidated(); // Sets `signal.aborted` to true
}
return this.signal.aborted;
}
get isValid(): boolean {
return !this.isInvalid;
}
/**
* Add a listener that is called when the content script's context is invalidated.
*
* @returns A function to remove the listener.
*
* @example
* browser.runtime.onMessage.addListener(cb);
* const removeInvalidatedListener = ctx.onInvalidated(() => {
* browser.runtime.onMessage.removeListener(cb);
* })
* // ...
* removeInvalidatedListener();
*/
onInvalidated(cb: () => void): () => void {
this.signal.addEventListener('abort', cb);
return () => this.signal.removeEventListener('abort', cb);
}
/**
* Return a promise that never resolves. Useful if you have an async function that shouldn't run
* after the context is expired.
*
* @example
* const getValueFromStorage = async () => {
* if (ctx.isInvalid) return ctx.block();
*
* // ...
* }
*/
block<T>(): Promise<T> {
return new Promise(() => {
// noop
});
}
/**
* Wrapper around `window.setInterval` that automatically clears the interval when invalidated.
*/
setInterval(handler: () => void, timeout?: number): number {
const id = setInterval(() => {
if (this.isValid) handler();
}, timeout) as unknown as number;
this.onInvalidated(() => clearInterval(id));
return id;
}
/**
* Wrapper around `window.setTimeout` that automatically clears the interval when invalidated.
*/
setTimeout(handler: () => void, timeout?: number): number {
const id = setTimeout(() => {
if (this.isValid) handler();
}, timeout) as unknown as number;
this.onInvalidated(() => clearTimeout(id));
return id;
}
/**
* Wrapper around `window.requestAnimationFrame` that automatically cancels the request when
* invalidated.
*/
requestAnimationFrame(callback: FrameRequestCallback): number {
const id = requestAnimationFrame((...args) => {
if (this.isValid) callback(...args);
});
this.onInvalidated(() => cancelAnimationFrame(id));
return id;
}
/**
* Wrapper around `window.requestIdleCallback` that automatically cancels the request when
* invalidated.
*/
requestIdleCallback(
callback: IdleRequestCallback,
options?: IdleRequestOptions,
): number {
const id = requestIdleCallback((...args) => {
if (!this.signal.aborted) callback(...args);
}, options);
this.onInvalidated(() => cancelIdleCallback(id));
return id;
}
/**
* Call `target.addEventListener` and remove the event listener when the context is invalidated.
*
* @example
* ctx.addEventListener(window, "mousemove", () => {
* // ...
* });
* ctx.addEventListener(document, "visibilitychange", () => {
* // ...
* });
*/
addEventListener(
target: any,
type: string,
handler: (event: Event) => void,
options?: AddEventListenerOptions,
) {
target.addEventListener?.(type, handler, options);
this.onInvalidated(
() => target.removeEventListener?.(type, handler, options),
);
}
/**
* Abort the abort controller and execute all `onInvalidated` listeners.
*/
notifyInvalidated() {
this.abort('Content script context invalidated');
logger.debug(
`Content script "${this.contentScriptName}" context invalidated`,
);
}
#stopOldScripts() {
// Use postMessage so it get's sent to all the frames of the page.
window.postMessage(
{
event: ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE,
contentScriptName: this.contentScriptName,
},
'*',
);
}
#listenForNewerScripts() {
const cb = (event: MessageEvent) => {
if (
event.data?.type === ContentScriptContext.SCRIPT_STARTED_MESSAGE_TYPE &&
event.data?.contentScriptName === this.contentScriptName
) {
this.notifyInvalidated();
}
};
addEventListener('message', cb);
this.onInvalidated(() => removeEventListener('message', cb));
}
}
+2
View File
@@ -1,4 +1,6 @@
function print(method: (...args: any[]) => void, ...args: any[]) {
if (import.meta.env.MODE === 'production') return;
if (typeof args[0] === 'string') {
const message = args.shift();
method(`[wxt] ${message}`, ...args);
@@ -1,9 +1,13 @@
import definition from 'virtual:user-content-script';
import { logger } from '../utils/logger';
import { ContentScriptContext } from '../utils/ContentScriptContext';
(async () => {
try {
await definition.main();
const { main, ...options } = definition;
const ctx = new ContentScriptContext(__ENTRYPOINT__, options);
await main(ctx);
} catch (err) {
logger.error('The content script crashed on startup!', err);
}
@@ -21,3 +21,4 @@ declare const __DEV_SERVER_PROTOCOL__: string;
declare const __DEV_SERVER_HOSTNAME__: string;
declare const __DEV_SERVER_PORT__: string;
declare const __MANIFEST_VERSION__: 2 | 3;
declare const __ENTRYPOINT__: string;
+32 -1
View File
@@ -11,6 +11,9 @@ import fs from 'fs-extra';
import { groupEntrypoints } from './utils/groupEntrypoints';
import { formatDuration } from './utils/formatDuration';
import { printBuildSummary } from './log/printBuildSummary';
import { execaCommand } from 'execa';
import glob from 'fast-glob';
import { unnormalizePath } from './utils/paths';
/**
* Builds the extension based on an internal config.
@@ -50,6 +53,13 @@ export async function buildInternal(
config,
);
if (config.analysis.enabled) {
await combineAnalysisStats(config);
config.logger.info(
`Analysis complete:\n ${pc.gray('└─')} ${pc.yellow('stats.html')}`,
);
}
return output;
}
@@ -71,6 +81,9 @@ export async function rebuild(
publicAssets: [],
},
): Promise<{ output: BuildOutput; manifest: Manifest.WebExtensionManifest }> {
const { default: ora } = await import('ora');
const spinner = ora(`Preparing...`).start();
// Update types directory with new files and types
const allEntrypoints = await findEntrypoints(config);
await generateTypesDir(allEntrypoints, config).catch((err) => {
@@ -80,7 +93,7 @@ export async function rebuild(
});
// Build and merge the outputs
const newOutput = await buildEntrypoints(entrypointGroups, config);
const newOutput = await buildEntrypoints(entrypointGroups, config, spinner);
const mergedOutput: Omit<BuildOutput, 'manifest'> = {
steps: [...existingOutput.steps, ...newOutput.steps],
publicAssets: [...existingOutput.publicAssets, ...newOutput.publicAssets],
@@ -99,6 +112,9 @@ export async function rebuild(
// Write manifest
await writeManifest(newManifest, finalOutput, config);
// Stop the spinner and remove it from the CLI output
spinner.clear().stop();
return {
output: {
manifest: newManifest,
@@ -111,3 +127,18 @@ export async function rebuild(
manifest: newManifest,
};
}
async function combineAnalysisStats(config: InternalConfig): Promise<void> {
const unixFiles = await glob(`stats-*.json`, {
cwd: config.outDir,
absolute: true,
});
const absolutePaths = unixFiles.map(unnormalizePath);
await execaCommand(
`rollup-plugin-visualizer ${absolutePaths.join(' ')} --template ${
config.analysis.template
}`,
{ cwd: config.root, stdio: 'inherit' },
);
}
@@ -9,13 +9,13 @@ import {
import { resolve } from 'path';
import { findEntrypoints } from '../findEntrypoints';
import fs from 'fs-extra';
import { importTsFile } from '../../utils/importTsFile';
import { importEntrypointFile } from '../../utils/importEntrypointFile';
import glob from 'fast-glob';
import { fakeInternalConfig } from '../../../testing/fake-objects';
import { unnormalizePath } from '../../utils/paths';
vi.mock('../../utils/importTsFile');
const importTsFileMock = vi.mocked(importTsFile);
vi.mock('../../utils/importEntrypointFile');
const importEntrypointFileMock = vi.mocked(importEntrypointFile);
vi.mock('fast-glob');
const globMock = vi.mocked(glob);
@@ -191,13 +191,16 @@ describe('findEntrypoints', () => {
matches: ['<all_urls>'],
};
globMock.mockResolvedValueOnce([path]);
importTsFileMock.mockResolvedValue(options);
importEntrypointFileMock.mockResolvedValue(options);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importTsFileMock).toBeCalledWith(expected.inputPath, config);
expect(importEntrypointFileMock).toBeCalledWith(
expected.inputPath,
config,
);
},
);
@@ -211,6 +214,15 @@ describe('findEntrypoints', () => {
outputDir: config.outDir,
},
],
[
'background/index.ts',
{
type: 'background',
name: 'background',
inputPath: resolve(config.entrypointsDir, 'background/index.ts'),
outputDir: config.outDir,
},
],
])(
'should find and load background entrypoint config from %s',
async (path, expected) => {
@@ -218,13 +230,16 @@ describe('findEntrypoints', () => {
type: 'module',
};
globMock.mockResolvedValueOnce([path]);
importTsFileMock.mockResolvedValue(options);
importEntrypointFileMock.mockResolvedValue(options);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toHaveLength(1);
expect(entrypoints[0]).toEqual({ ...expected, options });
expect(importTsFileMock).toBeCalledWith(expected.inputPath, config);
expect(importEntrypointFileMock).toBeCalledWith(
expected.inputPath,
config,
);
},
);
@@ -255,6 +270,7 @@ describe('findEntrypoints', () => {
name: 'sandbox',
inputPath: resolve(config.entrypointsDir, 'sandbox.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -264,6 +280,7 @@ describe('findEntrypoints', () => {
name: 'sandbox',
inputPath: resolve(config.entrypointsDir, 'sandbox/index.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -273,6 +290,7 @@ describe('findEntrypoints', () => {
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sandbox.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -282,6 +300,7 @@ describe('findEntrypoints', () => {
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sandbox/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -293,6 +312,7 @@ describe('findEntrypoints', () => {
name: 'bookmarks',
inputPath: resolve(config.entrypointsDir, 'bookmarks.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -302,6 +322,7 @@ describe('findEntrypoints', () => {
name: 'bookmarks',
inputPath: resolve(config.entrypointsDir, 'bookmarks/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -313,6 +334,7 @@ describe('findEntrypoints', () => {
name: 'history',
inputPath: resolve(config.entrypointsDir, 'history.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -322,6 +344,7 @@ describe('findEntrypoints', () => {
name: 'history',
inputPath: resolve(config.entrypointsDir, 'history/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -333,6 +356,7 @@ describe('findEntrypoints', () => {
name: 'newtab',
inputPath: resolve(config.entrypointsDir, 'newtab.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -342,6 +366,7 @@ describe('findEntrypoints', () => {
name: 'newtab',
inputPath: resolve(config.entrypointsDir, 'newtab/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -353,6 +378,7 @@ describe('findEntrypoints', () => {
name: 'sidepanel',
inputPath: resolve(config.entrypointsDir, 'sidepanel.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -362,6 +388,7 @@ describe('findEntrypoints', () => {
name: 'sidepanel',
inputPath: resolve(config.entrypointsDir, 'sidepanel/index.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -371,6 +398,7 @@ describe('findEntrypoints', () => {
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sidepanel.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -380,6 +408,7 @@ describe('findEntrypoints', () => {
name: 'named',
inputPath: resolve(config.entrypointsDir, 'named.sidepanel/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -391,6 +420,7 @@ describe('findEntrypoints', () => {
name: 'devtools',
inputPath: resolve(config.entrypointsDir, 'devtools.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -400,6 +430,7 @@ describe('findEntrypoints', () => {
name: 'devtools',
inputPath: resolve(config.entrypointsDir, 'devtools/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -411,6 +442,7 @@ describe('findEntrypoints', () => {
name: 'onboarding',
inputPath: resolve(config.entrypointsDir, 'onboarding.html'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -420,6 +452,7 @@ describe('findEntrypoints', () => {
name: 'onboarding',
inputPath: resolve(config.entrypointsDir, 'onboarding/index.html'),
outputDir: config.outDir,
options: {},
},
],
@@ -431,6 +464,7 @@ describe('findEntrypoints', () => {
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected.ts'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -440,6 +474,7 @@ describe('findEntrypoints', () => {
name: 'injected',
inputPath: resolve(config.entrypointsDir, 'injected/index.ts'),
outputDir: config.outDir,
options: {},
},
],
@@ -451,6 +486,7 @@ describe('findEntrypoints', () => {
name: 'iframe',
inputPath: resolve(config.entrypointsDir, 'iframe.scss'),
outputDir: config.outDir,
options: {},
},
],
[
@@ -460,6 +496,7 @@ describe('findEntrypoints', () => {
name: 'iframe',
inputPath: resolve(config.entrypointsDir, 'iframe.css'),
outputDir: config.outDir,
options: {},
},
],
@@ -471,6 +508,7 @@ describe('findEntrypoints', () => {
name: 'content',
inputPath: resolve(config.entrypointsDir, 'content.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
},
],
[
@@ -480,6 +518,7 @@ describe('findEntrypoints', () => {
name: 'overlay',
inputPath: resolve(config.entrypointsDir, 'overlay.content.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
},
],
[
@@ -489,6 +528,7 @@ describe('findEntrypoints', () => {
name: 'content',
inputPath: resolve(config.entrypointsDir, 'content/index.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
},
],
[
@@ -498,6 +538,7 @@ describe('findEntrypoints', () => {
name: 'overlay',
inputPath: resolve(config.entrypointsDir, 'overlay.content/index.css'),
outputDir: resolve(config.outDir, 'content-scripts'),
options: {},
},
],
])('should find entrypoint for %s', async (path, expected) => {
@@ -521,4 +562,148 @@ describe('findEntrypoints', () => {
expectedPaths.join(', '),
);
});
describe('include option', () => {
it("should filter out the background when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['background.ts']);
importEntrypointFileMock.mockResolvedValue({
include: ['not' + config.browser],
});
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it("should filter out content scripts when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['example.content.ts']);
importEntrypointFileMock.mockResolvedValue({
include: ['not' + config.browser],
});
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it("should filter out the popup when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['popup.html']);
readFileMock.mockResolvedValueOnce(
`<html>
<head>
<meta name="manifest.include" content="['${
'not' + config.browser
}']" />
</head>
</html>`,
);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it("should filter out the options page when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['options.html']);
readFileMock.mockResolvedValueOnce(
`<html>
<head>
<meta name="manifest.include" content="['${
'not' + config.browser
}']" />
</head>
</html>`,
);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it("should filter out an unlisted page when include doesn't contain the target browser", async () => {
globMock.mockResolvedValueOnce(['unlisted.html']);
readFileMock.mockResolvedValueOnce(
`<html>
<head>
<meta name="manifest.include" content="['${
'not' + config.browser
}']" />
</head>
</html>`,
);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
});
describe('exclude option', () => {
it('should filter out the background when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['background.ts']);
importEntrypointFileMock.mockResolvedValue({
exclude: [config.browser],
});
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it('should filter out content scripts when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['example.content.ts']);
importEntrypointFileMock.mockResolvedValue({
exclude: [config.browser],
});
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it('should filter out the popup when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['popup.html']);
readFileMock.mockResolvedValueOnce(
`<html>
<head>
<meta name="manifest.exclude" content="['${config.browser}']" />
</head>
</html>`,
);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it('should filter out the options page when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['options.html']);
readFileMock.mockResolvedValueOnce(
`<html>
<head>
<meta name="manifest.exclude" content="['${config.browser}']" />
</head>
</html>`,
);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
it('should filter out an unlisted page when exclude contains the target browser', async () => {
globMock.mockResolvedValueOnce(['unlisted.html']);
readFileMock.mockResolvedValueOnce(
`<html>
<head>
<meta name="manifest.exclude" content="['${config.browser}']" />
</head>
</html>`,
);
const entrypoints = await findEntrypoints(config);
expect(entrypoints).toEqual([]);
});
});
});
+36 -7
View File
@@ -12,13 +12,26 @@ import { getEntrypointBundlePath } from '../utils/entrypoints';
import fs from 'fs-extra';
import { dirname, resolve } from 'path';
import { getPublicFiles } from '../utils/public';
import { getEntrypointGlobals } from '../utils/globals';
import type { Ora } from 'ora';
import pc from 'picocolors';
export async function buildEntrypoints(
groups: EntrypointGroup[],
config: InternalConfig,
spinner: Ora,
): Promise<Omit<BuildOutput, 'manifest'>> {
const steps: BuildStepOutput[] = [];
for (const group of groups) {
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
spinner.text =
pc.dim(`[${i + 1}/${groups.length}]`) +
` ${[group]
.flat()
.map((e) => e.name)
.join(pc.dim(', '))}`;
const step = Array.isArray(group)
? await buildMultipleEntrypoints(group, config)
: await buildSingleEntrypoint(group, config);
@@ -71,17 +84,29 @@ async function buildSingleEntrypoint(
config.outDir,
'.js',
),
// 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}.[ext]`,
// Output content script CSS to `content-scripts/`, but all other scripts are written to
// `assets/`.
assetFileNames: ({ name }) => {
if (entrypoint.type === 'content-script' && name?.endsWith('css')) {
return `content-scripts/${entrypoint.name}.[ext]`;
} else {
return `assets/${entrypoint.name}.[ext]`;
}
},
},
},
},
define: {
// See https://github.com/aklinker1/vite-plugin-web-extension/issues/96
'process.env.NODE_ENV': JSON.stringify(config.mode),
},
};
for (const global of getEntrypointGlobals(config, entrypoint.name)) {
libMode.define![global.name] = JSON.stringify(global.value);
}
const entryConfig = vite.mergeConfig(
libMode,
config.vite,
await config.vite(config.env),
) as vite.InlineConfig;
const result = await vite.build(entryConfig);
@@ -116,11 +141,15 @@ async function buildMultipleEntrypoints(
},
},
},
define: {},
};
for (const global of getEntrypointGlobals(config, 'html')) {
multiPage.define![global.name] = JSON.stringify(global.value);
}
const entryConfig = vite.mergeConfig(
multiPage,
config.vite,
await config.vite(config.env),
) as vite.UserConfig;
const result = await vite.build(entryConfig);
+86 -16
View File
@@ -2,9 +2,11 @@ import { relative, resolve } from 'path';
import {
BackgroundEntrypoint,
BackgroundScriptDefintition,
BaseEntrypointOptions,
ContentScriptDefinition,
ContentScriptEntrypoint,
Entrypoint,
GenericEntrypoint,
InternalConfig,
OptionsEntrypoint,
PopupEntrypoint,
@@ -13,7 +15,7 @@ import fs from 'fs-extra';
import { minimatch } from 'minimatch';
import { parseHTML } from 'linkedom';
import JSON5 from 'json5';
import { importTsFile } from '../utils/importTsFile';
import { importEntrypointFile } from '../utils/importEntrypointFile';
import glob from 'fast-glob';
import { getEntrypointName } from '../utils/entrypoints';
import { VIRTUAL_NOOP_BACKGROUND_MODULE_ID } from '../vite-plugins/noopBackground';
@@ -75,12 +77,19 @@ export async function findEntrypoints(
path,
);
break;
case 'unlisted-page':
entrypoint = await getUnlistedPageEntrypoint(config, path);
break;
case 'content-script-style':
entrypoint = {
type,
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: resolve(config.outDir, CONTENT_SCRIPT_OUT_DIR),
options: {
include: undefined,
exclude: undefined,
},
};
break;
default:
@@ -89,6 +98,10 @@ export async function findEntrypoints(
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: config.outDir,
options: {
include: undefined,
exclude: undefined,
},
};
}
@@ -112,7 +125,47 @@ export async function findEntrypoints(
await getBackgroundEntrypoint(config, VIRTUAL_NOOP_BACKGROUND_MODULE_ID),
);
}
return entrypoints;
config.logger.debug('All entrypoints:', entrypoints);
const targetEntrypoints = entrypoints.filter((entry) => {
const { include, exclude } = entry.options;
if (include?.length && exclude?.length) {
config.logger.warn(
`The ${entry.name} entrypoint lists both include and exclude, but only one can be used per entrypoint. Entrypoint ignored.`,
);
return false;
}
if (exclude?.length && !include?.length) {
return !exclude.includes(config.browser);
}
if (include?.length && !exclude?.length) {
return include.includes(config.browser);
}
return true;
});
config.logger.debug(`${config.browser} entrypoints:`, targetEntrypoints);
return targetEntrypoints;
}
function getHtmlBaseOptions(document: Document): BaseEntrypointOptions {
const options: BaseEntrypointOptions = {};
const includeContent = document
.querySelector("meta[name='manifest.include']")
?.getAttribute('content');
if (includeContent) {
options.include = JSON5.parse(includeContent);
}
const excludeContent = document
.querySelector("meta[name='manifest.exclude']")
?.getAttribute('content');
if (excludeContent) {
options.exclude = JSON5.parse(excludeContent);
}
return options;
}
/**
@@ -123,11 +176,11 @@ async function getPopupEntrypoint(
config: InternalConfig,
path: string,
): Promise<PopupEntrypoint> {
const options: PopupEntrypoint['options'] = {};
const content = await fs.readFile(path, 'utf-8');
const { document } = parseHTML(content);
const options: PopupEntrypoint['options'] = getHtmlBaseOptions(document);
const title = document.querySelector('title');
if (title != null) options.defaultTitle = title.textContent ?? undefined;
@@ -170,11 +223,11 @@ async function getOptionsEntrypoint(
config: InternalConfig,
path: string,
): Promise<OptionsEntrypoint> {
const options: OptionsEntrypoint['options'] = {};
const content = await fs.readFile(path, 'utf-8');
const { document } = parseHTML(content);
const options: OptionsEntrypoint['options'] = getHtmlBaseOptions(document);
const openInTabContent = document
.querySelector("meta[name='manifest.open_in_tab']")
?.getAttribute('content');
@@ -205,6 +258,26 @@ async function getOptionsEntrypoint(
};
}
/**
* @param path Absolute path to the HTML file.
* @param content String contents of the file at the path.
*/
async function getUnlistedPageEntrypoint(
config: InternalConfig,
path: string,
): Promise<GenericEntrypoint> {
const content = await fs.readFile(path, 'utf-8');
const { document } = parseHTML(content);
return {
type: 'unlisted-page',
name: getEntrypointName(config.entrypointsDir, path),
inputPath: path,
outputDir: config.outDir,
options: getHtmlBaseOptions(document),
};
}
/**
* @param path Absolute path to the background's TS file.
*/
@@ -214,10 +287,8 @@ async function getBackgroundEntrypoint(
): Promise<BackgroundEntrypoint> {
let options: Omit<BackgroundScriptDefintition, 'main'> = {};
if (path !== VIRTUAL_NOOP_BACKGROUND_MODULE_ID) {
const defaultExport = await importTsFile<BackgroundScriptDefintition>(
path,
config,
);
const defaultExport =
await importEntrypointFile<BackgroundScriptDefintition>(path, config);
if (defaultExport == null) {
throw Error('Background script does not have a default export');
}
@@ -241,10 +312,8 @@ async function getContentScriptEntrypoint(
name: string,
path: string,
): Promise<ContentScriptEntrypoint> {
const { main: _, ...options } = await importTsFile<ContentScriptDefinition>(
path,
config,
);
const { main: _, ...options } =
await importEntrypointFile<ContentScriptDefinition>(path, config);
if (options == null) {
throw Error(`Content script ${name} does not have a default export`);
}
@@ -281,6 +350,7 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
'devtools/index.html': 'devtools',
'background.ts': 'background',
'background/index.ts': 'background',
[VIRTUAL_NOOP_BACKGROUND_MODULE_ID]: 'background',
'content.ts?(x)': 'content-script',
@@ -305,8 +375,8 @@ const PATH_GLOB_TO_TYPE_MAP: Record<string, Entrypoint['type'] | 'ignored'> = {
[`*.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
[`*/index.${CSS_EXTENSIONS_PATTERN}`]: 'unlisted-style',
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files
'*/*': 'ignored',
// Don't warn about any files in subdirectories, like CSS or JS entrypoints for HTML files or tests
'*/**': 'ignored',
};
const CONTENT_SCRIPT_OUT_DIR = 'content-scripts';
+16 -10
View File
@@ -1,10 +1,10 @@
import { createUnimport } from 'unimport';
import { UnimportOptions, createUnimport } from 'unimport';
import { Entrypoint, InternalConfig } from '../types';
import fs from 'fs-extra';
import { relative, resolve } from 'path';
import { getEntrypointBundlePath } from '../utils/entrypoints';
import { getUnimportOptions } from '../utils/auto-imports';
import { getGlobals } from '../utils/globals';
import { getEntrypointGlobals, getGlobals } from '../utils/globals';
import { getPublicFiles } from '../utils/public';
import { normalizePath } from '../utils/paths';
import path from 'node:path';
@@ -21,7 +21,12 @@ export async function generateTypesDir(
await fs.ensureDir(config.typesDir);
const references: string[] = [];
references.push(await writeImportsDeclarationFile(config));
const imports = getUnimportOptions(config);
if (imports !== false) {
references.push(await writeImportsDeclarationFile(config, imports));
}
references.push(await writePathsDeclarationFile(entrypoints, config));
references.push(await writeI18nDeclarationFile(config));
references.push(await writeGlobalsDeclarationFile(config));
@@ -32,9 +37,10 @@ export async function generateTypesDir(
async function writeImportsDeclarationFile(
config: InternalConfig,
unimportOptions: Partial<UnimportOptions>,
): Promise<string> {
const filePath = resolve(config.typesDir, 'imports.d.ts');
const unimport = createUnimport(getUnimportOptions(config));
const unimport = createUnimport(unimportOptions);
// Load project imports into unimport memory so they are output via generateTypeDeclarations
await unimport.scanImportsFromDir(undefined, { cwd: config.srcDir });
@@ -151,7 +157,7 @@ async function writeGlobalsDeclarationFile(
config: InternalConfig,
): Promise<string> {
const filePath = resolve(config.typesDir, 'globals.d.ts');
const globals = getGlobals(config);
const globals = [...getGlobals(config), ...getEntrypointGlobals(config, '')];
await writeFileIfDifferent(
filePath,
[
@@ -207,14 +213,14 @@ async function writeTsConfigFile(
"lib": ["DOM", "WebWorker"],
"skipLibCheck": true,
"paths": {
"@@": ["${rootPath}"],
"@@/*": ["${rootPath}/*"],
"~~": ["${rootPath}"],
"~~/*": ["${rootPath}/*"],
"@": ["${srcPath}"],
"@/*": ["${srcPath}/*"],
"~": ["${srcPath}"],
"~/*": ["${srcPath}/*"]
"~/*": ["${srcPath}/*"],
"@@": ["${rootPath}"],
"@@/*": ["${rootPath}/*"],
"~~": ["${rootPath}"],
"~~/*": ["${rootPath}/*"]
}
},
"include": [
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest';
import { createExtensionRunner } from '..';
import { fakeInternalConfig } from '../../../testing/fake-objects';
import { mock } from 'vitest-mock-extended';
import { createSafariRunner } from '../safari';
import { ExtensionRunner } from '../extension-runner';
import { createWslRunner } from '../wsl';
import { createManualRunner } from '../manual';
import { isWsl } from '../../utils/wsl';
import { createWebExtRunner } from '../web-ext';
vi.mock('../../utils/wsl');
const isWslMock = vi.mocked(isWsl);
vi.mock('../safari');
const createSafariRunnerMock = vi.mocked(createSafariRunner);
vi.mock('../wsl');
const createWslRunnerMock = vi.mocked(createWslRunner);
vi.mock('../manual');
const createManualRunnerMock = vi.mocked(createManualRunner);
vi.mock('../web-ext');
const createWebExtRunnerMock = vi.mocked(createWebExtRunner);
describe('createExtensionRunner', () => {
it('should return a Safari runner when browser is "safari"', async () => {
const config = fakeInternalConfig({
browser: 'safari',
});
const safariRunner = mock<ExtensionRunner>();
createSafariRunnerMock.mockReturnValue(safariRunner);
await expect(createExtensionRunner(config)).resolves.toBe(safariRunner);
});
it('should return a WSL runner when `is-wsl` is true', async () => {
isWslMock.mockResolvedValueOnce(true);
const config = fakeInternalConfig({
browser: 'chrome',
});
const wslRunner = mock<ExtensionRunner>();
createWslRunnerMock.mockReturnValue(wslRunner);
await expect(createExtensionRunner(config)).resolves.toBe(wslRunner);
});
it('should return a manual runner when `runner.disabled` is true', async () => {
isWslMock.mockResolvedValueOnce(false);
const config = fakeInternalConfig({
browser: 'chrome',
runnerConfig: {
config: {
disabled: true,
},
},
});
const manualRunner = mock<ExtensionRunner>();
createManualRunnerMock.mockReturnValue(manualRunner);
await expect(createExtensionRunner(config)).resolves.toBe(manualRunner);
});
it('should return a web-ext runner otherwise', async () => {
const config = fakeInternalConfig({
browser: 'chrome',
runnerConfig: {
config: {
disabled: undefined,
},
},
});
const manualRunner = mock<ExtensionRunner>();
createWebExtRunnerMock.mockReturnValue(manualRunner);
await expect(createExtensionRunner(config)).resolves.toBe(manualRunner);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { InternalConfig } from '../types';
import { ExtensionRunner } from './extension-runner';
import { createWslRunner } from './wsl';
import { createWebExtRunner } from './web-ext';
import { createSafariRunner } from './safari';
import { createManualRunner } from './manual';
import { isWsl } from '../utils/wsl';
export async function createExtensionRunner(
config: InternalConfig,
): Promise<ExtensionRunner> {
if (config.browser === 'safari') return createSafariRunner();
if (await isWsl()) return createWslRunner();
if (config.runnerConfig.config?.disabled) return createManualRunner();
return createWebExtRunner();
}
+21
View File
@@ -0,0 +1,21 @@
import { ExtensionRunner } from './extension-runner';
import { relative } from 'node:path';
/**
* The manual runner tells the user to load the unpacked extension manually.
*/
export function createManualRunner(): ExtensionRunner {
return {
async openBrowser(config) {
config.logger.info(
`Load "${relative(
process.cwd(),
config.outDir,
)}" as an unpacked extension manually`,
);
},
async closeBrowser() {
// noop
},
};
}
+21
View File
@@ -0,0 +1,21 @@
import { ExtensionRunner } from './extension-runner';
import { relative } from 'node:path';
/**
* The Safari runner just logs a warning message because `web-ext` doesn't work with Safari.
*/
export function createSafariRunner(): ExtensionRunner {
return {
async openBrowser(config) {
config.logger.warn(
`Cannot Safari using web-ext. Load "${relative(
process.cwd(),
config.outDir,
)}" as an unpacked extension manually`,
);
},
async closeBrowser() {
// noop
},
};
}
@@ -1,5 +1,5 @@
import type { WebExtRunInstance } from 'web-ext-run';
import { ExtensionRunner } from './ExtensionRunner';
import { ExtensionRunner } from './extension-runner';
/**
* Create an `ExtensionRunner` backed by `web-ext`.
@@ -9,10 +9,7 @@ export function createWebExtRunner(): ExtensionRunner {
return {
async openBrowser(config) {
if (config.browser === 'safari') {
config.logger.warn('Cannot open safari automatically.');
return;
}
config.logger.info('Opening browser...');
// Use the plugin's logger instead of web-ext's built-in one.
const webExtLogger = await import('web-ext-run/util/logger');
@@ -57,6 +54,8 @@ export function createWebExtRunner(): ExtensionRunner {
const webExt = await import('web-ext-run');
runner = await webExt.default.cmd.run(finalConfig, options);
config.logger.success('Opened!');
},
async closeBrowser() {
+21
View File
@@ -0,0 +1,21 @@
import { ExtensionRunner } from './extension-runner';
import { relative } from 'node:path';
/**
* The WSL runner just logs a warning message because `web-ext` doesn't work in WSL.
*/
export function createWslRunner(): ExtensionRunner {
return {
async openBrowser(config) {
config.logger.warn(
`Cannot open browser when using WSL. Load "${relative(
process.cwd(),
config.outDir,
)}" as an unpacked extension manually`,
);
},
async closeBrowser() {
// noop
},
};
}
+15 -8
View File
@@ -7,9 +7,12 @@ import {
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 {
getContentScriptCssFiles,
getContentScriptsCssMap,
} from './utils/manifest';
import { buildInternal } from './build';
import { createExtensionRunner } from './runners';
export async function getServerInfo(): Promise<ServerInfo> {
const { default: getPort, portNumbers } = await import('get-port');
@@ -34,10 +37,10 @@ export async function setupServer(
serverInfo: ServerInfo,
config: InternalConfig,
): Promise<WxtDevServer> {
const runner = createWebExtRunner();
const runner = await createExtensionRunner(config);
const viteServer = await vite.createServer(
vite.mergeConfig(serverInfo, config.vite),
vite.mergeConfig(serverInfo, await config.vite(config.env)),
);
const start = async () => {
@@ -45,9 +48,7 @@ export async function setupServer(
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 = () => {
@@ -101,10 +102,16 @@ export function reloadContentScripts(
if (Array.isArray(entry) || entry.type !== 'content-script') return;
const js = [getEntrypointBundlePath(entry, config.outDir, '.js')];
const css = getContentScriptCssFiles([entry], server.currentOutput);
const cssMap = getContentScriptsCssMap(server.currentOutput, [entry]);
const css = getContentScriptCssFiles([entry], cssMap);
server.reloadContentScript({
...entry.options,
allFrames: entry.options.allFrames,
excludeMatches: entry.options.excludeMatches,
matches: entry.options.matches,
runAt: entry.options.runAt,
// @ts-expect-error: Chrome accepts this, not typed in webextension-polyfill (https://developer.chrome.com/docs/extensions/reference/scripting/#type-RegisteredContentScript)
world: entry.options.world,
js,
css,
});
+117 -49
View File
@@ -3,6 +3,8 @@ import { Manifest, Scripting } from 'webextension-polyfill';
import { UnimportOptions } from 'unimport';
import { EntrypointGroup } from '.';
import { LogLevel } from 'consola';
import { ContentScriptContext } from '../../client/utils/ContentScriptContext';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
export interface InlineConfig {
/**
@@ -32,7 +34,7 @@ export interface InlineConfig {
/**
* > Only available when using the JS API. Not available in `wxt.config.ts` files
*
* Path to `"wxt.config.ts"` file or false to disable config file discovery.
* Path to `wxt.config.ts` file or `false` to disable config file discovery.
*
* @default "wxt.config.ts"
*/
@@ -43,23 +45,25 @@ export interface InlineConfig {
* @default false
*/
debug?: boolean;
/**
* 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.
* Customize auto-import options. Set to `false` to disable auto-imports.
*
* For example, to add a directory to auto-import from, you can use:
*
* ```ts
* export default defineConfig({
* imports: {
* dirs: ["some-directory"]
* }
* })
* ```
*/
imports?: Partial<UnimportOptions>;
imports?: Partial<UnimportOptions> | false;
/**
* Explicitly set a browser to build for. This will override the default browser for each command,
* and can be overridden by the command line `--browser` option.
@@ -81,18 +85,21 @@ export interface InlineConfig {
*/
logger?: Logger;
/**
* Custom Vite options.
* Return custom Vite options from a function. See
* <https://vitejs.dev/config/shared-options.html>.
*
* [`root`](#root), [`configFile`](#configfile), and [`mode`](#mode) should be set in WXT's config
* instead of Vite's.
*
* This is a function because any vite plugins added need to be recreated for each individual
* build step, incase they have internal state causing them to fail when reused.
*/
vite?: Omit<vite.UserConfig, 'root' | 'configFile' | 'mode'>;
vite?: (env: ConfigEnv) => WxtViteConfig | Promise<WxtViteConfig>;
/**
* 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.
*/
@@ -149,6 +156,41 @@ export interface InlineConfig {
*/
ignoredSources?: string[];
};
/**
* Transform the final manifest before it's written to the file system. Edit the `manifest`
* parameter directly, do not return a new object. Return values are ignored.
*
* @example
* defineConfig({
* // Add a CSS-only content script.
* transformManifest(manifest) {
* manifest.content_scripts.push({
* matches: ["*://google.com/*"],
* css: ["content-scripts/some-example.css"],
* });
* }
* })
*/
transformManifest?: (manifest: Manifest.WebExtensionManifest) => void;
analysis?: {
/**
* Explicitly include bundle analysis when running `wxt build`. This can be overridden by the
* command line `--analysis` option.
*
* @default false
*/
enabled?: boolean;
/**
* When running `wxt build --analyze` or setting `analysis.enabled` to true, customize how the
* bundle will be visualized. See
* [`rollup-plugin-visualizer`](https://github.com/btd/rollup-plugin-visualizer#how-to-use-generated-files)
* for more details.
*
* @default "treemap"
*/
template?: PluginVisualizerOptions['template'];
};
}
export interface WxtInlineViteConfig
@@ -214,7 +256,7 @@ export interface WxtDevServer extends vite.ViteDevServer {
) => void;
}
export type TargetBrowser = 'chrome' | 'firefox' | 'safari' | 'edge' | 'opera';
export type TargetBrowser = string;
export type TargetManifestVersion = 2 | 3;
export type UserConfig = Omit<InlineConfig, 'configFile'>;
@@ -230,6 +272,11 @@ export interface Logger {
level: LogLevel;
}
export interface BaseEntrypointOptions {
include?: TargetBrowser[];
exclude?: TargetBrowser[];
}
export interface BaseEntrypoint {
/**
* The entrypoint's name. This is the filename or dirname without the type suffix.
@@ -257,6 +304,7 @@ export interface BaseEntrypoint {
* subdirectory of it.
*/
outputDir: string;
options: BaseEntrypointOptions;
}
export interface GenericEntrypoint extends BaseEntrypoint {
@@ -278,12 +326,12 @@ export interface BackgroundEntrypoint extends BaseEntrypoint {
options: {
persistent?: boolean;
type?: 'module';
};
} & BaseEntrypointOptions;
}
export interface ContentScriptEntrypoint extends BaseEntrypoint {
type: 'content-script';
options: Omit<ContentScriptDefinition, 'main'>;
options: Omit<ContentScriptDefinition, 'main'> & BaseEntrypointOptions;
}
export interface PopupEntrypoint extends BaseEntrypoint {
@@ -295,7 +343,7 @@ export interface PopupEntrypoint extends BaseEntrypoint {
mv2Key?: 'browser_action' | 'page_action';
defaultIcon?: Record<string, string>;
defaultTitle?: string;
};
} & BaseEntrypointOptions;
}
export interface OptionsEntrypoint extends BaseEntrypoint {
@@ -304,7 +352,7 @@ export interface OptionsEntrypoint extends BaseEntrypoint {
openInTab?: boolean;
browserStyle?: boolean;
chromeStyle?: boolean;
};
} & BaseEntrypointOptions;
}
export type Entrypoint =
@@ -316,7 +364,7 @@ export type Entrypoint =
export type OnContentScriptStopped = (cb: () => void) => void;
export interface ContentScriptDefinition {
export interface ContentScriptDefinition extends ExcludableEntrypoint {
matches: Manifest.ContentScript['matches'];
/**
* See https://developer.chrome.com/docs/extensions/mv3/content_scripts/
@@ -358,17 +406,46 @@ export interface ContentScriptDefinition {
* @default "ISOLATED"
*/
world?: 'ISOLATED' | 'MAIN';
/**
* Customize how imported/generated styles are injected with the content script. Regardless of the
* mode selected, CSS will always be built and included in the output directory.
*
* - `"manifest"` - Include the CSS in the manifest, under the content script's `css` array.
* - `"manual"` - Exclude the CSS from the manifest. You are responsible for manually loading it
* onto the page. Use `browser.runtime.getURL("content-scripts/<name>.css")` to get the file's
* URL
*
* @default "manifest"
*/
cssInjectionMode?: 'manifest' | 'manual' | 'ui';
/**
* Main function executed when the content script is loaded.
*/
main(): void | Promise<void>;
main(ctx: ContentScriptContext): void | Promise<void>;
}
export interface BackgroundScriptDefintition {
export interface BackgroundScriptDefintition extends ExcludableEntrypoint {
type?: 'module';
main(): void;
}
export interface ExcludableEntrypoint {
/**
* List of target browsers to include this entrypoint in. Defaults to being included in all
* builds. Cannot be used with `exclude`. You must choose one of the two options.
*
* @default undefined
*/
include?: TargetBrowser[];
/**
* List of target browsers to exclude this entrypoint from. Cannot be used with `include`. You
* must choose one of the two options.
*
* @default undefined
*/
exclude?: TargetBrowser[];
}
/**
* Manifest customization available in the `wxt.config.ts` file. You cannot configure entrypoints
* here, they are configured inline.
@@ -413,6 +490,12 @@ export interface ConfigEnv {
* Configure how the browser starts up.
*/
export interface ExtensionRunnerConfig {
/**
* Whether or not to open the browser with the extension installed in dev mode.
*
* @default false
*/
disabled?: boolean;
/**
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#browser-console
*/
@@ -423,31 +506,11 @@ export interface ExtensionRunnerConfig {
openDevtools?: boolean;
/**
* List of browser names and the binary that should be used to open the browser.
*
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#chromium-binary
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#firefox
*/
binaries?: {
/**
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#chromium-binary
*/
chrome?: string;
/**
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#chromium-binary
*/
edge?: string;
/**
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#chromium-binary
*/
opera?: string;
/**
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#firefox
*/
firefox?:
| 'firefox'
| 'beta'
| 'nightly'
| 'deved'
| 'firefoxdeveloperedition'
| string;
};
binaries?: Record<string, string>;
/**
* @see https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#firefox-profile
*/
@@ -473,3 +536,8 @@ export interface ExtensionRunnerConfig {
*/
startUrls?: string[];
}
export type WxtViteConfig = Omit<
vite.UserConfig,
'root' | 'configFile' | 'mode'
>;
+13 -7
View File
@@ -7,9 +7,13 @@ import {
TargetManifestVersion,
UserManifest,
ExtensionRunnerConfig,
ConfigEnv,
UserConfig,
} from './external';
import { UnimportOptions } from 'unimport';
import { ResolvedConfig } from 'c12';
import { Manifest } from 'webextension-polyfill';
import type { PluginVisualizerOptions } from 'rollup-plugin-visualizer';
export interface InternalConfig {
root: string;
@@ -21,18 +25,14 @@ export interface InternalConfig {
outBaseDir: string;
outDir: string;
debug: boolean;
storeIds: {
chrome?: string;
firefox?: string;
edge?: string;
};
mode: string;
command: 'build' | 'serve';
browser: TargetBrowser;
manifestVersion: TargetManifestVersion;
env: ConfigEnv;
logger: Logger;
imports: Partial<UnimportOptions>;
vite: vite.InlineConfig;
imports: false | Partial<UnimportOptions>;
vite: (env: ConfigEnv) => Promise<vite.InlineConfig> | vite.InlineConfig;
manifest: UserManifest;
fsCache: FsCache;
server?: WxtDevServer;
@@ -44,6 +44,12 @@ export interface InternalConfig {
ignoredSources: string[];
sourcesRoot: string;
};
transformManifest: (manifest: Manifest.WebExtensionManifest) => void;
analysis: {
enabled: boolean;
template: NonNullable<PluginVisualizerOptions['template']>;
};
userConfigMetadata: Omit<ResolvedConfig<UserConfig>, 'config'>;
}
export type EntrypointGroup = Entrypoint | Entrypoint[];
@@ -42,6 +42,7 @@ describe('Entrypoint Utils', () => {
inputPath: '...',
name,
outputDir,
options: {},
};
const actual = getEntrypointOutputFile(entrypoint, ext);
@@ -23,6 +23,7 @@ const unlistedScript: Entrypoint = {
name: 'injected',
inputPath: '/injected.ts',
outputDir: '/.output/injected',
options: {},
};
const popup: Entrypoint = {
type: 'popup',
@@ -36,6 +37,7 @@ const unlistedPage: Entrypoint = {
name: 'onboarding',
inputPath: '/onboarding.html',
outputDir: '/.output/onboarding',
options: {},
};
const options: Entrypoint = {
type: 'options',
@@ -49,24 +51,28 @@ const sandbox1: Entrypoint = {
name: 'sandbox',
inputPath: '/sandbox1.html',
outputDir: '/.output/sandbox1',
options: {},
};
const sandbox2: Entrypoint = {
type: 'sandbox',
name: 'sandbox2',
inputPath: '/sandbox2.html',
outputDir: '/.output/sandbox2',
options: {},
};
const unlistedStyle: Entrypoint = {
type: 'unlisted-style',
name: 'injected',
inputPath: '/injected.scss',
outputDir: '/.output',
options: {},
};
const contentScriptStyle: Entrypoint = {
type: 'content-script-style',
name: 'injected',
inputPath: '/overlay.content.scss',
outputDir: '/.output/content-scripts',
options: {},
};
describe('groupEntrypoints', () => {
+3 -1
View File
@@ -4,7 +4,9 @@ import { mergeConfig } from 'vite';
export function getUnimportOptions(
config: InternalConfig,
): Partial<UnimportOptions> {
): Partial<UnimportOptions | false> {
if (config.imports === false) return false;
const defaultOptions: Partial<UnimportOptions> = {
debugLog: config.logger.debug,
imports: [{ name: 'defineConfig', from: 'wxt' }],
+211 -136
View File
@@ -1,176 +1,125 @@
import { loadConfig } from 'c12';
import {
ConfigEnv,
ExtensionRunnerConfig,
InlineConfig,
InternalConfig,
UserConfig,
UserManifest,
ConfigEnv,
UserManifestFn,
UserManifest,
WxtViteConfig,
ExtensionRunnerConfig,
} from '../types';
import path, { resolve } from 'node:path';
import path from 'node:path';
import * as vite from 'vite';
import { LogLevels, consola } from 'consola';
import * as plugins from '../vite-plugins';
import { createFsCache } from './createFsCache';
import consola, { LogLevels } from 'consola';
import * as plugins from '../vite-plugins';
import { getGlobals } from './globals';
import { loadConfig } from 'c12';
/**
* Given an inline config, discover the config file if necessary, merge the results, resolve any
* relative paths, and apply any defaults.
*
* Inline config always has priority over user config. Cli flags are passed as inline config if set.
* If unset, undefined is passed in, letting this function decide default values.
*/
export async function getInternalConfig(
config: InlineConfig,
inlineConfig: InlineConfig,
command: 'build' | 'serve',
): Promise<InternalConfig> {
// Apply defaults to a base config
const root = config.root ? path.resolve(config.root) : process.cwd();
const mode =
config.mode ?? (command === 'build' ? 'production' : 'development');
const browser = config.browser ?? 'chrome';
const manifestVersion =
config.manifestVersion ?? (browser == 'firefox' ? 2 : 3);
const outBaseDir = path.resolve(root, '.output');
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
const logger = config.logger ?? consola;
const debug = !!config.debug;
if (debug) logger.level = LogLevels.debug;
// Load user config
const baseConfig: InternalConfigNoUserDirs = {
root,
outDir,
outBaseDir,
storeIds: config.storeIds ?? {},
browser,
manifestVersion,
mode,
command,
debug,
logger,
vite: config.vite ?? {},
imports: config.imports ?? {},
runnerConfig: await loadConfig<ExtensionRunnerConfig>({
name: 'web-ext',
cwd: root,
globalRc: true,
rcFile: '.webextrc',
overrides: config.runner,
}),
};
// Load user config from file
let userConfig: UserConfig = {
mode,
};
if (config.configFile !== false) {
const loaded = await loadConfig<UserConfig>({
let userConfig: UserConfig = {};
let userConfigMetadata: InternalConfig['userConfigMetadata'] | undefined;
if (inlineConfig.configFile !== false) {
const { config: loadedConfig, ...metadata } = await loadConfig<UserConfig>({
name: 'wxt',
cwd: root,
cwd: inlineConfig.root ?? process.cwd(),
rcFile: false,
});
userConfig = loaded.config ?? {};
userConfig = loadedConfig ?? {};
userConfigMetadata = metadata;
}
// Merge inline and user configs
const merged = vite.mergeConfig(
baseConfig,
userConfig,
) as InternalConfigNoUserDirs;
// Merge it into the inline config
// Apply user config and create final config
const srcDir = userConfig.srcDir ? resolve(root, userConfig.srcDir) : root;
const entrypointsDir = resolve(
srcDir,
userConfig.entrypointsDir ?? 'entrypoints',
const mergedConfig = mergeInlineConfig(inlineConfig, userConfig);
// Apply defaults to make internal config.
const debug = mergedConfig.debug ?? false;
const logger = mergedConfig.logger ?? consola;
if (debug) logger.level = LogLevels.debug;
const browser = mergedConfig.browser ?? 'chrome';
const manifestVersion =
mergedConfig.manifestVersion ?? (browser == 'firefox' ? 2 : 3);
const mode =
mergedConfig.mode ?? (command === 'build' ? 'production' : 'development');
const env: ConfigEnv = { browser, command, manifestVersion, mode };
const root = path.resolve(
inlineConfig.root ?? userConfig.root ?? process.cwd(),
);
const publicDir = resolve(srcDir, userConfig.publicDir ?? 'public');
const wxtDir = resolve(root, '.wxt');
const typesDir = resolve(wxtDir, 'types');
const wxtDir = path.resolve(root, '.wxt');
const srcDir = path.resolve(root, mergedConfig.srcDir ?? root);
const entrypointsDir = path.resolve(
srcDir,
mergedConfig.entrypointsDir ?? 'entrypoints',
);
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
const typesDir = path.resolve(wxtDir, 'types');
const outBaseDir = path.resolve(root, '.output');
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
// 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 runnerConfig = await loadConfig<ExtensionRunnerConfig>({
name: 'web-ext',
cwd: root,
globalRc: true,
rcFile: '.webextrc',
overrides: inlineConfig.runner,
defaults: userConfig.runner,
});
const finalConfig: InternalConfig = {
...merged,
srcDir,
browser,
command,
debug,
entrypointsDir,
publicDir,
wxtDir: wxtDir,
typesDir,
env,
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 ?? []),
],
imports: mergedConfig.imports ?? {},
logger,
manifest: await resolveManifestConfig(env, mergedConfig.manifest),
manifestVersion,
mode,
outBaseDir,
outDir,
publicDir,
root,
runnerConfig,
srcDir,
typesDir,
vite: () => ({}), // Real value added after this object is initialized.
wxtDir,
zip: resolveInternalZipConfig(root, mergedConfig),
transformManifest(manifest) {
userConfig.transformManifest?.(manifest);
inlineConfig.transformManifest?.(manifest);
},
analysis: {
enabled: mergedConfig.analysis?.enabled ?? false,
template: mergedConfig.analysis?.template ?? 'treemap',
},
userConfigMetadata: userConfigMetadata ?? {},
};
// Customize the default vite config
finalConfig.vite.root = root;
finalConfig.vite.configFile = false;
finalConfig.vite.logLevel = 'warn';
finalConfig.vite.build ??= {};
finalConfig.vite.build.outDir = outDir;
finalConfig.vite.build.emptyOutDir = false;
finalConfig.vite.plugins ??= [];
finalConfig.vite.plugins.push(plugins.download(finalConfig));
finalConfig.vite.plugins.push(plugins.devHtmlPrerender(finalConfig));
finalConfig.vite.plugins.push(plugins.unimport(finalConfig));
finalConfig.vite.plugins.push(
plugins.virtualEntrypoin('background', finalConfig),
);
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.plugins.push(plugins.noopBackground());
finalConfig.vite.define ??= {};
getGlobals(finalConfig).forEach((global) => {
finalConfig.vite.define![global.name] = JSON.stringify(global.value);
});
finalConfig.vite = (env) =>
resolveInternalViteConfig(env, mergedConfig, finalConfig);
return finalConfig;
}
/**
* Helper type for defining a base config, since user-configurable directories must be set after
* reading in the user config.
*/
type InternalConfigNoUserDirs = Omit<
InternalConfig,
| 'srcDir'
| 'publicDir'
| 'entrypointsDir'
| 'wxtDir'
| 'typesDir'
| 'fsCache'
| 'manifest'
| 'zip'
>;
async function resolveManifestConfig(
env: ConfigEnv,
manifest: UserManifest | Promise<UserManifest> | UserManifestFn | undefined,
@@ -179,3 +128,129 @@ async function resolveManifestConfig(
? manifest(env)
: manifest ?? {});
}
/**
* Merge the inline config and user config. Inline config is given priority. Defaults are not applied here.
*/
function mergeInlineConfig(
inlineConfig: InlineConfig,
userConfig: UserConfig,
): InlineConfig {
let imports: InlineConfig['imports'];
if (inlineConfig.imports === false || userConfig.imports === false) {
imports = false;
} else if (userConfig.imports == null && inlineConfig.imports == null) {
imports = undefined;
} else {
imports = vite.mergeConfig(
userConfig.imports ?? {},
inlineConfig.imports ?? {},
);
}
const manifest: UserManifestFn = async (env) => {
const user = await resolveManifestConfig(env, userConfig.manifest);
const inline = await resolveManifestConfig(env, inlineConfig.manifest);
return vite.mergeConfig(user, inline);
};
const viteConfig = async (env: ConfigEnv): Promise<WxtViteConfig> => {
const user = await userConfig.vite?.(env);
const inline = await inlineConfig.vite?.(env);
return vite.mergeConfig(user ?? {}, inline ?? {});
};
const runner: InlineConfig['runner'] = vite.mergeConfig(
userConfig.runner ?? {},
inlineConfig.runner ?? {},
);
const zip: InlineConfig['zip'] = vite.mergeConfig(
userConfig.zip ?? {},
inlineConfig.zip ?? {},
);
return {
root: inlineConfig.root ?? userConfig.root,
browser: inlineConfig.browser ?? userConfig.browser,
manifestVersion: inlineConfig.manifestVersion ?? userConfig.manifestVersion,
configFile: inlineConfig.configFile,
debug: inlineConfig.debug ?? userConfig.debug,
entrypointsDir: inlineConfig.entrypointsDir ?? userConfig.entrypointsDir,
imports,
logger: inlineConfig.logger ?? userConfig.logger,
manifest,
mode: inlineConfig.mode ?? userConfig.mode,
publicDir: inlineConfig.publicDir ?? userConfig.publicDir,
runner,
srcDir: inlineConfig.srcDir ?? userConfig.srcDir,
vite: viteConfig,
zip,
analysis: {
enabled: inlineConfig.analysis?.enabled ?? userConfig.analysis?.enabled,
template:
inlineConfig.analysis?.template ?? userConfig.analysis?.template,
},
};
}
function resolveInternalZipConfig(
root: string,
mergedConfig: InlineConfig,
): InternalConfig['zip'] {
return {
sourcesTemplate: '{{name}}-{{version}}-sources.zip',
artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip',
sourcesRoot: root,
...mergedConfig.zip,
ignoredSources: [
'**/node_modules',
// WXT files
'**/web-ext.config.ts',
// Hidden files
'**/.*',
// Tests
'**/__tests__/**',
'**/*.+(test|spec).?(c|m)+(j|t)s?(x)',
// From user
...(mergedConfig.zip?.ignoredSources ?? []),
],
};
}
async function resolveInternalViteConfig(
env: ConfigEnv,
mergedConfig: InlineConfig,
finalConfig: InternalConfig,
) {
const internalVite: vite.InlineConfig =
(await mergedConfig.vite?.(env)) ?? {};
internalVite.root = finalConfig.root;
internalVite.configFile = false;
internalVite.logLevel = 'warn';
internalVite.mode = env.mode;
internalVite.build ??= {};
internalVite.build.outDir = finalConfig.outDir;
internalVite.build.emptyOutDir = false;
internalVite.plugins ??= [];
internalVite.plugins.push(plugins.download(finalConfig));
internalVite.plugins.push(plugins.devHtmlPrerender(finalConfig));
internalVite.plugins.push(plugins.unimport(finalConfig));
internalVite.plugins.push(
plugins.virtualEntrypoin('background', finalConfig),
);
internalVite.plugins.push(
plugins.virtualEntrypoin('content-script', finalConfig),
);
internalVite.plugins.push(plugins.devServerGlobals(finalConfig));
internalVite.plugins.push(plugins.tsconfigPaths(finalConfig));
internalVite.plugins.push(plugins.noopBackground());
if (finalConfig.analysis.enabled) {
internalVite.plugins.push(plugins.bundleAnalysis());
}
internalVite.define ??= {};
for (const global of getGlobals(finalConfig)) {
internalVite.define[global.name] = JSON.stringify(global.value);
}
return internalVite;
}
+13
View File
@@ -46,3 +46,16 @@ export function getGlobals(
},
];
}
export function getEntrypointGlobals(
config: InternalConfig,
entrypointName: string,
) {
return [
{
name: '__ENTRYPOINT__',
value: entrypointName,
type: `string`,
},
];
}
@@ -1,12 +1,12 @@
import createJITI from 'jiti';
import createJITI, { TransformOptions as JitiTransformOptions } from 'jiti';
import { InternalConfig } from '../types';
import { createUnimport } from 'unimport';
import fs from 'fs-extra';
import { resolve } from 'path';
import transform from 'jiti/dist/babel';
import { getUnimportOptions } from './auto-imports';
import { removeImportStatements } from './strings';
import { removeProjectImportStatements } from './strings';
import { normalizePath } from './paths';
import { TransformOptions, transformSync } from 'esbuild';
/**
* Get the value from the default export of a `path`.
@@ -23,7 +23,7 @@ import { normalizePath } from './paths';
* 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>(
export async function importEntrypointFile<T>(
path: string,
config: InternalConfig,
): Promise<T> {
@@ -39,7 +39,7 @@ export async function importTsFile<T>(
await unimport.init();
const text = await fs.readFile(path, 'utf-8');
const textNoImports = removeImportStatements(text);
const textNoImports = removeProjectImportStatements(text);
const { code } = await unimport.injectImports(textNoImports);
config.logger.debug(
['Text:', text, 'No imports:', textNoImports, 'Code:', code].join('\n'),
@@ -47,6 +47,7 @@ export async function importTsFile<T>(
const jiti = createJITI(__filename, {
cache: false,
debug: config.debug,
esmResolve: true,
interopDefault: true,
alias: {
@@ -55,10 +56,14 @@ export async function importTsFile<T>(
'node_modules/wxt/dist/virtual-modules/fake-browser.js',
),
},
extensions: ['.ts', '.tsx', '.cjs', '.js', '.mjs'],
transform(opts) {
if (opts.filename === normalPath)
return transform({ ...opts, source: code });
else return transform(opts);
const isEntrypoint = opts.filename === normalPath;
return transformSync(
// Use modified source code for entrypoints
isEntrypoint ? code : opts.source,
getEsbuildOptions(opts),
);
},
});
@@ -69,3 +74,12 @@ export async function importTsFile<T>(
throw err;
}
}
function getEsbuildOptions(opts: JitiTransformOptions): TransformOptions {
const isJsx = opts.filename?.endsWith('x');
return {
format: 'cjs',
loader: isJsx ? 'tsx' : 'ts',
jsx: isJsx ? 'automatic' : undefined,
};
}
+99 -29
View File
@@ -19,6 +19,8 @@ import {
import { getPackageJson } from './package';
import { normalizePath } from './paths';
import { writeFileIfDifferent } from './fs';
import { produce } from 'immer';
import * as vite from 'vite';
/**
* Writes the manifest to the output directory and the build output.
@@ -55,41 +57,44 @@ export async function generateMainfest(
): Promise<Manifest.WebExtensionManifest> {
const pkg = await getPackageJson(config);
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,
icons: discoverIcons(buildOutput),
},
config.manifest,
);
const baseManifest: Manifest.WebExtensionManifest = {
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,
icons: discoverIcons(buildOutput),
};
const userManifest = config.manifest;
const manifest = vite.mergeConfig(
baseManifest,
userManifest,
) as Manifest.WebExtensionManifest;
addEntrypoints(manifest, entrypoints, buildOutput, config);
if (config.command === 'serve') addDevModeCsp(manifest, config);
if (config.command === 'serve') addDevModePermissions(manifest, config);
// TODO: transform manifest here.
const finalManifest = produce(manifest, config.transformManifest);
if (manifest.name == null)
if (finalManifest.name == null)
throw Error(
"Manifest 'name' is missing. Either:\n1. Set the name in your <rootDir>/package.json\n2. Set a name via the manifest option in your wxt.config.ts",
);
if (manifest.version == null) {
if (finalManifest.version == null) {
throw Error(
"Manifest 'version' is missing. Either:\n1. Add a version in your <rootDir>/package.json\n2. Pass the version via the manifest option in your wxt.config.ts",
);
}
return manifest;
return finalManifest;
}
/**
@@ -283,6 +288,8 @@ function addEntrypoints(
}
if (contentScripts?.length) {
const cssMap = getContentScriptsCssMap(buildOutput, contentScripts);
// 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) {
@@ -308,7 +315,7 @@ function addEntrypoints(
...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(),
css: getContentScriptCssFiles(scripts, cssMap)?.sort(),
js: scripts
.map((entry) =>
getEntrypointBundlePath(entry, config.outDir, '.js'),
@@ -321,6 +328,16 @@ function addEntrypoints(
manifest.content_scripts.push(...newContentScripts);
}
}
const contentScriptCssResources = getContentScriptCssWebAccessibleResources(
config,
contentScripts,
cssMap,
);
if (contentScriptCssResources.length > 0) {
manifest.web_accessible_resources ??= [];
manifest.web_accessible_resources.push(...contentScriptCssResources);
}
}
}
@@ -408,24 +425,77 @@ function addDevModePermissions(
*/
export function getContentScriptCssFiles(
contentScripts: ContentScriptEntrypoint[],
buildOutput: Omit<BuildOutput, 'manifest'>,
contentScriptCssMap: Record<string, string | undefined>,
): string[] | undefined {
const css: string[] = [];
const allChunks = buildOutput.steps.flatMap((step) => step.chunks);
contentScripts.forEach((script) => {
// 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);
if (
script.options.cssInjectionMode === 'manual' ||
script.options.cssInjectionMode === 'ui'
)
return;
const cssFile = contentScriptCssMap[script.name];
if (cssFile == null) return;
if (cssFile) css.push(cssFile);
});
if (css.length > 0) return css;
return undefined;
}
/**
* Content scripts configured with `cssInjectionMode: "ui"` need to add their CSS files to web
* accessible resources so they can be fetched as text and added to shadow roots that the UI is
* added to.
*/
export function getContentScriptCssWebAccessibleResources(
config: InternalConfig,
contentScripts: ContentScriptEntrypoint[],
contentScriptCssMap: Record<string, string | undefined>,
): any[] {
const resources: any[] = [];
contentScripts.forEach((script) => {
if (script.options.cssInjectionMode !== 'ui') return;
const cssFile = contentScriptCssMap[script.name];
if (cssFile == null) return;
if (config.manifestVersion === 2) {
resources.push(cssFile);
} else {
resources.push({
resources: [cssFile],
matches: script.options.matches,
});
}
});
return resources;
}
/**
* Based on the build output, return a Record of each content script's name to it CSS file if the
* script includes one.
*/
export function getContentScriptsCssMap(
buildOutput: Omit<BuildOutput, 'manifest'>,
scripts: ContentScriptEntrypoint[],
) {
const map: Record<string, string | undefined> = {};
const allChunks = buildOutput.steps.flatMap((step) => step.chunks);
scripts.forEach((script) => {
const relatedCss = allChunks.find(
(chunk) => chunk.fileName === `content-scripts/${script.name}.css`,
);
if (relatedCss != null) map[script.name] = relatedCss.fileName;
});
return map;
}
function addPermission(
manifest: Manifest.WebExtensionManifest,
permission: string,
+11
View File
@@ -15,3 +15,14 @@ export function removeImportStatements(text: string): string {
'',
);
}
/**
* Removes imports, ensuring that some of WXT's client imports are present, so that entrypoints can be parsed if auto-imports are disabled.
*/
export function removeProjectImportStatements(text: string): string {
const noImports = removeImportStatements(text);
return `import { defineContentScript, defineBackground } from 'wxt/client';
${noImports}`;
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Returns true when running on WSL or WSL2.
*/
export async function isWsl(): Promise<boolean> {
const { default: isWsl } = await import('is-wsl'); // ESM only, requires dynamic import
return isWsl;
}
+12
View File
@@ -0,0 +1,12 @@
import * as vite from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
let increment = 0;
export function bundleAnalysis(): vite.Plugin {
return visualizer({
emitFile: true,
template: 'raw-data',
filename: `stats-${increment++}.json`,
}) as vite.Plugin;
}
+1
View File
@@ -7,3 +7,4 @@ export * from './virtualEntrypoint';
export * from './tsconfigPaths';
export * from './noopBackground';
export * from './cssEntrypoints';
export * from './bundleAnalysis';
+4 -2
View File
@@ -1,7 +1,7 @@
import { createUnimport } from 'unimport';
import { InternalConfig } from '../types';
import { getUnimportOptions } from '../utils/auto-imports';
import { Plugin } from 'vite';
import * as vite from 'vite';
import { extname } from 'path';
const ENABLED_EXTENSIONS: Record<string, boolean | undefined> = {
@@ -16,8 +16,10 @@ const ENABLED_EXTENSIONS: Record<string, boolean | undefined> = {
/**
* Inject any global imports defined by unimport
*/
export function unimport(config: InternalConfig): Plugin {
export function unimport(config: InternalConfig): vite.PluginOption {
const options = getUnimportOptions(config);
if (options === false) return [];
const unimport = createUnimport(options);
return {
+7 -6
View File
@@ -1,7 +1,6 @@
import { BuildOutput, WxtDevServer, InlineConfig } from './core/types';
import { getInternalConfig } from './core/utils/getInternalConfig';
import pc from 'picocolors';
import * as vite from 'vite';
import { detectDevChanges } from './core/utils/detectDevChanges';
import { Mutex } from 'async-mutex';
import { consola } from 'consola';
@@ -38,12 +37,14 @@ export async function createServer(
): Promise<WxtDevServer> {
const serverInfo = await getServerInfo();
const getLatestInternalConfig = () => {
const viteConfig: vite.InlineConfig = vite.mergeConfig(
serverInfo.viteServerConfig,
config?.vite ?? {},
const getLatestInternalConfig = async () => {
return getInternalConfig(
{
...config,
vite: () => serverInfo.viteServerConfig,
},
'serve',
);
return getInternalConfig({ ...config, vite: viteConfig }, 'serve');
};
let internalConfig = await getLatestInternalConfig();
+47 -32
View File
@@ -77,6 +77,7 @@ export const fakeUnlistedScriptEntrypoint =
inputPath: fakeFile('src'),
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
options: {},
}));
export const fakeOptionsEntrypoint = fakeObjectCreator<OptionsEntrypoint>(
@@ -133,6 +134,7 @@ export const fakeGenericEntrypoint = fakeObjectCreator<GenericEntrypoint>(
inputPath: fakeFile('src'),
name: faker.string.alpha(),
outputDir: fakeDir('.output'),
options: {},
}),
);
@@ -196,35 +198,48 @@ export function fakeArray<T>(createItem: () => T, count = 3): T[] {
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: {},
},
debug: faker.datatype.boolean(),
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(),
},
}));
export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
const browser = faker.helpers.arrayElement(['chrome', 'firefox']);
const command = faker.helpers.arrayElement(['build', 'serve'] as const);
const manifestVersion = faker.helpers.arrayElement([2, 3] as const);
const mode = faker.helpers.arrayElement(['development', 'production']);
return {
browser,
command,
entrypointsDir: fakeDir(),
env: { browser, command, manifestVersion, mode },
fsCache: mock<FsCache>(),
imports: {},
logger: mock(),
manifest: fakeManifest(),
manifestVersion,
mode,
outBaseDir: fakeDir(),
outDir: fakeDir(),
publicDir: fakeDir(),
root: fakeDir(),
runnerConfig: {
config: {},
},
debug: faker.datatype.boolean(),
srcDir: fakeDir(),
typesDir: fakeDir(),
vite: () => ({}),
wxtDir: fakeDir(),
server: mock<WxtDevServer>(),
analysis: {
enabled: false,
template: 'treemap',
},
zip: {
artifactTemplate: '{{name}}-{{version}}.zip',
ignoredSources: [],
sourcesRoot: fakeDir(),
sourcesTemplate: '{{name}}-sources.zip',
name: faker.person.firstName().toLowerCase(),
},
transformManifest: () => {},
userConfigMetadata: {},
};
});
+3
View File
@@ -9,6 +9,9 @@ lerna-debug.log*
node_modules
.output
stats.html
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
+1 -1
View File
@@ -23,6 +23,6 @@
"@types/react-dom": "^18.2.6",
"@vitejs/plugin-react": "^4.0.3",
"typescript": "^5.1.6",
"wxt": "^0.4.0"
"wxt": "^0.5.0"
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react';
// See https://wxt.dev/config.html
export default defineConfig({
vite: {
vite: () => ({
plugins: [react()],
},
}),
});
+3
View File
@@ -9,6 +9,9 @@ lerna-debug.log*
node_modules
.output
stats.html
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
+1 -1
View File
@@ -20,6 +20,6 @@
"devDependencies": {
"typescript": "^5.1.6",
"vite-plugin-solid": "^2.7.0",
"wxt": "^0.4.0"
"wxt": "^0.5.0"
}
}
+2 -2
View File
@@ -3,10 +3,10 @@ import Solid from 'vite-plugin-solid';
// See https://wxt.dev/config.html
export default defineConfig({
vite: {
vite: () => ({
build: {
target: 'esnext',
},
plugins: [Solid()],
},
}),
});
+3
View File
@@ -9,6 +9,9 @@ lerna-debug.log*
node_modules
.output
stats.html
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*
+1 -1
View File
@@ -20,6 +20,6 @@
"svelte-check": "^3.4.6",
"tslib": "^2.6.0",
"typescript": "^5.1.6",
"wxt": "^0.4.0"
"wxt": "^0.5.0"
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
// See https://wxt.dev/config.html
export default defineConfig({
srcDir: 'src',
vite: {
vite: () => ({
plugins: [
svelte({
// Using a svelte.config.js file causes a segmentation fault when importing the file
@@ -12,5 +12,5 @@ export default defineConfig({
preprocess: [vitePreprocess()],
}),
],
},
}),
});
+3
View File
@@ -9,6 +9,9 @@ lerna-debug.log*
node_modules
.output
stats.html
.wxt
web-ext.config.ts
# Editor directories and files
.vscode/*

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