feat: add i18n API, render hooks, and new intercept hooks to plugin system
This commit is contained in:
+27
-2
@@ -14,6 +14,7 @@ import type {
|
||||
AdminPageSection,
|
||||
CalendarEventAction,
|
||||
SlotName,
|
||||
PluginI18n,
|
||||
} from './plugin-types';
|
||||
import { IMPLICIT_PERMISSIONS as IMPLICIT } from './plugin-types';
|
||||
import {
|
||||
@@ -22,8 +23,9 @@ import {
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks,
|
||||
sidebarAppHooks, avatarHooks,
|
||||
sidebarAppHooks, avatarHooks, renderHooks,
|
||||
} from './plugin-hooks';
|
||||
import { createPluginI18n } from './plugin-i18n';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
@@ -109,6 +111,8 @@ function createPluginLogger(pluginId: string) {
|
||||
|
||||
export interface PluginAPI {
|
||||
plugin: { id: string; version: string; settings: Record<string, unknown> };
|
||||
/** Localisation API — register translations and call t() to get strings */
|
||||
i18n: PluginI18n;
|
||||
ui: {
|
||||
registerToolbarAction: (action: ToolbarAction) => Disposable;
|
||||
registerEmailBanner: (factory: BannerFactory) => Disposable;
|
||||
@@ -149,6 +153,8 @@ export interface PluginHooksAPI {
|
||||
onEmailClose: (handler: () => void) => Disposable;
|
||||
onEmailContentRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onThreadExpand: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept — receives ComposeOptions, may mutate fields, return false to cancel */
|
||||
onBeforeCompose: (handler: (options: import('./plugin-types').ComposeOptions) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onComposerOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailSend: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -157,6 +163,10 @@ export interface PluginHooksAPI {
|
||||
onAfterEmailDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterEmailMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Emitted after emails are moved to the Archive mailbox */
|
||||
onEmailArchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||
/** Emitted after emails are moved out of the Archive mailbox */
|
||||
onEmailUnarchive: (handler: (emailIds: string[]) => void) => Disposable;
|
||||
onEmailReadStateChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailStarToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onEmailSpamToggle: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -173,6 +183,8 @@ export interface PluginHooksAPI {
|
||||
onNewEmailReceived: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onPushConnectionChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onQuotaChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept — receives MailtoContext, return false to prevent the system mail client */
|
||||
onMailtoIntercept: (handler: (ctx: import('./plugin-types').MailtoContext) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
// Calendar
|
||||
onCalendarEventOpen: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeEventCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -215,6 +227,8 @@ export interface PluginHooksAPI {
|
||||
onDirectoryCreate: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onBeforeFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onAfterFileDelete: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
/** Intercept — receives { file: FileResourceView, newName: string }, return false to cancel */
|
||||
onBeforeFileRename: (handler: (ctx: { file: import('./plugin-types').FileResourceView; newName: string }) => boolean | void | Promise<boolean | void>) => Disposable;
|
||||
onFileRename: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileMove: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
onFileCopy: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
@@ -316,6 +330,9 @@ export interface PluginHooksAPI {
|
||||
onSidebarAppChange: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Avatar
|
||||
onAvatarResolve: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
// Render — transform hook for email list row badges
|
||||
// Handler: (badges: EmailListBadge[], ctx: { emailId: string; email: EmailReadView }) => EmailListBadge[]
|
||||
onEmailListItemRender: (handler: (...args: unknown[]) => unknown) => Disposable;
|
||||
}
|
||||
|
||||
// --- Permission mapping for hooks ----------------------------
|
||||
@@ -324,14 +341,17 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
// Email
|
||||
onEmailOpen: 'email:read', onEmailClose: 'email:read',
|
||||
onEmailContentRender: 'email:read', onThreadExpand: 'email:read',
|
||||
onComposerOpen: 'email:read', onDraftAutoSave: 'email:read',
|
||||
onBeforeCompose: 'email:read', onComposerOpen: 'email:read',
|
||||
onDraftAutoSave: 'email:read',
|
||||
onMailboxChange: 'email:read', onMailboxesRefresh: 'email:read',
|
||||
onSearch: 'email:read', onSearchResults: 'email:read',
|
||||
onEmailSelectionChange: 'email:read', onNewEmailReceived: 'email:read',
|
||||
onPushConnectionChange: 'email:read', onQuotaChange: 'email:read',
|
||||
onMailtoIntercept: 'email:read', onEmailListItemRender: 'email:read',
|
||||
onBeforeEmailSend: 'email:send', onAfterEmailSend: 'email:send',
|
||||
onBeforeEmailDelete: 'email:write', onAfterEmailDelete: 'email:write',
|
||||
onBeforeEmailMove: 'email:write', onAfterEmailMove: 'email:write',
|
||||
onEmailArchive: 'email:write', onEmailUnarchive: 'email:write',
|
||||
onEmailReadStateChange: 'email:write', onEmailStarToggle: 'email:write',
|
||||
onEmailSpamToggle: 'email:write', onEmailKeywordChange: 'email:write',
|
||||
onMailboxCreate: 'email:write', onMailboxRename: 'email:write',
|
||||
@@ -358,6 +378,7 @@ const HOOK_PERMISSIONS: Record<string, Permission> = {
|
||||
onBeforeFileUpload: 'files:write', onAfterFileUpload: 'files:write',
|
||||
onFileUploadCancel: 'files:write', onDirectoryCreate: 'files:write',
|
||||
onBeforeFileDelete: 'files:write', onAfterFileDelete: 'files:write',
|
||||
onBeforeFileRename: 'files:write',
|
||||
onFileRename: 'files:write', onFileMove: 'files:write', onFileCopy: 'files:write',
|
||||
onFileDuplicate: 'files:write', onFileFavoriteToggle: 'files:write', onFileUndo: 'files:write',
|
||||
// Auth
|
||||
@@ -469,6 +490,8 @@ const HOOK_BUSES: Record<string, { register: (pluginId: string, handler: (...arg
|
||||
...Object.fromEntries(Object.entries(sidebarAppHooks)),
|
||||
// Avatar
|
||||
...Object.fromEntries(Object.entries(avatarHooks)),
|
||||
// Render
|
||||
...Object.fromEntries(Object.entries(renderHooks)),
|
||||
};
|
||||
|
||||
// --- Slot registration bridge --------------------------------
|
||||
@@ -530,6 +553,8 @@ export function createPluginAPI(plugin: InstalledPlugin): PluginAPI {
|
||||
settings: { ...plugin.settings },
|
||||
},
|
||||
|
||||
i18n: createPluginI18n(plugin.id),
|
||||
|
||||
ui: {
|
||||
registerToolbarAction: (action: ToolbarAction) => {
|
||||
requirePermission(plugin, 'ui:toolbar');
|
||||
|
||||
+26
-1
@@ -172,6 +172,10 @@ export const emailHooks = {
|
||||
onEmailClose: new HookBus(),
|
||||
onEmailContentRender: new HookBus(),
|
||||
onThreadExpand: new HookBus(),
|
||||
// Intercept hook — fires before the composer opens.
|
||||
// Handlers receive ComposeOptions and may mutate fields in place.
|
||||
// Return false to cancel opening the composer.
|
||||
onBeforeCompose: new HookBus(),
|
||||
onComposerOpen: new HookBus(),
|
||||
onBeforeEmailSend: new HookBus(),
|
||||
onAfterEmailSend: new HookBus(),
|
||||
@@ -180,6 +184,10 @@ export const emailHooks = {
|
||||
onAfterEmailDelete: new HookBus(),
|
||||
onBeforeEmailMove: new HookBus(),
|
||||
onAfterEmailMove: new HookBus(),
|
||||
// Fired after one or more emails are archived to the Archive mailbox
|
||||
onEmailArchive: new HookBus(),
|
||||
// Fired after one or more emails are moved out of the Archive mailbox
|
||||
onEmailUnarchive: new HookBus(),
|
||||
onEmailReadStateChange: new HookBus(),
|
||||
onEmailStarToggle: new HookBus(),
|
||||
onEmailSpamToggle: new HookBus(),
|
||||
@@ -196,6 +204,9 @@ export const emailHooks = {
|
||||
onNewEmailReceived: new HookBus(),
|
||||
onPushConnectionChange: new HookBus(),
|
||||
onQuotaChange: new HookBus(),
|
||||
// Intercept hook — fired when a mailto: link is clicked.
|
||||
// Return false to prevent the browser from opening the system mail client.
|
||||
onMailtoIntercept: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.2 Calendar Hooks
|
||||
@@ -250,6 +261,10 @@ export const fileHooks = {
|
||||
onDirectoryCreate: new HookBus(),
|
||||
onBeforeFileDelete: new HookBus(),
|
||||
onAfterFileDelete: new HookBus(),
|
||||
// Intercept hook — fires before a file is renamed.
|
||||
// Receives { file: FileResourceView, newName: string }.
|
||||
// Return false to cancel the rename.
|
||||
onBeforeFileRename: new HookBus(),
|
||||
onFileRename: new HookBus(),
|
||||
onFileMove: new HookBus(),
|
||||
onFileCopy: new HookBus(),
|
||||
@@ -406,6 +421,16 @@ export const avatarHooks = {
|
||||
onAvatarResolve: new HookBus(),
|
||||
};
|
||||
|
||||
// §7.22 Render Hooks
|
||||
export const renderHooks = {
|
||||
// Transform hook — runs for each visible email list row.
|
||||
// Initial value: EmailListBadge[] (always starts as [])
|
||||
// Second argument: { emailId: string; email: EmailReadView }
|
||||
// Handlers return a new (or extended) badges array.
|
||||
// Rendered by the email list row component next to the subject line.
|
||||
onEmailListItemRender: new HookBus(),
|
||||
};
|
||||
|
||||
// ─── Aggregate: remove all handlers for a plugin across all buses ───
|
||||
|
||||
const allHookGroups = [
|
||||
@@ -414,7 +439,7 @@ const allHookGroups = [
|
||||
taskHooks, templateHooks, smimeHooks, vacationHooks,
|
||||
uiHooks, themeHooks, toastHooks, dragDropHooks,
|
||||
keyboardHooks, appLifecycleHooks, accountSecurityHooks, sidebarAppHooks,
|
||||
avatarHooks,
|
||||
avatarHooks, renderHooks,
|
||||
];
|
||||
|
||||
export function removeAllPluginHooks(pluginId: string): void {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Plugin i18n registry — manages per-plugin translation tables
|
||||
//
|
||||
// Each plugin gets its own namespace keyed by:
|
||||
// pluginId → locale → { messageKey → translated string }
|
||||
//
|
||||
// Resolution order when calling t(key):
|
||||
// 1. Exact locale match ("fr-CA")
|
||||
// 2. Language-prefix match ("fr" from "fr-CA")
|
||||
// 3. English fallback ("en")
|
||||
// 4. Raw key (plugin is never broken by missing strings)
|
||||
//
|
||||
// Interpolation uses {paramName} placeholders.
|
||||
|
||||
// ─── Registry ────────────────────────────────────────────────
|
||||
|
||||
/** pluginId → locale → key → translated string */
|
||||
const registry = new Map<string, Map<string, Record<string, string>>>();
|
||||
|
||||
let currentLocale = 'en';
|
||||
|
||||
// ─── Locale sync (called by plugin-loader) ───────────────────
|
||||
|
||||
/** Keep the registry in sync with the app locale */
|
||||
export function setPluginI18nLocale(locale: string): void {
|
||||
currentLocale = locale;
|
||||
}
|
||||
|
||||
export function getPluginI18nLocale(): string {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────
|
||||
|
||||
/** Remove all translations for a plugin (called on deactivation) */
|
||||
export function clearPluginI18nTranslations(pluginId: string): void {
|
||||
registry.delete(pluginId);
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => String(params[key] ?? `{${key}}`));
|
||||
}
|
||||
|
||||
function resolve(pluginId: string, key: string): string | undefined {
|
||||
const byLocale = registry.get(pluginId);
|
||||
if (!byLocale) return undefined;
|
||||
|
||||
// 1. Exact locale (e.g. "fr-CA")
|
||||
const exact = byLocale.get(currentLocale)?.[key];
|
||||
if (exact !== undefined) return exact;
|
||||
|
||||
// 2. Language prefix (e.g. "fr" from "fr-CA")
|
||||
const lang = currentLocale.split('-')[0];
|
||||
if (lang !== currentLocale) {
|
||||
const langMatch = byLocale.get(lang)?.[key];
|
||||
if (langMatch !== undefined) return langMatch;
|
||||
}
|
||||
|
||||
// 3. English fallback
|
||||
return byLocale.get('en')?.[key];
|
||||
}
|
||||
|
||||
// ─── Public API factory ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the i18n API object exposed as `api.i18n` inside each plugin.
|
||||
*
|
||||
* @example
|
||||
* // In your plugin activate():
|
||||
* api.i18n.addTranslations('en', { 'banner.title': 'Hello' });
|
||||
* api.i18n.addTranslations('de', { 'banner.title': 'Hallo' });
|
||||
*
|
||||
* // Later, in any React component the plugin renders:
|
||||
* const title = api.i18n.t('banner.title');
|
||||
* const greeting = api.i18n.t('welcome', { name: 'Alice' }); // 'Hello, {name}!'
|
||||
*/
|
||||
export function createPluginI18n(pluginId: string) {
|
||||
return {
|
||||
/**
|
||||
* Register translations for one locale.
|
||||
* Multiple calls for the same locale are merged (last-write-wins on key collision).
|
||||
*
|
||||
* @param locale BCP-47 locale tag, e.g. "en", "de", "fr-CA"
|
||||
* @param strings Key → translated string map. Use {paramName} for interpolation.
|
||||
*/
|
||||
addTranslations(locale: string, strings: Record<string, string>): void {
|
||||
let byLocale = registry.get(pluginId);
|
||||
if (!byLocale) {
|
||||
byLocale = new Map<string, Record<string, string>>();
|
||||
registry.set(pluginId, byLocale);
|
||||
}
|
||||
const existing = byLocale.get(locale) ?? {};
|
||||
byLocale.set(locale, { ...existing, ...strings });
|
||||
},
|
||||
|
||||
/**
|
||||
* Translate a key using the current app locale.
|
||||
* Falls back through: exact locale → language prefix → 'en' → raw key.
|
||||
*
|
||||
* @param key Translation key, e.g. `'banner.title'`
|
||||
* @param params Optional interpolation values, e.g. `{ count: 3 }`
|
||||
*/
|
||||
t(key: string, params?: Record<string, string | number>): string {
|
||||
const template = resolve(pluginId, key);
|
||||
if (template !== undefined) return interpolate(template, params);
|
||||
return key; // never throw — just return the key
|
||||
},
|
||||
|
||||
/** The current app locale (e.g. "en", "de", "fr") */
|
||||
getLocale(): string {
|
||||
return currentLocale;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginI18nInstance = ReturnType<typeof createPluginI18n>;
|
||||
+25
-1
@@ -1,15 +1,18 @@
|
||||
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
||||
// Plugin Loader — loads and activates plugins via blob URL dynamic import
|
||||
|
||||
import type { InstalledPlugin, Disposable } from './plugin-types';
|
||||
import { pluginStorage } from './plugin-storage';
|
||||
import { createPluginAPI, type PluginAPI } from './plugin-api';
|
||||
import { removeAllPluginHooks, pluginErrorTracker } from './plugin-hooks';
|
||||
import { setPluginI18nLocale, clearPluginI18nTranslations } from './plugin-i18n';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import * as ReactJSX from 'react/jsx-runtime';
|
||||
|
||||
// --- Shared React (window.__PLUGIN_EXTERNALS__) -------------
|
||||
|
||||
let localeSyncInitialised = false;
|
||||
|
||||
export function exposePluginExternals(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -18,6 +21,16 @@ export function exposePluginExternals(): void {
|
||||
ReactDOM,
|
||||
ReactJSX,
|
||||
};
|
||||
|
||||
// Sync plugin i18n with the app locale (runs once per page load)
|
||||
if (!localeSyncInitialised) {
|
||||
localeSyncInitialised = true;
|
||||
// Dynamic import avoids a circular dependency chain at module evaluation time
|
||||
import('@/stores/locale-store').then(({ useLocaleStore }) => {
|
||||
setPluginI18nLocale(useLocaleStore.getState().locale);
|
||||
useLocaleStore.subscribe((state) => setPluginI18nLocale(state.locale));
|
||||
}).catch(() => {/* locale sync is best-effort */});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Active plugin tracking ----------------------------------
|
||||
@@ -78,6 +91,14 @@ export async function loadPlugin(plugin: InstalledPlugin): Promise<void> {
|
||||
// 4. Build sandboxed API
|
||||
const api = createPluginAPI(plugin);
|
||||
|
||||
// 4b. Auto-register translations bundled in the manifest (plugin.locales)
|
||||
// Plugins may still call api.i18n.addTranslations() in activate() to add more.
|
||||
if (plugin.locales) {
|
||||
for (const [locale, strings] of Object.entries(plugin.locales)) {
|
||||
api.i18n.addTranslations(locale, strings);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Call activate
|
||||
const disposable = await mod.activate(api);
|
||||
|
||||
@@ -119,6 +140,9 @@ export function deactivatePlugin(pluginId: string): void {
|
||||
// Remove all hook subscriptions for this plugin
|
||||
removeAllPluginHooks(pluginId);
|
||||
|
||||
// Clear cached translations (avoids memory leak on repeated enable/disable cycles)
|
||||
clearPluginI18nTranslations(pluginId);
|
||||
|
||||
// Reset error tracker
|
||||
pluginErrorTracker.reset(pluginId);
|
||||
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface PluginManifest {
|
||||
entrypoint: string;
|
||||
minAppVersion?: string;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
/**
|
||||
* Bundled translations shipped inside the plugin ZIP.
|
||||
* Keyed by BCP-47 locale tag ("en", "de", "fr-CA", …).
|
||||
* The loader auto-registers these before calling activate(),
|
||||
* so plugins can use api.i18n.t() without calling addTranslations() first.
|
||||
*/
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface SettingFieldSchema {
|
||||
@@ -83,6 +90,8 @@ export interface InstalledPlugin {
|
||||
adminApproved?: boolean;
|
||||
settingsSchema?: Record<string, SettingFieldSchema>;
|
||||
settings: Record<string, unknown>;
|
||||
/** Bundled translations, carried over from the manifest on install. */
|
||||
locales?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
// ─── UI Slots ────────────────────────────────────────────────
|
||||
@@ -388,6 +397,86 @@ export interface ComposerContext {
|
||||
originalSubject?: string;
|
||||
}
|
||||
|
||||
// ─── New hook context types ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Passed to onBeforeCompose handlers.
|
||||
* Handlers may mutate the object in place to pre-fill fields; returning false cancels the compose.
|
||||
*/
|
||||
export interface ComposeOptions {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
subject: string;
|
||||
body: string;
|
||||
mode: 'new' | 'reply' | 'reply-all' | 'forward';
|
||||
}
|
||||
|
||||
/**
|
||||
* A small visual indicator injected into an email list row via onEmailListItemRender.
|
||||
*/
|
||||
export interface EmailListBadge {
|
||||
/** Stable unique key within the plugin — used as React key */
|
||||
key: string;
|
||||
/** Short label text displayed in the badge */
|
||||
label: string;
|
||||
/** CSS color value for the badge background, e.g. "#e74c3c" or "var(--color-warning)" */
|
||||
color?: string;
|
||||
/** Tooltip / aria-label */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passed to onMailtoIntercept handlers.
|
||||
* Return false to prevent the browser from opening the system mail client.
|
||||
*/
|
||||
export interface MailtoContext {
|
||||
/** The raw href, e.g. "mailto:alice@example.com?subject=Hello" */
|
||||
href: string;
|
||||
/** Parsed list of recipient addresses */
|
||||
to: string[];
|
||||
subject?: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
// ─── Plugin i18n API ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Localisation API exposed as `api.i18n` inside every plugin.
|
||||
*
|
||||
* Plugins ship their own translation tables; the app locale is tracked
|
||||
* automatically so `t()` always returns the right string without any
|
||||
* extra setup from the plugin side.
|
||||
*/
|
||||
export interface PluginI18n {
|
||||
/**
|
||||
* Register translations for one locale.
|
||||
* Multiple calls for the same locale are merged (last-write-wins per key).
|
||||
*
|
||||
* @param locale BCP-47 tag, e.g. "en", "de", "fr-CA"
|
||||
* @param strings Key → translated string map. Use {paramName} for interpolation.
|
||||
*
|
||||
* @example
|
||||
* api.i18n.addTranslations('en', { 'banner.title': 'Tracking blocked' });
|
||||
* api.i18n.addTranslations('de', { 'banner.title': 'Tracking blockiert' });
|
||||
*/
|
||||
addTranslations(locale: string, strings: Record<string, string>): void;
|
||||
|
||||
/**
|
||||
* Return the translated string for `key` using the current app locale,
|
||||
* with optional {param} interpolation.
|
||||
*
|
||||
* Falls back: exact locale → language prefix → "en" → raw key.
|
||||
*
|
||||
* @example
|
||||
* api.i18n.t('banner.title')
|
||||
* api.i18n.t('items_found', { count: 3 }) // 'Found {count} items' → 'Found 3 items'
|
||||
*/
|
||||
t(key: string, params?: Record<string, string | number>): string;
|
||||
|
||||
/** The current app locale string (e.g. "en", "de", "fr") */
|
||||
getLocale(): string;
|
||||
}
|
||||
|
||||
// ─── Permission Reference ────────────────────────────────────
|
||||
|
||||
export const ALL_PERMISSIONS = [
|
||||
|
||||
Reference in New Issue
Block a user