Compare commits

...

22 Commits

Author SHA1 Message Date
Aaron Klinker 98bc1f2cd1 Bump version to alpha2 2024-01-14 16:27:38 -06:00
Aaron Klinker f6a066f166 Fix tests 2024-01-14 16:26:54 -06:00
Aaron Klinker c20478cde2 Bump version to 0.14.2-alpha1 2024-01-14 16:19:54 -06:00
Aaron Klinker ebd50079a7 Cleanup unsed imports 2024-01-14 16:18:47 -06:00
Aaron Klinker b17bd87865 Add docs 2024-01-14 16:18:19 -06:00
Aaron Klinker e8b471e23f Improve typing and add tests for it 2024-01-14 15:05:52 -06:00
Aaron Klinker 32ffca4168 Add client tests 2024-01-14 14:49:15 -06:00
Aaron Klinker e9c573d5a1 Implement and generate types for wxt/i18n 2024-01-12 23:58:55 -06:00
Aaron Klinker ae5126d726 Update lockfile 2024-01-12 22:49:13 -06:00
Aaron Klinker 24a77ca9b5 Add i18n module to named export list 2024-01-12 22:49:03 -06:00
Aaron Klinker 174cbbc5eb Add i18n module to auto-imports 2024-01-12 22:45:58 -06:00
Aaron Klinker 607c70dd56 Build wxt/i18n 2024-01-12 22:45:41 -06:00
Aaron Klinker 616d012326 Update demo code for testing 2024-01-12 22:45:18 -06:00
Aaron Klinker 75b6ca7172 Add conversion code and tests 2024-01-12 22:45:04 -06:00
Aaron Klinker 1497b3099c Move demo files 2024-01-12 22:44:33 -06:00
Aaron Klinker a329e24818 chore: Fix typo in function name 2024-01-12 13:03:43 -06:00
Aaron 249cf63da8 perf: Only call findEntrypoint once per build (#342) 2024-01-12 13:02:44 -06:00
Aaron Klinker 70a496108a chore: Update templates to ^0.14.0 2024-01-12 08:53:32 -06:00
GitHub Actions 214889e052 chore(release): v0.14.1 2024-01-12 14:50:29 +00:00
Aaron Klinker b6ab7a9567 fix: Use Alt+R/Opt+R to reload extension during development
This closes #339
2024-01-12 08:47:21 -06:00
Aaron Klinker 1ceb9e2e80 Add missing breaking change to changelog 2024-01-11 17:00:43 -06:00
Aaron Klinker 15ea81514f Update changelog 2024-01-11 13:28:41 -06:00
44 changed files with 1022 additions and 158 deletions
+15 -1
View File
@@ -1,5 +1,13 @@
# Changelog
## v0.14.1
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.14.0...v0.14.1)
### 🩹 Fixes
- Use `Alt+R`/`Opt+R` to reload extension during development ([b6ab7a9](https://github.com/wxt-dev/wxt/commit/b6ab7a9))
## v0.14.0
[compare changes](https://github.com/wxt-dev/wxt/compare/v0.13.5...v0.14.0)
@@ -10,7 +18,13 @@
#### ⚠️ Breaking Changes
- ⚠️ Refactor content script UI functions and add helper for "integrated" UIs ([#333](https://github.com/wxt-dev/wxt/pull/333))
`createContentScriptUi` and `createContentScriptIframe`, and some of their options, have been renamed:
- `createContentScriptUi({ ... })` → `createShadowRootUi({ ... })`
- `createContentScriptIframe({ ... })` → `createIframeUi({ ... })`
- `type: "inline" | "overlay" | "modal"` has been changed to `position: "inline" | "overlay" | "modal"`
- `onRemove` is now called ***before*** the UI is removed from the DOM, previously it was called after the UI was removed
- `mount` option has been renamed to `onMount`, to better match the related option, `onRemove`.
## v0.13.5
+20 -10
View File
@@ -1,5 +1,3 @@
import messages from 'public/_locales/en/messages.json';
export default defineBackground(() => {
console.log(browser.runtime.id);
logId();
@@ -8,20 +6,32 @@ export default defineBackground(() => {
chrome: __IS_CHROME__,
firefox: __IS_FIREFOX__,
manifestVersion: __MANIFEST_VERSION__,
messages,
});
// @ts-expect-error: should only accept entrypoints or public assets
browser.runtime.getURL('/');
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icon/128.png');
browser.runtime.getURL('/icon-128.png');
// @ts-expect-error: should only accept known message names
browser.i18n.getMessage('test');
browser.i18n.getMessage('prompt_for_name');
browser.i18n.getMessage('hello', 'Aaron');
browser.i18n.getMessage('bye', ['Aaron']);
browser.i18n.getMessage('@@extension_id');
console.log([
// @ts-expect-error: browser.i18n should only accept known message names
browser.i18n.getMessage('test'),
browser.i18n.getMessage('promptForName'),
browser.i18n.getMessage('hello', ['Aaron']),
browser.i18n.getMessage('bye', ['Aaron']),
browser.i18n.getMessage('@@extension_id'),
browser.i18n.getMessage('nItems'),
]);
console.log([
// @ts-expect-error: i18n should only accept known message names
i18n.t('test'),
i18n.t('promptForName'),
i18n.t('hello', ['Aaron']),
i18n.t('bye', ['Aaron']),
i18n.t('@@extension_id'),
i18n.tp('nItems', 0, ['0']),
]);
console.log('WXT MODE:', {
MODE: import.meta.env.MODE,
+21
View File
@@ -0,0 +1,21 @@
promptForName: What's your name?
hello:
message: Hello, $USER$
description: Greet the user
placeholders:
user:
content: $1
example: Paul
bye:
message: Goodbye, $USER$. Come back to $OUR_SITE$ soon!
description: Say goodbye to the user
placeholders:
our_site:
content: Example.com
user:
content: $1
example: Paul
nItems:
0: 0 items
1: 1 item
n: $1 items
-29
View File
@@ -1,29 +0,0 @@
{
"prompt_for_name": {
"message": "What's your name?",
"description": "Ask for the user's name"
},
"hello": {
"message": "Hello, $USER$",
"description": "Greet the user",
"placeholders": {
"user": {
"content": "$1",
"example": "Cira"
}
}
},
"bye": {
"message": "Goodbye, $USER$. Come back to $OUR_SITE$ soon!",
"description": "Say goodbye to the user",
"placeholders": {
"our_site": {
"content": "Example.com"
},
"user": {
"content": "$1",
"example": "Cira"
}
}
}
}

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 504 B

Before

Width:  |  Height:  |  Size: 936 B

After

Width:  |  Height:  |  Size: 936 B

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -89,6 +89,7 @@ export default defineConfig({
{ text: 'Storage', link: '/guide/storage.md' },
{ text: 'Assets', link: '/guide/assets.md' },
{ text: 'Content Script UI', link: '/guide/content-script-ui.md' },
{ text: 'Localization', link: '/guide/localization.md' },
{ text: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
{ text: 'Vite', link: '/guide/vite.md' },
+159
View File
@@ -0,0 +1,159 @@
---
outline: deep
---
# Localization
WXT includes a util, [`i18n`](/api/wxt/i18n/), that provides a type-safe feature-rich alternative to `browser.i18n.getMessage`.
It is available automatically when you create a messages file under the `locales/` directory, and add a `default_locale` to the manifest:
```
<srcDir>
└─ locales/
├─ en.json
├─ es.json5
├─ fr.yml
├─ de.yaml
└─ ...
```
> You can use JSON, JSON5, or YAML formats.
```ts
// wxt.config.ts
export default defineConfig({
manifest: {
default_locale: 'en',
},
});
```
## Message File Format
```yml
# Use plain strings
simpleMessage: Hello world!
# Nest strings in objects
popup:
overview:
title: Nested text
# Plural form support
items:
1: 1 item
n: $1 items
# Optionally include a custom string for 0
cartSize:
0: Empty
1: 1 item
n: $1 items
# Or stick with the standard web extension format (with a message, description, and placeholders)
manifestMessage:
message: $THIS$ is translated
description: This is not-translated, helps translators
placeholder:
this:
content: This
```
:::tip
`locales/<code>.json` is 100% compatible with the standard web extension localization format (`_locales/<code>/messages.json`). If you have existing messages files, just move them into the `locales/` directory.
:::
## Usage
`i18n` is auto-imported, but can be manually imported from `wxt/i18n`.
```ts
import { i18n } from 'wxt/i18n';
```
### Basic Usage
You can access messages by their name:
```yml
helloWorld: Hello world!
```
```ts
i18n.t('helloWorld'); // "Hello world!"
```
Nested messages are combined into one string using an `_`.
```yml
popup:
overview:
title: Hello world!
```
```ts
i18n.t('popup_overview_title'); // "Hello world!"
```
If a message is in the standard web extension format, don't include a `_message`, even if it's nested.
```yml
helloWorld:
message: Hello world!
description: Some description
popup:
overview:
title:
message: Nested Title
```
```ts
i18n.t('helloWorld'); // "Hello world!"
i18n.t('popup_overview_title'); // "Nested Title"
```
### Substitutions
To insert a custom string into a translation, pass an array of values as the second parameter of the `i18n.t` function:
```yml
hello: Hello, $1, my name is $2.
```
```ts
i18n.t('hello', ['Aaron', 'Mark']); // "Hello Aaron, my name is Mark."
```
### Plural Form
When getting the translation for text with a plural form, use the `i18n.tp` function.
```yml
friends:
0: I have no friends.
1: I have a friend.
n: I have many friends.
```
```ts
i18n.tp('friends', 0); // "I have no friends."
i18n.tp('friends', 1); // "I have one friend."
i18n.tp('friends', 2); // "I have many friends."
```
The first number is the `count`. It is what decides which form will be used.
Substitutions are not required. But usually, a plural form will look something like this:
```yml
items:
1: 1 item
n: $1 items
```
```ts
i18n.tp('items', 0, ['0']); // "0 items"
i18n.tp('items', 1, ['1']); // "1 item"
i18n.tp('items', 2, ['2']); // "2 items"
i18n.tp('items', 3, ['3']); // "3 items"
```
+2 -1
View File
@@ -5,7 +5,8 @@
"../src/browser.ts",
"../src/sandbox",
"../src/storage.ts",
"../src/testing"
"../src/testing",
"../src/i18n"
],
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
"out": "./api",
+1
View File
@@ -28,6 +28,7 @@ describe('Auto Imports', () => {
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
const i18n: typeof import('wxt/i18n')['i18n']
const storage: typeof import('wxt/storage')['storage']
}
"
+51 -32
View File
@@ -61,7 +61,7 @@ describe('TypeScript Project', () => {
const project = new TestProject();
project.addFile('entrypoints/unlisted.html');
project.addFile(
'public/_locales/en/messages.json',
'locales/en.json',
JSON.stringify({
prompt_for_name: {
message: "What's your name?",
@@ -119,6 +119,36 @@ describe('TypeScript Project', () => {
}
export interface WxtI18n extends I18n.Static {
/**
* Ask for the user's name
*
* "What's your name?"
*/
getMessage(
messageName: "prompt_for_name",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Greet the user
*
* "Hello, $USER$"
*/
getMessage(
messageName: "hello",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Say goodbye to the user
*
* "Goodbye, $USER$. Come back to $OUR_SITE$ soon!"
*/
getMessage(
messageName: "bye",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.
Note: You can't use this message in a manifest file.
@@ -131,7 +161,7 @@ describe('TypeScript Project', () => {
options?: GetMessageOptions,
): string;
/**
*
* No message description.
*
* "<browser.i18n.getUiLocale()>"
*/
@@ -180,36 +210,25 @@ describe('TypeScript Project', () => {
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Ask for the user's name
*
* "What's your name?"
*/
getMessage(
messageName: "prompt_for_name",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Greet the user
*
* "Hello, $USER$"
*/
getMessage(
messageName: "hello",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
/**
* Say goodbye to the user
*
* "Goodbye, $USER$. Come back to $OUR_SITE$ soon!"
*/
getMessage(
messageName: "bye",
substitutions?: string | string[],
options?: GetMessageOptions,
): string;
}
}
declare module "wxt/i18n" {
export interface WxtMessageSchema {
t: {
"prompt_for_name": any;
"hello": any;
"bye": any;
"@@extension_id": any;
"@@ui_locale": any;
"@@bidi_dir": any;
"@@bidi_reversed_dir": any;
"@@bidi_start_edge": any;
"@@bidi_end_edge": any;
};
tp: {
};
}
}
"
+12 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wxt",
"type": "module",
"version": "0.14.0",
"version": "0.14.2-alpha2",
"description": "Next gen framework for developing web extensions",
"engines": {
"node": ">=18",
@@ -77,6 +77,16 @@
"default": "./dist/storage.cjs"
}
},
"./i18n": {
"import": {
"types": "./dist/i18n.d.ts",
"default": "./dist/i18n.js"
},
"require": {
"types": "./dist/i18n.d.cts",
"default": "./dist/i18n.cjs"
}
},
"./vite-builder-env": {
"types": "./dist/vite-builder-env.d.ts"
}
@@ -135,6 +145,7 @@
"vite": "^5.0.0",
"web-ext-run": "^0.1.0",
"webextension-polyfill": "^0.10.0",
"yaml": "^2.3.4",
"zip-dir": "^2.0.0"
},
"devDependencies": {
+3 -1
View File
@@ -104,6 +104,9 @@ importers:
webextension-polyfill:
specifier: ^0.10.0
version: 0.10.0
yaml:
specifier: ^2.3.4
version: 2.3.4
zip-dir:
specifier: ^2.0.0
version: 2.0.0
@@ -5385,7 +5388,6 @@ packages:
/yaml@2.3.4:
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
engines: {node: '>= 14'}
dev: true
/yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
+1
View File
@@ -58,6 +58,7 @@ const config: tsup.Options[] = [
index: 'src/index.ts',
testing: 'src/testing/index.ts',
storage: 'src/storage.ts',
i18n: 'src/i18n/index.ts',
},
format: ['cjs', 'esm'],
clean: true,
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import * as wxtPlugins from './plugins';
import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
export async function craeteViteBuilder(
export async function createViteBuilder(
inlineConfig: InlineConfig,
userConfig: UserConfig,
wxtConfig: Omit<InternalConfig, 'builder'>,
+3
View File
@@ -20,6 +20,7 @@ import {
getInternalConfig,
detectDevChanges,
rebuild,
findEntrypoints,
} from '~/core/utils/building';
import { createExtensionRunner } from '~/core/runners';
import { consola } from 'consola';
@@ -159,8 +160,10 @@ function createFileReloader(options: {
.join(pc.dim(', '));
// Rebuild entrypoints on change
const allEntrypoints = await findEntrypoints(config);
const { output: newOutput } = await rebuild(
config,
allEntrypoints,
// TODO: this excludes new entrypoints, so they're not built until the dev command is restarted
changes.rebuildGroups,
changes.cachedOutput,
+1 -1
View File
@@ -896,7 +896,7 @@ describe('Manifest Utils', () => {
const reloadCommandName = 'wxt:reload-extension';
const reloadCommand = {
suggested_key: {
default: 'Ctrl+E',
default: 'Alt+R',
},
};
+38 -2
View File
@@ -6,9 +6,11 @@ import {
} from '~/types';
import { getPublicFiles } from '~/core/utils/fs';
import fs from 'fs-extra';
import { dirname, resolve } from 'path';
import { dirname, resolve, extname } from 'node:path';
import type { Ora } from 'ora';
import pc from 'picocolors';
import { unnormalizePath } from '~/core/utils/paths';
import { convertMessagesToManifest, readMessagesFile } from '~/i18n/node';
export async function buildEntrypoints(
groups: EntrypointGroup[],
@@ -25,7 +27,13 @@ export async function buildEntrypoints(
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
steps.push(await config.builder.build(group));
}
const publicAssets = await copyPublicDirectory(config);
const publicAssets = (
await Promise.all([
copyPublicDirectory(config),
copyLocalesDirectory(config),
])
).flat();
return { publicAssets, steps };
}
@@ -51,3 +59,31 @@ async function copyPublicDirectory(
return publicAssets;
}
async function copyLocalesDirectory(
config: InternalConfig,
): Promise<BuildOutput['publicAssets']> {
const localesExist = await fs.exists(config.localesDir);
if (!localesExist || config.manifest.default_locale == null) return [];
const files = await fs.readdir(config.localesDir);
return await Promise.all(
files.map(async (file) => {
const locale = file.replace(extname(file), '');
const fileName = unnormalizePath(`_locales/${locale}/messages.json`);
const srcPath = resolve(config.localesDir, file);
const outPath = resolve(config.outDir, fileName);
const messages = await readMessagesFile(srcPath);
const json = convertMessagesToManifest(messages);
await fs.ensureDir(dirname(outPath));
await fs.writeJson(outPath, json);
return {
fileName,
type: 'asset',
};
}),
);
}
+38 -15
View File
@@ -6,9 +6,9 @@ import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import { getUnimportOptions } from '~/core/utils/unimport';
import { getEntrypointGlobals, getGlobals } from '~/core/utils/globals';
import { normalizePath } from '~/core/utils/paths';
import path from 'node:path';
import { Message, parseI18nMessages } from '~/core/utils/i18n';
import { writeFileIfDifferent, getPublicFiles } from '~/core/utils/fs';
import glob from 'fast-glob';
import { Message, PREDEFINED_MESSAGES, readMessagesFile } from '~/i18n/node';
/**
* Generate and write all the files inside the `InternalConfig.typesDir` directory.
@@ -113,30 +113,38 @@ declare module "wxt/browser" {
}
export interface WxtI18n extends I18n.Static {
{{ overrides }}
{{ browserOverrides }}
}
}
declare module "wxt/i18n" {
export interface WxtMessageSchema {
t: {
{{ translationTOverrides }}
};
tp: {
{{ translationTpOverrides }}
};
}
}
`;
let messages: Message[];
if (defaultLocale) {
const defaultLocalePath = path.resolve(
config.publicDir,
'_locales',
defaultLocale,
'messages.json',
);
const content = JSON.parse(await fs.readFile(defaultLocalePath, 'utf-8'));
messages = parseI18nMessages(content);
const [defaultLocalePath] = await glob(`${defaultLocale}.*`, {
cwd: config.localesDir,
absolute: true,
});
messages = await readMessagesFile(defaultLocalePath);
} else {
messages = parseI18nMessages({});
messages = PREDEFINED_MESSAGES;
}
const overrides = messages.map((message) => {
return ` /**
* ${message.description ?? 'No message description.'}
* ${message.entry.description ?? 'No message description.'}
*
* "${message.message}"
* "${message.entry.message}"
*/
getMessage(
messageName: "${message.name}",
@@ -146,7 +154,22 @@ declare module "wxt/browser" {
});
await writeFileIfDifferent(
filePath,
template.replace('{{ overrides }}', overrides.join('\n')),
template
.replace('{{ browserOverrides }}', overrides.join('\n'))
.replace(
'{{ translationTOverrides }}',
messages
.filter((message) => !message.isPlural)
.map((message) => ` "${message.name}": any;`)
.join('\n'),
)
.replace(
'{{ translationTpOverrides }}',
messages
.filter((message) => message.isPlural)
.map((message) => ` "${message.name}": any;`)
.join('\n'),
),
);
return filePath;
@@ -12,7 +12,7 @@ import {
import path from 'node:path';
import { createFsCache } from '~/core/utils/cache';
import consola, { LogLevels } from 'consola';
import { craeteViteBuilder } from '~/core/builders/vite';
import { createViteBuilder } from '~/core/builders/vite';
import defu from 'defu';
import { NullablyRequired } from '../types';
@@ -73,6 +73,7 @@ export async function getInternalConfig(
mergedConfig.entrypointsDir ?? 'entrypoints',
);
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
const localesDir = path.resolve(srcDir, mergedConfig.localesDir ?? 'locales');
const typesDir = path.resolve(wxtDir, 'types');
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
@@ -111,6 +112,7 @@ export async function getInternalConfig(
outBaseDir,
outDir,
publicDir,
localesDir,
root,
runnerConfig,
srcDir,
@@ -134,7 +136,7 @@ export async function getInternalConfig(
server,
};
const builder = await craeteViteBuilder(
const builder = await createViteBuilder(
inlineConfig,
userConfig,
finalConfig,
@@ -196,6 +198,7 @@ function mergeInlineConfig(
manifest,
mode: inlineConfig.mode ?? userConfig.mode,
publicDir: inlineConfig.publicDir ?? userConfig.publicDir,
localesDir: inlineConfig.localesDir ?? userConfig.localesDir,
runner,
srcDir: inlineConfig.srcDir ?? userConfig.srcDir,
outDir: inlineConfig.outDir ?? userConfig.outDir,
+1 -1
View File
@@ -38,7 +38,7 @@ export async function internalBuild(
const entrypoints = await findEntrypoints(config);
config.logger.debug('Detected entrypoints:', entrypoints);
const groups = groupEntrypoints(entrypoints);
const { output } = await rebuild(config, groups, undefined);
const { output } = await rebuild(config, entrypoints, groups, undefined);
// Post-build
await printBuildSummary(
+13 -3
View File
@@ -1,6 +1,10 @@
import type { Manifest } from '~/browser';
import { BuildOutput, EntrypointGroup, InternalConfig } from '~/types';
import { findEntrypoints } from './find-entrypoints';
import {
BuildOutput,
Entrypoint,
EntrypointGroup,
InternalConfig,
} from '~/types';
import { generateTypesDir } from './generate-wxt-dir';
import { buildEntrypoints } from './build-entrypoints';
import { generateManifest, writeManifest } from '~/core/utils/manifest';
@@ -14,9 +18,16 @@ import { generateManifest, writeManifest } from '~/core/utils/manifest';
* 2. Build the `entrypointGroups` (and copies public files)
* 3. Generate the latest manifest for all entrypoints
* 4. Write the new manifest to the file system
*
* @param config Internal config containing all the project information.
* @param allEntrypoints List of entrypoints used to generate the types inside .wxt directory.
* @param entrypointGroups The list of entrypoint groups to build.
* @param existingOutput The previous output to combine the rebuild results into. An emptry array if
* this is the first build.
*/
export async function rebuild(
config: InternalConfig,
allEntrypoints: Entrypoint[],
entrypointGroups: EntrypointGroup[],
existingOutput: Omit<BuildOutput, 'manifest'> = {
steps: [],
@@ -27,7 +38,6 @@ export async function rebuild(
const spinner = ora(`Preparing...`).start();
// Update types directory with new files and types
const allEntrypoints = await findEntrypoints(config);
await generateTypesDir(allEntrypoints, config).catch((err) => {
config.logger.warn('Failed to update .wxt directory:', err);
// Throw the error if doing a regular build, don't for dev mode.
-52
View File
@@ -1,52 +0,0 @@
export interface Message {
name: string;
message: string;
description?: string;
}
const predefinedMessages = {
'@@extension_id': {
message: '<browser.runtime.id>',
description:
"The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.\nNote: You can't use this message in a manifest file.",
},
'@@ui_locale': {
message: '<browser.i18n.getUiLocale()>',
description: '',
},
'@@bidi_dir': {
message: '<ltr|rtl>',
description:
'The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.',
},
'@@bidi_reversed_dir': {
message: '<rtl|ltr>',
description:
'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".',
},
'@@bidi_start_edge': {
message: '<left|right>',
description:
'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".',
},
'@@bidi_end_edge': {
message: '<right|left>',
description:
'If the @@bidi_dir is "ltr", then this is "right"; otherwise, it\'s "left".',
},
};
/**
* Get a list of all messages and their metadata from JSON file contents.
*
* @param messagesJson The contents of a `_locales/en/messages.json` file.
*/
export function parseI18nMessages(messagesJson: object): Message[] {
return Object.entries({
...predefinedMessages,
...messagesJson,
}).map<Message>(([name, details]) => ({
name,
...details,
}));
}
+1 -1
View File
@@ -80,7 +80,7 @@ export async function generateManifest(
'wxt:reload-extension': {
description: 'Reload the extension during development',
suggested_key: {
default: 'Ctrl+E',
default: 'Alt+R',
},
},
};
+1
View File
@@ -211,6 +211,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
outBaseDir: fakeDir(),
outDir: fakeDir(),
publicDir: fakeDir(),
localesDir: fakeDir(),
root: fakeDir(),
runnerConfig: {
config: {},
+1
View File
@@ -12,6 +12,7 @@ export function getUnimportOptions(
imports: [
{ name: 'defineConfig', from: 'wxt' },
{ name: 'fakeBrowser', from: 'wxt/testing' },
{ name: 'i18n', from: 'wxt/i18n' },
],
presets: [
{ package: 'wxt/client' },
+165
View File
@@ -0,0 +1,165 @@
import { describe, it, vi, expect } from 'vitest';
import { createExtensionI18n } from '../client';
import { fakeBrowser } from '@webext-core/fake-browser';
let getMessageMock = vi.fn();
fakeBrowser.i18n.getMessage = getMessageMock;
describe('createExtensionI18n', () => {
describe('t', () => {
it('should return the text from browsesr.i18n.getMessage', () => {
const expected = 'Hello world';
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(expected);
const actual = i18n.t('key', ['']);
expect(actual).toBe(expected);
});
it("should return a blank string when the message doesn't exist", () => {
const i18n = createExtensionI18n();
// browser.i18n.getMessage returns a blank string when the message doesn't exist
getMessageMock.mockReturnValue('');
const actual = i18n.t('unknown-key');
expect(actual).toBe('');
});
it('should pass substitutions in correctly', () => {
const i18n = createExtensionI18n();
const key = 'key';
const subs = ['1', 'two'];
i18n.t(key, subs);
expect(getMessageMock).toBeCalledWith(key, subs);
});
});
describe('tp', () => {
it.each([
[0, '0 items'],
[1, '1 items'],
[2, '2 items'],
[3, '3 items'],
])(
'should return the correct string for format "{n}" and count=%s',
(count, expected) => {
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(`${count} items`);
const actual = i18n.tp('key', count, [String(count)]);
expect(actual).toBe(expected);
},
);
it.each([
[0, '0 items'],
[1, '1 item'],
[2, '2 items'],
[3, '3 items'],
])(
'should return the correct string for format "{1} | {n}" and count=%s',
(count, expected) => {
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(`1 item | ${count} items`);
const actual = i18n.tp('key', count, [String(count)]);
expect(actual).toBe(expected);
},
);
it.each([
[0, '0 items'],
[1, '1 item'],
[2, '2 items'],
[3, '3 items'],
])(
'should return the correct string for format "{0} | {1} | {n}" and count=%s',
(count, expected) => {
const i18n = createExtensionI18n();
getMessageMock.mockReturnValue(`0 items | 1 item | ${count} items`);
const actual = i18n.tp('key', count, [String(count)]);
expect(actual).toBe(expected);
},
);
it('should pass substitutions in correctly', () => {
const i18n = createExtensionI18n();
const key = 'key';
const count = 4;
const subs = ['1', 'two'];
getMessageMock.mockReturnValue(`0 items | 1 item | ${count} items`);
i18n.tp(key, count, subs);
expect(getMessageMock).toBeCalledWith(key, subs);
});
});
it('should support custom types', () => {
const i18n = createExtensionI18n<{
t: {
singularNoSubs: undefined;
singularOneSub: [name: string];
singularTwoSubs: [string, string];
};
tp: {
pluralNoSubs: undefined;
pluralOneSub: [name: string];
pluralTwoSubs: [string, string];
};
}>();
getMessageMock.mockReturnValue('');
i18n.t('singularNoSubs');
// @ts-expect-error
i18n.t('singularNoSubs', ['1', '2']);
i18n.t('singularOneSub', ['one']);
// @ts-expect-error
i18n.t('singularOneSub');
i18n.t('singularTwoSubs', ['one', 'two']);
// @ts-expect-error
i18n.t('singularTwoSubs', ['one']);
// @ts-expect-error
i18n.t('singularTwoSubs');
// @ts-expect-error
i18n.t('pluralNoSubs');
// @ts-expect-error
i18n.t('pluralOneSub');
// @ts-expect-error
i18n.t('pluralTwoSubs');
i18n.tp('pluralNoSubs', 0);
// @ts-expect-error
i18n.tp('pluralNoSubs', 0, ['1', '2']);
// @ts-expect-error
i18n.tp('pluralNoSubs');
i18n.tp('pluralOneSub', 0, ['one']);
// @ts-expect-error
i18n.tp('pluralOneSub', 0);
i18n.tp('pluralTwoSubs', 0, ['one', 'two']);
// @ts-expect-error
i18n.tp('pluralTwoSubs', 0, ['one']);
// @ts-expect-error
i18n.tp('pluralTwoSubs', 0);
// @ts-expect-error
i18n.tp('singularNoSubs');
// @ts-expect-error
i18n.tp('singularOneSub');
// @ts-expect-error
i18n.tp('singularTwoSubs');
});
});
+13
View File
@@ -0,0 +1,13 @@
simple: Hello world!
manifestStyle:
message: This is the translated text
description: This is a description to give translators more context (it's never translated)
interpolation: Hello $1, my name is $2
some:
nested:
translation: You can nest translations inside deep objects
escapeTheDollarSign: You owe me $$100
pluralForm:
n: $1 items
1: 1 item
0: Zero items
+21
View File
@@ -0,0 +1,21 @@
{
"simple": {
"message": "Hello world!"
},
"manifestStyle": {
"message": "This is the translated text",
"description": "This is a description to give translators more context (it's never translated)"
},
"interpolation": {
"message": "Hello $1, my name is $2"
},
"some_nested_translation": {
"message": "You can nest translations inside deep objects"
},
"escapeTheDollarSign": {
"message": "You owe me $$100"
},
"pluralForm": {
"message": "Zero items | 1 item | $1 items"
}
}
+87
View File
@@ -0,0 +1,87 @@
[
{
"entry": {
"message": "Hello world!"
},
"name": "simple"
},
{
"entry": {
"description": "This is a description to give translators more context (it's never translated)",
"message": "This is the translated text"
},
"name": "manifestStyle"
},
{
"entry": {
"message": "Hello $1, my name is $2"
},
"name": "interpolation"
},
{
"entry": {
"message": "You can nest translations inside deep objects"
},
"name": "some_nested_translation"
},
{
"entry": {
"message": "You owe me $$100"
},
"name": "escapeTheDollarSign"
},
{
"entry": {
"message": "Zero items | 1 item | $1 items"
},
"isPlural": true,
"name": "pluralForm"
},
{
"entry": {
"description": "The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.\nNote: You can't use this message in a manifest file.",
"message": "<browser.runtime.id>"
},
"isBuiltin": true,
"name": "@@extension_id"
},
{
"entry": {
"message": "<browser.i18n.getUiLocale()>"
},
"isBuiltin": true,
"name": "@@ui_locale"
},
{
"entry": {
"description": "The text direction for the current locale, either \"ltr\" for left-to-right languages such as English or \"rtl\" for right-to-left languages such as Japanese.",
"message": "<ltr|rtl>"
},
"isBuiltin": true,
"name": "@@bidi_dir"
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"rtl\"; otherwise, it's \"ltr\".",
"message": "<rtl|ltr>"
},
"isBuiltin": true,
"name": "@@bidi_reversed_dir"
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"left\"; otherwise, it's \"right\".",
"message": "<left|right>"
},
"isBuiltin": true,
"name": "@@bidi_start_edge"
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"right\"; otherwise, it's \"left\".",
"message": "<right|left>"
},
"isBuiltin": true,
"name": "@@bidi_end_edge"
}
]
+29
View File
@@ -0,0 +1,29 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { convertMessagesToManifest, readMessagesFile } from '../node';
const inputFile = resolve(__dirname, 'fixtures/input.yml');
const messagesFile = resolve(__dirname, 'fixtures/messages.json');
const manifestFile = resolve(__dirname, 'fixtures/manifest.json');
describe('I18n Node Utils', () => {
describe('readMessagesFile', () => {
it('should return all available messages and metadata', async () => {
const expected = JSON.parse(await readFile(messagesFile, 'utf-8'));
const actual = await readMessagesFile(inputFile);
expect(actual).toEqual(expected);
});
});
describe('convertMessagesToManifest', () => {
it('should return all available messages and metadata', async () => {
const input = JSON.parse(await readFile(messagesFile, 'utf-8'));
const expected = JSON.parse(await readFile(manifestFile, 'utf-8'));
const actual = convertMessagesToManifest(input);
expect(actual).toEqual(expected);
});
});
});
+75
View File
@@ -0,0 +1,75 @@
import { browser } from '~/browser';
export interface DefaultMessageSchema {
t: {
[key: string]: string[] | undefined;
};
tp: {
[key: string]: string[] | undefined;
};
}
export function createExtensionI18n<
TMessageSchema = unknown,
>(): ExtensionI18n<TMessageSchema> {
const untyped: UntypedExtensionI18n = {
t(key, substitutions) {
return browser.i18n.getMessage(key, substitutions);
},
tp(key, count, substitutions) {
const plural = browser.i18n
.getMessage(key as string, substitutions)
.split(' | ');
// "n items"
if (plural.length === 1) return plural[0];
// "1 item | n items"
if (plural.length === 2) {
if (count === 1) return plural[0];
return plural[1];
}
// "0 items | 1 item | n items"
if (count === 0 || count === 1) return plural[count];
return plural[2];
},
};
return untyped as ExtensionI18n<TMessageSchema>;
}
export type ExtensionI18n<TMessageSchema> =
TMessageSchema extends DefaultMessageSchema
? TypedExtensionI18n<TMessageSchema>
: UntypedExtensionI18n;
export interface TypedExtensionI18n<
TMessageSchema extends DefaultMessageSchema,
> {
t<TKey extends KeysWithoutSub<TMessageSchema['t']>>(key: TKey): string;
t<TKey extends KeysWithSub<TMessageSchema['t']>>(
key: TKey,
substitutions: TMessageSchema['t'][TKey],
): string;
tp<TKey extends KeysWithoutSub<TMessageSchema['tp']>>(
key: TKey,
count: number,
): string;
tp<TKey extends KeysWithSub<TMessageSchema['tp']>>(
key: TKey,
count: number,
substitutions: TMessageSchema['tp'][TKey],
): string;
}
export interface UntypedExtensionI18n {
t(key: string, substitutions?: string[]): string;
tp(key: string, count: number, substitutions?: string[]): string;
}
type FilterKeys<TObject, TFilterType> = {
[K in keyof TObject]-?: TObject[K] extends TFilterType ? K : never;
}[keyof TObject];
type KeysWithSub<TObject> = FilterKeys<TObject, string[]>;
type KeysWithoutSub<TObject> = FilterKeys<TObject, undefined>;
+21
View File
@@ -0,0 +1,21 @@
/**
* Simple, type-safe alternative to `browser.i18n.getMessage` with support for placeholders and plurals.
*
* See [the guide](https://wxt.dev/guide/localization.html) for more information.
*
* @module wxt/i18n
*/
import { DefaultMessageSchema, createExtensionI18n } from './client';
export interface WxtMessageSchema extends DefaultMessageSchema {
// Overriden per-project
}
export const i18n = createExtensionI18n<WxtMessageSchema>();
export {
DefaultMessageSchema,
ExtensionI18n,
TypedExtensionI18n,
UntypedExtensionI18n,
} from './client';
+210
View File
@@ -0,0 +1,210 @@
import { readFile } from 'node:fs/promises';
import JSON5 from 'json5';
import YAML from 'yaml';
/**
* Convert a file containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export async function readMessagesFile(file: string): Promise<Message[]> {
const text = await readFile(file, 'utf-8');
return readMessagesText(text);
}
/**
* Convert a string containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export function readMessagesText(text: string): Message[] {
const parsers: Array<(text: string) => any> = [
JSON.parse,
JSON5.parse,
YAML.parse,
];
for (const parse of parsers) {
try {
const result = parse(text);
if (typeof result === 'object') {
return readMessagesObject(result);
}
} catch {
continue;
}
}
throw Error('I18n messages text is not valid JSON, JSON5, or YAML');
}
/**
* Convert an object containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export function readMessagesObject(input: ExtensionI18nSchema): Message[] {
const messagesFromInput = findEntries([], input);
return [...messagesFromInput, ...PREDEFINED_MESSAGES];
}
export function convertMessagesToManifest(
messages: Message[],
): RawExtensionI18nSchema {
return messages
.filter((message) => !message.isBuiltin)
.reduce<RawExtensionI18nSchema>((schema, { name, entry }) => {
schema[name] = entry;
return schema;
}, {});
}
function findEntries(
keyPath: string[],
input: ExtensionI18nSchema | ExtensionI18nEntry,
): Message[] {
const name = keyPath.join('_');
if (isBasicEntry(input))
return [
{
name,
entry: { message: input },
},
];
if (isManifestEntry(input))
return [
{
name,
entry: input,
},
];
if (isPluralEntry(input))
return [
{
name,
entry: {
message: Object.values(input).join(' | '),
},
isPlural: true,
},
];
return Object.entries(input).reduce<Message[]>((items, [key, child]) => {
const nestedEntries = findEntries(keyPath.concat(key), child);
return [...items, ...nestedEntries];
}, []);
}
function isBasicEntry(
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nBasicEntry {
return typeof entry === 'string';
}
function isManifestEntry(
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nManifestEntry {
const keys = Object.keys(entry);
if (keys.length < 1 || keys.length > 3) return false;
const knownKeys = new Set(['message', 'placeholders', 'description']);
const unknownKeys = keys.filter((key) => !knownKeys.has(key));
return unknownKeys.length === 0;
}
function isPluralEntry(
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nPluralEntry {
const keys = Object.keys(entry);
if (keys.length === 0) return false;
const invalidKeys = keys.filter((key) => key !== 'n' && isNaN(Number(key)));
return invalidKeys.length === 0;
}
export const PREDEFINED_MESSAGES: Message[] = [
{
name: '@@extension_id',
isBuiltin: true,
entry: {
message: '<browser.runtime.id>',
description:
"The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.\nNote: You can't use this message in a manifest file.",
},
},
{
name: '@@ui_locale',
isBuiltin: true,
entry: {
message: '<browser.i18n.getUiLocale()>',
},
},
{
name: '@@bidi_dir',
isBuiltin: true,
entry: {
message: '<ltr|rtl>',
description:
'The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.',
},
},
{
name: '@@bidi_reversed_dir',
isBuiltin: true,
entry: {
message: '<rtl|ltr>',
description:
'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".',
},
},
{
name: '@@bidi_start_edge',
isBuiltin: true,
entry: {
message: '<left|right>',
description:
'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".',
},
},
{
name: '@@bidi_end_edge',
isBuiltin: true,
entry: {
message: '<right|left>',
description:
'If the @@bidi_dir is "ltr", then this is "right"; otherwise, it\'s "left".',
},
},
];
export type RawExtensionI18nSchema = Record<string, RawExtensionI18nEntry>;
export interface RawExtensionI18nEntry {
message: string;
description?: string;
placeholders?: Record<
string,
{
content: string;
example?: string;
}
>;
}
export type ExtensionI18nBasicEntry = string;
export type ExtensionI18nManifestEntry = RawExtensionI18nEntry;
export type ExtensionI18nPluralEntry = {
n: string;
[i: number]: string;
};
export type ExtensionI18nEntry =
| ExtensionI18nBasicEntry
| ExtensionI18nManifestEntry
| ExtensionI18nPluralEntry;
export interface ExtensionI18nSchema {
[name: string]: ExtensionI18nSchema | ExtensionI18nEntry;
}
export interface Message {
name: string;
entry: RawExtensionI18nEntry;
isPlural?: boolean;
isBuiltin?: boolean;
}
+6
View File
@@ -27,6 +27,12 @@ export interface InlineConfig {
* @default "${config.root}/public"
*/
publicDir?: string;
/**
* Directory containing localization files used for translation.
*
* @default "${config.srcDir}/locales"
*/
localesDir?: string;
/**
* @default "${config.srcDir}/entrypoints"
*/
+1
View File
@@ -18,6 +18,7 @@ export interface InternalConfig {
root: string;
srcDir: string;
publicDir: string;
localesDir: string;
wxtDir: string;
typesDir: string;
entrypointsDir: string;
+1 -1
View File
@@ -23,6 +23,6 @@
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"devDependencies": {
"typescript": "^5.3.3",
"vite-plugin-solid": "^2.8.0",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -20,6 +20,6 @@
"svelte-check": "^3.6.2",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -16,6 +16,6 @@
},
"devDependencies": {
"typescript": "^5.3.3",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}
+1 -1
View File
@@ -21,6 +21,6 @@
"@vitejs/plugin-vue": "^5.0.1",
"typescript": "^5.3.3",
"vue-tsc": "^1.8.27",
"wxt": "^0.13.0"
"wxt": "^0.14.0"
}
}