diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml new file mode 100644 index 000000000..bae5e76fc --- /dev/null +++ b/.github/workflows/auto-label.yml @@ -0,0 +1,111 @@ +name: ✨ Auto-label PR + +on: + pull_request_target: + types: [opened, synchronized, reopened] + +jobs: + update-pr: + name: Update PR + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Gather Info + id: check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + // Check if PR has assignees + const hasAssignees = pr.assignees && pr.assignees.length > 0; + core.setOutput('has_assignees', hasAssignees); + core.setOutput('author', pr.user.login); + + // Get list of changed files + const { data: files } = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + + // Find all packages that were modified + const packagesRegex = /^packages\/([^\/]+)\//; + const affectedPackages = new Set(); + + for (const file of files) { + const match = file.filename.match(packagesRegex); + if (match) { + affectedPackages.add(match[1]); + } + } + + const labels = Array.from(affectedPackages).map(pkg => `pkg/${pkg}`); + core.setOutput('labels', JSON.stringify(labels)); + console.log('Detected package labels:', labels); + + // Get current labels on the PR that match pkg/* pattern + const currentPkgLabels = pr.labels + .map(label => label.name) + .filter(name => name.startsWith('pkg/')); + + core.setOutput('current_pkg_labels', JSON.stringify(currentPkgLabels)); + console.log('Current pkg labels:', currentPkgLabels); + + - name: Sync Author + if: steps.check.outputs.has_assignees == 'false' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + assignees: ['${{ steps.check.outputs.author }}'] + }); + + console.log('Assigned PR author: ${{ steps.check.outputs.author }}'); + + - name: Sync Labels + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const newLabels = ${{ steps.check.outputs.labels }}; + const currentLabels = ${{ steps.check.outputs.current_pkg_labels }}; + + // Find labels to add (in newLabels but not in currentLabels) + const labelsToAdd = newLabels.filter(label => !currentLabels.includes(label)); + + // Find labels to remove (in currentLabels but not in newLabels) + const labelsToRemove = currentLabels.filter(label => !newLabels.includes(label)); + + // Add new labels + if (labelsToAdd.length > 0) { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: labelsToAdd + }); + console.log('Added labels:', labelsToAdd); + } + + // Remove obsolete labels + for (const label of labelsToRemove) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + name: label + }); + console.log('Removed label:', label); + } + + if (labelsToAdd.length === 0 && labelsToRemove.length === 0) { + console.log('No label changes needed'); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2eef0eeb..ab5bcfef4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,7 @@ on: - analytics - auto-icons - i18n + - is-background - module-react - module-solid - module-svelte diff --git a/.github/workflows/sync-releases.yml b/.github/workflows/sync-releases.yml index b26061bcc..c12a50b36 100644 --- a/.github/workflows/sync-releases.yml +++ b/.github/workflows/sync-releases.yml @@ -10,6 +10,7 @@ on: - analytics - auto-icons - i18n + - is-background - module-react - module-solid - module-svelte diff --git a/.github/workflows/vhs.yml b/.github/workflows/vhs.yml index a507e78d1..882413e75 100644 --- a/.github/workflows/vhs.yml +++ b/.github/workflows/vhs.yml @@ -11,7 +11,7 @@ permissions: jobs: vhs: name: Create VHS - runs-on: ubuntu-22.04 + runs-on: macos-latest if: ${{ github.repository == 'wxt-dev/wxt' }} permissions: contents: write @@ -24,6 +24,11 @@ jobs: with: install: false + - name: Setup Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 + with: + go-version: '1.25.1' + # This prevents pnpm dlx from downloading WXT in the video - name: Pre-install WXT run: | @@ -31,9 +36,10 @@ jobs: pnpm dlx wxt@latest --version - name: Record VHS - uses: charmbracelet/vhs-action@59641cdc7fadf3978db65eb8c6937ea2752f4ec3 # v2.1.0 - with: - path: 'docs/tapes/init-demo.tape' + run: | + brew install ttyd ffmpeg + go install github.com/charmbracelet/vhs@517bcda0faf416728bcf6b7fe489eb0e2469d9b5 # v0.10.0 + vhs docs/tapes/init-demo.tape - name: Save recorded GIF uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 38315f909..3bf3adc52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,13 +163,6 @@ cp -r templates/vanilla templates/ That's it. Once your template is merged, it will be available inside `wxt init` immediately. You don't need to release a new version of WXT to release a new template. -## Releasing Updates - -Releases are done with GitHub actions: - -- Use the [Release workflow](https://github.com/wxt-dev/wxt/actions/workflows/release.yml) to release a single package in the monorepo. This automatically detects the version change with conventional commits, builds and uploads the package to NPM, and creates a GitHub release. -- Use the [Sync Releases workflow](https://github.com/wxt-dev/wxt/actions/workflows/sync-releases.yml) to sync the GitHub releases with changes to the changelog. To change a release, update the `CHANGELOG.md` file and run the workflow. It will sync the releases of a single package in the monorepo. - ## Upgrading Dependencies WXT has custom rules around what dependencies can be upgraded. Use the `scripts/upgrade-deps.ts` script to upgrade dependencies and follow these rules. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index b25d31fb9..2e9bafc05 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -47,3 +47,44 @@ Here's an example of how to ask for a reproduction: + +1. Create the package. + +2. Update CI workflow inputs. + +3. Add docs page and version for "Other Packages" dropdown. + +4. Merge the PR. + +5. Tag the commit (look at other tags for pattern): + + ```sh + git tag -v + git push --tags + ``` + +6. Publish the package to NPM: + + ```sh + cd packages/ + pnpm publish --access public + ``` + +7. Create a basic release on GitHub mentioning the new package is available. + +A couple of things to note: + +- pkg.pr.new will fail on the original PR. It's fine to ignore and merge your PR as long as it fails due to your new package not being published to NPM yet. +- The regular release workflow DOES NOT WORK for new packages. You have to have at least one `-v` tag created before you can run that workflow for your new package. +- You don't need to create a CHANGELOG.md file for the package, it will be created automatically after future changes are released via the normal release workflow. diff --git a/SECURITY.md b/SECURITY.md index 81250dbca..4cd07775d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,12 +4,8 @@ While WXT is in prerelease, only the latest version will receive security update npm version - +If the vulnerability is accepted, I will open a public issue to track the fix. If the vulnerability is not accepted, no further action will be taken. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cb0e9f599..6b2e9041b 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -16,6 +16,7 @@ import { version as unocssVersion } from '../../packages/unocss/package.json'; import { version as storageVersion } from '../../packages/storage/package.json'; import { version as analyticsVersion } from '../../packages/analytics/package.json'; import { version as runnerVersion } from '../../packages/runner/package.json'; +import { version as isBackgroundVersion } from '../../packages/is-background/package.json'; import addKnowledge from 'vitepress-knowledge'; import { groupIconMdPlugin, @@ -44,6 +45,7 @@ const otherPackages = { storage: storageVersion, unocss: unocssVersion, runner: runnerVersion, + 'is-background': isBackgroundVersion, }; const knowledge = addKnowledge({ diff --git a/docs/assets/init-demo.gif b/docs/assets/init-demo.gif index 59f8427b0..6a17023d1 100644 Binary files a/docs/assets/init-demo.gif and b/docs/assets/init-demo.gif differ diff --git a/docs/guide/essentials/config/browser-startup.md b/docs/guide/essentials/config/browser-startup.md index facccd9c1..faa3516a6 100644 --- a/docs/guide/essentials/config/browser-startup.md +++ b/docs/guide/essentials/config/browser-startup.md @@ -4,11 +4,17 @@ outline: deep # Browser Startup -> See the [API Reference](/api/reference/wxt/interfaces/WebExtConfig) for a full list of config. +During development, WXT will use any of the below packages to automatically open a browser with your extension installed. + +- [`web-ext` by Mozilla](https://www.npmjs.com/package/web-ext) + +Just install the dependency you want WXT to use to open the browser. -During development, WXT uses [`web-ext` by Mozilla](https://www.npmjs.com/package/web-ext) to automatically open a browser window with your extension installed. +## `web-ext` Usage + +> See the [API Reference](/api/reference/wxt/interfaces/WebExtConfig) for a full list of config. -## Config Files +### Config Files You can configure browser startup in 3 places: @@ -25,9 +31,9 @@ You can configure browser startup in 3 places: 2. `/wxt.config.ts`: Via the [`webExt` config](/api/reference/wxt/interfaces/InlineConfig#webext), included in version control 3. `$HOME/web-ext.config.ts`: Provide default values for all WXT projects on your computer -## Recipes +### Recipes -### Set Browser Binaries +#### Set Browser Binaries To set or customize the browser opened during development: @@ -57,7 +63,7 @@ export default defineConfig({ By default, WXT will try to automatically discover where Chrome/Firefox are installed. However, if you have chrome installed in a non-standard location, you need to set it manually as shown above. -### Persist Data +#### Persist Data By default, to keep from modifying your browser's existing profiles, `web-ext` creates a brand new profile every time you run the `dev` script. @@ -94,9 +100,9 @@ Now, next time you run the `dev` script, a persistent profile will be created in You can use any directory you'd like for `--user-data-dir`, the examples above create a persistent profile for each WXT project. To create a profile for all WXT projects, you can put the `chrome-data` directory inside your user's home directory. ::: -### Disable Opening Browser +#### Disable Opening Browser -If you prefer to load the extension into your browser manually, you can disable the auto-open behavior: +If you don't want to uninstall `web-ext`, like to test in your normal profile, you can do so via `disabled: true`: ```ts [web-ext.config.ts] import { defineWebExtConfig } from 'wxt'; diff --git a/docs/guide/essentials/wxt-modules.md b/docs/guide/essentials/wxt-modules.md index f8171e8fa..22ac97634 100644 --- a/docs/guide/essentials/wxt-modules.md +++ b/docs/guide/essentials/wxt-modules.md @@ -1,3 +1,7 @@ +--- +outline: deep +--- + # WXT Modules WXT provides a "module system" that let's you run code at different steps in the build process to modify it. @@ -137,6 +141,58 @@ console.log(config.myModule); This is very useful when [generating runtime code](#generate-runtime-module). +#### Add custom entrypoint options + +Modules can add custom options to entrypoints by augmenting the entrypoint options types. This allows you to add custom configuration that can be accessed during the build process. + +```ts +import { defineWxtModule } from 'wxt/modules'; +import 'wxt'; + +declare module 'wxt' { + export interface BackgroundEntrypointOptions { + // Add custom options to the background entrypoint + myCustomOption?: string; + } +} + +export default defineWxtModule({ + setup(wxt) { + wxt.hook('entrypoints:resolved', (_, entrypoints) => { + const background = entrypoints.find((e) => e.type === 'background'); + if (background) { + console.log('Custom option:', background.options.myCustomOption); + } + }); + }, +}); +``` + +Now users can set the custom option in their entrypoint: + +```ts [entrypoints/background.ts] +export default defineBackground({ + myCustomOption: 'custom value', + main() { + // ... + }, +}); +``` + +This works for all other JS and HTML entrypoints, here's an example of how to pass a custom option from an HTML file. + +```html [entrypoints/popup.html] + + + + Popup + + + + + +``` + #### Generate output file ```ts diff --git a/docs/guide/resources/upgrading.md b/docs/guide/resources/upgrading.md index 523ae30ab..28cac30f8 100644 --- a/docs/guide/resources/upgrading.md +++ b/docs/guide/resources/upgrading.md @@ -35,6 +35,41 @@ Listed below are all the breaking changes you should address when upgrading to a Currently, WXT is in pre-release. This means changes to the second digit, `v0.X`, are considered major and have breaking changes. Once v1 is released, only major version bumps will have breaking changes. +## v0.20.0 → vX.Y.Z + +### Dev Browser Startup + +The package used to open the browser on startup has changed. Previously, WXT used `web-ext-run` as a direct dependency, but now we use `web-ext` as a peer dependency. + +:::details + +`web-ext-run` was a light-weight fork of `web-ext`, but it was difficult to maintain and quickly got out-of-date compared to `web-ext`. + +::: + +In v0.20, how automatic startup is enabled/disabled has changed: + +- To continue opening the browser automatically, add `web-ext` as a dependency. No changes are required in your `web-ext.config.ts` files. + + ```sh + pnpm add -D web-ext + ``` + +- To disable the browser automatically, **_DON'T_** add `web-ext` as a dependency. Additionally, you can remove any `web-ext.config.ts` files since they're not used if `web-ext` isn't installed: + + ```sh + rm web-ext.config.ts + + # Keep the config in your home dir until all your projects have been upgraded + rm ~/web-ext.config.ts + ``` + +## New Deprecations in v0.20 + +Deprecated APIs will be removed in the next major release. + +- `wxt.config.runnerConfig` renamed to `wxt.config.webExt`. + ## v0.19.0 → v0.20.0 v0.20 is a big release! There are lots of breaking changes because this version is intended to be a release candidate for v1.0. If all goes well, v1.0 will be released with no additional breaking changes. diff --git a/docs/is-background.md b/docs/is-background.md new file mode 100644 index 000000000..8b99821fd --- /dev/null +++ b/docs/is-background.md @@ -0,0 +1 @@ + diff --git a/packages/analytics/CHANGELOG.md b/packages/analytics/CHANGELOG.md index fa2113ca7..10bc082dd 100644 --- a/packages/analytics/CHANGELOG.md +++ b/packages/analytics/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## v0.5.3 + +[compare changes](https://github.com/wxt-dev/wxt/compare/analytics-v0.5.2...analytics-v0.5.3) + +### 🩹 Fixes + +- Add `getAppConfig` as an alias to `useAppConfig` ([#2144](https://github.com/wxt-dev/wxt/pull/2144)) +- Allow `userId` option to return `undefined` ([636cf1f8](https://github.com/wxt-dev/wxt/commit/636cf1f8)) +- Improve background script detection logic for analytics package ([#1808](https://github.com/wxt-dev/wxt/pull/1808)) + +### 🏡 Chore + +- Created new types, instead of `any` for `analytics` ([#2119](https://github.com/wxt-dev/wxt/pull/2119)) + +### ❤️ Contributors + +- Smit ([@sm17p](https://github.com/sm17p)) +- Aaron ([@aklinker1](https://github.com/aklinker1)) +- Patryk Kuniczak ([@PatrykKuniczak](https://github.com/PatrykKuniczak)) +- Sheng Zhang ([@Arktomson](https://github.com/Arktomson)) + ## v0.5.2 [compare changes](https://github.com/wxt-dev/wxt/compare/analytics-v0.5.1...analytics-v0.5.2) diff --git a/packages/analytics/modules/analytics/client.ts b/packages/analytics/modules/analytics/client.ts index d37d016a5..a06395b14 100644 --- a/packages/analytics/modules/analytics/client.ts +++ b/packages/analytics/modules/analytics/client.ts @@ -2,14 +2,30 @@ import { UAParser } from 'ua-parser-js'; import type { Analytics, AnalyticsConfig, + AnalyticsEventMetadata, AnalyticsPageViewEvent, + AnalyticsProvider, AnalyticsStorageItem, AnalyticsTrackEvent, BaseAnalyticsEvent, - AnalyticsEventMetadata, - AnalyticsProvider, } from './types'; import { browser } from '@wxt-dev/browser'; +import { isBackground } from '@wxt-dev/is-background'; + +type AnalyticsMessage = { + [K in keyof Analytics]: { + fn: K; + args: Parameters; + }; +}[keyof Analytics]; + +type AnalyticsMethod = + | ((...args: Parameters) => void) + | undefined; + +type MethodForwarder = ( + fn: K, +) => (...args: Parameters) => void; const ANALYTICS_PORT = '@wxt-dev/analytics'; @@ -40,10 +56,7 @@ export function createAnalytics(config?: AnalyticsConfig): Analytics { ); } - // TODO: This only works for standard WXT extensions, add a more generic - // background script detector that works with non-WXT projects. - if (location.pathname === '/background.js') - return createBackgroundAnalytics(config); + if (isBackground()) return createBackgroundAnalytics(config); return createFrontendAnalytics(); } @@ -163,7 +176,7 @@ function createBackgroundAnalytics( }, track: async ( eventName: string, - eventProperties?: Record, + eventProperties?: Record, meta: AnalyticsEventMetadata = getBackgroundMeta(), ) => { const baseEvent = await getBaseEvent(meta); @@ -197,9 +210,8 @@ function createBackgroundAnalytics( // Listen for messages from the rest of the extension browser.runtime.onConnect.addListener((port) => { if (port.name === ANALYTICS_PORT) { - port.onMessage.addListener(({ fn, args }) => { - // @ts-expect-error: Untyped fn key - void analytics[fn]?.(...args); + port.onMessage.addListener(({ fn, args }: AnalyticsMessage) => { + void (analytics[fn] as AnalyticsMethod)?.(...args); }); } }); @@ -217,17 +229,15 @@ function createFrontendAnalytics(): Analytics { sessionId, timestamp: Date.now(), language: navigator.language, - referrer: globalThis.document?.referrer || undefined, - screen: globalThis.window - ? `${globalThis.window.screen.width}x${globalThis.window.screen.height}` - : undefined, + referrer: document.referrer || undefined, + screen: `${window.screen.width}x${window.screen.height}`, url: location.href, title: document.title || undefined, }); - const methodForwarder = - (fn: string) => - (...args: any[]) => { + const methodForwarder: MethodForwarder = + (fn) => + (...args) => { port.postMessage({ fn, args: [...args, getFrontendMetadata()] }); }; @@ -238,11 +248,11 @@ function createFrontendAnalytics(): Analytics { setEnabled: methodForwarder('setEnabled'), autoTrack: (root) => { const onClick = (event: Event) => { - const element = event.target as any; + const element = event.target as HTMLElement | null; if ( !element || (!INTERACTIVE_TAGS.has(element.tagName) && - !INTERACTIVE_ROLES.has(element.getAttribute('role'))) + !INTERACTIVE_ROLES.has(element.getAttribute('role') ?? '')) ) return; @@ -251,7 +261,7 @@ function createFrontendAnalytics(): Analytics { id: element.id || undefined, className: element.className || undefined, textContent: element.textContent?.substring(0, 50) || undefined, // Limit text content length - href: element.href, + href: (element as HTMLAnchorElement).href, }); }; root.addEventListener('click', onClick, { capture: true, passive: true }); @@ -263,13 +273,18 @@ function createFrontendAnalytics(): Analytics { return analytics; } +function defineStorageItem(key: string): AnalyticsStorageItem; function defineStorageItem( key: string, - defaultValue?: NonNullable, -): AnalyticsStorageItem { + defaultValue: T, +): AnalyticsStorageItem; +function defineStorageItem( + key: string, + defaultValue?: unknown, +): AnalyticsStorageItem { return { getValue: async () => - (await browser.storage.local.get>(key))[key] ?? + (await browser.storage.local.get>(key))[key] ?? defaultValue, setValue: (newValue) => browser.storage.local.set({ [key]: newValue }), }; diff --git a/packages/analytics/modules/analytics/index.ts b/packages/analytics/modules/analytics/index.ts index e8236a810..44f62879f 100644 --- a/packages/analytics/modules/analytics/index.ts +++ b/packages/analytics/modules/analytics/index.ts @@ -44,9 +44,9 @@ export default defineWxtModule({ ? clientModuleId : normalizePath(relative(wxtAnalyticsFolder, clientModuleId)) }'; -import { getAppConfig } from '#imports'; +import { useAppConfig } from '#imports'; -export const analytics = createAnalytics(getAppConfig().analytics); +export const analytics = createAnalytics(useAppConfig().analytics); `; addAlias(wxt, '#analytics', wxtAnalyticsIndex); wxt.hook('prepare:types', async (_, entries) => { diff --git a/packages/analytics/modules/analytics/types.ts b/packages/analytics/modules/analytics/types.ts index d14d59f81..bf1405c52 100644 --- a/packages/analytics/modules/analytics/types.ts +++ b/packages/analytics/modules/analytics/types.ts @@ -2,7 +2,10 @@ export interface Analytics { /** Report a page change. */ page: (url: string) => void; /** Report a custom event. */ - track: (eventName: string, eventProperties?: Record) => void; + track: ( + eventName: string, + eventProperties?: Record, + ) => void; /** Save information about the user. */ identify: (userId: string, userProperties?: Record) => void; /** Automatically setup and track user interactions, returning a function to remove any listeners that were setup. */ @@ -32,7 +35,7 @@ export interface AnalyticsConfig { /** * Configure how the user Id is persisted. Defaults to using `browser.storage.local`. */ - userId?: AnalyticsStorageItem; + userId?: AnalyticsStorageItem; /** * Configure how user properties are persisted. Defaults to using `browser.storage.local`. */ @@ -94,6 +97,6 @@ export interface AnalyticsPageViewEvent extends BaseAnalyticsEvent { export interface AnalyticsTrackEvent extends BaseAnalyticsEvent { event: { name: string; - properties?: Record; + properties?: Record; }; } diff --git a/packages/analytics/package.json b/packages/analytics/package.json index d87c58003..a2f1cff6b 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,14 +1,36 @@ { "name": "@wxt-dev/analytics", - "version": "0.5.2", + "version": "0.5.3", "description": "Add analytics to your web extension", + "type": "module", + "scripts": { + "dev": "buildc --deps-only -- wxt", + "dev:build": "buildc --deps-only -- wxt build", + "check": "pnpm build && check", + "build": "buildc -- tsdown", + "prepack": "pnpm -s build", + "prepare": "buildc --deps-only -- wxt prepare" + }, + "dependencies": { + "@wxt-dev/browser": "workspace:^", + "@wxt-dev/is-background": "workspace:^", + "ua-parser-js": "^1.0.40" + }, + "peerDependencies": { + "wxt": ">=0.20.0" + }, + "devDependencies": { + "@types/ua-parser-js": "^0.7.39", + "publint": "^0.3.17", + "typescript": "^5.9.3", + "wxt": "workspace:*" + }, "repository": { "type": "git", "url": "git+https://github.com/wxt-dev/wxt.git", "directory": "packages/analytics" }, "license": "MIT", - "type": "module", "exports": { ".": { "types": "./dist/index.d.mts", @@ -38,26 +60,5 @@ "types": "./dist/index.d.mts", "files": [ "dist" - ], - "scripts": { - "dev": "buildc --deps-only -- wxt", - "dev:build": "buildc --deps-only -- wxt build", - "check": "pnpm build && check", - "build": "buildc -- tsdown", - "prepack": "pnpm -s build", - "prepare": "buildc --deps-only -- wxt prepare" - }, - "peerDependencies": { - "wxt": ">=0.20.0" - }, - "devDependencies": { - "@types/ua-parser-js": "^0.7.39", - "publint": "^0.3.17", - "typescript": "^5.9.3", - "wxt": "workspace:*" - }, - "dependencies": { - "@wxt-dev/browser": "workspace:^", - "ua-parser-js": "^1.0.40" - } + ] } diff --git a/packages/auto-icons/CHANGELOG.md b/packages/auto-icons/CHANGELOG.md index 04a9da8e7..cdb167434 100644 --- a/packages/auto-icons/CHANGELOG.md +++ b/packages/auto-icons/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## v1.1.1 + +[compare changes](https://github.com/wxt-dev/wxt/compare/auto-icons-v1.1.0...auto-icons-v1.1.1) + +### 🩹 Fixes + +- Auto icons override default icons ([#1616](https://github.com/wxt-dev/wxt/pull/1616)) + +### 💅 Refactors + +- Standardize file existence checks to `pathExists` ([#2083](https://github.com/wxt-dev/wxt/pull/2083)) + +### 🏡 Chore + +- **deps:** Upgrade oxlint from 0.16.8 to 1.14.0 ([a01928e0](https://github.com/wxt-dev/wxt/commit/a01928e0)) +- **deps:** Upgrade typescript from 5.8.3 to 5.9.2 ([a6eef643](https://github.com/wxt-dev/wxt/commit/a6eef643)) +- Create script for managing dependency upgrades ([#1875](https://github.com/wxt-dev/wxt/pull/1875)) +- **deps:** Upgrade all dev dependencies ([#1876](https://github.com/wxt-dev/wxt/pull/1876)) +- **deps:** Upgrade non-breaking production dependencies ([#1877](https://github.com/wxt-dev/wxt/pull/1877)) +- Upgrade dev and non-major prod dependencies ([#2000](https://github.com/wxt-dev/wxt/pull/2000)) +- Use `tsdown` to build packages ([#2006](https://github.com/wxt-dev/wxt/pull/2006)) +- Move script-only dev dependencies to top-level `package.json` ([#2007](https://github.com/wxt-dev/wxt/pull/2007)) +- Update dependencies ([#2069](https://github.com/wxt-dev/wxt/pull/2069)) + +### ❤️ Contributors + +- Omerfardemir +- Aaron ([@aklinker1](https://github.com/aklinker1)) + ## v1.1.0 [compare changes](https://github.com/wxt-dev/wxt/compare/auto-icons-v1.0.2...auto-icons-v1.1.0) diff --git a/packages/auto-icons/package.json b/packages/auto-icons/package.json index d0ecd451f..53903416e 100644 --- a/packages/auto-icons/package.json +++ b/packages/auto-icons/package.json @@ -24,7 +24,7 @@ } ], "license": "MIT", - "version": "1.1.0", + "version": "1.1.1", "type": "module", "module": "./dist/index.mjs", "types": "./dist/index.d.mts", @@ -39,7 +39,8 @@ ], "scripts": { "build": "buildc -- tsdown", - "check": "pnpm build && check" + "check": "pnpm build && check", + "test": "buildc --deps-only -- vitest" }, "peerDependencies": { "wxt": ">=0.19.0" @@ -53,6 +54,7 @@ "oxlint": "^1.43.0", "publint": "^0.3.17", "typescript": "^5.9.3", + "vitest": "^4.0.18", "wxt": "workspace:*" } } diff --git a/packages/auto-icons/src/__test__/index.test.ts b/packages/auto-icons/src/__test__/index.test.ts new file mode 100644 index 000000000..abe1732c3 --- /dev/null +++ b/packages/auto-icons/src/__test__/index.test.ts @@ -0,0 +1,629 @@ +import { describe, it, expect, vi, beforeEach, Mock } from 'vitest'; +import { resolve } from 'node:path'; +import * as fsExtra from 'fs-extra'; +import sharp from 'sharp'; +import type { Wxt, UserManifest } from 'wxt'; + +// Import the actual module +import autoIconsModule from '../index'; +import type { AutoIconsOptions } from '../index'; + +// Mock dependencies +vi.mock('fs-extra', () => ({ + ensureDir: vi.fn(), + pathExists: vi.fn(), +})); + +vi.mock('sharp', () => ({ + default: vi.fn(), +})); + +// Type definitions for better type safety +interface MockWxt { + config: { + srcDir: string; + outDir: string; + mode: 'development' | 'production'; + }; + logger: { + warn: Mock; + }; + hooks: { + hook: Mock; + }; +} + +interface PublicAsset { + type: string; + fileName: string; +} + +interface BuildOutput { + publicAssets: PublicAsset[]; +} + +describe('auto-icons module', () => { + const mockWxt: MockWxt = { + config: { + srcDir: '/mock/src', + outDir: '/mock/dist', + mode: 'development', + }, + logger: { + warn: vi.fn(), + }, + hooks: { + hook: vi.fn(), + }, + }; + + const createMockSharpInstance = () => { + const instance = { + png: vi.fn(), + grayscale: vi.fn(), + resize: vi.fn(), + toFile: vi.fn().mockResolvedValue(undefined), + }; + + // Make methods chainable + instance.png.mockReturnValue(instance); + instance.grayscale.mockReturnValue(instance); + instance.resize.mockImplementation(() => { + // Create a new instance for each resize to simulate real sharp behavior + const resizedInstance = { ...instance }; + resizedInstance.toFile = vi.fn().mockResolvedValue(undefined); + return resizedInstance; + }); + + return instance; + }; + + let mockSharpInstance: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSharpInstance = createMockSharpInstance(); + vi.mocked(sharp).mockReturnValue( + mockSharpInstance as unknown as sharp.Sharp, + ); + vi.mocked(fsExtra.pathExists).mockResolvedValue(true as any); + vi.mocked(fsExtra.ensureDir).mockResolvedValue(undefined as any); + }); + + describe('module setup', () => { + it('should have correct module metadata', () => { + expect(autoIconsModule.name).toBe('@wxt-dev/auto-icons'); + expect(autoIconsModule.configKey).toBe('autoIcons'); + expect(typeof autoIconsModule.setup).toBe('function'); + }); + }); + + describe('options handling', () => { + it('should use default options when not provided', async () => { + const options: AutoIconsOptions = {}; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + // Verify that the module was set up (hooks were registered) + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:manifestGenerated', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:done', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'prepare:publicPaths', + expect.any(Function), + ); + }); + + it('should merge custom options with defaults', async () => { + const options: AutoIconsOptions = { + sizes: [64, 32], + grayscaleOnDevelopment: false, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + // Verify that the module was set up with custom options + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:manifestGenerated', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'build:done', + expect.any(Function), + ); + expect(mockWxt.hooks.hook).toHaveBeenCalledWith( + 'prepare:publicPaths', + expect.any(Function), + ); + }); + }); + + describe('error handling', () => { + it('should warn when disabled', async () => { + const options: AutoIconsOptions = { + enabled: false, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + expect(mockWxt.logger.warn).toHaveBeenCalledWith( + '`[auto-icons]` @wxt-dev/auto-icons disabled', + ); + expect(mockWxt.hooks.hook).not.toHaveBeenCalled(); + }); + + it('should warn when base icon not found', async () => { + vi.mocked(fsExtra.pathExists).mockResolvedValue(false as any); + + const options: AutoIconsOptions = { + enabled: true, + baseIconPath: 'assets/missing-icon.png', + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + expect(mockWxt.logger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipping icon generation, no base icon found at', + ), + ); + expect(mockWxt.hooks.hook).not.toHaveBeenCalled(); + }); + }); + + describe('manifest generation hook', () => { + it('should update manifest with default icons when no custom sizes provided', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + expect(manifestHook).toBeDefined(); + + const manifest: UserManifest = {}; + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + // Should use default sizes: [128, 48, 32, 16] + expect(manifest.icons).toEqual({ + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + }); + + it('should merge custom sizes with defaults', async () => { + const options: AutoIconsOptions = { + enabled: true, + sizes: [96, 64], // These will be merged with defaults + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + expect(manifestHook).toBeDefined(); + + const manifest: UserManifest = {}; + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + // defu merges arrays, so we get both custom and default sizes + expect(manifest.icons).toEqual({ + 96: 'icons/96.png', + 64: 'icons/64.png', + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + }); + + it('should warn when overwriting existing icons in manifest', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + const manifest: UserManifest = { + icons: { + 128: 'existing-icon.png', + }, + }; + + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + expect(mockWxt.logger.warn).toHaveBeenCalledWith( + '`[auto-icons]` icons property found in manifest, overwriting with auto-generated icons', + ); + }); + }); + + describe('icon generation hook', () => { + it('should generate icons with default sizes', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + const output: BuildOutput = { + publicAssets: [], + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + expect(buildHook).toBeDefined(); + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + expect(sharp).toHaveBeenCalledWith( + resolve('/mock/src', 'assets/icon.png'), + ); + expect(mockSharpInstance.png).toHaveBeenCalled(); + + // Should resize to default sizes + expect(mockSharpInstance.resize).toHaveBeenCalledWith(128); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(48); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(32); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(16); + + expect(fsExtra.ensureDir).toHaveBeenCalledWith( + resolve('/mock/dist', 'icons'), + ); + + expect(output.publicAssets).toEqual([ + { type: 'asset', fileName: 'icons/128.png' }, + { type: 'asset', fileName: 'icons/48.png' }, + { type: 'asset', fileName: 'icons/32.png' }, + { type: 'asset', fileName: 'icons/16.png' }, + ]); + }); + + it('should generate icons with custom sizes merged with defaults', async () => { + const options: AutoIconsOptions = { + enabled: true, + sizes: [96, 64], + }; + + const output: BuildOutput = { + publicAssets: [], + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + expect(buildHook).toBeDefined(); + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + // Should include both custom and default sizes + expect(mockSharpInstance.resize).toHaveBeenCalledWith(96); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(64); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(128); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(48); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(32); + expect(mockSharpInstance.resize).toHaveBeenCalledWith(16); + + expect(output.publicAssets).toEqual([ + { type: 'asset', fileName: 'icons/96.png' }, + { type: 'asset', fileName: 'icons/64.png' }, + { type: 'asset', fileName: 'icons/128.png' }, + { type: 'asset', fileName: 'icons/48.png' }, + { type: 'asset', fileName: 'icons/32.png' }, + { type: 'asset', fileName: 'icons/16.png' }, + ]); + }); + + it('should apply grayscale in development mode', async () => { + const options: AutoIconsOptions = { + enabled: true, + grayscaleOnDevelopment: true, + sizes: [128], + }; + + const output: BuildOutput = { publicAssets: [] }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + expect(mockSharpInstance.grayscale).toHaveBeenCalled(); + }); + + it('should not apply grayscale in production mode', async () => { + const prodMockWxt = { + ...mockWxt, + config: { + ...mockWxt.config, + mode: 'production' as const, + }, + }; + + const options: AutoIconsOptions = { + enabled: true, + grayscaleOnDevelopment: true, + sizes: [128], + }; + + const output: BuildOutput = { publicAssets: [] }; + + await autoIconsModule.setup!(prodMockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(prodMockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + if (buildHook) { + await buildHook(prodMockWxt as unknown as Wxt, output); + } + + expect(mockSharpInstance.grayscale).not.toHaveBeenCalled(); + }); + + it('should not apply grayscale when disabled', async () => { + const options: AutoIconsOptions = { + enabled: true, + grayscaleOnDevelopment: false, + sizes: [128], + }; + + const output: BuildOutput = { publicAssets: [] }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + expect(mockSharpInstance.grayscale).not.toHaveBeenCalled(); + }); + }); + + describe('public paths hook', () => { + it('should add default icon paths to public paths', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const pathsHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'prepare:publicPaths')?.[1]; + + expect(pathsHook).toBeDefined(); + + const paths: string[] = []; + if (pathsHook) { + pathsHook(mockWxt as unknown as Wxt, paths); + } + + expect(paths).toEqual([ + 'icons/128.png', + 'icons/48.png', + 'icons/32.png', + 'icons/16.png', + ]); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle empty sizes array', async () => { + const options: AutoIconsOptions = { + enabled: true, + sizes: [], // Empty array should still merge with defaults + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + + const manifest: UserManifest = {}; + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + + // Should still have default sizes due to defu merge + expect(manifest.icons).toEqual({ + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + }); + + it('should handle sharp processing errors gracefully', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + // Make toFile throw an error - need to properly chain resize -> png -> toFile + const errorInstance = { + toFile: vi.fn().mockRejectedValue(new Error('Sharp processing failed')), + grayscale: vi.fn(), + composite: vi.fn(), + }; + errorInstance.grayscale.mockReturnValue(errorInstance); + errorInstance.composite.mockReturnValue(errorInstance); + + mockSharpInstance.resize = vi.fn().mockImplementation(() => ({ + png: vi.fn().mockReturnValue(errorInstance), + })); + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + const output: BuildOutput = { publicAssets: [] }; + + // Should throw the sharp error + if (buildHook) { + await expect( + buildHook(mockWxt as unknown as Wxt, output), + ).rejects.toThrow('Sharp processing failed'); + } + }); + + it('should handle file system errors during directory creation', async () => { + const options: AutoIconsOptions = { + enabled: true, + }; + + // Make ensureDir throw an error + vi.mocked(fsExtra.ensureDir).mockRejectedValue( + new Error('Directory creation failed'), + ); + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + const output: BuildOutput = { publicAssets: [] }; + + // The module doesn't await ensureDir, so it won't throw + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + // But ensureDir should have been called + expect(fsExtra.ensureDir).toHaveBeenCalled(); + } + }); + + it('should handle custom base icon path correctly', async () => { + const customPath = 'custom/icon.png'; + const options: AutoIconsOptions = { + enabled: true, + baseIconPath: customPath, + }; + + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + + const output: BuildOutput = { publicAssets: [] }; + + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + + // Should resolve the path relative to srcDir + expect(sharp).toHaveBeenCalledWith(resolve('/mock/src', customPath)); + }); + }); + + describe('integration test', () => { + it('should handle full workflow correctly', async () => { + const options: AutoIconsOptions = { + enabled: true, + baseIconPath: 'assets/custom-icon.png', + sizes: [96], // Will be merged with defaults + grayscaleOnDevelopment: false, + }; + + const manifest: UserManifest = {}; + const output: BuildOutput = { publicAssets: [] }; + const paths: string[] = []; + + // Setup the module + await autoIconsModule.setup!(mockWxt as unknown as Wxt, options); + + // Execute all hooks + const manifestHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:manifestGenerated')?.[1]; + const buildHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'build:done')?.[1]; + const pathsHook = vi + .mocked(mockWxt.hooks.hook) + .mock.calls.find((call) => call[0] === 'prepare:publicPaths')?.[1]; + + if (manifestHook) { + await manifestHook(mockWxt as unknown as Wxt, manifest); + } + if (buildHook) { + await buildHook(mockWxt as unknown as Wxt, output); + } + if (pathsHook) { + pathsHook(mockWxt as unknown as Wxt, paths); + } + + // Verify results - defu merges arrays + expect(manifest.icons).toEqual({ + 96: 'icons/96.png', + 128: 'icons/128.png', + 48: 'icons/48.png', + 32: 'icons/32.png', + 16: 'icons/16.png', + }); + + expect(output.publicAssets).toEqual([ + { type: 'asset', fileName: 'icons/96.png' }, + { type: 'asset', fileName: 'icons/128.png' }, + { type: 'asset', fileName: 'icons/48.png' }, + { type: 'asset', fileName: 'icons/32.png' }, + { type: 'asset', fileName: 'icons/16.png' }, + ]); + + expect(paths).toEqual([ + 'icons/96.png', + 'icons/128.png', + 'icons/48.png', + 'icons/32.png', + 'icons/16.png', + ]); + + expect(sharp).toHaveBeenCalledWith( + resolve('/mock/src', 'assets/custom-icon.png'), + ); + expect(mockSharpInstance.grayscale).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/auto-icons/src/index.ts b/packages/auto-icons/src/index.ts index b13616e53..1c8fb8c96 100644 --- a/packages/auto-icons/src/index.ts +++ b/packages/auto-icons/src/index.ts @@ -45,7 +45,7 @@ export default defineWxtModule({ wxt.hooks.hook('build:manifestGenerated', async (wxt, manifest) => { if (manifest.icons) - return wxt.logger.warn( + wxt.logger.warn( '`[auto-icons]` icons property found in manifest, overwriting with auto-generated icons', ); diff --git a/packages/auto-icons/vitest.config.ts b/packages/auto-icons/vitest.config.ts new file mode 100644 index 000000000..d6d6dd072 --- /dev/null +++ b/packages/auto-icons/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + mockReset: true, + restoreMocks: true, + }, +}); diff --git a/packages/i18n/CHANGELOG.md b/packages/i18n/CHANGELOG.md index 2e8e1cef4..88b19c7a5 100644 --- a/packages/i18n/CHANGELOG.md +++ b/packages/i18n/CHANGELOG.md @@ -1,5 +1,31 @@ # Changelog +## v0.2.5 + +[compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.4...i18n-v0.2.5) + +### 🩹 Fixes + +- Add `.jsonc` support for locale files to match docs ([#2066](https://github.com/wxt-dev/wxt/pull/2066)) + +### 🏡 Chore + +- Manually fix markdownlint errors ([#1711](https://github.com/wxt-dev/wxt/pull/1711)) +- **deps:** Upgrade oxlint from 0.16.8 to 1.14.0 ([a01928e0](https://github.com/wxt-dev/wxt/commit/a01928e0)) +- **deps:** Upgrade typescript from 5.8.3 to 5.9.2 ([a6eef643](https://github.com/wxt-dev/wxt/commit/a6eef643)) +- Create script for managing dependency upgrades ([#1875](https://github.com/wxt-dev/wxt/pull/1875)) +- **deps:** Upgrade all dev dependencies ([#1876](https://github.com/wxt-dev/wxt/pull/1876)) +- Upgrade dev and non-major prod dependencies ([#2000](https://github.com/wxt-dev/wxt/pull/2000)) +- Use `tsdown` to build packages ([#2006](https://github.com/wxt-dev/wxt/pull/2006)) +- Move script-only dev dependencies to top-level `package.json` ([#2007](https://github.com/wxt-dev/wxt/pull/2007)) +- Update dependencies ([#2069](https://github.com/wxt-dev/wxt/pull/2069)) +- Upgrade major deps ([#2070](https://github.com/wxt-dev/wxt/pull/2070)) + +### ❤️ Contributors + +- Ilya Kubariev ([@gymnasy55](https://github.com/gymnasy55)) +- Aaron ([@aklinker1](https://github.com/aklinker1)) + ## v0.2.4 [compare changes](https://github.com/wxt-dev/wxt/compare/i18n-v0.2.3...i18n-v0.2.4) diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 5c5555fbe..3f2bf52f4 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,7 +1,7 @@ { "name": "@wxt-dev/i18n", "description": "Type-safe wrapper around browser.i18n.getMessage with additional features", - "version": "0.2.4", + "version": "0.2.5", "type": "module", "repository": { "type": "git", diff --git a/packages/is-background/README.md b/packages/is-background/README.md new file mode 100644 index 000000000..f1afc0539 --- /dev/null +++ b/packages/is-background/README.md @@ -0,0 +1,17 @@ +# `@wxt-dev/is-background` + +Exports a getter to determine if the current JS context is the background or not. + +## Installation + +```sh +pnpm add @wxt-dev/is-background +``` + +## Usage + +```ts +import { isBackground } from '@wxt-dev/is-background'; + +isBackground(); // true | false +``` diff --git a/packages/is-background/package.json b/packages/is-background/package.json new file mode 100644 index 000000000..fb1d17a6d --- /dev/null +++ b/packages/is-background/package.json @@ -0,0 +1,56 @@ +{ + "name": "@wxt-dev/is-background", + "type": "module", + "version": "1.0.0", + "description": "Check if the current context is the background or not.", + "license": "MIT", + "scripts": { + "build": "buildc -- tsdown", + "check": "pnpm build && check", + "test": "buildc --deps-only -- vitest", + "test:coverage": "pnpm test run --coverage", + "prepack": "pnpm build" + }, + "dependencies": { + "@wxt-dev/browser": "workspace:^" + }, + "devDependencies": { + "oxlint": "^1.43.0", + "publint": "^0.3.17", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wxt-dev/wxt.git", + "directory": "packages/is-background" + }, + "keywords": [ + "wxt", + "chrome", + "web", + "extension", + "is", + "background", + "script", + "page", + "service", + "worker" + ], + "author": { + "name": "Aaron Klinker", + "email": "aaronklinker1+wxt@gmail.com" + }, + "funding": "https://github.com/sponsors/wxt-dev", + "files": [ + "dist" + ], + "types": "./dist/index.d.mts", + "module": "./dist/index.mjs", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + } + } +} diff --git a/packages/is-background/src/__tests__/getter.test.ts b/packages/is-background/src/__tests__/getter.test.ts new file mode 100644 index 000000000..65be10076 --- /dev/null +++ b/packages/is-background/src/__tests__/getter.test.ts @@ -0,0 +1,145 @@ +import { describe, it, vi, expect } from 'vitest'; +import { getIsBackground } from '../getter'; + +let mockBrowser: any; +vi.mock('@wxt-dev/browser', () => ({ + get browser() { + return mockBrowser; + }, +})); + +const backgroundWindow = Symbol('Background'); +const otherWindow = Symbol('Other'); + +function setupEnv(options: { + window: symbol | undefined; + hasExtensionApis: boolean; + hasGetBackgroundPage: boolean; + hasServiceWorkerGlobalScope: boolean; +}): void { + vi.unstubAllGlobals(); + mockBrowser = undefined; + + if (options.window) { + vi.stubGlobal('window', options.window); + } + + if (options.hasExtensionApis) { + mockBrowser = { + runtime: { + id: 'test', + }, + ...(options.hasGetBackgroundPage && { + extension: { + getBackgroundPage: () => backgroundWindow, + }, + }), + }; + } + + if (options.hasServiceWorkerGlobalScope) { + class ServiceWorkerGlobalScope {} + vi.stubGlobal('ServiceWorkerGlobalScope', ServiceWorkerGlobalScope); + vi.stubGlobal('self', new ServiceWorkerGlobalScope()); + } +} + +describe('isBackground Getter', () => { + describe('Non-extension contexts', () => { + it('should return false', () => { + setupEnv({ + hasServiceWorkerGlobalScope: false, + hasExtensionApis: false, + hasGetBackgroundPage: false, + window: otherWindow, + }); + + expect(getIsBackground()).toBe(false); + }); + }); + + describe('Chromium & Safari', () => { + const hasExtensionApis = true; + + describe('MV2', () => { + const hasServiceWorkerGlobalScope = false; + const hasGetBackgroundPage = true; + + it('should return true inside the background page', () => { + setupEnv({ + hasServiceWorkerGlobalScope, + hasExtensionApis, + hasGetBackgroundPage, + window: backgroundWindow, + }); + + expect(getIsBackground()).toBe(true); + }); + + it('should return false outside the background page', () => { + setupEnv({ + hasServiceWorkerGlobalScope, + hasExtensionApis, + hasGetBackgroundPage, + window: otherWindow, + }); + + expect(getIsBackground()).toBe(false); + }); + }); + + describe('MV3', () => { + const hasGetBackgroundPage = false; + + it('should return true inside the service worker', () => { + setupEnv({ + hasServiceWorkerGlobalScope: true, + hasExtensionApis, + hasGetBackgroundPage, + window: undefined, + }); + + expect(getIsBackground()).toBe(true); + }); + + it('should return false outside the service worker', () => { + setupEnv({ + hasServiceWorkerGlobalScope: false, + hasExtensionApis, + hasGetBackgroundPage, + window: otherWindow, + }); + + expect(getIsBackground()).toBe(false); + }); + }); + }); + + describe('Firefox, MV2 & MV3', () => { + const hasServiceWorkerGlobalScope = false; + const hasExtensionApis = true; + const hasGetBackgroundPage = true; + + it('should return true inside the background page', () => { + setupEnv({ + hasServiceWorkerGlobalScope, + hasExtensionApis, + hasGetBackgroundPage, + window: backgroundWindow, + }); + + expect(getIsBackground()).toBe(true); + }); + + it('should return false outside the background page', () => { + setupEnv({ + hasServiceWorkerGlobalScope, + hasExtensionApis, + hasGetBackgroundPage, + window: otherWindow, + }); + + expect(getIsBackground()).toBe(false); + }); + }); +}); diff --git a/packages/is-background/src/getter.ts b/packages/is-background/src/getter.ts new file mode 100644 index 000000000..700f98bce --- /dev/null +++ b/packages/is-background/src/getter.ts @@ -0,0 +1,32 @@ +import { browser } from '@wxt-dev/browser'; + +declare class ServiceWorkerGlobalScope {} + +export function getIsBackground(): boolean { + // Are we in an extension context? + if (!browser?.runtime?.id) return false; + + // Is this a true MV3 service worker? + // + // - ✅ Chromium MV3 + // - ❌ Firefox MV3 - Uses a non-persistent HTML page instead of a service worker. + // - ✅ Safari MV3 + if ( + typeof ServiceWorkerGlobalScope !== 'undefined' && + self instanceof ServiceWorkerGlobalScope + ) { + return true; + } + + // Is this the background page? + // + // - ✅ Chromium MV2 + // - ✅ Firefox MV2 + // - ✅ Firefox MV3 - Works with the non-persistent HTML page + // - ✅ Safari MV2 + return ( + typeof window !== 'undefined' && + typeof browser.extension?.getBackgroundPage === 'function' && + browser.extension.getBackgroundPage() === window + ); +} diff --git a/packages/is-background/src/index.ts b/packages/is-background/src/index.ts new file mode 100644 index 000000000..4b03d4d1e --- /dev/null +++ b/packages/is-background/src/index.ts @@ -0,0 +1,27 @@ +/** + * This module uses a lazy getter function so the logic isn't ran until it's needed. + * + * This has a few benefits: + * 1. Easier to mock in tests + * 2. Safe to import in NodeJS environments (but it should be safe to run just in-case) + * 3. Keeps startup fast by waiting to run the slow functions (`instanceof` or `browser.extension.getBackgroundPage`) until needed + * + * @module @wxt-dev/is-background + */ +import { getIsBackground } from './getter'; + +let cached: boolean | undefined; + +/** + * Getter that returns if the current context is apart of an extension's + * background or not. + * + * > This function caches the result when called for the first time so it + * > doesn't have to recalculate. + * + * @returns true when in a background page or service worker. + */ +export function isBackground(): boolean { + if (cached == null) cached = getIsBackground(); + return cached; +} diff --git a/packages/is-background/tsconfig.json b/packages/is-background/tsconfig.json new file mode 100644 index 000000000..9b2dc061d --- /dev/null +++ b/packages/is-background/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "exclude": ["node_modules/**", "dist/**"] +} diff --git a/packages/storage/CHANGELOG.md b/packages/storage/CHANGELOG.md index 1db482688..90fcb60cc 100644 --- a/packages/storage/CHANGELOG.md +++ b/packages/storage/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v1.2.8 + +[compare changes](https://github.com/wxt-dev/wxt/compare/storage-v1.2.7...storage-v1.2.8) + +### 🩹 Fixes + +- Correctly update version metadata when setting a value for the first time ([#2139](https://github.com/wxt-dev/wxt/pull/2139)) + +### ❤️ Contributors + +- Aaron ([@aklinker1](https://github.com/aklinker1)) + ## v1.2.7 [compare changes](https://github.com/wxt-dev/wxt/compare/storage-v1.2.6...storage-v1.2.7) diff --git a/packages/storage/package.json b/packages/storage/package.json index 22d2d32d5..a6ac36b71 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -1,7 +1,7 @@ { "name": "@wxt-dev/storage", "description": "Web extension storage API provided by WXT, supports all browsers.", - "version": "1.2.7", + "version": "1.2.8", "type": "module", "repository": { "type": "git", diff --git a/packages/wxt-demo/src/entrypoints/background.ts b/packages/wxt-demo/src/entrypoints/background.ts index ab38f0c3b..3243b0bc9 100644 --- a/packages/wxt-demo/src/entrypoints/background.ts +++ b/packages/wxt-demo/src/entrypoints/background.ts @@ -43,3 +43,14 @@ export default defineBackground({ storage.setItem('session:startTime', Date.now()); }, }); + +function _otherTypeChecksNotEvaluated() { + browser.scripting.executeScript({ + target: { tabId: 1 }, + files: [ + '/background.js', + // @ts-expect-error: Should error for non-existing paths + '/other.js', + ], + }); +} diff --git a/packages/wxt/e2e/tests/dev.test.ts b/packages/wxt/e2e/tests/dev.test.ts index c782bb94c..3eabac773 100644 --- a/packages/wxt/e2e/tests/dev.test.ts +++ b/packages/wxt/e2e/tests/dev.test.ts @@ -10,7 +10,7 @@ describe('Dev Mode', () => { ); const server = await project.startServer({ - runner: { + webExt: { disabled: true, }, }); diff --git a/packages/wxt/e2e/tests/hooks.test.ts b/packages/wxt/e2e/tests/hooks.test.ts index 9442c4088..47ecb1095 100644 --- a/packages/wxt/e2e/tests/hooks.test.ts +++ b/packages/wxt/e2e/tests/hooks.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { TestProject } from '../utils'; -import { WxtHooks } from '../../src/types'; +import type { WxtHooks } from '../../src'; const hooks: WxtHooks = { ready: vi.fn(), diff --git a/packages/wxt/e2e/tests/modules.test.ts b/packages/wxt/e2e/tests/modules.test.ts index 1a24efc19..af3ee0cf6 100644 --- a/packages/wxt/e2e/tests/modules.test.ts +++ b/packages/wxt/e2e/tests/modules.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi } from 'vitest'; import { TestProject } from '../utils'; -import type { GenericEntrypoint, InlineConfig } from '../../src/types'; +import type { InlineConfig, UnlistedScriptEntrypoint } from '../../src'; import { readFile } from 'fs-extra'; -import { normalizePath } from '../../src/core/utils/paths'; +import { normalizePath } from '../../src'; describe('Module Helpers', () => { describe('options', () => { @@ -49,7 +49,7 @@ describe('Module Helpers', () => { 'export default defineBackground(() => {})', ); - const entrypoint: GenericEntrypoint = { + const entrypoint: UnlistedScriptEntrypoint = { type: 'unlisted-script', inputPath: project.resolvePath('modules/test/injected.ts'), name: 'injected', diff --git a/packages/wxt/e2e/tests/output-structure.test.ts b/packages/wxt/e2e/tests/output-structure.test.ts index 3b0dc9339..2a691a9fe 100644 --- a/packages/wxt/e2e/tests/output-structure.test.ts +++ b/packages/wxt/e2e/tests/output-structure.test.ts @@ -459,4 +459,104 @@ describe('Output Directory Structure', () => { " `); }); + + describe('globalName option', () => { + it('generates an IIFE with a default name', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch(/^var content\s?=[\s\S]*^content;$/gm); + }); + + it('generates an IIFE with a specific name', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: "MyContentScript", + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch( + /^var MyContentScript =[\s\S]*^MyContentScript;$/gm, + ); + }); + + it('generates an IIFE with a specific name provided by a function', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: () => "MyContentScript", + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch( + /^var MyContentScript =[\s\S]*^MyContentScript;$/gm, + ); + }); + + it('generates an anonymous IIFE when not minified', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: false, + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: false } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch(/^\(function\(\) {[\s\S]*^}\)\(\);$/gm); + }); + + it('generates an anonymous IIFE when minified', async () => { + const project = new TestProject(); + project.addFile( + 'entrypoints/content.js', + `export default defineContentScript({ + globalName: false, + matches: ["*://*/*"], + main() {}, + })`, + ); + + await project.build({ vite: () => ({ build: { minify: true } }) }); + + const output = await project.serializeFile( + '.output/chrome-mv3/content-scripts/content.js', + ); + expect(output).toMatch(/^\(function\(\){[\s\S]*}\)\(\);$/gm); + }); + }); }); diff --git a/packages/wxt/e2e/tests/runners.test.ts b/packages/wxt/e2e/tests/runners.test.ts new file mode 100644 index 000000000..cc2ba82db --- /dev/null +++ b/packages/wxt/e2e/tests/runners.test.ts @@ -0,0 +1,159 @@ +import { expect, describe, vi, it, beforeEach } from 'vitest'; +import { ExtensionRunner } from '../../src'; +import { createSafariRunner } from '../../src/core/runners/safari'; +import { createManualRunner } from '../../src/core/runners/manual'; +import { createWebExtRunner } from '../../src/core/runners/web-ext'; +import { createWslRunner } from '../../src/core/runners/wsl'; +import { TestProject } from '../utils'; +import { wxt } from '../../src/core/wxt'; + +// Globals for modifying mock behaviors + +let isWsl = false; +let importWebExtRunnerError: Error | undefined = undefined; + +// Mock runners to create constants for checking equality + +type TestExtensionRunner = { name: string } & ExtensionRunner; + +function createMockExtensionRunner(name: string): TestExtensionRunner { + return { + name, + closeBrowser: () => Promise.resolve(), + openBrowser: () => Promise.resolve(), + }; +} + +vi.mock('../../src/core/runners/safari', () => { + const runner = createMockExtensionRunner('safari'); + return { createSafariRunner: () => runner }; +}); +const safariRunner = createSafariRunner(); + +vi.mock('../../src/core/runners/manual', () => { + const runner = createMockExtensionRunner('manual'); + return { createManualRunner: () => runner }; +}); +const manualRunner = createManualRunner(); + +vi.mock('../../src/core/runners/web-ext', () => { + const runner = createMockExtensionRunner('web-ext'); + return { + createWebExtRunner: () => { + if (!importWebExtRunnerError) return runner; + else throw importWebExtRunnerError; + }, + }; +}); +const webExtRunner = createWebExtRunner(); + +vi.mock('../../src/core/runners/wsl', () => { + const runner = createMockExtensionRunner('wsl'); + return { createWslRunner: () => runner }; +}); +const wslRunner = createWslRunner(); + +// Other mocks + +vi.mock('is-wsl', () => ({ + get default() { + return isWsl; + }, +})); + +/** + * Imitate a real module not found error - needs the correct `code` property. + */ +class ModuleNotFoundError extends Error { + code = 'ERR_MODULE_NOT_FOUND'; + + constructor(mod: string) { + super(`Cannot find package '${mod}' imported from ...`); + this.name = 'ModuleNotFoundError'; + } +} + +describe('Runners', () => { + beforeEach(() => { + isWsl = false; + importWebExtRunnerError = undefined; + }); + + describe('build', () => { + const command = 'build'; + + it('should use the manual runner as a placeholder since the runner is not used during builds', async () => { + await TestProject.simple().registerWxt(command); + + expect(wxt.config.runner).toBe(manualRunner); + }); + }); + + describe('dev', () => { + const command = 'serve'; + + describe('inside WSL', () => { + beforeEach(() => { + isWsl = true; + }); + + it('should use the WSL runner', async () => { + await TestProject.simple().registerWxt(command); + + expect(wxt.config.runner).toBe(wslRunner); + }); + }); + + describe('web-ext is installed', () => { + it('should use the web-ext runner', async () => { + await TestProject.simple().registerWxt(command); + + expect(wxt.config.runner).toBe(webExtRunner); + }); + + describe('disabled', () => { + it('should use the manual runner', async () => { + await TestProject.simple().registerWxt(command, { + webExt: { disabled: true }, + }); + + expect(wxt.config.runner).toBe(manualRunner); + }); + }); + }); + + describe('web-ext is not installed', () => { + beforeEach(() => { + importWebExtRunnerError = new ModuleNotFoundError('web-ext'); + }); + + it('should use the manual runner', async () => { + await TestProject.simple().registerWxt(command); + + expect(wxt.config.runner).toBe(manualRunner); + }); + }); + + describe('some other error when importing the web-ext runner', () => { + beforeEach(() => { + importWebExtRunnerError = Error('test'); + }); + + it('should throw the error', async () => { + await expect(TestProject.simple().registerWxt(command)).rejects.toThrow( + importWebExtRunnerError, + ); + }); + }); + + describe('targeting safari', () => { + it('should use the safari runner', async () => { + await TestProject.simple().registerWxt(command, { + browser: 'safari', + }); + + expect(wxt.config.runner).toBe(safariRunner); + }); + }); + }); +}); diff --git a/packages/wxt/e2e/tests/typescript-project.test.ts b/packages/wxt/e2e/tests/typescript-project.test.ts index 35d35a6aa..663c5d281 100644 --- a/packages/wxt/e2e/tests/typescript-project.test.ts +++ b/packages/wxt/e2e/tests/typescript-project.test.ts @@ -63,6 +63,29 @@ describe('TypeScript Project', () => { `); }); + it('should include CSS entrypoints in browser.runtime.getURL paths', async () => { + const project = new TestProject(); + project.addFile('entrypoints/unlisted.html', ''); + project.addFile( + 'entrypoints/plain.css', + `body { + color: red; + }`, + ); + project.addFile( + 'entrypoints/overlay.content.css', + `body { + color: blue; + }`, + ); + + await project.prepare(); + + const output = await project.serializeFile('.wxt/types/paths.d.ts'); + expect(output).toContain('| "/plain.css"'); + expect(output).toContain('| "/content-scripts/overlay.css"'); + }); + it('should augment the types for browser.i18n.getMessage', async () => { const project = new TestProject(); project.addFile('entrypoints/unlisted.html', ''); diff --git a/packages/wxt/e2e/tests/zip.test.ts b/packages/wxt/e2e/tests/zip.test.ts index 5a721e84a..f75c1cee5 100644 --- a/packages/wxt/e2e/tests/zip.test.ts +++ b/packages/wxt/e2e/tests/zip.test.ts @@ -77,21 +77,23 @@ describe('Zipping', () => { it('should correctly apply template variables for zip file names based on provided config', async () => { const project = new TestProject({ name: 'test', - version: '1.0.0', + version: '1.0.0-beta.1', }); project.addFile( 'entrypoints/background.ts', 'export default defineBackground(() => {});', ); - const artifactZip = '.output/test-1.0.0-firefox-development.zip'; - const sourcesZip = '.output/test-1.0.0-development-sources.zip'; + const artifactZip = '.output/test-1.0.0-beta.1-firefox-dev.zip'; + const sourcesZip = '.output/test-1.0.0-beta.1-sources-dev.zip'; await project.zip({ browser: 'firefox', mode: 'development', zip: { - artifactTemplate: '{{name}}-{{version}}-{{browser}}-{{mode}}.zip', - sourcesTemplate: '{{name}}-{{version}}-{{mode}}-sources.zip', + artifactTemplate: + '{{name}}-{{packageVersion}}-{{browser}}{{modeSuffix}}.zip', + sourcesTemplate: + '{{name}}-{{packageVersion}}-sources{{modeSuffix}}.zip', }, }); diff --git a/packages/wxt/e2e/utils.ts b/packages/wxt/e2e/utils.ts index f69682238..86133bfe0 100644 --- a/packages/wxt/e2e/utils.ts +++ b/packages/wxt/e2e/utils.ts @@ -6,12 +6,15 @@ import { dirname, relative, resolve } from 'path'; import { InlineConfig, UserConfig, + WxtCommand, build, createServer, prepare, zip, } from '../src'; import { normalizePath } from '../src/core/utils'; +import merge from 'lodash.merge'; +import { registerWxt } from '../src/core/wxt'; // Run "pnpm wxt" to use the "wxt" dev script, not the "wxt" binary from the // wxt package. This uses the TS files instead of the compiled JS package @@ -21,6 +24,18 @@ export const WXT_PACKAGE_DIR = resolve(__dirname, '..'); export const E2E_DIR = resolve(WXT_PACKAGE_DIR, 'e2e'); export class TestProject { + /** + * Create the simplest WXT project possible: one blank popup entrypoint, no + * custom config. + */ + static simple(): TestProject { + const project = new TestProject(); + + project.addFile('entrypoints/popup.html', ''); + + return project; + } + files: Array<[string, string]> = []; config: UserConfig | undefined; readonly root: string; @@ -87,6 +102,15 @@ export class TestProject { return this.resolvePath(filename); } + /** + * Register the global `wxt` object for this project. After calling, you can + * import `wxt` like normal and inspect it. + */ + async registerWxt(command: WxtCommand, config: InlineConfig = {}) { + await this.writeProjectToDisk(); + await registerWxt(command, { ...config, root: this.root }); + } + async prepare(config: InlineConfig = {}) { await this.writeProjectToDisk(); await prepare({ ...config, root: this.root }); diff --git a/packages/wxt/package.json b/packages/wxt/package.json index af7e4906b..b49bf293f 100644 --- a/packages/wxt/package.json +++ b/packages/wxt/package.json @@ -57,17 +57,19 @@ "prompts": "^2.4.2", "publish-browser-extension": "^2.3.0 || ^3.0.2 || ^4.0.0", "scule": "^1.3.0", - "unimport": "^3.13.1 || ^4.0.0 || ^5.0.0", - "vite": "^5.4.19 || ^6.3.4 || ^7.0.0", - "vite-node": "^3.2.4 || ^5.0.0", - "web-ext-run": "^0.2.4" + "unimport": "^3.13.1 || ^4.0.0 || ^5.0.0" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0" + "eslint": "^8.57.0 || ^9.0.0", + "vite": "^6.3.4 || ^7.0.0", + "web-ext": ">=9.2.0" }, "peerDependenciesMeta": { "eslint": { "optional": true + }, + "web-ext": { + "optional": true } }, "devDependencies": { @@ -84,8 +86,10 @@ "oxlint": "^1.43.0", "publint": "^0.3.17", "typescript": "^5.9.3", + "vite": "^7.3.1", "vitest": "^4.0.18", - "vitest-plugin-random-seed": "^1.1.2" + "vitest-plugin-random-seed": "^1.1.2", + "web-ext": "^9.2.0" }, "repository": { "type": "git", diff --git a/packages/wxt/src/@types/modules.d.ts b/packages/wxt/src/@types/modules.d.ts index 99fbc79af..3d4d058a5 100644 --- a/packages/wxt/src/@types/modules.d.ts +++ b/packages/wxt/src/@types/modules.d.ts @@ -1,23 +1,6 @@ // Custom TS definitions for non-TS packages -declare module 'zip-dir' { - // Represents the options object for zipdir function - interface ZipDirOptions { - saveTo?: string; - filter?: (path: string, stat: import('fs').Stats) => boolean; - each?: (path: string) => void; - } - - function zipdir( - dirPath: string, - options?: ZipDirOptions, - callback?: (error: Error | null, buffer: Buffer) => void, - ): Promise; - - export = zipdir; -} - -declare module 'web-ext-run' { +declare module 'web-ext' { export interface WebExtRunInstance { reloadAllExtensions(): Promise; exit(): Promise; @@ -31,7 +14,7 @@ declare module 'web-ext-run' { export default webExt; } -declare module 'web-ext-run/util/logger' { +declare module 'web-ext/util/logger' { // https://github.com/mozilla/web-ext/blob/e37e60a2738478f512f1255c537133321f301771/src/util/logger.js#L43 export interface IConsoleStream { stopCapturing(): void; diff --git a/packages/wxt/src/browser.ts b/packages/wxt/src/browser.ts index 0019efede..f205e1481 100644 --- a/packages/wxt/src/browser.ts +++ b/packages/wxt/src/browser.ts @@ -10,6 +10,7 @@ * @module wxt/browser */ import { browser as _browser, type Browser } from '@wxt-dev/browser'; +import type { ScriptPublicPath } from './utils/inject-script'; /** * This interface is empty because it is generated per-project when running `wxt prepare`. See: @@ -23,9 +24,39 @@ export interface WxtRuntime {} */ export interface WxtI18n {} -export type WxtBrowser = Omit & { +type ScriptInjection = + Browser.scripting.ScriptInjection extends infer T + ? T extends { files: string[] } + ? Omit & { files: ScriptPublicPath[] } + : T + : never; +type InjectionResult = Array< + Browser.scripting.InjectionResult> +>; + +export interface WxtScripting { + executeScript: { + /** + * @see {@link Browser.scripting.executeScript} + */ + ( + injection: ScriptInjection, + ): Promise>; + ( + injection: ScriptInjection, + callback: (results: InjectionResult) => void, + ): void; + }; +} + +export type WxtBrowser = Omit< + typeof _browser, + 'runtime' | 'i18n' | 'scripting' +> & { runtime: WxtRuntime & Omit<(typeof _browser)['runtime'], 'getURL'>; i18n: WxtI18n & Omit<(typeof _browser)['i18n'], 'getMessage'>; + scripting: WxtScripting & + Omit<(typeof _browser)['scripting'], 'executeScript'>; }; export const browser: WxtBrowser = _browser; diff --git a/packages/wxt/src/core/builders/vite/index.ts b/packages/wxt/src/core/builders/vite/index.ts index 4cbbc07df..d9ea5e45e 100644 --- a/packages/wxt/src/core/builders/vite/index.ts +++ b/packages/wxt/src/core/builders/vite/index.ts @@ -21,9 +21,6 @@ import { import { Hookable } from 'hookable'; import { toArray } from '../../utils/arrays'; import { safeVarName } from '../../utils/strings'; -import { ViteNodeServer } from 'vite-node/server'; -import { ViteNodeRunner } from 'vite-node/client'; -import { installSourcemapsSupport } from 'vite-node/source-map'; import { createExtensionEnvironment } from '../../utils/environments'; import { dirname, extname, join, relative } from 'node:path'; import fs from 'fs-extra'; @@ -71,7 +68,6 @@ export async function createViteBuilder( // TODO: Remove once https://github.com/wxt-dev/wxt/pull/1411 is merged config.legacy ??= {}; - // @ts-ignore: Untyped option: config.legacy.skipWebSocketTokenCheck = true; const server = getWxtDevServer?.(); @@ -90,8 +86,9 @@ export async function createViteBuilder( wxtPlugins.resolveAppConfig(wxtConfig), ); if ( + // TODO: Should this be migrated to use perEnvironmentState? wxtConfig.analysis.enabled && - // If included, vite-node entrypoint loader will increment the + // If included, entrypoint loader will increment the // bundleAnalysis's internal build index tracker, which we don't want !baseConfigOptions?.excludeAnalysisPlugin ) { @@ -109,7 +106,7 @@ export async function createViteBuilder( const plugins: NonNullable = [ wxtPlugins.entrypointGroupGlobals(entrypoint), ]; - const iifeReturnValueName = safeVarName(entrypoint.name); + let iifeReturnValueName = safeVarName(entrypoint.name); if ( entrypoint.type === 'content-script-style' || @@ -122,7 +119,17 @@ export async function createViteBuilder( entrypoint.type === 'content-script' || entrypoint.type === 'unlisted-script' ) { - plugins.push(wxtPlugins.iifeFooter(iifeReturnValueName)); + if (typeof entrypoint.options.globalName === 'string') { + iifeReturnValueName = entrypoint.options.globalName; + } else if (typeof entrypoint.options.globalName === 'function') { + iifeReturnValueName = entrypoint.options.globalName(entrypoint); + } + + if (entrypoint.options.globalName === false) { + plugins.push(wxtPlugins.iifeAnonymous(iifeReturnValueName)); + } else { + plugins.push(wxtPlugins.iifeFooter(iifeReturnValueName)); + } } return { @@ -224,8 +231,7 @@ export async function createViteBuilder( }, }; }; - - const createViteNodeImporter = async (paths: string[]) => { + const createImporterEnvironment = async (paths: string[]) => { const baseConfig = await getBaseConfig({ excludeAnalysisPlugin: true, }); @@ -238,30 +244,47 @@ export async function createViteBuilder( wxtPlugins.removeEntrypointMainFunction(wxtConfig, path), ), }; - const config = vite.mergeConfig(baseConfig, envConfig); - const server = await vite.createServer(config); - await server.pluginContainer.buildStart({}); - const node = new ViteNodeServer(server); - installSourcemapsSupport({ - getSourceMap: (source) => node.getSourceMap(source), - }); - const runner = new ViteNodeRunner({ - root: server.config.root, - base: server.config.base, - // when having the server and runner in a different context, - // you will need to handle the communication between them - // and pass to this function - fetchModule(id) { - return node.fetchModule(id); - }, - resolveId(id, importer) { - return node.resolveId(id, importer); + const importerConfig = vite.mergeConfig(baseConfig, envConfig); + + const config = await vite.resolveConfig( + vite.mergeConfig(importerConfig || {}, { + configFile: false, + envDir: false, + cacheDir: process.cwd(), + environments: { + inline: { + consumer: 'server', + dev: { + moduleRunnerTransform: true, + }, + resolve: { + external: true, + mainFields: [], + conditions: ['node'], + }, + }, + }, + } satisfies vite.InlineConfig), + 'serve', + ); + + const environment = vite.createRunnableDevEnvironment('inline', config, { + runnerOptions: { + hmr: { + logger: false, + }, }, + hot: false, }); - return { runner, server }; + await environment.init(); + + return environment; }; - const requireDefaultExport = (path: string, mod: any) => { + function requireDefaultExport( + path: string, + mod: any, + ): asserts mod is { default: unknown } { const relativePath = relative(wxtConfig.root, path); if (mod?.default == null) { const defineFn = relativePath.includes('.content') @@ -274,36 +297,37 @@ export async function createViteBuilder( `${relativePath}: Default export not found, did you forget to call "export default ${defineFn}(...)"?`, ); } - }; + } return { name: 'Vite', version: vite.version, async importEntrypoint(path) { - const env = createExtensionEnvironment(); - const { runner, server } = await createViteNodeImporter([path]); - const res = await env.run(() => runner.executeFile(path)); - await server.close(); - requireDefaultExport(path, res); - return res.default; + const [module] = await this.importEntrypoints([path]); + + return module as any; }, async importEntrypoints(paths) { - const env = createExtensionEnvironment(); - const { runner, server } = await createViteNodeImporter(paths); - const res = await env.run(() => - Promise.all( - paths.map(async (path) => { - const mod = await runner.executeFile(path); - requireDefaultExport(path, mod); - return mod.default; - }), - ), - ); - await server.close(); - return res; + const context = createExtensionEnvironment(); + const environment = await createImporterEnvironment(paths); + + try { + return await context.run( + async () => + await Promise.all( + paths.map(async (path) => { + const module = await environment.runner.import(path); + requireDefaultExport(path, module); + return module.default as any; + }), + ), + ); + } finally { + await environment.close(); + } }, async build(group) { - let entryConfig; + let entryConfig: vite.InlineConfig; if (Array.isArray(group)) entryConfig = getMultiPageConfig(group); else if ( group.type === 'content-script-style' || @@ -312,12 +336,16 @@ export async function createViteBuilder( entryConfig = getCssConfig(group); else entryConfig = getLibModeConfig(group); - const buildConfig = vite.mergeConfig(await getBaseConfig(), entryConfig); + const buildConfig: vite.InlineConfig = vite.mergeConfig( + await getBaseConfig(), + entryConfig, + ); await hooks.callHook( 'vite:build:extendConfig', toArray(group), buildConfig, ); + const result = await vite.build(buildConfig); const chunks = getBuildOutputChunks(result); return { diff --git a/packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts b/packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts new file mode 100644 index 000000000..fe4e0cc28 --- /dev/null +++ b/packages/wxt/src/core/builders/vite/plugins/iifeAnonymous.ts @@ -0,0 +1,17 @@ +import type { Plugin } from 'vite'; + +export function iifeAnonymous(iifeReturnValueName: string): Plugin { + return { + name: 'wxt:iife-anonymous', + generateBundle(_, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk' && chunk.isEntry) { + const namedIIFEPrefix = new RegExp( + `^var ${iifeReturnValueName}\\s*=\\s*(\\(function)`, + ); + chunk.code = chunk.code.replace(namedIIFEPrefix, '$1'); + } + } + }, + }; +} diff --git a/packages/wxt/src/core/builders/vite/plugins/index.ts b/packages/wxt/src/core/builders/vite/plugins/index.ts index 27ea24c82..703b3bb04 100644 --- a/packages/wxt/src/core/builders/vite/plugins/index.ts +++ b/packages/wxt/src/core/builders/vite/plugins/index.ts @@ -14,3 +14,4 @@ export * from './removeEntrypointMainFunction'; export * from './wxtPluginLoader'; export * from './resolveAppConfig'; export * from './iifeFooter'; +export * from './iifeAnonymous'; diff --git a/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts b/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts index 752f2c080..48af5805b 100644 --- a/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts +++ b/packages/wxt/src/core/builders/vite/plugins/removeEntrypointMainFunction.ts @@ -19,7 +19,7 @@ export function removeEntrypointMainFunction( handler(code, id) { if (id === absPath) { const newCode = removeMainFunctionCode(code); - config.logger.debug('vite-node transformed entrypoint', path); + config.logger.debug('transformed entrypoint', path); config.logger.debug(`Original:\n---\n${code}\n---`); config.logger.debug(`Transformed:\n---\n${newCode.code}\n---`); return newCode; diff --git a/packages/wxt/src/core/create-server.ts b/packages/wxt/src/core/create-server.ts index 557ed5a95..ab5abb818 100644 --- a/packages/wxt/src/core/create-server.ts +++ b/packages/wxt/src/core/create-server.ts @@ -18,7 +18,6 @@ import { rebuild, findEntrypoints, } from './utils/building'; -import { createExtensionRunner } from './runners'; import { Mutex } from 'async-mutex'; import pc from 'picocolors'; import { relative } from 'node:path'; @@ -56,10 +55,7 @@ async function createServerInternal(): Promise { return { host, port, origin }; }; - let [runner, builderServer] = await Promise.all([ - createExtensionRunner(), - wxt.builder.createServer(getServerInfo()), - ]); + let builderServer = await wxt.builder.createServer(getServerInfo()); // Used to track if modules need to be re-initialized let wasStopped = false; @@ -86,7 +82,6 @@ async function createServerInternal(): Promise { async start() { if (wasStopped) { await wxt.reloadConfig(); - runner = await createExtensionRunner(); builderServer = await wxt.builder.createServer(getServerInfo()); await initWxtModules(); } @@ -119,14 +114,15 @@ async function createServerInternal(): Promise { keyboardShortcuts.printHelp({ canReopenBrowser: - !wxt.config.runnerConfig.config.disabled && !!runner.canOpen?.(), + !wxt.config.runnerConfig.config.disabled && + !!wxt.config.runner.canOpen?.(), }); }, async stop() { wasStopped = true; keyboardShortcuts.stop(); - await runner.closeBrowser?.(); + await wxt.config.runner.closeBrowser?.(); await builderServer.close(); await wxt.hooks.callHook('server:closed', wxt, server); @@ -150,11 +146,10 @@ async function createServerInternal(): Promise { server.ws.send('wxt:reload-extension'); }, async restartBrowser() { - await runner.closeBrowser?.(); + await wxt.config.runner.closeBrowser?.(); keyboardShortcuts.stop(); await wxt.reloadConfig(); - runner = await createExtensionRunner(); - await runner.openBrowser(); + await wxt.config.runner.openBrowser(); keyboardShortcuts.start(); }, }; @@ -190,7 +185,7 @@ async function createServerInternal(): Promise { } // Open browser after everything is ready to go. - await runner.openBrowser(); + await wxt.config.runner.openBrowser(); }; builderServer.on?.('close', () => keyboardShortcuts.stop()); diff --git a/packages/wxt/src/core/generate-wxt-dir.ts b/packages/wxt/src/core/generate-wxt-dir.ts index c0d00381f..6832c7446 100644 --- a/packages/wxt/src/core/generate-wxt-dir.ts +++ b/packages/wxt/src/core/generate-wxt-dir.ts @@ -70,7 +70,7 @@ async function getPathsDeclarationEntry( getEntrypointBundlePath( entry, wxt.config.outDir, - isHtmlEntrypoint(entry) ? '.html' : '.js', + getEntrypointPublicExt(entry), ), ) .concat(await getPublicFiles()); @@ -107,6 +107,18 @@ declare module "wxt/browser" { }; } +function getEntrypointPublicExt(entry: Entrypoint): '.html' | '.js' | '.css' { + if (isHtmlEntrypoint(entry)) return '.html'; + + switch (entry.type) { + case 'content-script-style': + case 'unlisted-style': + return '.css'; + default: + return '.js'; + } +} + async function getI18nDeclarationEntry(): Promise { const defaultLocale = wxt.config.manifest.default_locale; const template = `// Generated by wxt diff --git a/packages/wxt/src/core/resolve-config.ts b/packages/wxt/src/core/resolve-config.ts index b8abe2aa1..112c65347 100644 --- a/packages/wxt/src/core/resolve-config.ts +++ b/packages/wxt/src/core/resolve-config.ts @@ -14,6 +14,7 @@ import { WxtModule, WxtModuleWithMetadata, ResolvedEslintrc, + ExtensionRunner, } from '../types'; import path from 'node:path'; import { createFsCache } from './utils/cache'; @@ -29,6 +30,10 @@ import { safeStringToNumber } from './utils/number'; import { loadEnv } from './utils/env'; import { getPort } from 'get-port-please'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createSafariRunner } from './runners/safari'; +import isWsl from 'is-wsl'; +import { createWslRunner } from './runners/wsl'; +import { createManualRunner } from './runners/manual'; /** * Given an inline config, discover the config file if necessary, merge the results, resolve any @@ -124,7 +129,7 @@ export async function resolveConfig( '`InlineConfig#runner` is deprecated, use `InlineConfig#webExt` instead. See https://wxt.dev/guide/resources/upgrading.html#v0-19-0-rarr-v0-20-0', ); } - const runnerConfig = await loadConfig({ + const webExt = await loadConfig({ name: 'web-ext', cwd: root, globalRc: true, @@ -217,7 +222,12 @@ export async function resolveConfig( publicDir, wxtModuleDir, root, - runnerConfig, + runnerConfig: webExt, + webExt, + runner: + command === 'serve' + ? await resolveRunner(browser, logger, mergedConfig) + : createManualRunner(), srcDir, typesDir, wxtDir, @@ -297,8 +307,9 @@ function resolveZipConfig( const downloadedPackagesDir = path.resolve(root, '.wxt/local_modules'); return { name: undefined, - sourcesTemplate: '{{name}}-{{version}}-sources.zip', - artifactTemplate: '{{name}}-{{version}}-{{browser}}.zip', + sourcesTemplate: '{{name}}-{{packageVersion}}-sources{{modeSuffix}}.zip', + artifactTemplate: + '{{name}}-{{packageVersion}}-{{browser}}{{modeSuffix}}.zip', sourcesRoot: root, includeSources: [], compressionLevel: 9, @@ -643,3 +654,28 @@ export async function resolveWxtUserModules( ); return [...npmModules, ...localModules]; } + +async function resolveRunner( + browser: string, + logger: Logger, + mergedConfig: InlineConfig, +): Promise { + if (browser === 'safari') return createSafariRunner(); + + if (isWsl) return createWslRunner(); + + try { + // This module imports `web-ext`, so if it fails, we know `web-ext` isn't installed + const { createWebExtRunner } = await import('./runners/web-ext'); + return mergedConfig.webExt?.disabled + ? createManualRunner() + : createWebExtRunner(); + } catch (err: any) { + if (err?.code !== 'ERR_MODULE_NOT_FOUND') throw err; + + console.log('error', err); + logger.debug('Error loading the web-ext runner', err); + } + + return createManualRunner(); +} diff --git a/packages/wxt/src/core/runners/__tests__/index.test.ts b/packages/wxt/src/core/runners/__tests__/index.test.ts deleted file mode 100644 index 93ac00f5b..000000000 --- a/packages/wxt/src/core/runners/__tests__/index.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createExtensionRunner } from '..'; -import { setFakeWxt } from '../../utils/testing/fake-objects'; -import { mock } from 'vitest-mock-extended'; -import { createSafariRunner } from '../safari'; -import { createWslRunner } from '../wsl'; -import { createManualRunner } from '../manual'; -import { isWsl } from '../../utils/wsl'; -import { createWebExtRunner } from '../web-ext'; -import { ExtensionRunner } from '../../../types'; - -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 () => { - setFakeWxt({ - config: { - browser: 'safari', - }, - }); - const safariRunner = mock(); - createSafariRunnerMock.mockReturnValue(safariRunner); - - await expect(createExtensionRunner()).resolves.toBe(safariRunner); - }); - - it('should return a WSL runner when `is-wsl` is true', async () => { - isWslMock.mockReturnValueOnce(true); - setFakeWxt({ - config: { - browser: 'chrome', - }, - }); - const wslRunner = mock(); - createWslRunnerMock.mockReturnValue(wslRunner); - - await expect(createExtensionRunner()).resolves.toBe(wslRunner); - }); - - it('should return a manual runner when `runner.disabled` is true', async () => { - isWslMock.mockReturnValueOnce(false); - setFakeWxt({ - config: { - browser: 'chrome', - runnerConfig: { - config: { - disabled: true, - }, - }, - }, - }); - const manualRunner = mock(); - createManualRunnerMock.mockReturnValue(manualRunner); - - await expect(createExtensionRunner()).resolves.toBe(manualRunner); - }); - - it('should return a web-ext runner otherwise', async () => { - setFakeWxt({ - config: { - browser: 'chrome', - runnerConfig: { - config: { - disabled: undefined, - }, - }, - }, - }); - const manualRunner = mock(); - createWebExtRunnerMock.mockReturnValue(manualRunner); - - await expect(createExtensionRunner()).resolves.toBe(manualRunner); - }); -}); diff --git a/packages/wxt/src/core/runners/index.ts b/packages/wxt/src/core/runners/index.ts deleted file mode 100644 index 1a3d82be8..000000000 --- a/packages/wxt/src/core/runners/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ExtensionRunner } from '../../types'; -import { isWsl } from '../utils/wsl'; -import { wxt } from '../wxt'; -import { createManualRunner } from './manual'; -import { createSafariRunner } from './safari'; -import { createWebExtRunner } from './web-ext'; -import { createWslRunner } from './wsl'; - -export async function createExtensionRunner(): Promise { - if (wxt.config.browser === 'safari') return createSafariRunner(); - - if (isWsl()) return createWslRunner(); - if (wxt.config.runnerConfig.config?.disabled) return createManualRunner(); - - return createWebExtRunner(); -} diff --git a/packages/wxt/src/core/runners/web-ext.ts b/packages/wxt/src/core/runners/web-ext.ts index d2820b3c5..8bc29010b 100644 --- a/packages/wxt/src/core/runners/web-ext.ts +++ b/packages/wxt/src/core/runners/web-ext.ts @@ -1,8 +1,10 @@ -import type { WebExtRunInstance } from 'web-ext-run'; +import type { WebExtRunInstance } from 'web-ext'; import { ExtensionRunner } from '../../types'; import { formatDuration } from '../utils/time'; import defu from 'defu'; import { wxt } from '../wxt'; +import webExt from 'web-ext'; +import { consoleStream } from 'web-ext/util/logger'; /** * Create an `ExtensionRunner` backed by `web-ext`. @@ -18,13 +20,12 @@ export function createWebExtRunner(): ExtensionRunner { const startTime = Date.now(); // Use WXT's logger instead of web-ext's built-in one. - const webExtLogger = await import('web-ext-run/util/logger'); - webExtLogger.consoleStream.write = ({ level, msg, name }) => { + consoleStream.write = ({ level, msg, name }) => { if (level >= ERROR_LOG_LEVEL) wxt.logger.error(name, msg); if (level >= WARN_LOG_LEVEL) wxt.logger.warn(msg); }; - const wxtUserConfig = wxt.config.runnerConfig.config; + const wxtUserConfig = wxt.config.webExt.config; const userConfig = { browserConsole: wxtUserConfig?.openConsole, devtools: wxtUserConfig?.openDevtools, @@ -71,8 +72,7 @@ export function createWebExtRunner(): ExtensionRunner { wxt.logger.debug('web-ext config:', finalConfig); wxt.logger.debug('web-ext options:', options); - const webExt = await import('web-ext-run'); - runner = await webExt.default.cmd.run(finalConfig, options); + runner = await webExt.cmd.run(finalConfig, options); const duration = Date.now() - startTime; wxt.logger.success(`Opened browser in ${formatDuration(duration)}`); diff --git a/packages/wxt/src/core/utils/__tests__/manifest.test.ts b/packages/wxt/src/core/utils/__tests__/manifest.test.ts index f5e715e30..5775be31c 100644 --- a/packages/wxt/src/core/utils/__tests__/manifest.test.ts +++ b/packages/wxt/src/core/utils/__tests__/manifest.test.ts @@ -42,7 +42,7 @@ describe('Manifest Utils', () => { defaultIcon: { '16': '/icon/16.png', }, - defaultTitle: 'Default Iitle', + defaultTitle: 'Default Title', }, outputDir: outDir, skipped: false, diff --git a/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts b/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts index 3355bd51b..a8edb2156 100644 --- a/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts +++ b/packages/wxt/src/core/utils/building/__tests__/find-entrypoints.test.ts @@ -112,7 +112,9 @@ describe('findEntrypoints', () => { name: 'options', inputPath: resolve(config.entrypointsDir, 'options.html'), outputDir: config.outDir, - options: {}, + options: { + title: 'Default Title', + }, skipped: false, }, ], @@ -133,6 +135,7 @@ describe('findEntrypoints', () => { outputDir: config.outDir, options: { openInTab: true, + title: 'Title', }, skipped: false, }, @@ -150,6 +153,33 @@ describe('findEntrypoints', () => { }, ); + it('should extract wxt.* meta tags from HTML entrypoints', async () => { + const path = 'popup.html'; + const content = ` + + + + + + Test Title + + + `; + + globMock.mockResolvedValueOnce([path]); + readFileMock.mockResolvedValueOnce(content); + + const entrypoints = await findEntrypoints(); + + expect(entrypoints).toHaveLength(1); + expect(entrypoints[0].options).toMatchObject({ + defaultIcon: { '16': '/icon/16.png' }, + customOption: 'custom_value', + anotherOption: true, + defaultTitle: 'Test Title', + }); + }); + it.each<[string, Omit]>([ [ 'content.ts', diff --git a/packages/wxt/src/core/utils/building/find-entrypoints.ts b/packages/wxt/src/core/utils/building/find-entrypoints.ts index 81abc0715..49c72c54b 100644 --- a/packages/wxt/src/core/utils/building/find-entrypoints.ts +++ b/packages/wxt/src/core/utils/building/find-entrypoints.ts @@ -10,6 +10,7 @@ import { SidepanelEntrypoint, MainWorldContentScriptEntrypointOptions, IsolatedWorldContentScriptEntrypointOptions, + UnlistedScriptEntrypoint, } from '../../../types'; import fs from 'fs-extra'; import { minimatch } from 'minimatch'; @@ -115,20 +116,14 @@ export async function findEntrypoints(): Promise { ...info, type, outputDir: resolve(wxt.config.outDir, CONTENT_SCRIPT_OUT_DIR), - options: { - include: options.include, - exclude: options.exclude, - }, + options, }; default: return { ...info, type, outputDir: wxt.config.outDir, - options: { - include: options.include, - exclude: options.exclude, - }, + options, }; } }), @@ -200,7 +195,7 @@ async function importEntrypoints(infos: EntrypointInfo[]) { return resMap; } -/** Extract `manifest.` options from meta tags, converting snake_case keys to camelCase */ +/** Extract `manifest.` and `wxt.` options from meta tags, converting snake_case keys to camelCase */ async function importHtmlEntrypoint( info: EntrypointInfo, ): Promise> { @@ -214,9 +209,16 @@ async function importHtmlEntrypoint( metaTags.forEach((tag) => { const name = tag.name; - if (!name.startsWith('manifest.')) return; + let key: string; + + if (name.startsWith('manifest.')) { + key = camelCase(name.slice(9)); + } else if (name.startsWith('wxt.')) { + key = camelCase(name.slice(4)); + } else { + return; + } - const key = camelCase(name.slice(9)); try { res[key] = JSON5.parse(tag.content); } catch { @@ -266,24 +268,20 @@ async function getPopupEntrypoint( info: EntrypointInfo, options: Record, ): Promise { + // Extract non-per-browser options + const { themeIcons, title, type, ...perBrowserOptions } = options; + const strictOptions: PopupEntrypoint['options'] = resolvePerBrowserOptions( { - browserStyle: options.browserStyle, - exclude: options.exclude, - include: options.include, - defaultIcon: options.defaultIcon, - defaultTitle: options.title, - mv2Key: options.type, - defaultArea: options.defaultArea, + ...perBrowserOptions, + defaultTitle: title, + mv2Key: type, }, wxt.config.browser, ); if (strictOptions.mv2Key && strictOptions.mv2Key !== 'page_action') strictOptions.mv2Key = 'browser_action'; - // themeIcons is an array of objects, not a per-browser option - const themeIcons = options.themeIcons; - return { type: 'popup', name: 'popup', @@ -300,16 +298,7 @@ async function getOptionsEntrypoint( return { type: 'options', name: 'options', - options: resolvePerBrowserOptions( - { - browserStyle: options.browserStyle, - chromeStyle: options.chromeStyle, - exclude: options.exclude, - include: options.include, - openInTab: options.openInTab, - }, - wxt.config.browser, - ), + options: resolvePerBrowserOptions(options, wxt.config.browser), inputPath: info.inputPath, outputDir: wxt.config.outDir, }; @@ -324,29 +313,20 @@ async function getUnlistedPageEntrypoint( name: info.name, inputPath: info.inputPath, outputDir: wxt.config.outDir, - options: { - include: options.include, - exclude: options.exclude, - }, + options, }; } async function getUnlistedScriptEntrypoint( { inputPath, name }: EntrypointInfo, options: Record, -): Promise { +): Promise { return { type: 'unlisted-script', name, inputPath, outputDir: wxt.config.outDir, - options: resolvePerBrowserOptions( - { - include: options.include, - exclude: options.exclude, - }, - wxt.config.browser, - ), + options: resolvePerBrowserOptions(options, wxt.config.browser), }; } @@ -355,15 +335,7 @@ async function getBackgroundEntrypoint( options: Record, ): Promise { const strictOptions: BackgroundEntrypoint['options'] = - resolvePerBrowserOptions( - { - include: options.include, - exclude: options.exclude, - persistent: options.persistent, - type: options.type, - }, - wxt.config.browser, - ); + resolvePerBrowserOptions(options, wxt.config.browser); if (wxt.config.manifestVersion !== 3) { delete strictOptions.type; @@ -400,17 +372,16 @@ async function getSidepanelEntrypoint( info: EntrypointInfo, options: Record, ): Promise { + // Extract non-per-browser options and rename title to defaultTitle + const { title, ...perBrowserOptions } = options; + return { type: 'sidepanel', name: info.name, options: resolvePerBrowserOptions( { - browserStyle: options.browserStyle, - exclude: options.exclude, - include: options.include, - defaultIcon: options.defaultIcon, - defaultTitle: options.title, - openAtInstall: options.openAtInstall, + ...perBrowserOptions, + defaultTitle: title, }, wxt.config.browser, ), diff --git a/packages/wxt/src/core/utils/testing/fake-objects.ts b/packages/wxt/src/core/utils/testing/fake-objects.ts index 62b2154d2..7398beee2 100644 --- a/packages/wxt/src/core/utils/testing/fake-objects.ts +++ b/packages/wxt/src/core/utils/testing/fake-objects.ts @@ -21,6 +21,7 @@ import { Wxt, SidepanelEntrypoint, BaseEntrypoint, + UnlistedScriptEntrypoint, } from '../../../types'; import { mock } from 'vitest-mock-extended'; import { vi } from 'vitest'; @@ -99,7 +100,7 @@ export const fakeBackgroundEntrypoint = fakeObjectCreator( ); export const fakeUnlistedScriptEntrypoint = - fakeObjectCreator(() => ({ + fakeObjectCreator(() => ({ type: 'unlisted-script', inputPath: fakeFile('src'), name: faker.string.alpha(), @@ -186,7 +187,6 @@ export const fakeGenericEntrypoint = fakeObjectCreator( 'newtab', 'devtools', 'unlisted-page', - 'unlisted-script', ]), inputPath: fakeFile('src'), name: faker.string.alpha(), @@ -269,6 +269,10 @@ export const fakeResolvedConfig = fakeObjectCreator(() => { runnerConfig: { config: {}, }, + webExt: { + config: {}, + }, + runner: mock(), debug: faker.datatype.boolean(), srcDir: fakeDir(), typesDir: fakeDir(), diff --git a/packages/wxt/src/core/zip.ts b/packages/wxt/src/core/zip.ts index 8f71c7871..fce4ee07b 100644 --- a/packages/wxt/src/core/zip.ts +++ b/packages/wxt/src/core/zip.ts @@ -30,6 +30,11 @@ export async function zip(config?: InlineConfig): Promise { const projectName = wxt.config.zip.name ?? safeFilename(packageJson?.name || path.basename(process.cwd())); + const modeSuffixes: Record = { + production: '', + development: '-dev', + }; + const modeSuffix = modeSuffixes[wxt.config.mode] ?? `-${wxt.config.mode}`; const applyTemplate = (template: string): string => template .replaceAll('{{name}}', projectName) @@ -39,6 +44,7 @@ export async function zip(config?: InlineConfig): Promise { output.manifest.version_name ?? output.manifest.version, ) .replaceAll('{{packageVersion}}', packageJson?.version) + .replaceAll('{{modeSuffix}}', modeSuffix) .replaceAll('{{mode}}', wxt.config.mode) .replaceAll('{{manifestVersion}}', `mv${wxt.config.manifestVersion}`); diff --git a/packages/wxt/src/types.ts b/packages/wxt/src/types.ts index 43ba58ea0..265ae4fa2 100644 --- a/packages/wxt/src/types.ts +++ b/packages/wxt/src/types.ts @@ -150,9 +150,10 @@ export interface InlineConfig { * - `{{packageVersion}}` - The version from the package.json * - `{{browser}}` - The target browser from the `--browser` CLI flag * - `{{mode}}` - The current mode + * - `{{modeSuffix}}`: A suffix based on the mode ('-dev' for development, '' for production) * - `{{manifestVersion}}` - Either "2" or "3" * - * @default "{{name}}-{{version}}-{{browser}}.zip" + * @default "{{name}}-{{packageVersion}}-{{browser}}{{modeSuffix}}.zip" */ artifactTemplate?: string; /** @@ -175,9 +176,10 @@ export interface InlineConfig { * - `{{packageVersion}}` - The version from the package.json * - `{{browser}}` - The target browser from the `--browser` CLI flag * - `{{mode}}` - The current mode + * - `{{modeSuffix}}`: A suffix based on the mode ('-dev' for development, '' for production) * - `{{manifestVersion}}` - Either "2" or "3" * - * @default "{{name}}-{{version}}-sources.zip" + * @default "{{name}}-{{packageVersion}}-sources{{modeSuffix}}.zip" */ sourcesTemplate?: string; /** @@ -566,7 +568,28 @@ export interface BackgroundEntrypointOptions extends BaseEntrypointOptions { type?: PerBrowserOption<'module'>; } -export interface BaseContentScriptEntrypointOptions extends BaseEntrypointOptions { +export interface BaseScriptEntrypointOptions extends BaseEntrypointOptions { + /** + * The variable name for the IIFE in the output bundle. + * + * This option is relevant for scripts inserted into the page context where the default IIFE + * variable name may conflict with an existing variable on the target page. This applies to content + * scripts with world=MAIN, and others, such as unlisted scripts, that could be dynamically injected + * into the page with a