feat: sync dashboard theme to plugin pages (#8390)

* feat(plugin): pass theme through plugin page asset URLs and initial context

Add theme query parameter propagation through the plugin page asset pipeline
so that the bridge SDK initial context includes isDark.

* feat(plugin): inject data-theme and color-scheme into plugin page HTML

Set data-theme on <html> and add color-scheme meta tag server-side
to prevent flash when entering plugin pages in dark mode.

* feat(webui): add isDark getter to customizer store

* feat(webui): sync theme to plugin iframe via URL param and postMessage

Append theme query parameter to iframe src URL and include isDark
in the postMessage context. Watch uiTheme changes to re-send context.

* feat(bridge): auto-apply data-theme from context in plugin bridge SDK

Set data-theme attribute on document.documentElement when context
includes isDark, enabling live theme switching via postMessage.

* docs: add light/dark theme adaptation guide for plugin pages

Add theme adaptation section to existing plugin-pages docs in both
Chinese and English, covering CSS variables and onContext() usage.

* test: add theme sync tests for plugin page bridge and content

Verify isDark in bridge SDK initial context with various theme params,
and verify data-theme and color-scheme injection in rewritten HTML.

* fix(plugin): use case-insensitive regex for head tag in HTML rewrite

Replace string match for <head> with case-insensitive regex to handle
uppercase tags and tags with attributes, preventing duplicate injection
of color-scheme meta tag.

* refactor(webui): generalize isDark getter to support any dark theme

Replace hardcoded PurpleThemeDark check with suffix-based detection
so all dark theme variants are recognized automatically.

* refactor(plugin): extract theme helpers for HTML rewriting

Extract _get_request_theme and _apply_theme_to_html to eliminate
duplicate theme-parsing logic and isolate HTML mutation. Use case-
insensitive regex for head tag detection to prevent duplicate
injection when tags use mixed casing.

* style: apply ruff formatting to plugin page tests

Wrap long function call lines for consistency with project style.

* fix(plugin): handle existing data-theme and case-sensitive fallback in HTML rewrite

Strip any existing data-theme attribute before adding the new one to
prevent duplicate attributes. Use case-insensitive regex for the
<head> fallback insertion to avoid corrupting <html> tag attributes.

* fix(webui): add null guard to isDark getter

Guard against undefined uiTheme to prevent TypeError when the
theme config has not been initialized.

* fix(webui): centralize theme mapping and preserve hash in plugin page URL

Extract themeParam computed to avoid drift between URL and context.
Include hash fragment in iframe URL to support SPA hash routing.

* fix(webui): address CR feedback - deduplicate color-scheme meta, harden isDark getter, consolidate theme source

- _apply_theme_to_html: strip existing color-scheme meta before injecting to
  avoid duplicates; merge data-theme strip+add into single-pass regex callback
- customizer.ts: replace brittle endsWith('Dark') with explicit theme name check
- _rewrite_plugin_page_html: use _get_request_theme() directly instead of
  reading theme from extra_query_params

* fix(webui): 简化 isDark 推导、优化查询参数构建、使用暗色主题集合

- isDark 推导简化为 theme == "dark"(None == "dark" 为 False,去掉多余三元表达式)
- _prepare_plugin_page_query_params 改为线性构建,先计算再构建字典
- isDark getter 改用显式 DARK_THEMES Set,替代硬编码单值比较

---------

Co-authored-by: lxfight <lxfight@192.168.5.50>
This commit is contained in:
lxfight
2026-06-06 00:35:47 +08:00
committed by GitHub
co-authored by lxfight
parent 7e22a07e0d
commit 65fe0574b9
7 changed files with 308 additions and 7 deletions
+6
View File
@@ -125,6 +125,12 @@
...(context || {}),
...nextContext,
};
if (typeof nextContext.isDark === "boolean") {
document.documentElement.setAttribute(
"data-theme",
nextContext.isDark ? "dark" : "light",
);
}
if (resolveReady) {
resolveReady(context);
resolveReady = null;
+66 -1
View File
@@ -229,6 +229,55 @@ class PluginRoute(Route):
return default
return locale
@staticmethod
def _get_request_theme() -> str | None:
theme = request.args.get("theme", "").strip()
return theme if theme in ("dark", "light") else None
@staticmethod
def _apply_theme_to_html(html: str, theme: str) -> str:
def _replace_html_tag(m: re.Match) -> str:
attrs = m.group(1) or ""
attrs = re.sub(
r'\s+data-theme\s*=\s*["\'][^"\']*["\']',
"",
attrs,
flags=re.IGNORECASE,
)
return f'<html{attrs} data-theme="{theme}">'
html = re.sub(
r"<html(\b[^>]*)>",
_replace_html_tag,
html,
count=1,
flags=re.IGNORECASE,
)
meta_tag = f'<meta name="color-scheme" content="{theme}">'
html = re.sub(
r'<meta\s[^>]*name\s*=\s*["\']color-scheme["\'][^>]*>',
"",
html,
flags=re.IGNORECASE,
)
head_match = re.search(r"<head\b[^>]*>", html, re.IGNORECASE)
if head_match:
html = html.replace(
head_match.group(0), f"{head_match.group(0)}{meta_tag}", 1
)
else:
html = re.sub(
r"(<html\b[^>]*>)",
rf"\1<head>{meta_tag}</head>",
html,
count=1,
flags=re.IGNORECASE,
)
return html
def _get_plugin_page_initial_context(self) -> dict | None:
asset_token = request.args.get("asset_token", "").strip()
if not asset_token:
@@ -277,6 +326,8 @@ class PluginRoute(Route):
self._get_by_path(locale_data, f"pages.{page_name}.title") or page_name
)
theme = self._get_request_theme()
return {
"pluginName": plugin.name,
"displayName": display_name,
@@ -284,6 +335,7 @@ class PluginRoute(Route):
"pageTitle": page_title,
"locale": locale,
"i18n": plugin_i18n,
"isDark": theme == "dark",
}
@staticmethod
@@ -590,6 +642,9 @@ class PluginRoute(Route):
return match.group(0)
rewritten_html = _HTML_ASSET_ATTR_RE.sub(replace_attr, html_text)
theme = self._get_request_theme()
if theme:
rewritten_html = self._apply_theme_to_html(rewritten_html, theme)
if "/api/plugin/page/bridge-sdk.js" not in rewritten_html:
bridge_tag = f'<script src="{self._get_plugin_page_bridge_sdk_url(extra_query_params)}"></script>'
if "</body>" in rewritten_html:
@@ -774,7 +829,17 @@ class PluginRoute(Route):
asset_token = (
self._issue_plugin_page_asset_token(plugin_name, page_name) or ""
)
return {"asset_token": asset_token} if asset_token else None
theme = self._get_request_theme()
if not asset_token and not theme:
return None
params: dict[str, str] = {}
if asset_token:
params["asset_token"] = asset_token
if theme:
params["theme"] = theme
return params
@staticmethod
async def _plugin_page_error_response(status_code: int, message: str):
+5 -1
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia';
import config from '@/config';
const DARK_THEMES: ReadonlySet<string> = new Set(['PurpleThemeDark']);
export const useCustomizerStore = defineStore("customizer", {
state: () => ({
Sidebar_drawer: config.Sidebar_drawer,
@@ -12,7 +14,9 @@ export const useCustomizerStore = defineStore("customizer", {
chatSidebarOpen: false // chat mode mobile sidebar state
}),
getters: {},
getters: {
isDark: (state) => state.uiTheme ? DARK_THEMES.has(state.uiTheme) : false,
},
actions: {
SET_SIDEBAR_DRAWER() {
this.Sidebar_drawer = !this.Sidebar_drawer;
+10 -1
View File
@@ -4,6 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useModuleI18n } from "@/i18n/composables";
import { usePluginI18n } from "@/utils/pluginI18n";
import { useCustomizerStore } from "@/stores/customizer";
const BRIDGE_CHANNEL = "astrbot-plugin-page";
@@ -15,6 +16,7 @@ const {
pluginName: pluginDisplayName,
pluginPageTitle,
} = usePluginI18n();
const customizer = useCustomizerStore();
const loading = ref(true);
const errorMessage = ref("");
@@ -36,6 +38,7 @@ const localizedPageTitle = computed(() =>
),
);
const getIframeWindow = () => iframeRef.value?.contentWindow || null;
const themeParam = computed(() => customizer.isDark ? "dark" : "light");
const toPostMessageData = (value, fallback = null) => {
try {
@@ -202,6 +205,7 @@ const sendIframeContext = () => {
pageTitle: localizedPageTitle.value,
locale: locale.value,
i18n: toPostMessageData(plugin.value.i18n, {}),
isDark: customizer.isDark,
},
});
};
@@ -432,7 +436,9 @@ const loadPluginPage = async () => {
plugin.value = pluginData;
page.value = pageEntry;
iframeSrc.value = pageEntry.content_path;
const contentUrl = new URL(pageEntry.content_path, window.location.origin);
contentUrl.searchParams.set('theme', themeParam.value);
iframeSrc.value = contentUrl.pathname + contentUrl.search + contentUrl.hash;
} catch (error) {
errorMessage.value =
error?.response?.data?.message ||
@@ -456,6 +462,9 @@ watch([pluginName, pageName], loadPluginPage, { immediate: true });
watch(locale, () => {
sendIframeContext();
});
watch(() => customizer.uiTheme, () => {
sendIframeContext();
});
</script>
<template>
+54 -2
View File
@@ -116,7 +116,8 @@ The current `ready()` context looks like this:
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {}
"i18n": {},
"isDark": false
}
```
@@ -168,6 +169,56 @@ If an inline script needs to access `window.AstrBotPluginPage` synchronously, mo
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## Light/Dark Theme Adaptation
When the user toggles light/dark mode in AstrBot, the theme state is automatically synced to Plugin Pages. The bridge SDK maintains a `data-theme` attribute on the `<html>` element:
- Light mode: `<html data-theme="light">`
- Dark mode: `<html data-theme="dark">`
### CSS Adaptation
CSS variables are the recommended approach:
```css
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}
```
AstrBot injects the `data-theme` attribute on the `<html>` tag server-side when serving HTML, so there is no initial flash.
### Responding to Theme Changes in JavaScript
The `isDark` field in the context indicates whether dark mode is active. Use `onContext()` to listen for theme changes:
```js
const bridge = window.AstrBotPluginPage;
function render() {
if (bridge.getContext()?.isDark) {
// JS logic for dark mode (e.g. chart filters)
}
}
await bridge.ready();
render();
bridge.onContext(render);
```
When the theme is toggled, the Dashboard sends the updated context to the iframe via the bridge. As long as the Page listens with `onContext()`, it will be notified.
## 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.
@@ -232,7 +283,8 @@ The context usually contains:
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "en-US",
"i18n": {}
"i18n": {},
"isDark": false
}
```
+54 -2
View File
@@ -116,7 +116,8 @@ class MyPlugin(Star):
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {}
"i18n": {},
"isDark": false
}
```
@@ -168,6 +169,56 @@ bridge.onContext(render);
<script src="/api/plugin/page/bridge-sdk.js"></script>
```
## 亮暗主题适配
AstrBot 切换亮色/暗色模式时,会自动将主题状态同步给插件 Page。bridge SDK 会在 `<html>` 元素上维护 `data-theme` 属性:
- 亮色模式:`<html data-theme="light">`
- 暗色模式:`<html data-theme="dark">`
### CSS 适配
推荐使用 CSS 变量:
```css
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
[data-theme="dark"] {
--bg: #1a1a1a;
--text: #e0e0e0;
}
body {
background: var(--bg);
color: var(--text);
}
```
AstrBot 在服务端返回 HTML 时,会自动在 `<html>` 标签上注入 `data-theme` 属性,不会出现初始闪烁。
### JavaScript 响应主题变化
上下文中的 `isDark` 字段表示当前是否为暗色模式。通过 `onContext()` 可以监听主题切换:
```js
const bridge = window.AstrBotPluginPage;
function render() {
if (bridge.getContext()?.isDark) {
// 暗色模式下的 JS 逻辑(如图表滤镜等)
}
}
await bridge.ready();
render();
bridge.onContext(render);
```
切换亮暗模式时,Dashboard 会通过 bridge 将更新后的上下文发送给 iframe;只要 Page 监听了 `onContext()`,就会收到通知。
## 静态资源路径规则
AstrBot 会重写相对资源路径,并自动补上短期 `asset_token`。你只需要正常写相对路径,不要自己拼接 `/api/plugin/page/content/...`。
@@ -232,7 +283,8 @@ console.log(context.pluginName, context.pageName, context.locale);
"pageName": "bridge-demo",
"pageTitle": "Bridge Demo",
"locale": "zh-CN",
"i18n": {}
"i18n": {},
"isDark": false
}
```
+113
View File
@@ -1593,6 +1593,119 @@ async def test_plugin_page_content_issues_scoped_asset_token(
assert cross_page_response.status_code == 401
@pytest.mark.asyncio
async def test_plugin_page_bridge_sdk_includes_is_dark_when_theme_param_provided(
app: Quart,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
"""Bridge SDK initial context should include isDark based on ?theme= query param."""
authorized_client = app.test_client()
response = await authorized_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/",
headers=authenticated_header,
)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
bridge_sdk_url = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
html_text,
)
assert bridge_sdk_url is not None
anonymous_client = app.test_client()
# theme=dark → isDark: true
dark_response = await anonymous_client.get(bridge_sdk_url.group(1) + "&theme=dark")
assert dark_response.status_code == 200
dark_js = (await dark_response.get_data()).decode("utf-8")
assert '"isDark": true' in dark_js
# theme=light → isDark: false
light_response = await anonymous_client.get(
bridge_sdk_url.group(1) + "&theme=light"
)
assert light_response.status_code == 200
light_js = (await light_response.get_data()).decode("utf-8")
assert '"isDark": false' in light_js
# no theme param → isDark: false (default)
base_response = await anonymous_client.get(bridge_sdk_url.group(1))
assert base_response.status_code == 200
base_js = (await base_response.get_data()).decode("utf-8")
assert '"isDark": false' in base_js
# invalid theme value → should NOT be treated as dark
invalid_response = await anonymous_client.get(
bridge_sdk_url.group(1) + "&theme=invalid"
)
assert invalid_response.status_code == 200
invalid_js = (await invalid_response.get_data()).decode("utf-8")
assert '"isDark": false' in invalid_js
@pytest.mark.asyncio
async def test_plugin_page_content_propagates_theme_in_rewritten_urls(
app: Quart,
authenticated_header: dict,
registered_plugin_page: StarMetadata,
):
"""Theme query param should be propagated through rewritten asset and bridge URLs."""
test_client = app.test_client()
response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
"?asset_token=&theme=dark",
headers=authenticated_header,
)
assert response.status_code == 200
html_text = (await response.get_data()).decode("utf-8")
# Verify theme=dark appears in bridge SDK URL in rewritten HTML
bridge_sdk_url_match = re.search(
r'src="([^"]+/bridge-sdk\.js[^"]*)"',
html_text,
)
assert bridge_sdk_url_match is not None
bridge_query = parse_qs(urlsplit(bridge_sdk_url_match.group(1)).query)
assert bridge_query.get("theme") == ["dark"]
# Verify theme=dark appears in CSS asset URL in rewritten HTML
css_url_match = re.search(
r'href="([^"]+/base\.css[^"]*)"',
html_text,
)
assert css_url_match is not None
css_query = parse_qs(urlsplit(css_url_match.group(1)).query)
assert css_query.get("theme") == ["dark"]
# Verify data-theme is injected on <html> tag to prevent flash
assert 'data-theme="dark"' in html_text
# Verify color-scheme meta tag is injected for browser-level default styles
assert '<meta name="color-scheme" content="dark">' in html_text
# theme=light → data-theme="light" on <html> and color-scheme meta
light_response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
"?asset_token=&theme=light",
headers=authenticated_header,
)
assert light_response.status_code == 200
light_html = (await light_response.get_data()).decode("utf-8")
assert 'data-theme="light"' in light_html
assert '<meta name="color-scheme" content="light">' in light_html
# no theme param → no data-theme or color-scheme meta on <html>
no_theme_response = await test_client.get(
f"/api/plugin/page/content/{PLUGIN_PAGE_DEMO_NAME}/{PLUGIN_PAGE_DEMO_PAGE_NAME}/"
"?asset_token=",
headers=authenticated_header,
)
assert no_theme_response.status_code == 200
no_theme_html = (await no_theme_response.get_data()).decode("utf-8")
assert "data-theme=" not in no_theme_html
assert "color-scheme" not in no_theme_html
@pytest.mark.asyncio
async def test_plugin_page_assets_require_dashboard_auth(
app: Quart,