Implement and generate types for wxt/i18n

This commit is contained in:
Aaron Klinker
2024-01-12 23:58:55 -06:00
parent ae5126d726
commit e9c573d5a1
14 changed files with 374 additions and 138 deletions
+18 -12
View File
@@ -13,19 +13,25 @@ export default defineBackground(() => {
browser.runtime.getURL('/background.js');
browser.runtime.getURL('/icon-128.png');
// @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');
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'),
]);
// @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');
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,
+38 -2
View File
@@ -6,9 +6,11 @@ import {
} from '~/types';
import { getPublicFiles } from '~/core/utils/fs';
import fs from 'fs-extra';
import { dirname, resolve } from 'path';
import { dirname, resolve, basename, extname } from 'node:path';
import type { Ora } from 'ora';
import pc from 'picocolors';
import { unnormalizePath } from '~/core/utils/paths';
import { convertMessagesToManifest, readMessagesFile } from '~/i18n/node';
export async function buildEntrypoints(
groups: EntrypointGroup[],
@@ -25,7 +27,13 @@ export async function buildEntrypoints(
spinner.text = pc.dim(`[${i + 1}/${groups.length}]`) + ` ${groupNames}`;
steps.push(await config.builder.build(group));
}
const publicAssets = await copyPublicDirectory(config);
const publicAssets = (
await Promise.all([
copyPublicDirectory(config),
copyLocalesDirectory(config),
])
).flat();
return { publicAssets, steps };
}
@@ -51,3 +59,31 @@ async function copyPublicDirectory(
return publicAssets;
}
async function copyLocalesDirectory(
config: InternalConfig,
): Promise<BuildOutput['publicAssets']> {
const localesExist = await fs.exists(config.localesDir);
if (!localesExist || config.manifest.default_locale == null) return [];
const files = await fs.readdir(config.localesDir);
return await Promise.all(
files.map(async (file) => {
const locale = file.replace(extname(file), '');
const fileName = unnormalizePath(`_locales/${locale}/messages.json`);
const srcPath = resolve(config.localesDir, file);
const outPath = resolve(config.outDir, fileName);
const messages = await readMessagesFile(srcPath);
const json = convertMessagesToManifest(messages);
await fs.ensureDir(dirname(outPath));
await fs.writeJson(outPath, json);
return {
fileName,
type: 'asset',
};
}),
);
}
+38 -15
View File
@@ -6,9 +6,9 @@ import { getEntrypointBundlePath } from '~/core/utils/entrypoints';
import { getUnimportOptions } from '~/core/utils/unimport';
import { getEntrypointGlobals, getGlobals } from '~/core/utils/globals';
import { normalizePath } from '~/core/utils/paths';
import path from 'node:path';
import { Message, parseI18nMessages } from '~/core/utils/i18n';
import { writeFileIfDifferent, getPublicFiles } from '~/core/utils/fs';
import glob from 'fast-glob';
import { Message, PREDEFINED_MESSAGES, readMessagesFile } from '~/i18n/node';
/**
* Generate and write all the files inside the `InternalConfig.typesDir` directory.
@@ -113,30 +113,38 @@ declare module "wxt/browser" {
}
export interface WxtI18n extends I18n.Static {
{{ overrides }}
{{ browserOverrides }}
}
}
declare module "wxt/i18n" {
export interface WxtMessageSchema {
t: {
{{ translationTOverrides }}
};
tp: {
{{ translationTpOverrides }}
};
}
}
`;
let messages: Message[];
if (defaultLocale) {
const defaultLocalePath = path.resolve(
config.publicDir,
'_locales',
defaultLocale,
'messages.json',
);
const content = JSON.parse(await fs.readFile(defaultLocalePath, 'utf-8'));
messages = parseI18nMessages(content);
const [defaultLocalePath] = await glob(`${defaultLocale}.*`, {
cwd: config.localesDir,
absolute: true,
});
messages = await readMessagesFile(defaultLocalePath);
} else {
messages = parseI18nMessages({});
messages = PREDEFINED_MESSAGES;
}
const overrides = messages.map((message) => {
return ` /**
* ${message.description ?? 'No message description.'}
* ${message.entry.description ?? 'No message description.'}
*
* "${message.message}"
* "${message.entry.message}"
*/
getMessage(
messageName: "${message.name}",
@@ -146,7 +154,22 @@ declare module "wxt/browser" {
});
await writeFileIfDifferent(
filePath,
template.replace('{{ overrides }}', overrides.join('\n')),
template
.replace('{{ browserOverrides }}', overrides.join('\n'))
.replace(
'{{ translationTOverrides }}',
messages
.filter((message) => !message.isPlural)
.map((message) => ` "${message.name}": any;`)
.join('\n'),
)
.replace(
'{{ translationTpOverrides }}',
messages
.filter((message) => message.isPlural)
.map((message) => ` "${message.name}": any;`)
.join('\n'),
),
);
return filePath;
@@ -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,
@@ -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,
-52
View File
@@ -1,52 +0,0 @@
export interface Message {
name: string;
message: string;
description?: string;
}
const predefinedMessages = {
'@@extension_id': {
message: '<browser.runtime.id>',
description:
"The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.\nNote: You can't use this message in a manifest file.",
},
'@@ui_locale': {
message: '<browser.i18n.getUiLocale()>',
description: '',
},
'@@bidi_dir': {
message: '<ltr|rtl>',
description:
'The text direction for the current locale, either "ltr" for left-to-right languages such as English or "rtl" for right-to-left languages such as Japanese.',
},
'@@bidi_reversed_dir': {
message: '<rtl|ltr>',
description:
'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".',
},
'@@bidi_start_edge': {
message: '<left|right>',
description:
'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".',
},
'@@bidi_end_edge': {
message: '<right|left>',
description:
'If the @@bidi_dir is "ltr", then this is "right"; otherwise, it\'s "left".',
},
};
/**
* Get a list of all messages and their metadata from JSON file contents.
*
* @param messagesJson The contents of a `_locales/en/messages.json` file.
*/
export function parseI18nMessages(messagesJson: object): Message[] {
return Object.entries({
...predefinedMessages,
...messagesJson,
}).map<Message>(([name, details]) => ({
name,
...details,
}));
}
+87
View File
@@ -0,0 +1,87 @@
[
{
"entry": {
"message": "Hello world!"
},
"name": ["simple"]
},
{
"entry": {
"description": "This is a description to give translators more context (it's never translated)",
"message": "This is the translated text"
},
"name": ["manifestStyle"]
},
{
"entry": {
"message": "Hello $1, my name is $2"
},
"name": ["interpolation"]
},
{
"entry": {
"message": "You can nest translations inside deep objects"
},
"name": ["some", "nested", "translation"]
},
{
"entry": {
"message": "You owe me $$100"
},
"name": ["escapeTheDollarSign"]
},
{
"entry": {
"message": "Zero items | 1 item | $1 items"
},
"isPlural": true,
"name": ["pluralForm"]
},
{
"entry": {
"description": "The extension or app ID; you might use this string to construct URLs for resources inside the extension. Even unlocalized extensions can use this message.\nNote: You can't use this message in a manifest file.",
"message": "<browser.runtime.id>"
},
"isBuiltin": true,
"name": ["@@extension_id"]
},
{
"entry": {
"message": "<browser.i18n.getUiLocale()>"
},
"isBuiltin": true,
"name": ["@@ui_locale"]
},
{
"entry": {
"description": "The text direction for the current locale, either \"ltr\" for left-to-right languages such as English or \"rtl\" for right-to-left languages such as Japanese.",
"message": "<ltr|rtl>"
},
"isBuiltin": true,
"name": ["@@bidi_dir"]
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"rtl\"; otherwise, it's \"ltr\".",
"message": "<rtl|ltr>"
},
"isBuiltin": true,
"name": ["@@bidi_reversed_dir"]
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"left\"; otherwise, it's \"right\".",
"message": "<left|right>"
},
"isBuiltin": true,
"name": ["@@bidi_start_edge"]
},
{
"entry": {
"description": "If the @@bidi_dir is \"ltr\", then this is \"right\"; otherwise, it's \"left\".",
"message": "<right|left>"
},
"isBuiltin": true,
"name": ["@@bidi_end_edge"]
}
]
+18 -10
View File
@@ -1,19 +1,27 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { convertMessagesFile } from '../node';
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('convertMessagesFile', () => {
it("should return an object that can be written to the extension's _locales directory", async () => {
const inputFile = resolve(__dirname, 'fixtures/messages-input.yml');
const expectedFile = resolve(
__dirname,
'fixtures/messages-expected.json',
);
const expected = JSON.parse(await readFile(expectedFile, 'utf-8'));
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);
});
});
const actual = await convertMessagesFile(inputFile);
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);
});
+50
View File
@@ -0,0 +1,50 @@
import { browser } from '~/browser';
export interface DefaultMessageSchema {
t: {
[key: string]: string[] | undefined;
};
tp: {
[key: string]: string[] | undefined;
};
}
export function createExtensionI18n<
TMessageSchema extends DefaultMessageSchema = DefaultMessageSchema,
>(): ExtensionI18n<TMessageSchema> {
return {
t(key: string, substitutions?: string[]) {
return browser.i18n.getMessage(key as string, substitutions);
},
tp(key: string, count: number, substitutions?: string[]) {
const plural = browser.i18n
.getMessage(key as string, substitutions)
.split(' | ');
return plural[count] || plural.at(-1)!;
},
};
}
export interface ExtensionI18n<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;
}
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>;
+8
View File
@@ -0,0 +1,8 @@
import { DefaultMessageSchema, createExtensionI18n } from './client';
export interface WxtMessageSchema extends DefaultMessageSchema {
// Overriden per-project
}
export const i18n = createExtensionI18n<WxtMessageSchema>();
export * from './client';
+107 -47
View File
@@ -6,18 +6,16 @@ import YAML from 'yaml';
* Convert a file containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export async function convertMessagesFile(
file: string,
): Promise<ExtensionI18nSchema> {
export async function readMessagesFile(file: string): Promise<Message[]> {
const text = await readFile(file, 'utf-8');
return convertMessagesText(text);
return readMessagesText(text);
}
/**
* Convert a string containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export function convertMessagesText(text: string): ExtensionI18nSchema {
export function readMessagesText(text: string): Message[] {
const parsers: Array<(text: string) => any> = [
JSON.parse,
JSON5.parse,
@@ -28,7 +26,7 @@ export function convertMessagesText(text: string): ExtensionI18nSchema {
try {
const result = parse(text);
if (typeof result === 'object') {
return convertMessagesObject(result);
return readMessagesObject(result);
}
} catch {
continue;
@@ -42,62 +40,67 @@ export function convertMessagesText(text: string): ExtensionI18nSchema {
* Convert an object containing localized text in WXT's custom format into valid extension manifest
* format.
*/
export function convertMessagesObject(
input: WxtI18nSchema,
): ExtensionI18nSchema {
const entries = findEntries([], input);
return entries.reduce<ExtensionI18nSchema>((schema, { name, entry }) => {
schema[name.join('_')] = entry;
return schema;
}, {});
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: WxtI18nSchema | WxtI18nEntry,
): DetectedEntry[] {
input: ExtensionI18nSchema | ExtensionI18nEntry,
): Message[] {
const name = keyPath.join('_');
if (isBasicEntry(input))
return [
{
name: keyPath,
name,
entry: { message: input },
},
];
if (isManifestEntry(input))
return [
{
name: keyPath,
name,
entry: input,
},
];
if (isPluralEntry(input))
return [
{
name: keyPath,
name,
entry: {
message: Object.values(input).join(' | '),
},
isPlural: true,
},
];
return Object.entries(input).reduce<DetectedEntry[]>(
(items, [key, child]) => {
const nestedEntries = findEntries(keyPath.concat(key), child);
return [...items, ...nestedEntries];
},
[],
);
return Object.entries(input).reduce<Message[]>((items, [key, child]) => {
const nestedEntries = findEntries(keyPath.concat(key), child);
return [...items, ...nestedEntries];
}, []);
}
function isBasicEntry(
entry: WxtI18nSchema | WxtI18nEntry,
): entry is WxtI18nBasicEntry {
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nBasicEntry {
return typeof entry === 'string';
}
function isManifestEntry(
entry: WxtI18nSchema | WxtI18nEntry,
): entry is WxtI18nManifestEntry {
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nManifestEntry {
const keys = Object.keys(entry);
if (keys.length < 1 || keys.length > 3) return false;
@@ -107,8 +110,8 @@ function isManifestEntry(
}
function isPluralEntry(
entry: WxtI18nSchema | WxtI18nEntry,
): entry is WxtI18nPluralEntry {
entry: ExtensionI18nSchema | ExtensionI18nEntry,
): entry is ExtensionI18nPluralEntry {
const keys = Object.keys(entry);
if (keys.length === 0) return false;
@@ -116,10 +119,65 @@ function isPluralEntry(
return invalidKeys.length === 0;
}
export type ExtensionI18nSchema = Record<string, ExtensionI18nEntry>;
export interface ExtensionI18nEntry {
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;
descritption?: string;
description?: string;
placeholders?: Record<
string,
{
@@ -129,22 +187,24 @@ export interface ExtensionI18nEntry {
>;
}
export type WxtI18nBasicEntry = string;
export type WxtI18nManifestEntry = ExtensionI18nEntry;
export type WxtI18nPluralEntry = {
export type ExtensionI18nBasicEntry = string;
export type ExtensionI18nManifestEntry = RawExtensionI18nEntry;
export type ExtensionI18nPluralEntry = {
n: string;
[i: number]: string;
};
export type WxtI18nEntry =
| WxtI18nBasicEntry
| WxtI18nManifestEntry
| WxtI18nPluralEntry;
export type ExtensionI18nEntry =
| ExtensionI18nBasicEntry
| ExtensionI18nManifestEntry
| ExtensionI18nPluralEntry;
export interface WxtI18nSchema {
[name: string]: WxtI18nSchema | WxtI18nEntry;
export interface ExtensionI18nSchema {
[name: string]: ExtensionI18nSchema | ExtensionI18nEntry;
}
interface DetectedEntry {
name: string[];
entry: ExtensionI18nEntry;
export interface Message {
name: string;
entry: RawExtensionI18nEntry;
isPlural?: boolean;
isBuiltin?: boolean;
}
+6
View File
@@ -27,6 +27,12 @@ export interface InlineConfig {
* @default "${config.root}/public"
*/
publicDir?: string;
/**
* Directory containing localization files used for translation.
*
* @default "${config.srcDir}/locales"
*/
localesDir?: string;
/**
* @default "${config.srcDir}/entrypoints"
*/
+1
View File
@@ -18,6 +18,7 @@ export interface InternalConfig {
root: string;
srcDir: string;
publicDir: string;
localesDir: string;
wxtDir: string;
typesDir: string;
entrypointsDir: string;