diff --git a/astrbot/core/star/star.py b/astrbot/core/star/star.py index 91797dd7419..2aa2f4aaca6 100644 --- a/astrbot/core/star/star.py +++ b/astrbot/core/star/star.py @@ -75,6 +75,12 @@ class StarMetadata: views: list[dict] = field(default_factory=list) """插件注册的 Views 元数据。""" + readmes: dict[str, str] = field(default_factory=dict) + """插件声明的多语言 README:locale -> 相对于插件根目录的 Markdown 路径。 + + "default" 键表示默认版本,其余键为 locale(如 "zh-CN")。 + """ + @property def plugin_id(self) -> str: p_name = (self.name or "unknown").lower().replace("/", "_") diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index b600282ed9f..d3397adfc65 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -560,6 +560,7 @@ def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | N if isinstance(metadata.get("pages"), list) else [], i18n=PluginManager._load_plugin_i18n(plugin_path), + readmes=PluginManager._load_plugin_readmes(metadata.get("readmes")), ) return metadata @@ -670,6 +671,30 @@ def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str: PluginManager._validate_importable_name(plugin_dir_name) return plugin_dir_name + @staticmethod + def _load_plugin_readmes(raw_readmes: object) -> dict[str, str]: + """Normalize the optional metadata.yaml "readmes" mapping. + + Args: + raw_readmes: Parsed "readmes" value from metadata.yaml. + + Returns: + Mapping of locale (or "default") to a relative Markdown path. + Invalid entries are dropped so a typo cannot break plugin loading. + """ + if not isinstance(raw_readmes, dict): + return {} + readmes: dict[str, str] = {} + for locale, path in raw_readmes.items(): + if not isinstance(locale, str) or not isinstance(path, str): + continue + locale = locale.strip() + path = path.strip() + if not locale or not path: + continue + readmes[locale] = path + return readmes + @staticmethod def _validate_astrbot_version_specifier( version_spec: str | None, diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index 4cdf0fccfa0..8120e60fcb2 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -832,11 +832,12 @@ async def delete_plugin_config_file_by_id( @router.get("/plugins/readme") async def get_plugin_readme_by_id( plugin_id: str = Query(...), + locale: str | None = Query(None), _auth: AuthContext = Depends(require_plugin_scope), service: PluginService = Depends(get_service), ): return await _run_service( - lambda: service.get_plugin_readme(plugin_id), + lambda: service.get_plugin_readme(plugin_id, locale), log_label="/api/plugin/readme", ) @@ -1082,11 +1083,12 @@ async def delete_plugin_config_file( @router.get("/plugins/{plugin_id}/readme") async def get_plugin_readme( plugin_id: str, + locale: str | None = Query(None), _auth: AuthContext = Depends(require_plugin_scope), service: PluginService = Depends(get_service), ): return await _run_service( - lambda: service.get_plugin_readme(plugin_id), + lambda: service.get_plugin_readme(plugin_id, locale), log_label="/api/plugin/readme", ) @@ -1424,7 +1426,10 @@ async def dashboard_get_plugin_readme( service: PluginService = Depends(get_service), ): return await _run_service( - lambda: service.get_plugin_readme(request.query_params.get("name")), + lambda: service.get_plugin_readme( + request.query_params.get("name"), + request.query_params.get("locale"), + ), log_label="/api/plugin/readme", ) diff --git a/astrbot/dashboard/services/plugin_service.py b/astrbot/dashboard/services/plugin_service.py index 4d6906b7393..68163ee828e 100644 --- a/astrbot/dashboard/services/plugin_service.py +++ b/astrbot/dashboard/services/plugin_service.py @@ -1931,21 +1931,134 @@ def resolve_plugin_dir(self, plugin_name: str) -> Path: raise PluginServiceError(f"无法找到插件 {plugin_name} 的目录") return plugin_dir - def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]: + def _get_plugin_readmes(self, plugin_name: str) -> dict[str, str]: + """Return the locale -> README path mapping declared by one plugin. + + Args: + plugin_name: Plugin name (metadata.name). + + Returns: + The declared mapping, or an empty dict for plugins that only ship + a single README.md. + """ + for plugin in self.plugin_manager.context.get_all_stars(): + if plugin.name == plugin_name: + return dict(getattr(plugin, "readmes", None) or {}) + return {} + + @staticmethod + def _normalize_locale(locale: str | None) -> str: + """Normalize a locale tag to lowercase with a hyphen separator. + + Args: + locale: Raw locale such as "zh_CN" or "zh-CN". + + Returns: + The normalized tag, or an empty string when no locale is given. + """ + return (locale or "").strip().replace("_", "-").lower() + + @staticmethod + def _resolve_declared_readme(plugin_dir: Path, filename: str) -> Path | None: + """Resolve a README path declared in metadata.yaml inside the plugin directory. + + Args: + plugin_dir: Root directory of the plugin. + filename: Declared relative Markdown path. + + Returns: + The resolved file path, or None when the entry escapes the plugin + directory, is not a file, or is not Markdown. + """ + relative = Path(filename.strip()) + if not relative.name or relative.is_absolute() or ".." in relative.parts: + return None + if relative.suffix.lower() not in {".md", ".markdown"}: + return None + try: + resolved = (plugin_dir / relative).resolve(strict=True) + root = plugin_dir.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + # ValueError covers paths with embedded NUL bytes. + return None + if not resolved.is_file(): + return None + try: + resolved.relative_to(root) + except ValueError: + return None + return resolved + + def get_plugin_readme( + self, + plugin_name: str | None, + locale: str | None = None, + ) -> tuple[dict, str]: + """Read the plugin README that best matches the requested locale. + + Resolution order is exact locale, base language (zh-TW -> zh), then the + declared default entry. Plugins that do not declare "readmes" in + metadata.yaml keep the legacy README.md behavior. + + Args: + plugin_name: Plugin name (metadata.name). + locale: WebUI locale used to pick a language variant. + + Returns: + A tuple of the response payload (content, resolved_locale, + available_locales) and a human readable message. + + Raises: + PluginServiceError: If the plugin is unknown or no README exists. + """ if not plugin_name: logger.warning("The plugin name is empty.") raise PluginServiceError("插件名称不能为空") plugin_dir = self.resolve_plugin_dir(plugin_name) - readme_path = plugin_dir / "README.md" + declared = self._get_plugin_readmes(plugin_name) + available_locales = [key for key in declared if key != "default"] + requested = self._normalize_locale(locale) + # Map the normalized tag to the originally declared locale key so the + # resolved locale matches the spelling used in available_locales. + locale_paths = { + self._normalize_locale(key): (key, value) + for key, value in declared.items() + if key != "default" + } + + candidates: list[tuple[str | None, str]] = [] + if requested: + matched_locale = None + if requested in locale_paths: + matched_locale = requested + elif "-" in requested: + base_language = requested.split("-", 1)[0] + if base_language in locale_paths: + matched_locale = base_language + if matched_locale: + declared_locale, path = locale_paths[matched_locale] + candidates.append((declared_locale, path)) + candidates.append((None, declared.get("default") or "README.md")) + + readme_path = None + resolved_locale = None + for candidate_locale, filename in candidates: + candidate_path = self._resolve_declared_readme(plugin_dir, filename) + if candidate_path: + readme_path = candidate_path + resolved_locale = candidate_locale + break - if not readme_path.is_file(): + if readme_path is None: logger.warning(f"Plugin {plugin_name} has no README file.") raise PluginServiceError(f"插件 {plugin_name} 没有README文件") try: return { - "content": readme_path.read_text(encoding="utf-8") + "content": readme_path.read_text(encoding="utf-8"), + "resolved_locale": resolved_locale, + "available_locales": available_locales, }, "成功获取README内容" except Exception as exc: logger.warning(f"Failed to read README for plugin {plugin_name}: {exc}") @@ -1957,8 +2070,9 @@ def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]: def get_plugin_readme_from_dashboard_query( self, plugin_name: str | None, + locale: str | None = None, ) -> tuple[dict, str]: - return self.get_plugin_readme(plugin_name) + return self.get_plugin_readme(plugin_name, locale) def get_plugin_changelog(self, plugin_name: str | None) -> tuple[dict, str]: logger.debug(f"Getting the changelog for plugin {plugin_name}") diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index d8d99e9be26..0620e0c90a2 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -1907,6 +1907,10 @@ export type DeletePluginConfigFileByIdError = unknown; export type GetPluginReadmeByIdData = { query: { + /** + * Preferred README locale such as zh-CN or en-US + */ + locale?: string; plugin_id: string; }; }; @@ -2070,6 +2074,12 @@ export type GetPluginReadmeData = { path: { plugin_id: string; }; + query?: { + /** + * Preferred README locale such as zh-CN or en-US + */ + locale?: string; + }; }; export type GetPluginReadmeResponse = (string); diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 685e9b61d47..8e874621ed7 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -1353,9 +1353,11 @@ export const pluginApi = { }), ); }, - readme(pluginId: string) { + readme(pluginId: string, locale?: string) { return typed( - openApiV1.getPluginReadmeById({ query: { plugin_id: pluginId } }), + openApiV1.getPluginReadmeById({ + query: { plugin_id: pluginId, locale }, + }), ); }, changelog(pluginId: string) { diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index e17999203e1..be4f35d631a 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -111,6 +111,8 @@ "notFound": "Plugin not found", "docsTitle": "Documentation", "docsEmpty": "No documentation", + "readmeLanguage": "Documentation language", + "readmeLanguageAuto": "Follow interface language", "changelogTitle": "Changelog", "changelogEmpty": "No changelog", "subCommandsCount": "{count} sub-commands", diff --git a/dashboard/src/i18n/locales/ja-JP/features/extension.json b/dashboard/src/i18n/locales/ja-JP/features/extension.json index c8def6652db..0aa9f6d6f29 100644 --- a/dashboard/src/i18n/locales/ja-JP/features/extension.json +++ b/dashboard/src/i18n/locales/ja-JP/features/extension.json @@ -111,6 +111,8 @@ "notFound": "プラグインが見つかりません", "docsTitle": "ドキュメント", "docsEmpty": "ドキュメントはありません", + "readmeLanguage": "ドキュメントの言語", + "readmeLanguageAuto": "UI 言語に合わせる", "changelogTitle": "変更履歴", "changelogEmpty": "変更履歴はありません", "subCommandsCount": "{count} 件のサブコマンド", diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index e9467672400..ed149a8c598 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -111,6 +111,8 @@ "notFound": "Плагин не найден", "docsTitle": "Документация", "docsEmpty": "Документация отсутствует", + "readmeLanguage": "Язык документации", + "readmeLanguageAuto": "Как в интерфейсе", "changelogTitle": "Журнал изменений", "changelogEmpty": "Журнал изменений отсутствует", "subCommandsCount": "{count} подкоманд", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index dfd04a40c3f..84e3c106c83 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -111,6 +111,8 @@ "notFound": "未找到该插件", "docsTitle": "文档", "docsEmpty": "暂无文档", + "readmeLanguage": "文档语言", + "readmeLanguageAuto": "跟随界面语言", "changelogTitle": "更新日志", "changelogEmpty": "暂无更新日志", "subCommandsCount": "{count} 个子指令", diff --git a/dashboard/src/views/extension/PluginDetailPage.vue b/dashboard/src/views/extension/PluginDetailPage.vue index c94ee39b627..1774d2ddcde 100644 --- a/dashboard/src/views/extension/PluginDetailPage.vue +++ b/dashboard/src/views/extension/PluginDetailPage.vue @@ -92,6 +92,20 @@ const readmeLoading = ref(false); const readmeError = ref(""); const readmeEmpty = ref(false); const renderedReadme = ref(""); +// Empty string means "follow the WebUI locale"; otherwise the user picked a +// language variant declared by the plugin in metadata.yaml. +const readmeLocale = ref(""); +const readmeAvailableLocales = ref([]); +let readmeRequestId = 0; +let detailRequestId = 0; +let changelogRequestId = 0; +// Set when the plugin-switch watcher resets readmeLocale, so the locale +// watcher skips the fetch that the plugin-switch flow already performs. +let skipNextLocaleWatch = false; +const readmeLocaleItems = computed(() => [ + { title: `${tm("detail.readmeLanguageAuto")} (${locale.value})`, value: "" }, + ...readmeAvailableLocales.value.map((item) => ({ title: item, value: item })), +]); const changelogLoading = ref(false); const changelogError = ref(""); const changelogEmpty = ref(false); @@ -664,11 +678,19 @@ const scrollToHashTarget = async () => { }; const fetchPluginDetail = async () => { + const requestId = ++detailRequestId; + const pluginName = props.plugin?.name; pluginDetail.value = null; - if (isMarketDetail.value || !props.plugin?.name) return; + if (isMarketDetail.value || !pluginName) return true; + + const isCurrent = () => + requestId === detailRequestId && props.plugin?.name === pluginName; try { - const res = await pluginApi.get(props.plugin.name); + const res = await pluginApi.get(pluginName); + // Ignore slow responses that belong to an older plugin selection + // (e.g. plugin A's request completes after switching to plugin B). + if (!isCurrent()) return false; if (res.data.status === "ok" && res.data.data) { pluginDetail.value = res.data.data; await scrollToHashTarget(); @@ -676,6 +698,7 @@ const fetchPluginDetail = async () => { } catch (err) { console.debug("Failed to fetch plugin detail:", err); } + return isCurrent(); }; const getDocumentUrl = (fieldName) => { @@ -694,12 +717,19 @@ const fetchRemoteMarkdown = async (url) => { const fetchReadme = async () => { const plugin = pluginData.value || {}; - if (!plugin?.name) return; - + const pluginName = plugin.name; + if (!pluginName) return; + + const requestId = ++readmeRequestId; + // A response is stale when a newer README request started or the user has + // switched to another plugin since this request began. + const isCurrent = () => + requestId === readmeRequestId && props.plugin?.name === pluginName; readmeLoading.value = true; readmeError.value = ""; readmeEmpty.value = false; renderedReadme.value = ""; + readmeAvailableLocales.value = []; if (isMarketDetail.value) { const readmeUrl = getDocumentUrl("readme_url"); @@ -711,15 +741,19 @@ const fetchReadme = async () => { try { const content = await fetchRemoteMarkdown(readmeUrl); + if (!isCurrent()) return; if (!content.trim()) { readmeEmpty.value = true; return; } renderedReadme.value = renderMarkdown(content); } catch (err) { + if (!isCurrent()) return; readmeError.value = err?.message || String(err); } finally { - readmeLoading.value = false; + if (isCurrent()) { + readmeLoading.value = false; + } } return; } @@ -742,13 +776,20 @@ const fetchReadme = async () => { } try { - const res = await pluginApi.readme(plugin.name); + const res = await pluginApi.readme( + plugin.name, + readmeLocale.value || locale.value, + ); + // Ignore slow responses that belong to an older locale or plugin selection. + if (!isCurrent()) return; if (res.data.status !== "ok") { readmeError.value = res.data.message || tm("messages.operationFailed"); return; } + readmeAvailableLocales.value = res.data.data?.available_locales || []; + const content = res.data.data?.content || ""; if (!content) { readmeEmpty.value = true; @@ -757,15 +798,25 @@ const fetchReadme = async () => { renderedReadme.value = renderMarkdown(content); } catch (err) { + if (!isCurrent()) return; readmeError.value = err?.message || String(err); } finally { - readmeLoading.value = false; + if (isCurrent()) { + readmeLoading.value = false; + } } }; const fetchChangelog = async () => { const plugin = pluginData.value || {}; - if (!plugin?.name) return; + const pluginName = plugin.name; + if (!pluginName) return; + + const requestId = ++changelogRequestId; + // A response is stale when a newer changelog request started or the user + // has switched to another plugin since this request began. + const isCurrent = () => + requestId === changelogRequestId && props.plugin?.name === pluginName; changelogLoading.value = true; changelogError.value = ""; @@ -782,21 +833,26 @@ const fetchChangelog = async () => { try { const content = await fetchRemoteMarkdown(changelogUrl); + if (!isCurrent()) return; if (!content.trim()) { changelogEmpty.value = true; return; } renderedChangelog.value = renderMarkdown(content); } catch (err) { + if (!isCurrent()) return; changelogError.value = err?.message || String(err); } finally { - changelogLoading.value = false; + if (isCurrent()) { + changelogLoading.value = false; + } } return; } try { const res = await pluginApi.changelog(plugin.name); + if (!isCurrent()) return; if (res.data.status !== "ok") { changelogError.value = res.data.message || tm("messages.operationFailed"); @@ -811,9 +867,12 @@ const fetchChangelog = async () => { renderedChangelog.value = renderMarkdown(content); } catch (err) { + if (!isCurrent()) return; changelogError.value = err?.message || String(err); } finally { - changelogLoading.value = false; + if (isCurrent()) { + changelogLoading.value = false; + } } }; @@ -834,7 +893,14 @@ watch( ], async () => { logoLoadFailed.value = false; - await fetchPluginDetail(); + // The locale watcher below would fire on this reset and duplicate the + // README fetch that this flow performs after the detail request. + skipNextLocaleWatch = readmeLocale.value !== ""; + readmeLocale.value = ""; + const isCurrent = await fetchPluginDetail(); + // The plugin selection changed while the detail request was pending; + // do not start README/changelog fetches for the stale selection. + if (!isCurrent) return; fetchReadme(); fetchChangelog(); scrollToHashTarget(); @@ -842,6 +908,15 @@ watch( { immediate: true }, ); +// Follow the WebUI language until the user picks a README variant explicitly. +watch([() => locale.value, readmeLocale], () => { + if (skipNextLocaleWatch) { + skipNextLocaleWatch = false; + return; + } + fetchReadme(); +}); + onMounted(() => { updateHeaderStuckState(); scrollToHashTarget(); @@ -1085,7 +1160,19 @@ onBeforeUnmount(() => {
-

{{ tm("detail.docsTitle") }}

+
+

{{ tm("detail.docsTitle") }}

+ +
@@ -1235,6 +1322,24 @@ onBeforeUnmount(() => { margin-top: 28px; } +.detail-section__header { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; + margin-bottom: 12px; +} + +.detail-section__header .detail-section__title { + margin-bottom: 0; +} + +.docs-locale-select { + flex: 0 0 220px; + max-width: 100%; +} + .detail-section__title { font-size: 1.25rem; font-weight: 700; diff --git a/docs/en/dev/star/plugin-publish.md b/docs/en/dev/star/plugin-publish.md index e2fcd7126ea..a9c3c054bdf 100644 --- a/docs/en/dev/star/plugin-publish.md +++ b/docs/en/dev/star/plugin-publish.md @@ -26,10 +26,17 @@ repo: https://github.com/your-name/repo # Plugin repository URL # social_link: https://github.com/your-web # (Optional) Your personal website, GitHub profile, etc. # tags: # (Optional) Tag list for marketplace categorization and search # - example +# readmes: # (Optional) Per-locale README variants, follows the WebUI language +# default: README.md # Default variant (falls back to README.md when omitted) +# zh-CN: README_zh.md +# en-US: README_en.md ``` [Supported platform adapters](plugin-new.md#declare-supported-platforms-optional) +`readmes` serves the plugin documentation in multiple languages. The dashboard resolves the exact UI locale first, then the base language (for example `zh-TW` falls back to `zh`), and finally `default`. Declared paths must be relative Markdown files inside the plugin directory; `..`, absolute paths and symlinks that escape the plugin directory are rejected. Plugins without `readmes` keep reading `README.md` from the repository root. + + ::: warning Size Limit The plugin zip package submitted to the marketplace **must not exceed 16MB**. diff --git a/docs/zh/dev/star/plugin-publish.md b/docs/zh/dev/star/plugin-publish.md index 2dc9c50fc46..29858010bfa 100644 --- a/docs/zh/dev/star/plugin-publish.md +++ b/docs/zh/dev/star/plugin-publish.md @@ -26,10 +26,17 @@ repo: https://github.com/your-name/repo # 插件仓库地址 # social_link: https://github.com/your-web # (可选)你的个人网站、GitHub 主页等 # tags: # (可选)标签列表,用于插件市场分类和搜索 # - example +# readmes: # (可选)多语言 README,按 WebUI 语言自动切换 +# default: README.md # 默认版本(缺省时回退到 README.md) +# zh-CN: README_zh.md +# en-US: README_en.md ``` [目前支持的平台适配器](plugin-new.md#声明支持平台-optional) +`readmes` 用于为插件文档提供多语言版本。管理面板会先按当前界面语言精确匹配,再回退到基础语言(例如 `zh-TW` 回退到 `zh`),最后回退到 `default`。声明的路径必须是插件目录内的相对 Markdown 路径,包含 `..`、绝对路径或指向插件目录外的符号链接都会被拒绝。没有声明 `readmes` 的插件继续读取仓库根目录的 `README.md`。 + + ::: warning 大小限制 发布到插件市场的插件压缩包(zip)大小**不得超过 16MB**。 diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index da1778b3075..673ada0f1af 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -2095,6 +2095,11 @@ paths: required: true schema: type: string + - name: locale + in: query + description: Preferred README locale such as zh-CN or en-US + schema: + type: string responses: "200": $ref: "#/components/responses/Text" @@ -2338,6 +2343,11 @@ paths: x-astrbot-scope: plugin parameters: - $ref: "#/components/parameters/PluginId" + - name: locale + in: query + description: Preferred README locale such as zh-CN or en-US + schema: + type: string responses: "200": $ref: "#/components/responses/Text" diff --git a/tests/test_plugin_readme_locale.py b/tests/test_plugin_readme_locale.py new file mode 100644 index 00000000000..45fa31b6173 --- /dev/null +++ b/tests/test_plugin_readme_locale.py @@ -0,0 +1,154 @@ +from pathlib import Path + +import pytest + +from astrbot.core.star.star import StarMetadata +from astrbot.dashboard.services.plugin_service import PluginService, PluginServiceError + + +class _FakeContext: + def __init__(self, stars: list[StarMetadata]) -> None: + self._stars = stars + + def get_all_stars(self) -> list[StarMetadata]: + return self._stars + + +class _FakePluginManager: + def __init__(self, root: Path, stars: list[StarMetadata]) -> None: + self.reserved_plugin_path = root + self.plugin_store_path = root + self.context = _FakeContext(stars) + + +def _make_service( + tmp_path: Path, readmes: dict[str, str] | None = None +) -> tuple[PluginService, Path]: + plugin_dir = tmp_path / "demo" + plugin_dir.mkdir() + star = StarMetadata(name="demo", root_dir_name="demo", readmes=readmes or {}) + service = PluginService(None, _FakePluginManager(tmp_path, [star])) + return service, plugin_dir + + +def test_legacy_readme_without_declaration(tmp_path: Path): + service, plugin_dir = _make_service(tmp_path) + (plugin_dir / "README.md").write_text("# hello", encoding="utf-8") + + payload, _message = service.get_plugin_readme("demo", "zh-CN") + + assert payload == { + "content": "# hello", + "resolved_locale": None, + "available_locales": [], + } + + +def test_declared_locale_is_preferred(tmp_path: Path): + service, plugin_dir = _make_service( + tmp_path, + { + "default": "README.md", + "zh-CN": "README_zh.md", + "en-US": "README_en.md", + }, + ) + (plugin_dir / "README.md").write_text("default", encoding="utf-8") + (plugin_dir / "README_zh.md").write_text("zh", encoding="utf-8") + (plugin_dir / "README_en.md").write_text("en", encoding="utf-8") + + payload, _message = service.get_plugin_readme("demo", "zh-CN") + assert payload["content"] == "zh" + assert payload["resolved_locale"] == "zh-CN" + assert payload["available_locales"] == ["zh-CN", "en-US"] + + payload, _message = service.get_plugin_readme("demo", "en_US") + assert payload["content"] == "en" + + +def test_base_language_fallback(tmp_path: Path): + service, plugin_dir = _make_service( + tmp_path, + {"default": "README.md", "zh": "README_zh.md"}, + ) + (plugin_dir / "README.md").write_text("default", encoding="utf-8") + (plugin_dir / "README_zh.md").write_text("zh", encoding="utf-8") + + payload, _message = service.get_plugin_readme("demo", "zh-TW") + + assert payload["content"] == "zh" + assert payload["resolved_locale"] == "zh" + + +def test_unknown_locale_falls_back_to_default(tmp_path: Path): + service, plugin_dir = _make_service( + tmp_path, + {"default": "README.md", "zh-CN": "README_zh.md"}, + ) + (plugin_dir / "README.md").write_text("default", encoding="utf-8") + (plugin_dir / "README_zh.md").write_text("zh", encoding="utf-8") + + payload, _message = service.get_plugin_readme("demo", "fr-FR") + + assert payload["content"] == "default" + assert payload["resolved_locale"] is None + + +def test_missing_declared_file_falls_back_to_default(tmp_path: Path): + service, plugin_dir = _make_service( + tmp_path, + {"default": "README.md", "zh-CN": "missing.md"}, + ) + (plugin_dir / "README.md").write_text("default", encoding="utf-8") + + payload, _message = service.get_plugin_readme("demo", "zh-CN") + + assert payload["content"] == "default" + + +def test_parent_directory_escape_is_rejected(tmp_path: Path): + service, plugin_dir = _make_service( + tmp_path, + {"default": "../secret.md"}, + ) + (tmp_path / "secret.md").write_text("secret", encoding="utf-8") + + with pytest.raises(PluginServiceError): + service.get_plugin_readme("demo") + + +def test_absolute_path_is_rejected(tmp_path: Path): + outside = tmp_path / "outside.md" + outside.write_text("outside", encoding="utf-8") + service, _plugin_dir = _make_service(tmp_path, {"default": str(outside)}) + + with pytest.raises(PluginServiceError): + service.get_plugin_readme("demo") + + +def test_non_markdown_file_is_rejected(tmp_path: Path): + service, plugin_dir = _make_service(tmp_path, {"default": "config.json"}) + (plugin_dir / "config.json").write_text("{}", encoding="utf-8") + + with pytest.raises(PluginServiceError): + service.get_plugin_readme("demo") + + +def test_symlink_escape_is_rejected(tmp_path: Path): + service, plugin_dir = _make_service(tmp_path, {"default": "README.md"}) + outside = tmp_path / "outside.md" + outside.write_text("outside", encoding="utf-8") + link = plugin_dir / "README.md" + try: + link.symlink_to(outside) + except (OSError, NotImplementedError): + pytest.skip("symlinks are not available on this platform") + + with pytest.raises(PluginServiceError): + service.get_plugin_readme("demo") + + +def test_normalize_locale(): + assert PluginService._normalize_locale("zh_CN") == "zh-cn" + assert PluginService._normalize_locale(" zh-CN ") == "zh-cn" + assert PluginService._normalize_locale(None) == ""