diff --git a/demo/src/entrypoints/background.ts b/demo/src/entrypoints/background.ts index 2e85b6a5..20f81375 100644 --- a/demo/src/entrypoints/background.ts +++ b/demo/src/entrypoints/background.ts @@ -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, diff --git a/src/core/utils/building/build-entrypoints.ts b/src/core/utils/building/build-entrypoints.ts index b5f9d85e..9e8ac0d8 100644 --- a/src/core/utils/building/build-entrypoints.ts +++ b/src/core/utils/building/build-entrypoints.ts @@ -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 { + 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', + }; + }), + ); +} diff --git a/src/core/utils/building/generate-wxt-dir.ts b/src/core/utils/building/generate-wxt-dir.ts index 654bb60a..8f48edc4 100644 --- a/src/core/utils/building/generate-wxt-dir.ts +++ b/src/core/utils/building/generate-wxt-dir.ts @@ -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; diff --git a/src/core/utils/building/get-internal-config.ts b/src/core/utils/building/get-internal-config.ts index 34693842..a036c132 100644 --- a/src/core/utils/building/get-internal-config.ts +++ b/src/core/utils/building/get-internal-config.ts @@ -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, diff --git a/src/core/utils/i18n.ts b/src/core/utils/i18n.ts index 7e839e56..e69de29b 100644 --- a/src/core/utils/i18n.ts +++ b/src/core/utils/i18n.ts @@ -1,52 +0,0 @@ -export interface Message { - name: string; - message: string; - description?: string; -} - -const predefinedMessages = { - '@@extension_id': { - message: '', - 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: '', - description: '', - }, - '@@bidi_dir': { - message: '', - 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: '', - description: - 'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".', - }, - '@@bidi_start_edge': { - message: '', - description: - 'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".', - }, - '@@bidi_end_edge': { - message: '', - 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(([name, details]) => ({ - name, - ...details, - })); -} diff --git a/src/i18n/__tests__/fixtures/messages-input.yml b/src/i18n/__tests__/fixtures/input.yml similarity index 100% rename from src/i18n/__tests__/fixtures/messages-input.yml rename to src/i18n/__tests__/fixtures/input.yml diff --git a/src/i18n/__tests__/fixtures/messages-expected.json b/src/i18n/__tests__/fixtures/manifest.json similarity index 100% rename from src/i18n/__tests__/fixtures/messages-expected.json rename to src/i18n/__tests__/fixtures/manifest.json diff --git a/src/i18n/__tests__/fixtures/messages.json b/src/i18n/__tests__/fixtures/messages.json new file mode 100644 index 00000000..3caf2615 --- /dev/null +++ b/src/i18n/__tests__/fixtures/messages.json @@ -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": "" + }, + "isBuiltin": true, + "name": ["@@extension_id"] + }, + { + "entry": { + "message": "" + }, + "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": "" + }, + "isBuiltin": true, + "name": ["@@bidi_dir"] + }, + { + "entry": { + "description": "If the @@bidi_dir is \"ltr\", then this is \"rtl\"; otherwise, it's \"ltr\".", + "message": "" + }, + "isBuiltin": true, + "name": ["@@bidi_reversed_dir"] + }, + { + "entry": { + "description": "If the @@bidi_dir is \"ltr\", then this is \"left\"; otherwise, it's \"right\".", + "message": "" + }, + "isBuiltin": true, + "name": ["@@bidi_start_edge"] + }, + { + "entry": { + "description": "If the @@bidi_dir is \"ltr\", then this is \"right\"; otherwise, it's \"left\".", + "message": "" + }, + "isBuiltin": true, + "name": ["@@bidi_end_edge"] + } +] diff --git a/src/i18n/__tests__/node.test.ts b/src/i18n/__tests__/node.test.ts index 2a6ff07b..e81a26a7 100644 --- a/src/i18n/__tests__/node.test.ts +++ b/src/i18n/__tests__/node.test.ts @@ -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); }); diff --git a/src/i18n/client.ts b/src/i18n/client.ts new file mode 100644 index 00000000..d10d5f3a --- /dev/null +++ b/src/i18n/client.ts @@ -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 { + 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 { + t>(key: TKey): string; + t>( + key: TKey, + substitutions: TMessageSchema['t'][TKey], + ): string; + + tp>( + key: TKey, + count: number, + ): string; + tp>( + key: TKey, + count: number, + substitutions: TMessageSchema['tp'][TKey], + ): string; +} + +type FilterKeys = { + [K in keyof TObject]-?: TObject[K] extends TFilterType ? K : never; +}[keyof TObject]; +type KeysWithSub = FilterKeys; +type KeysWithoutSub = FilterKeys; diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 00000000..4ff89409 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,8 @@ +import { DefaultMessageSchema, createExtensionI18n } from './client'; + +export interface WxtMessageSchema extends DefaultMessageSchema { + // Overriden per-project +} + +export const i18n = createExtensionI18n(); +export * from './client'; diff --git a/src/i18n/node.ts b/src/i18n/node.ts index 07d0ccd3..dc77f068 100644 --- a/src/i18n/node.ts +++ b/src/i18n/node.ts @@ -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 { +export async function readMessagesFile(file: string): Promise { 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((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((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( - (items, [key, child]) => { - const nestedEntries = findEntries(keyPath.concat(key), child); - return [...items, ...nestedEntries]; - }, - [], - ); + return Object.entries(input).reduce((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; -export interface ExtensionI18nEntry { +export const PREDEFINED_MESSAGES: Message[] = [ + { + name: '@@extension_id', + isBuiltin: true, + entry: { + message: '', + 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: '', + }, + }, + { + name: '@@bidi_dir', + isBuiltin: true, + entry: { + message: '', + 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: '', + description: + 'If the @@bidi_dir is "ltr", then this is "rtl"; otherwise, it\'s "ltr".', + }, + }, + { + name: '@@bidi_start_edge', + isBuiltin: true, + entry: { + message: '', + description: + 'If the @@bidi_dir is "ltr", then this is "left"; otherwise, it\'s "right".', + }, + }, + { + name: '@@bidi_end_edge', + isBuiltin: true, + entry: { + message: '', + description: + 'If the @@bidi_dir is "ltr", then this is "right"; otherwise, it\'s "left".', + }, + }, +]; + +export type RawExtensionI18nSchema = Record; +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; } diff --git a/src/types/external.ts b/src/types/external.ts index e535ae7f..fef693b6 100644 --- a/src/types/external.ts +++ b/src/types/external.ts @@ -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" */ diff --git a/src/types/internal.ts b/src/types/internal.ts index fc8a631c..9c13baad 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -18,6 +18,7 @@ export interface InternalConfig { root: string; srcDir: string; publicDir: string; + localesDir: string; wxtDir: string; typesDir: string; entrypointsDir: string;