Compare commits
15 Commits
main
...
v0.14.2-alpha2
| Author | SHA1 | Date | |
|---|---|---|---|
| 98bc1f2cd1 | |||
| f6a066f166 | |||
| c20478cde2 | |||
| ebd50079a7 | |||
| b17bd87865 | |||
| e8b471e23f | |||
| 32ffca4168 | |||
| e9c573d5a1 | |||
| ae5126d726 | |||
| 24a77ca9b5 | |||
| 174cbbc5eb | |||
| 607c70dd56 | |||
| 616d012326 | |||
| 75b6ca7172 | |||
| 1497b3099c |
@@ -1,5 +1,3 @@
|
|||||||
import messages from 'public/_locales/en/messages.json';
|
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
console.log(browser.runtime.id);
|
console.log(browser.runtime.id);
|
||||||
logId();
|
logId();
|
||||||
@@ -8,20 +6,32 @@ export default defineBackground(() => {
|
|||||||
chrome: __IS_CHROME__,
|
chrome: __IS_CHROME__,
|
||||||
firefox: __IS_FIREFOX__,
|
firefox: __IS_FIREFOX__,
|
||||||
manifestVersion: __MANIFEST_VERSION__,
|
manifestVersion: __MANIFEST_VERSION__,
|
||||||
messages,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-expect-error: should only accept entrypoints or public assets
|
// @ts-expect-error: should only accept entrypoints or public assets
|
||||||
browser.runtime.getURL('/');
|
browser.runtime.getURL('/');
|
||||||
browser.runtime.getURL('/background.js');
|
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
|
console.log([
|
||||||
browser.i18n.getMessage('test');
|
// @ts-expect-error: browser.i18n should only accept known message names
|
||||||
browser.i18n.getMessage('prompt_for_name');
|
browser.i18n.getMessage('test'),
|
||||||
browser.i18n.getMessage('hello', 'Aaron');
|
browser.i18n.getMessage('promptForName'),
|
||||||
browser.i18n.getMessage('bye', ['Aaron']);
|
browser.i18n.getMessage('hello', ['Aaron']),
|
||||||
browser.i18n.getMessage('@@extension_id');
|
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:', {
|
console.log('WXT MODE:', {
|
||||||
MODE: import.meta.env.MODE,
|
MODE: import.meta.env.MODE,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 |
@@ -89,6 +89,7 @@ export default defineConfig({
|
|||||||
{ text: 'Storage', link: '/guide/storage.md' },
|
{ text: 'Storage', link: '/guide/storage.md' },
|
||||||
{ text: 'Assets', link: '/guide/assets.md' },
|
{ text: 'Assets', link: '/guide/assets.md' },
|
||||||
{ text: 'Content Script UI', link: '/guide/content-script-ui.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: 'Multiple Browsers', link: '/guide/multiple-browsers.md' },
|
||||||
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
|
{ text: 'Auto-imports', link: '/guide/auto-imports.md' },
|
||||||
{ text: 'Vite', link: '/guide/vite.md' },
|
{ text: 'Vite', link: '/guide/vite.md' },
|
||||||
|
|||||||
@@ -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"
|
||||||
|
```
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
"../src/browser.ts",
|
"../src/browser.ts",
|
||||||
"../src/sandbox",
|
"../src/sandbox",
|
||||||
"../src/storage.ts",
|
"../src/storage.ts",
|
||||||
"../src/testing"
|
"../src/testing",
|
||||||
|
"../src/i18n"
|
||||||
],
|
],
|
||||||
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
|
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
|
||||||
"out": "./api",
|
"out": "./api",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('Auto Imports', () => {
|
|||||||
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
|
const defineContentScript: typeof import('wxt/sandbox')['defineContentScript']
|
||||||
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
|
const defineUnlistedScript: typeof import('wxt/sandbox')['defineUnlistedScript']
|
||||||
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
|
const fakeBrowser: typeof import('wxt/testing')['fakeBrowser']
|
||||||
|
const i18n: typeof import('wxt/i18n')['i18n']
|
||||||
const storage: typeof import('wxt/storage')['storage']
|
const storage: typeof import('wxt/storage')['storage']
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('TypeScript Project', () => {
|
|||||||
const project = new TestProject();
|
const project = new TestProject();
|
||||||
project.addFile('entrypoints/unlisted.html');
|
project.addFile('entrypoints/unlisted.html');
|
||||||
project.addFile(
|
project.addFile(
|
||||||
'public/_locales/en/messages.json',
|
'locales/en.json',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
prompt_for_name: {
|
prompt_for_name: {
|
||||||
message: "What's your name?",
|
message: "What's your name?",
|
||||||
@@ -119,6 +119,36 @@ describe('TypeScript Project', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WxtI18n extends I18n.Static {
|
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.
|
* 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.
|
Note: You can't use this message in a manifest file.
|
||||||
@@ -131,7 +161,7 @@ describe('TypeScript Project', () => {
|
|||||||
options?: GetMessageOptions,
|
options?: GetMessageOptions,
|
||||||
): string;
|
): string;
|
||||||
/**
|
/**
|
||||||
*
|
* No message description.
|
||||||
*
|
*
|
||||||
* "<browser.i18n.getUiLocale()>"
|
* "<browser.i18n.getUiLocale()>"
|
||||||
*/
|
*/
|
||||||
@@ -180,36 +210,25 @@ describe('TypeScript Project', () => {
|
|||||||
substitutions?: string | string[],
|
substitutions?: string | string[],
|
||||||
options?: GetMessageOptions,
|
options?: GetMessageOptions,
|
||||||
): string;
|
): string;
|
||||||
/**
|
}
|
||||||
* Ask for the user's name
|
}
|
||||||
*
|
|
||||||
* "What's your name?"
|
declare module "wxt/i18n" {
|
||||||
*/
|
export interface WxtMessageSchema {
|
||||||
getMessage(
|
t: {
|
||||||
messageName: "prompt_for_name",
|
"prompt_for_name": any;
|
||||||
substitutions?: string | string[],
|
"hello": any;
|
||||||
options?: GetMessageOptions,
|
"bye": any;
|
||||||
): string;
|
"@@extension_id": any;
|
||||||
/**
|
"@@ui_locale": any;
|
||||||
* Greet the user
|
"@@bidi_dir": any;
|
||||||
*
|
"@@bidi_reversed_dir": any;
|
||||||
* "Hello, $USER$"
|
"@@bidi_start_edge": any;
|
||||||
*/
|
"@@bidi_end_edge": any;
|
||||||
getMessage(
|
};
|
||||||
messageName: "hello",
|
tp: {
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "wxt",
|
"name": "wxt",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.14.1",
|
"version": "0.14.2-alpha2",
|
||||||
"description": "Next gen framework for developing web extensions",
|
"description": "Next gen framework for developing web extensions",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18",
|
"node": ">=18",
|
||||||
@@ -77,6 +77,16 @@
|
|||||||
"default": "./dist/storage.cjs"
|
"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": {
|
"./vite-builder-env": {
|
||||||
"types": "./dist/vite-builder-env.d.ts"
|
"types": "./dist/vite-builder-env.d.ts"
|
||||||
}
|
}
|
||||||
@@ -135,6 +145,7 @@
|
|||||||
"vite": "^5.0.0",
|
"vite": "^5.0.0",
|
||||||
"web-ext-run": "^0.1.0",
|
"web-ext-run": "^0.1.0",
|
||||||
"webextension-polyfill": "^0.10.0",
|
"webextension-polyfill": "^0.10.0",
|
||||||
|
"yaml": "^2.3.4",
|
||||||
"zip-dir": "^2.0.0"
|
"zip-dir": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ importers:
|
|||||||
webextension-polyfill:
|
webextension-polyfill:
|
||||||
specifier: ^0.10.0
|
specifier: ^0.10.0
|
||||||
version: 0.10.0
|
version: 0.10.0
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.3.4
|
||||||
|
version: 2.3.4
|
||||||
zip-dir:
|
zip-dir:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
@@ -5385,7 +5388,6 @@ packages:
|
|||||||
/yaml@2.3.4:
|
/yaml@2.3.4:
|
||||||
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
|
resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/yargs-parser@21.1.1:
|
/yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const config: tsup.Options[] = [
|
|||||||
index: 'src/index.ts',
|
index: 'src/index.ts',
|
||||||
testing: 'src/testing/index.ts',
|
testing: 'src/testing/index.ts',
|
||||||
storage: 'src/storage.ts',
|
storage: 'src/storage.ts',
|
||||||
|
i18n: 'src/i18n/index.ts',
|
||||||
},
|
},
|
||||||
format: ['cjs', 'esm'],
|
format: ['cjs', 'esm'],
|
||||||
clean: true,
|
clean: true,
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
} from '~/types';
|
} from '~/types';
|
||||||
import { getPublicFiles } from '~/core/utils/fs';
|
import { getPublicFiles } from '~/core/utils/fs';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve, extname } from 'node:path';
|
||||||
import type { Ora } from 'ora';
|
import type { Ora } from 'ora';
|
||||||
import pc from 'picocolors';
|
import pc from 'picocolors';
|
||||||
|
import { unnormalizePath } from '~/core/utils/paths';
|
||||||
|
import { convertMessagesToManifest, readMessagesFile } from '~/i18n/node';
|
||||||
|
|
||||||
export async function buildEntrypoints(
|
export async function buildEntrypoints(
|
||||||
groups: EntrypointGroup[],
|
groups: EntrypointGroup[],
|
||||||
@@ -25,7 +27,13 @@ export async function buildEntrypoints(
|
|||||||
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
|
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
|
||||||
steps.push(await config.builder.build(group));
|
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 };
|
return { publicAssets, steps };
|
||||||
}
|
}
|
||||||
@@ -51,3 +59,31 @@ async function copyPublicDirectory(
|
|||||||
|
|
||||||
return publicAssets;
|
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',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
|
|||||||
import { getUnimportOptions } from '~/core/utils/unimport';
|
import { getUnimportOptions } from '~/core/utils/unimport';
|
||||||
import { getEntrypointGlobals, getGlobals } from '~/core/utils/globals';
|
import { getEntrypointGlobals, getGlobals } from '~/core/utils/globals';
|
||||||
import { normalizePath } from '~/core/utils/paths';
|
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 { 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.
|
* 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 {
|
export interface WxtI18n extends I18n.Static {
|
||||||
{{ overrides }}
|
{{ browserOverrides }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "wxt/i18n" {
|
||||||
|
export interface WxtMessageSchema {
|
||||||
|
t: {
|
||||||
|
{{ translationTOverrides }}
|
||||||
|
};
|
||||||
|
tp: {
|
||||||
|
{{ translationTpOverrides }}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let messages: Message[];
|
let messages: Message[];
|
||||||
if (defaultLocale) {
|
if (defaultLocale) {
|
||||||
const defaultLocalePath = path.resolve(
|
const [defaultLocalePath] = await glob(`${defaultLocale}.*`, {
|
||||||
config.publicDir,
|
cwd: config.localesDir,
|
||||||
'_locales',
|
absolute: true,
|
||||||
defaultLocale,
|
});
|
||||||
'messages.json',
|
messages = await readMessagesFile(defaultLocalePath);
|
||||||
);
|
|
||||||
const content = JSON.parse(await fs.readFile(defaultLocalePath, 'utf-8'));
|
|
||||||
messages = parseI18nMessages(content);
|
|
||||||
} else {
|
} else {
|
||||||
messages = parseI18nMessages({});
|
messages = PREDEFINED_MESSAGES;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrides = messages.map((message) => {
|
const overrides = messages.map((message) => {
|
||||||
return ` /**
|
return ` /**
|
||||||
* ${message.description ?? 'No message description.'}
|
* ${message.entry.description ?? 'No message description.'}
|
||||||
*
|
*
|
||||||
* "${message.message}"
|
* "${message.entry.message}"
|
||||||
*/
|
*/
|
||||||
getMessage(
|
getMessage(
|
||||||
messageName: "${message.name}",
|
messageName: "${message.name}",
|
||||||
@@ -146,7 +154,22 @@ declare module "wxt/browser" {
|
|||||||
});
|
});
|
||||||
await writeFileIfDifferent(
|
await writeFileIfDifferent(
|
||||||
filePath,
|
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;
|
return filePath;
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export async function getInternalConfig(
|
|||||||
mergedConfig.entrypointsDir ?? 'entrypoints',
|
mergedConfig.entrypointsDir ?? 'entrypoints',
|
||||||
);
|
);
|
||||||
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
|
const publicDir = path.resolve(srcDir, mergedConfig.publicDir ?? 'public');
|
||||||
|
const localesDir = path.resolve(srcDir, mergedConfig.localesDir ?? 'locales');
|
||||||
const typesDir = path.resolve(wxtDir, 'types');
|
const typesDir = path.resolve(wxtDir, 'types');
|
||||||
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
|
const outBaseDir = path.resolve(root, mergedConfig.outDir ?? '.output');
|
||||||
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
|
const outDir = path.resolve(outBaseDir, `${browser}-mv${manifestVersion}`);
|
||||||
@@ -111,6 +112,7 @@ export async function getInternalConfig(
|
|||||||
outBaseDir,
|
outBaseDir,
|
||||||
outDir,
|
outDir,
|
||||||
publicDir,
|
publicDir,
|
||||||
|
localesDir,
|
||||||
root,
|
root,
|
||||||
runnerConfig,
|
runnerConfig,
|
||||||
srcDir,
|
srcDir,
|
||||||
@@ -196,6 +198,7 @@ function mergeInlineConfig(
|
|||||||
manifest,
|
manifest,
|
||||||
mode: inlineConfig.mode ?? userConfig.mode,
|
mode: inlineConfig.mode ?? userConfig.mode,
|
||||||
publicDir: inlineConfig.publicDir ?? userConfig.publicDir,
|
publicDir: inlineConfig.publicDir ?? userConfig.publicDir,
|
||||||
|
localesDir: inlineConfig.localesDir ?? userConfig.localesDir,
|
||||||
runner,
|
runner,
|
||||||
srcDir: inlineConfig.srcDir ?? userConfig.srcDir,
|
srcDir: inlineConfig.srcDir ?? userConfig.srcDir,
|
||||||
outDir: inlineConfig.outDir ?? userConfig.outDir,
|
outDir: inlineConfig.outDir ?? userConfig.outDir,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
|||||||
outBaseDir: fakeDir(),
|
outBaseDir: fakeDir(),
|
||||||
outDir: fakeDir(),
|
outDir: fakeDir(),
|
||||||
publicDir: fakeDir(),
|
publicDir: fakeDir(),
|
||||||
|
localesDir: fakeDir(),
|
||||||
root: fakeDir(),
|
root: fakeDir(),
|
||||||
runnerConfig: {
|
runnerConfig: {
|
||||||
config: {},
|
config: {},
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function getUnimportOptions(
|
|||||||
imports: [
|
imports: [
|
||||||
{ name: 'defineConfig', from: 'wxt' },
|
{ name: 'defineConfig', from: 'wxt' },
|
||||||
{ name: 'fakeBrowser', from: 'wxt/testing' },
|
{ name: 'fakeBrowser', from: 'wxt/testing' },
|
||||||
|
{ name: 'i18n', from: 'wxt/i18n' },
|
||||||
],
|
],
|
||||||
presets: [
|
presets: [
|
||||||
{ package: 'wxt/client' },
|
{ package: 'wxt/client' },
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -27,6 +27,12 @@ export interface InlineConfig {
|
|||||||
* @default "${config.root}/public"
|
* @default "${config.root}/public"
|
||||||
*/
|
*/
|
||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
|
/**
|
||||||
|
* Directory containing localization files used for translation.
|
||||||
|
*
|
||||||
|
* @default "${config.srcDir}/locales"
|
||||||
|
*/
|
||||||
|
localesDir?: string;
|
||||||
/**
|
/**
|
||||||
* @default "${config.srcDir}/entrypoints"
|
* @default "${config.srcDir}/entrypoints"
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface InternalConfig {
|
|||||||
root: string;
|
root: string;
|
||||||
srcDir: string;
|
srcDir: string;
|
||||||
publicDir: string;
|
publicDir: string;
|
||||||
|
localesDir: string;
|
||||||
wxtDir: string;
|
wxtDir: string;
|
||||||
typesDir: string;
|
typesDir: string;
|
||||||
entrypointsDir: string;
|
entrypointsDir: string;
|
||||||
|
|||||||