feat: enhance plugin page internationalization (#7998)

* feat: enhance plugin page internationalization

- Updated PluginRoute to read initial context from JWT and set it in the bridge SDK.
- Added methods to retrieve locale and plugin metadata for better i18n support.
- Enhanced pluginI18n utility to resolve page-specific translations and added new functions for page titles and descriptions.
- Modified PluginPagePage and PluginDetailPage to utilize new i18n features for dynamic content rendering.
- Improved documentation for plugin page i18n structure and usage.
- Added tests to verify the correct integration of i18n in plugin pages and context handling.

* fix test
This commit is contained in:
Weilong Liao
2026-05-04 20:15:21 +08:00
committed by GitHub
parent 319f50be2a
commit cb4f941e43
12 changed files with 952 additions and 72 deletions

View File

@@ -20,6 +20,7 @@ When the current locale has no translation, a field is missing, or the locale fi
- Plugin names, card short descriptions, and descriptions fall back to `display_name`, `short_desc`, and `desc` in `metadata.yaml`.
- Configuration text falls back to `description`, `hint`, and `labels` in `_conf_schema.json`.
- Page text falls back to the Page directory name, default Page title, or fallback text provided by page code.
## Metadata
@@ -77,6 +78,52 @@ Corresponding `.astrbot-plugin/i18n/zh-CN.json`:
`options` are stored configuration values and should usually not be translated. Use `labels` for select display text.
## Plugin Pages
`pages` overrides plugin Dashboard Page titles, descriptions, and custom text inside plugin pages. The structure is nested by Page directory name.
Example plugin page directory:
```text
pages/
settings/
index.html
```
Corresponding `.astrbot-plugin/i18n/en-US.json`:
```json
{
"pages": {
"settings": {
"title": "Settings",
"description": "Manage advanced settings for this plugin.",
"save": "Save",
"reset": "Reset"
}
}
}
```
`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page. Other fields are read by the page through the bridge:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.getElementById("save").textContent = bridge.t(
"pages.settings.save",
"Save",
);
}
await bridge.ready();
render();
bridge.onContext(render);
```
Use `onContext()` to react to WebUI language changes; with this listener, the Page usually does not need a refresh.
## Nested Configuration
For `object` items in `_conf_schema.json`, translations use the same nested field structure.

View File

@@ -96,6 +96,10 @@ Inside a plugin Page, use `window.AstrBotPluginPage` directly:
- `ready()`: Wait until the bridge is ready and return the context
- `getContext()`: Read the current context
- `getLocale()`: Read the current WebUI locale
- `getI18n()`: Read the current plugin i18n resources
- `t(key, fallback)`: Read text from plugin i18n resources by key, returning fallback when missing
- `onContext(handler)`: Listen for context changes, such as rerendering after the WebUI locale changes
- `apiGet(endpoint, params)`: Send a GET request
- `apiPost(endpoint, body)`: Send a POST request
- `upload(endpoint, file)`: Upload one file as `multipart/form-data`
@@ -108,12 +112,62 @@ The current `ready()` context looks like this:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo"
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {}
}
```
`endpoint` must be a plugin-local path. It must not be empty, contain `\`, contain a URL scheme, contain query strings or fragments, or contain `.` / `..` path segments.
## Page Internationalization
Plugin Pages reuse plugin i18n resource files. Add `pages.<page_name>` to `.astrbot-plugin/i18n/<locale>.json`:
```json
{
"pages": {
"bridge-demo": {
"title": "Bridge Demo",
"description": "Shows how a plugin page reads the WebUI locale and translations.",
"heading": "Plugin Page",
"refresh": "Render again"
}
}
}
```
`title` is used by the WebUI shell title and the Page component name on the plugin detail page. `description` is used by the Page component description on the plugin detail page.
Inside the Page, render text with `t()` and react to language changes with `onContext()`:
```js
const bridge = window.AstrBotPluginPage;
function render() {
document.title = bridge.t("pages.bridge-demo.title", "Bridge Demo");
document.getElementById("heading").textContent = bridge.t(
"pages.bridge-demo.heading",
"Plugin Page",
);
document.getElementById("locale").textContent = bridge.getLocale();
}
await bridge.ready();
render();
bridge.onContext(render);
```
After the WebUI locale changes, the Dashboard sends the new `locale` and plugin i18n resources to the iframe through the bridge. If the Page listens with `onContext()`, it usually does not need a refresh.
If an inline script needs to access `window.AstrBotPluginPage` synchronously, move the code to an external module file or explicitly include the bridge SDK before your script:
```html
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## Asset Path Rules
AstrBot rewrites relative asset URLs and appends a short-lived `asset_token`. Write normal relative paths and do not hardcode `/api/plugin/page/content/...` yourself.
@@ -151,3 +205,184 @@ AstrBot also adds security headers to asset responses, including:
- Reload the plugin after adding or removing a Page directory
- For most edits under `pages/<page_name>/`, refreshing the Page is enough
- If a Page does not appear, check that `pages/<page_name>/index.html` exists and the plugin is enabled
## Appendix: Bridge API Details
Start page scripts by keeping a bridge reference:
```js
const bridge = window.AstrBotPluginPage;
```
### `ready()`
Waits for the parent page to send the initial context and returns `Promise<context>`. Page initialization should wait for this before reading context-dependent values.
```js
const context = await bridge.ready();
console.log(context.pluginName, context.pageName, context.locale);
```
The context usually contains:
```json
{
"pluginName": "astrbot_plugin_page_demo",
"displayName": "Plugin Page Demo",
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {}
}
```
### `getContext()`
Synchronously reads the latest context. Use it after `ready()` or inside an `onContext()` callback.
```js
function renderHeader() {
const context = bridge.getContext();
document.getElementById("title").textContent = context.pageTitle;
}
```
### `getLocale()`
Synchronously reads the current WebUI locale. It returns `zh-CN` when no context exists yet.
```js
document.documentElement.lang = bridge.getLocale();
```
### `getI18n()`
Synchronously reads the full plugin i18n resource object. Prefer `t()` for normal rendering; use this for custom traversal or debugging.
```js
console.log(Object.keys(bridge.getI18n()));
```
### `t(key, fallback)`
Reads text from plugin i18n by dot-separated key. If the current locale is missing, the bridge tries fallbacks; if still missing, it returns `fallback`.
```js
saveButton.textContent = bridge.t("pages.settings.save", "Save");
```
### `onContext(handler)`
Listens for context changes and returns an unsubscribe function. The callback runs when the WebUI locale changes, so pages that need live language switching should rerender here.
```js
function render() {
document.title = bridge.t("pages.settings.title", "Settings");
}
await bridge.ready();
render();
const off = bridge.onContext(render);
window.addEventListener("beforeunload", () => {
off();
});
```
### `apiGet(endpoint, params)`
Sends a GET request to the plugin backend and returns `Promise<data>`. `endpoint` is a plugin-local relative path; the Dashboard forwards it to `/api/plug/<plugin_name>/<endpoint>`.
```js
const stats = await bridge.apiGet("stats", { limit: 20 });
```
Backend registration example:
```python
context.register_web_api(
f"/{PLUGIN_NAME}/stats",
self.get_stats,
["GET"],
"Get stats",
)
```
### `apiPost(endpoint, body)`
Sends a POST request to the plugin backend. `body` is sent as JSON and the method returns `Promise<data>`.
```js
await bridge.apiPost("settings/save", {
enabled: true,
threshold: 0.8,
});
```
### `upload(endpoint, file)`
Uploads one file as `multipart/form-data` with the field name `file`, returning `Promise<data>`.
```js
const input = document.querySelector("input[type=file]");
const file = input.files[0];
const result = await bridge.upload("files/import", file);
```
### `download(endpoint, params, filename)`
Requests a plugin backend file endpoint and triggers a browser download. `params` are sent as query parameters. `filename` is optional; when omitted, the bridge tries to use the response filename header.
```js
await bridge.download("files/export", { format: "json" }, "export.json");
```
### `subscribeSSE(endpoint, handlers, params)`
Subscribes to plugin backend SSE and returns `Promise<subscriptionId>`. `handlers` may include `onOpen`, `onMessage`, and `onError`.
```js
const subscriptionId = await bridge.subscribeSSE(
"events",
{
onOpen() {
console.log("SSE opened");
},
onMessage(event) {
console.log(event.raw, event.parsed, event.lastEventId);
},
onError() {
console.warn("SSE error");
},
},
{ topic: "logs" },
);
```
`event.parsed` is automatically parsed when the message is a JSON string; otherwise it equals the raw string.
### `unsubscribeSSE(subscriptionId)`
Cancels an SSE subscription.
```js
await bridge.unsubscribeSSE(subscriptionId);
```
Clean up subscriptions when the page unloads:
```js
window.addEventListener("beforeunload", () => {
bridge.unsubscribeSSE(subscriptionId);
});
```
### endpoint Rules
The `endpoint` used by `apiGet`, `apiPost`, `upload`, `download`, and `subscribeSSE` must be a plugin-local relative path:
- Allowed: `"stats"`, `"settings/save"`, `"files/export"`
- Not allowed: empty string, `"/stats"`, `"../stats"`, `"https://example.com"`, `"stats?x=1"`, `"stats#top"`
Pass query parameters through `params`; do not append them to `endpoint`.