Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c20478cde2 | |||
| ebd50079a7 | |||
| b17bd87865 | |||
| e8b471e23f | |||
| 32ffca4168 | |||
| e9c573d5a1 | |||
| ae5126d726 | |||
| 24a77ca9b5 | |||
| 174cbbc5eb | |||
| 607c70dd56 | |||
| 616d012326 | |||
| 75b6ca7172 | |||
| 1497b3099c | |||
| a329e24818 | |||
| 249cf63da8 | |||
| 70a496108a | |||
| 214889e052 | |||
| b6ab7a9567 | |||
| 1ceb9e2e80 | |||
| 15ea81514f |
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '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' },
|
||||
|
||||
@@ -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/sandbox",
|
||||
"../src/storage.ts",
|
||||
"../src/testing"
|
||||
"../src/testing",
|
||||
"../src/i18n"
|
||||
],
|
||||
"plugin": ["typedoc-plugin-markdown", "typedoc-vitepress-theme"],
|
||||
"out": "./api",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "wxt",
|
||||
"type": "module",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.2-alpha1",
|
||||
"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": {
|
||||
|
||||
@@ -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==}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -896,7 +896,7 @@ describe('Manifest Utils', () => {
|
||||
const reloadCommandName = 'wxt:reload-extension';
|
||||
const reloadCommand = {
|
||||
suggested_key: {
|
||||
default: 'Ctrl+E',
|
||||
default: 'Alt+R',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ export const fakeInternalConfig = fakeObjectCreator<InternalConfig>(() => {
|
||||
outBaseDir: fakeDir(),
|
||||
outDir: fakeDir(),
|
||||
publicDir: fakeDir(),
|
||||
localesDir: fakeDir(),
|
||||
root: fakeDir(),
|
||||
runnerConfig: {
|
||||
config: {},
|
||||
|
||||
@@ -18,6 +18,7 @@ export function getUnimportOptions(
|
||||
{ package: 'wxt/browser' },
|
||||
{ package: 'wxt/sandbox' },
|
||||
{ package: 'wxt/storage' },
|
||||
{ package: 'wxt/i18n' },
|
||||
],
|
||||
warn: config.logger.warn,
|
||||
dirs: ['components', 'composables', 'hooks', 'utils'],
|
||||
|
||||
@@ -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,16 @@
|
||||
/**
|
||||
* 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 * 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"
|
||||
*/
|
||||
publicDir?: string;
|
||||
/**
|
||||
* Directory containing localization files used for translation.
|
||||
*
|
||||
* @default "${config.srcDir}/locales"
|
||||
*/
|
||||
localesDir?: string;
|
||||
/**
|
||||
* @default "${config.srcDir}/entrypoints"
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface InternalConfig {
|
||||
root: string;
|
||||
srcDir: string;
|
||||
publicDir: string;
|
||||
localesDir: string;
|
||||
wxtDir: string;
|
||||
typesDir: string;
|
||||
entrypointsDir: string;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vite-plugin-solid": "^2.8.0",
|
||||
"wxt": "^0.13.0"
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"wxt": "^0.13.0"
|
||||
"wxt": "^0.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||