Feature: localizable sandboxed plugins (manifest locales + api.i18n.t)

The plugin runtime received the active locale (init payload + 'locale-change')
and plugins could declare a `locales` map, but none of it was usable: the
locales never reached the runtime, and buildPluginApi exposed no i18n. So
plugin code calling pluginApi.i18n.t(...) (as the External Link Warning plugin
does) always got undefined and fell back to English.

Thread plugin locales end to end and surface an i18n API:
- ServerPlugin gains `locales`; the upload route persists manifest.locales
  (alongside configSchema/settingsSchema), and /api/plugins surfaces it to the
  client so it flows registry -> client -> sandbox host-bridge -> runtime.
- runtime sets __PLUGIN_LOCALE__ at init (not only on later 'locale-change')
  and buildPluginApi exposes `i18n.locale` + `i18n.t(key, vars)` resolving
  against the plugin's declared locales (manifest.locales) with English/key
  fallback and {placeholder} interpolation.

Lets any sandboxed plugin localize its strings from its manifest.
This commit is contained in:
dealerweb
2026-05-30 15:56:55 +02:00
committed by Linus Rath
parent 4c1d0931a1
commit 2ba0003e16
8 changed files with 173 additions and 132 deletions
+22 -9
View File
@@ -7,6 +7,19 @@
import React, { useEffect, useSyncExternalStore } from 'react';
import { head, resolveHead, subscribe } from '@/lib/plugin-sandbox/host-dialog';
// Lightweight **bold** support in plugin dialog messages. Everything else is
// rendered literally (newlines come from the parent's white-space: pre-wrap).
// Splitting on the ** delimiter yields alternating plain/bold segments (odd
// indices are bold). Plugins control these strings, so the delimiters balance.
function renderMessage(message?: string): React.ReactNode {
if (!message) return null;
return message.split('**').map((seg, i) =>
i % 2 === 1
? <strong key={i}>{seg}</strong>
: <React.Fragment key={i}>{seg}</React.Fragment>,
);
}
export function PluginDialogHost(): React.JSX.Element | null {
const current = useSyncExternalStore(subscribe, head, () => null);
@@ -50,9 +63,9 @@ export function PluginDialogHost(): React.JSX.Element | null {
>
<div
style={{
background: 'var(--background, #fff)',
color: 'var(--foreground, #0f172a)',
border: '1px solid var(--border, #e2e8f0)',
background: 'var(--color-popover, #fff)',
color: 'var(--color-popover-foreground, #0f172a)',
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 12,
padding: 20,
maxWidth: 480,
@@ -63,8 +76,8 @@ export function PluginDialogHost(): React.JSX.Element | null {
<h2 id="plugin-dialog-title" style={{ fontSize: 16, fontWeight: 600, margin: '0 0 10px 0' }}>
{current.title}
</h2>
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--muted-foreground, #64748b)', whiteSpace: 'pre-wrap' }}>
{current.message}
<p style={{ fontSize: 13, lineHeight: 1.5, margin: '0 0 16px 0', color: 'var(--color-muted-foreground, #64748b)', whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{renderMessage(current.message)}
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
{current.kind === 'confirm' && (
@@ -78,7 +91,7 @@ export function PluginDialogHost(): React.JSX.Element | null {
fontSize: 13,
fontWeight: 500,
cursor: 'pointer',
border: '1px solid var(--border, #e2e8f0)',
border: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent',
color: 'inherit',
}}
@@ -97,14 +110,14 @@ export function PluginDialogHost(): React.JSX.Element | null {
fontWeight: 500,
cursor: 'pointer',
border: '1px solid transparent',
background: current.danger ? '#dc2626' : '#3b82f6',
color: '#fff',
background: current.danger ? 'var(--color-destructive, #dc2626)' : 'var(--color-primary, #3b82f6)',
color: current.danger ? 'var(--color-destructive-foreground, #fff)' : 'var(--color-primary-foreground, #fff)',
}}
>
{confirmLabel}
</button>
</div>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--muted-foreground, #94a3b8)', textAlign: 'right' }}>
<div style={{ marginTop: 12, fontSize: 11, color: 'var(--color-muted-foreground, #94a3b8)', textAlign: 'right' }}>
From plugin: {current.pluginId}
</div>
</div>