From 03caf2f1f2e852687b5f52dc563d2a9b1383ed51 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 14:14:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(settings):=20=E6=8F=92=E4=BB=B6=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E6=94=AF=E6=8C=81=E5=8C=85=E5=86=85=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=20l10n/<=E8=AF=AD=E8=A8=80>.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 插件包可以带 l10n/<语言>.json,按设置 ID、选项值、分组原文和按钮 ID 翻译设置页的标题、说明、占位符、单位、选项标题、分组标题和按钮文字。 ID、值、分组和顺序不变,文件没写到的文字显示 plugin.json 原文。 - plugins.declarative.settingsState 新增可选 language、localizations, 返回 localization:候选文件(语言标签逐级截短)、是否需要读取、 选中的文件和被拒绝的文件。Core 不做 I/O,宿主按候选读包内文件后再调一次。 - 有问题的文件整份不用,报 plugin.settings.invalidLocalization 并带字段; 宿主请求格式错误报 invalid_request。 - 新增 plugin-l10n.schema.json、fixtures/settings/l10n/zh-Hans.json 和 localization-cases.json(13 个用例),契约、Core API、API 总表 API-37、设置功能页和更新记录同步更新。 - is_language_tag 从 marketplace 移到 plugin-model 共用。 --- rust/contracts/rust-core-api.md | 43 +- .../src/tests/plugin_settings_documents.rs | 160 +++++ rust/crates/plugin-manifest/src/settings.rs | 26 +- .../plugin-manifest/src/settings_surfaces.rs | 1 + rust/crates/plugin-marketplace/src/lib.rs | 18 +- rust/crates/plugin-model/src/lib.rs | 6 +- rust/crates/plugin-model/src/names.rs | 14 + rust/crates/plugin-model/src/settings.rs | 4 + .../plugin-model/src/settings/documents.rs | 28 +- .../plugin-model/src/settings/localization.rs | 621 ++++++++++++++++++ .../crates/plugin-model/src/settings/pages.rs | 15 +- shared/plugins/README.md | 24 +- shared/plugins/contract/v1/README.md | 58 +- .../v1/fixtures/settings/l10n/zh-Hans.json | 22 + .../fixtures/settings/localization-cases.json | 101 +++ .../contract/v1/plugin-l10n.schema.json | 71 ++ shared/plugins/reference/README.md | 4 +- shared/plugins/reference/api-catalog.md | 4 +- shared/plugins/reference/domains/settings.md | 72 +- .../plugins/reference/protocol-changelog.md | 1 + 20 files changed, 1242 insertions(+), 51 deletions(-) create mode 100644 rust/crates/plugin-model/src/settings/localization.rs create mode 100644 shared/plugins/contract/v1/fixtures/settings/l10n/zh-Hans.json create mode 100644 shared/plugins/contract/v1/fixtures/settings/localization-cases.json create mode 100644 shared/plugins/contract/v1/plugin-l10n.schema.json diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index bf599066..f46c0460 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -426,7 +426,7 @@ or removing a command fails until the table changes in the same commit. | `plugins.settings.catalog` | Project enabled plugin settings pages from installed manifests | | `plugins.settings.navigation` | Project searched settings navigation without platform policy | | `plugins.settings.delivery` | Route committed preference effects using native ownership facts | -| `plugins.declarative.settingsState` | Read complete plugin preference documents into Core-normalized rows, sources, and bounded native read plans | +| `plugins.declarative.settingsState` | Read complete plugin preference documents and packaged translations into Core-normalized rows, sources, and bounded native read plans | | `plugins.declarative.settingsActionPlan` | Resolve an installed settings action and its native confirmation requirement | | `plugins.installed.select` | Select numeric installed versions from native facts without reading paths | | `plugins.installed.lifecycle` | Report installed plugins' enablement and plan confirming an install, enabling, disabling or uninstalling one | @@ -4086,7 +4086,8 @@ Hosts never merge layers themselves. `plugins.declarative.settingsState` accepts `{ manifest, globalDocument?: string|null, projectDocument?: string|null, - owner?: { pluginID, version } }`. + owner?: { pluginID, version }, language?: string, + localizations?: { [path]: string } }`. Send each complete captured UTF-8 document; null or omission means a missing file. An existing empty, malformed, oversized, or duplicate-key document is an error, not an empty layer. Native adapters must reject undecodable bytes rather @@ -4098,8 +4099,33 @@ rejects a mismatched manifest ID or version with `plugin.settings.ownerChanged` before returning editable sources or catalog metadata. This binds native package identity; it neither enables capabilities nor grants process execution. +`language` is the interface language as a tag such as `zh-Hans` or `en`; send +it to show the plugin's own translation of its setting text (the +`l10n/.json` files of the plugin contract). Without it the state is +not translated and `localization` is null. `localizations` holds the complete +UTF-8 text of each candidate file the native adapter found in the installed +package, keyed by the package-relative path from `localization.candidates`; a +missing file is left out, and `{}` means none exists. A first call with +`language` and without `localizations` returns the candidates to read, the same +way the first call without documents returns `storage`; both reads can be +answered in the second call. Native adapters read only these paths, only from +the installed package, and never write there. + Success returns `{ status: "ok", state }`. The state contains: +- `localization`: null without `language`; otherwise `{ language, candidates, + maximumBytes: 1048576, readRequired: bool, applied: string|null, + rejected: [{ path, error: { code, message, field? } }] }`. `candidates` are + `l10n/.json` for the tag and each shorter prefix, most specific first + (`zh-Hans-CN` gives `zh-Hans-CN`, `zh-Hans`, `zh`). `readRequired` is true + until `localizations` is sent. Core checks every supplied file, applies the + first candidate that passes every check and names it in `applied`; a valid + file after it is not used. Every file that fails a check, before or after + the applied one, is listed in `rejected` with code + `plugin.settings.invalidLocalization` and the `field` of its first problem + (absent when the problem is the file as a whole). A refused file is never + partly applied, and text a file leaves out stays as written in the manifest. Hosts + should log rejections for the plugin's author; the page still shows. - `pluginID`, `actions`, and `rows` in validated declaration order. Each row has `declaration`, normalized `scope` and `effect`, `global` and `effective` `{ id, value, source }`, and `globalStored`/`projectStored` flags. `global` is @@ -4107,7 +4133,8 @@ Success returns `{ status: "ok", state }`. The state contains: - `metadata`: `{ displayName, version, vendor }` from the validated manifest, for installed-plugin presentation without reinterpreting the manifest in a host. - `sections`: `{ title, rows: number[], actions: number[] }[]`, ordered by - section title (the empty heading first). References are zero-based indices + the manifest's section title (the empty heading first), so a translation + changes only the shown `title`. References are zero-based indices into `rows` and `actions`; declaration order is retained within each section. - `editors`: `{ global, project }`, each an array in exactly `rows` order. Every entry contains `value`, `resetValue`, `source`, `editable`, `overridden` @@ -4162,7 +4189,9 @@ commands. They do not alter the plugin/SDK protocol or justify advertising Invalid documents use `plugin.settings.invalidDocument`; invalid batches, values, or edit scopes use `plugin.settings.invalidEdit`, with a safe `field`. -Malformed envelopes use `invalid_request`; manifest failures retain existing +Malformed envelopes use `invalid_request`, as do a `language` that is not a +tag, `localizations` without `language`, and a `localizations` path that is +not one of the candidates. Manifest failures retain existing rejection codes. Rejection never includes partial replacement text. Rust and desktop consumers share `rust/fixtures/plugins/settings-documents.json`. @@ -5928,6 +5957,8 @@ no function invocation. Otherwise an unconfirmed action whose declaration has Once confirmed (or when the declaration needs no confirmation), `kind:"invoke"` adds `function` and `params:{}`. Labels and function identity always come from the validated manifest; callers cannot supply a different target or arguments. +`title` is therefore the manifest text; a host that shows a translated +settings page labels the confirmation with the state's `actions[].title`. See `rust/fixtures/plugins/settings-actions.json`. `confirmed` and `workspaceAvailable` are facts from the native owner, not a @@ -5972,7 +6003,9 @@ with stable `key`, `kind` (`language` or `buildSystem`), opaque `id`, display `sections`). Sections reference indices in the complete settingsState rows and actions. Only enabled contributors create pages; action-only pages are valid. Names come from enabled language/build declarations, with opaque ID fallback. -`settingsState.pages` supplies the same per-plugin placement projections. +`settingsState.pages` supplies the same per-plugin placement projections, with +section titles translated when the state is. Catalog contributor sections keep +the manifest headings. `plugins.settings.navigation` accepts at most 576 two-level `items` (512 contributed pages plus 64 host entries) with stable `id`, `title`, nullable `parent`, `group`, `expanded`, optional `keywords`, plus diff --git a/rust/crates/core/src/tests/plugin_settings_documents.rs b/rust/crates/core/src/tests/plugin_settings_documents.rs index 7789193b..9404ea9e 100644 --- a/rust/crates/core/src/tests/plugin_settings_documents.rs +++ b/rust/crates/core/src/tests/plugin_settings_documents.rs @@ -375,3 +375,163 @@ fn settings_navigation_reserves_capacity_for_host_rows() { assert_eq!(response["data"]["status"], "ok", "{response}"); assert_eq!(response["data"]["rows"].as_array().unwrap().len(), 513); } + +fn contract_settings_file(name: &str) -> String { + std::fs::read_to_string(format!( + "{}/../../../shared/plugins/contract/v1/fixtures/settings/{name}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap() +} + +fn localized_state(language: &str, localizations: Option) -> Value { + let mut payload = json!({"manifest": manifest(), "language": language}); + if let Some(localizations) = localizations { + payload["localizations"] = localizations; + } + let result = execute("plugins.declarative.settingsState", payload); + assert_eq!(result["data"]["status"], "ok", "{result}"); + result["data"]["state"].clone() +} + +fn section_titles(sections: &Value) -> Vec { + sections + .as_array() + .unwrap() + .iter() + .map(|section| section["title"].as_str().unwrap().to_owned()) + .collect() +} + +#[test] +fn settings_state_names_translation_candidates_before_they_are_read() { + let plain = execute( + "plugins.declarative.settingsState", + json!({"manifest": manifest()}), + ); + assert_eq!(plain["data"]["state"]["localization"], Value::Null); + let state = localized_state("zh-Hans", None); + assert_eq!( + state["localization"], + json!({"language":"zh-Hans","candidates":["l10n/zh-Hans.json","l10n/zh.json"], + "maximumBytes":1048576,"readRequired":true,"applied":null,"rejected":[]}) + ); + assert_eq!(state["rows"][0]["declaration"]["title"], "Checks"); +} + +#[test] +fn settings_state_shows_packaged_translation_and_keeps_manifest_identity() { + let text = contract_settings_file("l10n/zh-Hans.json"); + let state = localized_state("zh-Hans", Some(json!({ "l10n/zh-Hans.json": text }))); + assert_eq!(state["localization"]["applied"], "l10n/zh-Hans.json"); + let row = |id: &str| { + state["rows"] + .as_array() + .unwrap() + .iter() + .find(|row| row["declaration"]["id"] == id) + .unwrap() + .clone() + }; + let checks = row("checks"); + assert_eq!(checks["declaration"]["title"], "检查项"); + assert_eq!(checks["declaration"]["options"][0]["value"], "format"); + assert_eq!(checks["declaration"]["options"][0]["title"], "格式检查"); + assert_eq!(checks["declaration"]["options"][1]["title"], "Credo"); + assert_eq!(row("dialyzer")["declaration"]["title"], "Dialyzer 静态分析"); + let mix_env = row("mixEnv"); + assert_eq!(mix_env["declaration"]["title"], "MIX_ENV"); + assert_eq!(mix_env["declaration"]["section"], "Server"); + assert_eq!(state["actions"][0]["title"], "重建 PLT"); + assert_eq!(state["actions"][0]["function"], "dialyzer.rebuild"); + let english = localized_state("en", Some(json!({}))); + assert_eq!(english["localization"]["applied"], Value::Null); + assert_eq!(section_titles(&english["sections"]), ["", "Server"]); + // Translated headings keep the manifest's grouping and order. + assert_eq!(section_titles(&state["sections"]), ["", "服务器"]); + for (translated, original) in state["sections"] + .as_array() + .unwrap() + .iter() + .zip(english["sections"].as_array().unwrap()) + { + assert_eq!(translated["rows"], original["rows"]); + assert_eq!(translated["actions"], original["actions"]); + } + let elixir = state["pages"] + .as_array() + .unwrap() + .iter() + .find(|page| page["key"] == "language:elixir") + .unwrap(); + assert_eq!(section_titles(&elixir["sections"]), ["", "服务器"]); +} + +#[test] +fn settings_state_localization_fixture_cases() { + let fixture: Value = + serde_json::from_str(&contract_settings_file("localization-cases.json")).unwrap(); + assert_eq!(fixture["manifest"], "settings.plugin.json"); + for case in fixture["cases"].as_array().unwrap() { + let files: serde_json::Map = case["files"] + .as_object() + .unwrap() + .iter() + .map(|(path, file)| { + let text = file + .as_str() + .map_or_else(|| file.to_string(), str::to_owned); + (path.clone(), json!(text)) + }) + .collect(); + let state = localized_state( + case["language"].as_str().unwrap(), + Some(Value::Object(files)), + ); + let report = &state["localization"]; + assert_eq!(report["applied"], case["applied"], "case {}", case["name"]); + let rejected: Vec<_> = report["rejected"] + .as_array() + .unwrap() + .iter() + .map(|rejection| { + assert_eq!( + rejection["error"]["code"], "plugin.settings.invalidLocalization", + "case {}", + case["name"] + ); + json!({"path": rejection["path"], "field": rejection["error"]["field"]}) + }) + .collect(); + assert_eq!( + Value::Array(rejected), + case["rejected"], + "case {}", + case["name"] + ); + } +} + +#[test] +fn settings_state_refuses_malformed_localization_requests() { + for (payload, detail) in [ + (json!({"language": "zh_Hans"}), "language must be a tag"), + ( + json!({"language": "zh-Hans", "localizations": {"l10n/en.json": "{}"}}), + "not a candidate", + ), + (json!({"localizations": {}}), "require language"), + ] { + let mut request = payload.clone(); + request["manifest"] = manifest(); + let response = execute("plugins.declarative.settingsState", request); + assert_eq!(response["ok"], false, "{payload}: {response}"); + assert_eq!(response["error"]["code"], "invalid_request", "{response}"); + assert!( + response["error"]["details"] + .as_str() + .is_some_and(|details| details.contains(detail)), + "{response}" + ); + } +} diff --git a/rust/crates/plugin-manifest/src/settings.rs b/rust/crates/plugin-manifest/src/settings.rs index 43f1006f..28c815fb 100644 --- a/rust/crates/plugin-manifest/src/settings.rs +++ b/rust/crates/plugin-manifest/src/settings.rs @@ -2,6 +2,7 @@ //! settings state, action, edit and workspace configuration commands. use super::*; +use lithe_plugin_model::settings::LocalizationRequest; impl DeclarativePluginManifest { /// Checks the declared settings, sorts them by ID, and returns the IDs @@ -129,13 +130,29 @@ struct SettingsStatePayload { /// Missing files are null; an existing empty or malformed file is an error. global_document: Option, project_document: Option, + /// Interface language whose plugin translation to apply, such as `zh-Hans`. + #[serde(default)] + language: Option, + /// Translation files the native adapter found among the state's + /// `localization.candidates`, by package-relative path. Absent until read. + #[serde(default)] + localizations: Option>, } /// `plugins.declarative.settingsState`: checked UI rows and raw declared launch /// layers. Reading a state neither edits storage nor admits plugin capabilities. pub fn settings_state_command(payload: Value) -> Result { - let payload: SettingsStatePayload = - decode_payload(payload, "plugins.declarative.settingsState")?; + const COMMAND: &str = "plugins.declarative.settingsState"; + let payload: SettingsStatePayload = decode_payload(payload, COMMAND)?; + let localization = + LocalizationRequest::new(payload.language.as_deref(), payload.localizations.as_ref()) + .map_err(|reason| { + CoreError::new( + ErrorCode::InvalidRequest, + format!("Invalid {COMMAND} payload"), + ) + .with_details(reason) + })?; Ok(outcome(validate_manifest_value(payload.manifest).and_then( |manifest| { if payload.owner.as_ref().is_some_and(|owner| { @@ -147,15 +164,20 @@ pub fn settings_state_command(payload: Value) -> Result { "The manifest does not match the captured installed package identity.", )); } + let (translation, report) = localization.as_ref().map_or((None, Value::Null), |request| { + request.select(&manifest.settings, &manifest.setting_actions) + }); lithe_plugin_model::settings::document_state( &manifest.id, &manifest.settings, &manifest.setting_actions, payload.global_document.as_deref(), payload.project_document.as_deref(), + translation.as_ref(), ) .map(|mut state| { state["metadata"] = json!({"displayName": manifest.display_name, "version":manifest.version, "vendor":manifest.vendor.display_name}); + state["localization"] = report; ("state", state) }) }, diff --git a/rust/crates/plugin-manifest/src/settings_surfaces.rs b/rust/crates/plugin-manifest/src/settings_surfaces.rs index eb3dac8e..7b5f2da9 100644 --- a/rust/crates/plugin-manifest/src/settings_surfaces.rs +++ b/rust/crates/plugin-manifest/src/settings_surfaces.rs @@ -84,6 +84,7 @@ pub fn settings_catalog_command(payload: Value) -> Result { for page in lithe_plugin_model::settings::page_sections( &manifest.settings, &manifest.setting_actions, + None, ) { let key = page["key"].as_str().unwrap_or_default().to_owned(); let target = pages.entry(key.clone()).or_insert_with(|| json!({ diff --git a/rust/crates/plugin-marketplace/src/lib.rs b/rust/crates/plugin-marketplace/src/lib.rs index 82871557..5fb5b98b 100644 --- a/rust/crates/plugin-marketplace/src/lib.rs +++ b/rust/crates/plugin-marketplace/src/lib.rs @@ -11,8 +11,8 @@ use lithe_plugin_manifest::{validate_manifest_value, SUPPORTED_PLUGIN_API_VERSIONS}; use lithe_plugin_model::capabilities::{self, HostCapabilities, Requires}; use lithe_plugin_model::{ - decode_payload, is_official_namespace, is_plugin_id, is_semantic_version, PluginRejection, - Validation, + decode_payload, is_language_tag, is_official_namespace, is_plugin_id, is_semantic_version, + PluginRejection, Validation, }; use lithe_protocol::CoreError; use serde::Deserialize; @@ -205,20 +205,6 @@ fn validate_index( Ok(json!({ "plugins": plugins, "rejected": rejected })) } -/// Language tag in the `language[-Script][-REGION]` form used by `.lproj` -/// directories, e.g. `zh-Hans` or `en`. -fn is_language_tag(tag: &str) -> bool { - let mut parts = tag.split('-'); - let Some(language) = parts.next() else { - return false; - }; - (2..=3).contains(&language.len()) - && language.chars().all(|c| c.is_ascii_lowercase()) - && parts.all(|part| { - (2..=8).contains(&part.len()) && part.chars().all(|c| c.is_ascii_alphanumeric()) - }) -} - fn validate_entry( entry: IndexEntry, source: SourceKind, diff --git a/rust/crates/plugin-model/src/lib.rs b/rust/crates/plugin-model/src/lib.rs index 87d853df..c0329822 100644 --- a/rust/crates/plugin-model/src/lib.rs +++ b/rust/crates/plugin-model/src/lib.rs @@ -28,9 +28,9 @@ mod version; pub use icon::UIIcon; pub use manifest::PluginManifest; pub use names::{ - is_bare_command, is_function_id, is_identifier, is_local_tool_id, is_official_namespace, - is_plugin_id, is_safe_relative_path, is_semantic_version, is_tool_id, is_tool_namespace, - sorted_unique, MAX_FUNCTION_ID_BYTES, OFFICIAL_PLUGIN_NAMESPACE, + is_bare_command, is_function_id, is_identifier, is_language_tag, is_local_tool_id, + is_official_namespace, is_plugin_id, is_safe_relative_path, is_semantic_version, is_tool_id, + is_tool_namespace, sorted_unique, MAX_FUNCTION_ID_BYTES, OFFICIAL_PLUGIN_NAMESPACE, }; pub use rejection::{ decode_payload, invalid, invalid_template, outcome, PluginRejection, Validation, diff --git a/rust/crates/plugin-model/src/names.rs b/rust/crates/plugin-model/src/names.rs index 071c9bcd..749b62a9 100644 --- a/rust/crates/plugin-model/src/names.rs +++ b/rust/crates/plugin-model/src/names.rs @@ -26,6 +26,20 @@ pub fn is_bare_command(value: &str) -> bool { && !value.chars().any(char::is_whitespace) } +/// Language tag in the `language[-Script][-REGION]` form used by `.lproj` +/// directories and `l10n/` files, e.g. `zh-Hans` or `en`. +pub fn is_language_tag(tag: &str) -> bool { + let mut parts = tag.split('-'); + let Some(language) = parts.next() else { + return false; + }; + (2..=3).contains(&language.len()) + && language.chars().all(|c| c.is_ascii_lowercase()) + && parts.all(|part| { + (2..=8).contains(&part.len()) && part.chars().all(|c| c.is_ascii_alphanumeric()) + }) +} + /// A short identifier: lowercase letters, digits and `-`. pub fn is_identifier(value: &str) -> bool { !value.is_empty() diff --git a/rust/crates/plugin-model/src/settings.rs b/rust/crates/plugin-model/src/settings.rs index 2bd8e048..8e06892f 100644 --- a/rust/crates/plugin-model/src/settings.rs +++ b/rust/crates/plugin-model/src/settings.rs @@ -16,6 +16,10 @@ mod documents; pub use documents::{document_state, edit_document, SettingsEdit}; +mod localization; +pub use localization::{ + localization_candidates, LocalizationRequest, SettingsLocalization, MAX_LOCALIZATION_BYTES, +}; mod pages; pub use pages::page_sections; diff --git a/rust/crates/plugin-model/src/settings/documents.rs b/rust/crates/plugin-model/src/settings/documents.rs index c8f9a7cb..96277019 100644 --- a/rust/crates/plugin-model/src/settings/documents.rs +++ b/rust/crates/plugin-model/src/settings/documents.rs @@ -3,8 +3,10 @@ //! Native adapters own storage, conflict checks and file publication. This //! module never opens paths or grants permission to start a plugin process. +use super::localization::shown_section; use super::{ resolve, SettingAction, SettingDeclaration, SettingEffect, SettingLayers, SettingScope, + SettingsLocalization, }; use crate::names::is_plugin_id; use crate::rejection::{PluginRejection, Validation}; @@ -66,12 +68,15 @@ fn read_document(content: Option<&str>, field: &str) -> Validation { /// Projects declared rows and raw launch layers from complete native documents. /// Unknown settings remain in storage but are not sent across the UI boundary. +/// `localization` translates the text rows, actions and section headings show; +/// IDs, values, grouping and order stay those of the manifest. pub fn document_state( plugin: &str, declarations: &[SettingDeclaration], actions: &[SettingAction], global: Option<&str>, project: Option<&str>, + localization: Option<&SettingsLocalization>, ) -> Validation { let global = read_document(global, "globalDocument")?; let project = read_document(project, "projectDocument")?; @@ -90,8 +95,14 @@ pub fn document_state( }, ); let effective = resolve(declarations, SettingLayers { global, project }); + let shown = |declaration: &SettingDeclaration| { + localization.map_or_else( + || declaration.clone(), + |localization| localization.declaration(declaration), + ) + }; let rows: Vec<_> = declarations.iter().zip(global_resolved.iter()).zip(effective.iter()).map(|((declaration, global_value), effective)| json!({ - "declaration": declaration, "scope": declaration.scope(), "effect": declaration.effect(), + "declaration": shown(declaration), "scope": declaration.scope(), "effect": declaration.effect(), "global": global_value, "effective": effective, "globalStored": global.contains_key(&declaration.id), "projectStored": project.contains_key(&declaration.id), @@ -148,11 +159,20 @@ pub fn document_state( } let sections: Vec<_> = sections .into_iter() - .map(|(title, (rows, actions))| json!({"title":title, "rows":rows, "actions":actions})) + .map(|(title, (rows, actions))| json!({"title":shown_section(localization, title), "rows":rows, "actions":actions})) + .collect(); + let shown_actions: Vec<_> = actions + .iter() + .map(|action| { + localization.map_or_else( + || action.clone(), + |localization| localization.action(action), + ) + }) .collect(); Ok( - json!({"pluginID":plugin, "rows":rows, "actions":actions, "sections":sections, - "pages":super::page_sections(declarations, actions), + json!({"pluginID":plugin, "rows":rows, "actions":shown_actions, "sections":sections, + "pages":super::page_sections(declarations, actions, localization), "editors":{"global":global_editor,"project":project_editor}, "settings":declared_values(global), "projectSettings":declared_values(project), "storage":{"globalRelativePath":"plugin-settings.json", "projectRelativePath":".lithe/plugin-settings.json", "maximumBytes":MAX_DOCUMENT_BYTES, "readRequired":!declarations.is_empty()}}), diff --git a/rust/crates/plugin-model/src/settings/localization.rs b/rust/crates/plugin-model/src/settings/localization.rs new file mode 100644 index 00000000..827d09ee --- /dev/null +++ b/rust/crates/plugin-model/src/settings/localization.rs @@ -0,0 +1,621 @@ +//! Plugin-supplied translations of the text a settings page shows. +//! +//! A package may carry `l10n/.json`. Core names the files that may +//! serve the interface language, the native adapter reads the ones that exist +//! from the installed package, and Core applies the most specific file that +//! is valid. A translation replaces only text the manifest already has and +//! never renames, adds, or reorders anything, so text a file omits keeps the +//! manifest's wording and a partial translation never hides a setting. +//! +//! Note: the decision is recorded in .agents/notes/implemented/architecture/2026-10-21-plugin-settings-surfaces.md + +use super::{ + check_label, SettingAction, SettingDeclaration, MAX_SETTING_TEXT, SETTING_ENUM, + SETTING_MULTI_SELECT, +}; +use crate::names::is_language_tag; +use crate::rejection::PluginRejection; +use lithe_protocol::json_document::{parse_unique_json, UniqueMap}; +use serde::{Deserialize, Deserializer}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Package directory that holds translation files. +const LOCALIZATION_DIRECTORY: &str = "l10n"; + +/// Most UTF-8 bytes one translation file may contain. +pub const MAX_LOCALIZATION_BYTES: usize = 1024 * 1024; + +/// Error code of a translation file Core did not apply. +const INVALID_LOCALIZATION: &str = "plugin.settings.invalidLocalization"; + +/// The parts of a translation file this module reads. Other top-level keys +/// are left for later surfaces, so a file written for a newer host still +/// translates settings on this one. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LocalizationFile { + #[serde(default)] + settings: UniqueMap, + #[serde(default)] + sections: UniqueMap, + #[serde(default)] + setting_actions: UniqueMap, +} + +/// Translated text of one setting, by the manifest field it replaces. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct SettingText { + #[serde(default, deserialize_with = "present")] + title: Option, + #[serde(default, deserialize_with = "present")] + description: Option, + #[serde(default, deserialize_with = "present")] + placeholder: Option, + #[serde(default, deserialize_with = "present")] + unit: Option, + /// Option titles by option value. + #[serde(default, deserialize_with = "present")] + options: Option>, +} + +/// Translated text of one settings-page action. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ActionText { + #[serde(default, deserialize_with = "present")] + title: Option, + #[serde(default, deserialize_with = "present")] + description: Option, +} + +/// A written key always carries a value: `null` is rejected rather than +/// read as "not translated", so a broken export cannot pass silently. +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result, D::Error> { + T::deserialize(deserializer).map(Some) +} + +/// A checked translation for one plugin's declarations. +#[derive(Debug, Default)] +pub struct SettingsLocalization { + settings: BTreeMap, + sections: BTreeMap, + actions: BTreeMap, Option)>, +} + +#[derive(Debug, Default)] +struct CheckedSettingText { + title: Option, + description: Option, + placeholder: Option, + unit: Option, + options: BTreeMap, +} + +impl SettingsLocalization { + /// The declaration with its shown text translated. The section keeps its + /// manifest heading because rows are grouped by it; [`Self::section`] + /// translates the heading where it is shown. + pub fn declaration(&self, declaration: &SettingDeclaration) -> SettingDeclaration { + let mut shown = declaration.clone(); + let Some(text) = self.settings.get(&declaration.id) else { + return shown; + }; + if let Some(title) = &text.title { + shown.title.clone_from(title); + } + replace(&mut shown.description, &text.description); + replace(&mut shown.placeholder, &text.placeholder); + replace(&mut shown.unit, &text.unit); + for option in &mut shown.options { + if let Some(title) = text.options.get(&option.value) { + option.title.clone_from(title); + } + } + shown + } + + /// The action with its shown text translated. + pub fn action(&self, action: &SettingAction) -> SettingAction { + let mut shown = action.clone(); + if let Some((title, description)) = self.actions.get(&action.id) { + if let Some(title) = title { + shown.title.clone_from(title); + } + replace(&mut shown.description, description); + } + shown + } + + /// The heading shown for the manifest section heading `title`. + pub fn section(&self, title: &str) -> String { + self.sections + .get(title) + .cloned() + .unwrap_or_else(|| title.to_owned()) + } +} + +/// The heading shown for a section grouped under the manifest heading `title`. +pub(super) fn shown_section(localization: Option<&SettingsLocalization>, title: String) -> String { + match localization { + Some(localization) => localization.section(&title), + None => title, + } +} + +fn replace(field: &mut Option, translated: &Option) { + if field.is_some() { + if let Some(text) = translated { + *field = Some(text.clone()); + } + } +} + +/// The translation file paths for `language`, most specific first: the tag, +/// then each shorter tag left after removing its last subtag +/// (`zh-Hans-CN`, `zh-Hans`, `zh`). +pub fn localization_candidates(language: &str) -> Vec { + let mut tag = language; + let mut candidates = vec![format!("{LOCALIZATION_DIRECTORY}/{tag}.json")]; + while let Some((shorter, _)) = tag.rsplit_once('-') { + tag = shorter; + candidates.push(format!("{LOCALIZATION_DIRECTORY}/{tag}.json")); + } + candidates +} + +/// A host's checked request for the translation of one interface language. +#[derive(Debug)] +pub struct LocalizationRequest<'a> { + language: &'a str, + candidates: Vec, + /// The candidates the native adapter found, by path; `None` until it has + /// read them. + files: Option<&'a BTreeMap>, +} + +impl<'a> LocalizationRequest<'a> { + /// Checks what the host sent: `Ok(None)` when it names no language, and + /// `Err` with the reason when the request is malformed, which is a host + /// fault rather than a plugin rejection. + pub fn new( + language: Option<&'a str>, + files: Option<&'a BTreeMap>, + ) -> Result, String> { + let Some(language) = language else { + return match files { + Some(_) => Err("localizations require language.".to_owned()), + None => Ok(None), + }; + }; + if !is_language_tag(language) { + return Err("language must be a tag such as zh-Hans or en.".to_owned()); + } + let candidates = localization_candidates(language); + if let Some(path) = files + .into_iter() + .flat_map(BTreeMap::keys) + .find(|path| !candidates.contains(path)) + { + return Err(format!( + "localizations names {path}, which is not a candidate for {language}." + )); + } + Ok(Some(Self { + language, + candidates, + files, + })) + } + + /// Chooses the translation and reports the choice. The most specific valid + /// file wins; every invalid file is reported with its rejection and never + /// applied in part. Before the adapter has read the candidates the report + /// asks it to, and nothing is translated. + pub fn select( + &self, + declarations: &[SettingDeclaration], + actions: &[SettingAction], + ) -> (Option, Value) { + let mut chosen = None; + let mut applied = Value::Null; + let mut rejected = Vec::new(); + for path in &self.candidates { + let Some(text) = self.files.and_then(|files| files.get(path)) else { + continue; + }; + match decode(text, declarations, actions) { + Ok(localization) if chosen.is_none() => { + chosen = Some(localization); + applied = json!(path); + } + Ok(_) => {} + Err(error) => rejected.push(json!({"path": path, "error": error})), + } + } + let report = json!({ + "language": self.language, + "candidates": self.candidates, + "maximumBytes": MAX_LOCALIZATION_BYTES, + "readRequired": self.files.is_none(), + "applied": applied, + "rejected": rejected, + }); + (chosen, report) + } +} + +fn rejection(field: impl Into, message: impl Into) -> PluginRejection { + PluginRejection::at(INVALID_LOCALIZATION, field, message) +} + +/// A rejection of the file as a whole. +fn file_rejection(message: impl Into) -> PluginRejection { + PluginRejection::new(INVALID_LOCALIZATION, message) +} + +/// Parses and checks one translation file against the declarations it +/// translates. +fn decode( + text: &str, + declarations: &[SettingDeclaration], + actions: &[SettingAction], +) -> Result { + if text.len() > MAX_LOCALIZATION_BYTES { + return Err(file_rejection("The translation file is larger than 1 MiB.")); + } + let checked = parse_unique_json(text) + .map_err(|_| file_rejection("The translation file is not JSON with unique keys."))?; + if !checked.is_object() { + return Err(file_rejection("The translation file is not a JSON object.")); + } + let file: LocalizationFile = serde_json::from_value(checked).map_err(|error| { + file_rejection(format!( + "The translation file does not match the l10n format: {error}." + )) + })?; + let mut localization = SettingsLocalization::default(); + for (id, text) in file.settings.0 { + let field = format!("settings.{id}"); + let declaration = declarations + .iter() + .find(|declaration| declaration.id == id) + .ok_or_else(|| rejection(&field, "The plugin declares no setting with this ID."))?; + localization + .settings + .insert(id, check_setting(text, declaration, &field)?); + } + let headings: BTreeSet<&str> = declarations + .iter() + .filter_map(|declaration| declaration.section.as_deref()) + .chain( + actions + .iter() + .filter_map(|action| action.section.as_deref()), + ) + .collect(); + for (heading, text) in file.sections.0 { + let field = format!("sections.{heading}"); + if !headings.contains(heading.as_str()) { + return Err(rejection( + &field, + "No setting or action uses this section heading.", + )); + } + label(&text, &field)?; + localization.sections.insert(heading, text); + } + for (id, text) in file.setting_actions.0 { + let field = format!("settingActions.{id}"); + let action = actions + .iter() + .find(|action| action.id == id) + .ok_or_else(|| rejection(&field, "The plugin declares no action with this ID."))?; + if let Some(title) = &text.title { + label(title, &format!("{field}.title"))?; + } + let description = translated( + text.description, + action.description.is_some(), + &format!("{field}.description"), + description_text, + )?; + localization.actions.insert(id, (text.title, description)); + } + Ok(localization) +} + +fn check_setting( + text: SettingText, + declaration: &SettingDeclaration, + field: &str, +) -> Result { + if let Some(title) = &text.title { + label(title, &format!("{field}.title"))?; + } + let mut checked = CheckedSettingText { + title: text.title, + description: translated( + text.description, + declaration.description.is_some(), + &format!("{field}.description"), + description_text, + )?, + placeholder: translated( + text.placeholder, + declaration.placeholder.is_some(), + &format!("{field}.placeholder"), + label, + )?, + unit: translated( + text.unit, + declaration.unit.is_some(), + &format!("{field}.unit"), + label, + )?, + options: BTreeMap::new(), + }; + if let Some(options) = text.options { + if !matches!( + declaration.kind.as_str(), + SETTING_ENUM | SETTING_MULTI_SELECT + ) { + return Err(rejection( + format!("{field}.options"), + "Only enum and multiSelect settings have options to translate.", + )); + } + for (value, title) in options.0 { + let option_field = format!("{field}.options.{value}"); + if !declaration.offers(&value) { + return Err(rejection( + &option_field, + "The setting declares no option with this value.", + )); + } + label(&title, &option_field)?; + checked.options.insert(value, title); + } + } + Ok(checked) +} + +/// A translation of a field the manifest leaves out would show text the +/// plugin never declared, so it is rejected. +fn translated( + text: Option, + declared: bool, + field: &str, + check: fn(&str, &str) -> Result<(), PluginRejection>, +) -> Result, PluginRejection> { + let Some(text) = text else { + return Ok(None); + }; + if !declared { + return Err(rejection( + field, + "The manifest has no text here to translate.", + )); + } + check(&text, field)?; + Ok(Some(text)) +} + +/// Titles, headings, units, placeholders and option titles follow the +/// manifest's length rule. +fn label(text: &str, field: &str) -> Result<(), PluginRejection> { + check_label(text, field, "A translation is short, non-empty text.") + .map_err(|error| rejection(field, error.message)) +} + +fn description_text(text: &str, field: &str) -> Result<(), PluginRejection> { + if text.trim().is_empty() || text.chars().count() > MAX_SETTING_TEXT * 4 { + return Err(rejection( + field, + "A translated description is non-empty and at most 480 characters.", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn declarations() -> Vec { + serde_json::from_value(json!([ + {"id":"checks","type":"multiSelect","title":"Checks","default":[],"section":"Server", + "options":[{"value":"format","title":"Format"},{"value":"credo","title":"Credo"}]}, + {"id":"mixEnv","type":"text","title":"MIX_ENV","description":"Environment.","default":"dev", + "pattern":"[a-z]+","placeholder":"dev","section":"Server"}, + {"id":"dialyzer","type":"boolean","title":"Dialyzer","default":false} + ])) + .expect("declarations") + } + + fn actions() -> Vec { + serde_json::from_value(json!([ + {"id":"rebuildPlt","title":"Rebuild PLT","function":"dev.lithe.fixture/rebuild","confirm":true} + ])) + .expect("actions") + } + + fn select_text( + language: Option<&str>, + files: Option<&BTreeMap>, + ) -> (Option, Value) { + LocalizationRequest::new(language, files) + .expect("well-formed request") + .map_or((None, Value::Null), |request| { + request.select(&declarations(), &actions()) + }) + } + + fn select(language: &str, files: &[(&str, Value)]) -> (Option, Value) { + let files = files + .iter() + .map(|(path, file)| (path.to_string(), file.to_string())) + .collect::>(); + select_text(Some(language), Some(&files)) + } + + #[test] + fn candidates_drop_one_subtag_at_a_time() { + assert_eq!( + localization_candidates("zh-Hans-CN"), + ["l10n/zh-Hans-CN.json", "l10n/zh-Hans.json", "l10n/zh.json"] + ); + assert_eq!(localization_candidates("en"), ["l10n/en.json"]); + } + + #[test] + fn translates_only_declared_text_and_keeps_the_rest() { + let (localization, report) = select( + "zh-Hans", + &[( + "l10n/zh-Hans.json", + json!({ + "settings": { + "checks": {"title": "检查", "options": {"credo": "Credo 检查"}}, + "mixEnv": {"description": "环境。", "placeholder": "开发"} + }, + "sections": {"Server": "服务器"}, + "settingActions": {"rebuildPlt": {"title": "重建 PLT"}}, + "commands": {"later": "ignored"} + }), + )], + ); + let localization = localization.expect("applied"); + assert_eq!(report["applied"], "l10n/zh-Hans.json"); + let declarations = declarations(); + let checks = serde_json::to_value(localization.declaration(&declarations[0])).unwrap(); + assert_eq!(checks["title"], "检查"); + assert_eq!(checks["options"][0]["title"], "Format"); + assert_eq!(checks["options"][1]["title"], "Credo 检查"); + assert_eq!(checks["section"], "Server"); + let mix_env = serde_json::to_value(localization.declaration(&declarations[1])).unwrap(); + assert_eq!(mix_env["title"], "MIX_ENV"); + assert_eq!(mix_env["placeholder"], "开发"); + assert_eq!(localization.section("Server"), "服务器"); + assert_eq!(localization.section("Other"), "Other"); + let action = serde_json::to_value(localization.action(&actions()[0])).unwrap(); + assert_eq!(action["title"], "重建 PLT"); + } + + #[test] + fn the_most_specific_valid_file_wins_and_invalid_files_are_reported() { + let (localization, report) = select( + "zh-Hans-CN", + &[ + ( + "l10n/zh-Hans-CN.json", + json!({"settings": {"missing": {"title": "无"}}}), + ), + ( + "l10n/zh-Hans.json", + json!({"settings": {"dialyzer": {"title": "简体"}}}), + ), + ( + "l10n/zh.json", + json!({"settings": {"dialyzer": {"title": "中文"}}}), + ), + ], + ); + let shown = localization + .expect("fallback") + .declaration(&declarations()[2]); + assert_eq!(serde_json::to_value(shown).unwrap()["title"], "简体"); + assert_eq!(report["applied"], "l10n/zh-Hans.json"); + assert_eq!(report["rejected"][0]["path"], "l10n/zh-Hans-CN.json"); + assert_eq!(report["rejected"][0]["error"]["code"], INVALID_LOCALIZATION); + assert_eq!(report["rejected"][0]["error"]["field"], "settings.missing"); + } + + #[test] + fn rejects_files_that_do_more_than_translate() { + for (file, field) in [ + ( + json!({"settings": {"dialyzer": {"description": "无原文"}}}), + "settings.dialyzer.description", + ), + ( + json!({"settings": {"dialyzer": {"options": {}}}}), + "settings.dialyzer.options", + ), + ( + json!({"settings": {"checks": {"options": {"lint": "检查"}}}}), + "settings.checks.options.lint", + ), + ( + json!({"settings": {"checks": {"title": " "}}}), + "settings.checks.title", + ), + ( + json!({"settings": {"checks": {"title": "x".repeat(121)}}}), + "settings.checks.title", + ), + (json!({"sections": {"Client": "客户端"}}), "sections.Client"), + ( + json!({"settingActions": {"rebuildPlt": {"description": "无原文"}}}), + "settingActions.rebuildPlt.description", + ), + ( + json!({"settingActions": {"clean": {"title": "清理"}}}), + "settingActions.clean", + ), + (json!({"settings": {"checks": {"title": null}}}), ""), + (json!({"settings": {"checks": {"label": "检查"}}}), ""), + (json!(["not", "an", "object"]), ""), + ] { + let (localization, report) = select("zh", &[("l10n/zh.json", file.clone())]); + assert!(localization.is_none(), "{file}"); + assert_eq!(report["applied"], Value::Null); + assert_eq!( + report["rejected"][0]["error"]["field"] + .as_str() + .unwrap_or(""), + field, + "{file}" + ); + } + } + + #[test] + fn rejects_duplicate_keys_and_oversized_files() { + let duplicate = r#"{"sections":{"Server":"一","Server":"二"}}"#.to_owned(); + let oversized = format!(r#"{{"padding":"{}"}}"#, "x".repeat(MAX_LOCALIZATION_BYTES)); + for text in [duplicate, oversized] { + let files = BTreeMap::from([("l10n/zh.json".to_owned(), text)]); + let (localization, report) = select_text(Some("zh"), Some(&files)); + assert!(localization.is_none()); + assert_eq!(report["rejected"][0]["error"]["code"], INVALID_LOCALIZATION); + } + } + + #[test] + fn reports_what_to_read_and_rejects_malformed_requests() { + let (localization, report) = select_text(Some("zh-Hans"), None); + assert!(localization.is_none()); + assert_eq!(report["readRequired"], true); + assert_eq!( + report["candidates"], + json!(["l10n/zh-Hans.json", "l10n/zh.json"]) + ); + let (_, read) = select("zh-Hans", &[]); + assert_eq!(read["readRequired"], false); + assert_eq!(read["applied"], Value::Null); + assert_eq!(select_text(None, None).1, Value::Null); + let files = BTreeMap::from([("l10n/en.json".to_owned(), "{}".to_owned())]); + for (language, files) in [ + (Some("zh_Hans"), None), + (Some("zh-Hans"), Some(&files)), + (None, Some(&files)), + ] { + assert!(LocalizationRequest::new(language, files).is_err()); + } + } +} diff --git a/rust/crates/plugin-model/src/settings/pages.rs b/rust/crates/plugin-model/src/settings/pages.rs index ab91286a..a8cd4a7c 100644 --- a/rust/crates/plugin-model/src/settings/pages.rs +++ b/rust/crates/plugin-model/src/settings/pages.rs @@ -1,6 +1,6 @@ //! Placement projections retain indices into the complete owner-bound editor. -use super::{SettingAction, SettingDeclaration, SettingPage}; +use super::{SettingAction, SettingDeclaration, SettingPage, SettingsLocalization}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -9,7 +9,13 @@ type SectionIndices = (Vec, Vec); /// Groups only explicitly placed rows and actions. Unplaced declarations remain /// on the plugin's own page; an action alone is sufficient to create a page. -pub fn page_sections(rows: &[SettingDeclaration], actions: &[SettingAction]) -> Vec { +/// Sections are grouped and ordered by their manifest heading; `localization` +/// changes only the heading shown. +pub fn page_sections( + rows: &[SettingDeclaration], + actions: &[SettingAction], + localization: Option<&SettingsLocalization>, +) -> Vec { let mut pages: BTreeMap> = BTreeMap::new(); for (index, row) in rows.iter().enumerate() { if let Some(page) = &row.page { @@ -38,6 +44,9 @@ pub fn page_sections(rows: &[SettingDeclaration], actions: &[SettingAction]) -> SettingPage::Language(id) => ("language", id), SettingPage::BuildSystem(id) => ("buildSystem", id), }; - json!({"key":format!("{kind}:{id}"),"kind":kind,"id":id,"sections":sections.into_iter().map(|(title,(rows,actions))| json!({"title":title,"rows":rows,"actions":actions})).collect::>()}) + let sections: Vec<_> = sections.into_iter().map(|(title, (rows, actions))| { + json!({"title":super::localization::shown_section(localization, title),"rows":rows,"actions":actions}) + }).collect(); + json!({"key":format!("{kind}:{id}"),"kind":kind,"id":id,"sections":sections}) }).collect() } diff --git a/shared/plugins/README.md b/shared/plugins/README.md index d2d147f8..56f5e1ba 100644 --- a/shared/plugins/README.md +++ b/shared/plugins/README.md @@ -65,14 +65,15 @@ Lithe 读取并校验 `plugin.json`,然后自己启动其中声明的外部进 ### 在编辑器里校验 plugin.json -契约里的两份 JSON Schema 地址固定: +契约里的 JSON Schema 地址固定: | 文件 | Schema | | --- | --- | | `plugin.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json` | | `lithe-plugin-index.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json` | +| `l10n/<语言>.json`(如 `l10n/zh-Hans.json`) | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-l10n.schema.json` | -> **不要在 `plugin.json` 里写 `"$schema"`。** Lithe 会拒绝清单中的未知字段,加了以后插件反而装不上。请在编辑器里按文件名关联 Schema。 +> **不要在 `plugin.json` 里写 `"$schema"`。** Lithe 会拒绝清单中的未知字段,加了以后插件反而装不上。请在编辑器里按文件名关联 Schema。翻译文件 `l10n/*.json` 可以写 `"$schema"`,Lithe 会忽略它。 VS Code:在插件仓库的 `.vscode/settings.json` 中加入 @@ -86,12 +87,16 @@ VS Code:在插件仓库的 `.vscode/settings.json` 中加入 { "fileMatch": ["**/lithe-plugin-index.json"], "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json" + }, + { + "fileMatch": ["**/l10n/*.json"], + "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-l10n.schema.json" } ] } ``` -JetBrains IDE:设置 → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings,添加上面的 URL,文件模式填 `plugin.json`。 +JetBrains IDE:设置 → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings,按上表添加每个 URL 和它的文件模式,例如 `plugin.json`。 Schema 只检查 JSON 结构。ID 引用、权限、激活事件、模板变量这类规则由 Lithe 在安装时检查,规则写在[插件契约](#插件契约)里。 @@ -166,6 +171,7 @@ SDK 的源码都在本目录的 `sdk/` 下,发布在 [Core 仓库的 Releases] | [`plugin-manifest.schema.json`](contract/v1/plugin-manifest.schema.json) | `plugin.json` 的 JSON Schema | | [`plugin-index.schema.json`](contract/v1/plugin-index.schema.json) | `lithe-plugin-index.json` 的 JSON Schema | | [`native-ui.schema.json`](contract/v1/native-ui.schema.json) | `ui` 能力里宿主渲染的原生内容的 JSON Schema | +| [`plugin-l10n.schema.json`](contract/v1/plugin-l10n.schema.json) | 插件翻译文件 `l10n/<语言>.json` 的 JSON Schema | | [`toolchains/`](contract/v1/toolchains/README.md) | 插件提供的工具链:工具定义、版本管理器提供方、提供方后端协商(`litheToolchains`)、查询与回复校验(含稳定失败码)、固定值(pin)和四层解析。Core 实现这些命令;Lithe 桌面端声明 `toolDefinitions`,未声明 `toolchainProviders` | | [`fixtures/`](contract/v1/fixtures) | 标准用例,按功能分目录(`manifest`、`languages`、`backend`、`build-systems`、`build-providers`、`marketplace` 等),和 [reference 功能页](reference/README.md#功能页)一一对应:合法和非法的清单、命令和构建规划、插件函数协议、市场索引,以及 Lithe 对每个用例的预期结果 | @@ -269,14 +275,15 @@ A capability may get a new version later. One change adds a new version to exact ### Validate plugin.json in your editor -The contract's two JSON Schemas have fixed addresses: +The contract's JSON Schemas have fixed addresses: | File | Schema | | --- | --- | | `plugin.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-manifest.schema.json` | | `lithe-plugin-index.json` | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json` | +| `l10n/.json` (such as `l10n/zh-Hans.json`) | `https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-l10n.schema.json` | -> **Do not put `"$schema"` in `plugin.json`.** Lithe refuses unknown manifest fields, so adding it makes the plugin uninstallable. Associate the schema by file name in your editor instead. +> **Do not put `"$schema"` in `plugin.json`.** Lithe refuses unknown manifest fields, so adding it makes the plugin uninstallable. Associate the schema by file name in your editor instead. Translation files `l10n/*.json` may contain `"$schema"`; Lithe ignores it. VS Code: add this to `.vscode/settings.json` in your plugin repository: @@ -290,12 +297,16 @@ VS Code: add this to `.vscode/settings.json` in your plugin repository: { "fileMatch": ["**/lithe-plugin-index.json"], "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-index.schema.json" + }, + { + "fileMatch": ["**/l10n/*.json"], + "url": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-l10n.schema.json" } ] } ``` -JetBrains IDEs: Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings; add the URL above with the file pattern `plugin.json`. +JetBrains IDEs: Settings → Languages & Frameworks → Schemas and DTDs → JSON Schema Mappings; add each URL from the table above with its file pattern, such as `plugin.json`. A schema checks only JSON structure. Rules such as ID references, permissions, activation events, and template variables are checked by Lithe at install time and are written in the [plugin contract](#plugin-contract). @@ -370,6 +381,7 @@ The plugin contract defines the meaning, validation rules, and error codes of ev | [`plugin-manifest.schema.json`](contract/v1/plugin-manifest.schema.json) | JSON Schema for `plugin.json` | | [`plugin-index.schema.json`](contract/v1/plugin-index.schema.json) | JSON Schema for `lithe-plugin-index.json` | | [`native-ui.schema.json`](contract/v1/native-ui.schema.json) | JSON Schema for the host-rendered native content of the `ui` capability | +| [`plugin-l10n.schema.json`](contract/v1/plugin-l10n.schema.json) | JSON Schema for a plugin's translation file `l10n/.json` | | [`toolchains/`](contract/v1/toolchains/README.md) | Plugin-owned toolchains: tool definitions, version manager providers, provider backend negotiation (`litheToolchains`), query and reply validation (with stable failure codes), pins, and four-layer resolution. Core implements these commands; the Lithe desktop client declares `toolDefinitions` and does not declare `toolchainProviders` | | [`fixtures/`](contract/v1/fixtures) | Conformance cases in one directory per feature (`manifest`, `languages`, `backend`, `build-systems`, `build-providers`, `marketplace`, and so on), matching the [reference feature pages](reference/README.md#feature-pages): valid and invalid manifests, command and build plans, plugin function protocol, marketplace indexes, and Lithe's expected result for each case | diff --git a/shared/plugins/contract/v1/README.md b/shared/plugins/contract/v1/README.md index 70f6fee2..e25f3760 100644 --- a/shared/plugins/contract/v1/README.md +++ b/shared/plugins/contract/v1/README.md @@ -14,6 +14,7 @@ This directory is the cross-platform source of truth: | `plugin-manifest.schema.json` | JSON shape of `plugin.json` | | `native-ui.schema.json` | JSON shape of native UI documents rendered by the host | | `plugin-index.schema.json` | JSON shape of a marketplace source index | +| `plugin-l10n.schema.json` | JSON shape of a plugin's translation file `l10n/.json` | | `fixtures/` | Valid example manifests and golden cases, one folder per contract area (below). Manifests are named after the capability they exercise rather than a published plugin. Case files name their base manifest by file name, so file names are unique across the folders | | `toolchains/README.md` | Implemented Core foundation: plugin definitions/provider declarations, registry (a short tool ID defined by several enabled plugins belongs to none of them), reply validation, directory pins, snapshot resolution, version matching and discovery plans; hosts that declare `toolDefinitions` use it for read-only settings pages and for Build and run tools | @@ -24,7 +25,7 @@ This directory is the cross-platform source of truth: | `fixtures/backend/` | Plugin backends (`backend`, `backend-workspace`, `command-backend`, `terminal-backend`); cross-plugin calls (`plugin-functions-exporter`, `plugin-functions-caller`); `function-protocol.json` holds golden plugin-function negotiation, registration, invocation, outcome, progress, and refusal cases, `plugin-logging.json` the golden `litheLogging` negotiation and notifications | | `fixtures/editor/` | Editor actions served by a backend: `editor-actions.plugin.json`, icons, alternatives and click results in `editor-actions.json`; editor providers in `editor-providers.plugin.json` with registration and result cases in `editor-providers.json` | | `fixtures/ui/` | Native controls, forms, and panels, and a web panel | -| `fixtures/settings/` | Plugin settings | +| `fixtures/settings/` | Plugin settings; `l10n/zh-Hans.json` translates `settings.plugin.json`, and `localization-cases.json` holds golden translation choices and rejections | | `fixtures/build-systems/` | Build systems (`build-*`: static file, command output, instance options, model producer and consumer, tool path, toolchains); `build-plans.json` holds golden build-system plans and decoded outputs | | `fixtures/build-providers/` | Build providers (`build-provider*`) and the golden cases of their configuration, run options, run and debug preparation, execution, and test protocols | | `fixtures/debug-adapters/` | Debug adapters (standalone, served by a language server, remote attach, and `debug-adapter-features` with declared features and defaults), `debug-adapter-rejections.json`, and `debug-start.json`: what `debug.decodeBackendConfiguration` returns for each `debug/start` case | @@ -149,6 +150,7 @@ Java and Rust SDKs implement `litheKeymapImport` 0. / ├── plugin.json # this contract ├── lithe-plugin-signature.json # publisher signature over every other file +├── l10n/.json # optional translations (see Translations) └── … # resources referenced by plugin.json ``` @@ -531,7 +533,7 @@ unless the setting's [`effect`](#placement-scope-effect-and-page-buttons) says o | --- | --- | | `id` | Starts with a lowercase letter, then letters and digits. Unique within the plugin. | | `type` | `integer` (requires `minimum` and `maximum`, optional `unit`), `enum` (requires one to sixteen `options`, each `{value, title}`; values contain only letters, digits, `.`, `_`, `-`), `boolean`, `multiSelect` (one to 32 `options`; the value is an array of distinct option values), `text` (requires `pattern`, matched against the whole value; optional `maxLength`, default 256 and at most 1024 Unicode scalars; optional `placeholder`), or `file` and `directory` (an absolute path of at most 1024 Unicode scalars, or empty for "not set"; optional `placeholder`). Text and paths never start with `-` and contain no control characters. | -| `title`, `description` | Text the host shows; the host localizes only its own chrome. | +| `title`, `description` | Text the host shows. Write it in English; the package may [translate](#translations) it for other interface languages. | | `default` | Value used until the user changes it. Its JSON type follows `type`, and it must be accepted by the declaration. | Because every value is a number, a boolean, or one of the declared option @@ -600,6 +602,57 @@ skipping a stored value the declaration no longer accepts. `plugins.declarative.resolveSettings` returns each effective value with its `source` (`project`, `global`, or `default`). +#### Translations + +A package may translate its setting text with `l10n/.json` files +([schema](plugin-l10n.schema.json)), where `` is a language tag such +as `zh-Hans` or `ja`. `plugin.json` remains the text for every language without +a translation. Fixture: `fixtures/settings/l10n/zh-Hans.json`, with golden cases +in `fixtures/settings/localization-cases.json`. + +```json +{ + "settings": { + "dialyzer": { "title": "Dialyzer 静态分析" }, + "checks": { "title": "检查项", "options": { "format": "格式检查" } } + }, + "sections": { "Server": "服务器" }, + "settingActions": { "rebuildPlt": { "title": "重建 PLT" } } +} +``` + +| Member | Translates | +| --- | --- | +| `settings.` | The setting's `title`, `description`, `placeholder`, and `unit`; `options.` is the title of an `enum` or `multiSelect` option | +| `sections.` | A section heading, named exactly as `section` writes it | +| `settingActions.` | The action's `title` and `description` | + +Every member is optional, and text a file leaves out stays as `plugin.json` +writes it. IDs, option values and their order, stored values, and the grouping +and order of sections are the same in every language. For the interface +language `zh-Hans-CN` the host reads `l10n/zh-Hans-CN.json`, `l10n/zh-Hans.json`, +and `l10n/zh.json`, and shows the first one Core accepts, so one `zh-Hans` file +serves every region. + +Core refuses a whole file, never part of it, with +`plugin.settings.invalidLocalization` when: + +- it is not a JSON object, is larger than 1 MiB, repeats a key, or holds `null`; +- an entry under `settings`, `sections`, or `settingActions` has a field this + format does not define; +- it names a setting, option value, heading, or action the manifest does not + declare, gives `options` to a setting without options, or translates a field + the manifest leaves out; +- a title, placeholder, unit, or heading is empty or longer than 120 + characters, or a description is empty or longer than 480. + +The host then tries the next candidate and finally shows the manifest text, so +a broken translation never hides a setting; it logs the refusal for the +plugin's author. Core ignores top-level members it does not know, so a later +host can read translations of more plugin text from the same files. +Translations need no capability or `requires` entry: a host that does not read +them shows the `plugin.json` text. + ### Execution providers `run` and `test` each name an `executable`: exactly one of a bare `command` @@ -3999,6 +4052,7 @@ command payload is a Core `invalid_request` failure instead. | `plugin.launch.unwritablePath` | Core | The answer writes outside the state, workspace state, log, and temporary directories | | `plugin.grammar.invalid` | Core | A language's grammar file is not a TextMate grammar Core accepts; `field` names the failing part | | `plugin.grammar.unavailable` | Core | The plugin declares no grammar for the language | +| `plugin.settings.invalidLocalization` | Core | A [translation file](#translations) does not fit its manifest or format; the host shows the next candidate or the manifest text | | `plugin.vcs.reserved` | Core | A VCS provider claims the ID of the built-in Git support | | `plugin.vcs.invalidOutput` | Core | A VCS provider's reply does not fit the [VCS provider contract](https://github.com/Lithe-IDEA/Lithe-core-rust/blob/main/rust/contracts/vcs-provider-v1.md) | | `plugin.index.invalid` | Core | Malformed index root or entry, or an invalid URL, checksum, platform, version, or date | diff --git a/shared/plugins/contract/v1/fixtures/settings/l10n/zh-Hans.json b/shared/plugins/contract/v1/fixtures/settings/l10n/zh-Hans.json new file mode 100644 index 00000000..0c9b90ff --- /dev/null +++ b/shared/plugins/contract/v1/fixtures/settings/l10n/zh-Hans.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-l10n.schema.json", + "settings": { + "dialyzer": { + "title": "Dialyzer 静态分析" + }, + "checks": { + "title": "检查项", + "options": { + "format": "格式检查" + } + } + }, + "sections": { + "Server": "服务器" + }, + "settingActions": { + "rebuildPlt": { + "title": "重建 PLT" + } + } +} diff --git a/shared/plugins/contract/v1/fixtures/settings/localization-cases.json b/shared/plugins/contract/v1/fixtures/settings/localization-cases.json new file mode 100644 index 00000000..c08f2e0c --- /dev/null +++ b/shared/plugins/contract/v1/fixtures/settings/localization-cases.json @@ -0,0 +1,101 @@ +{ + "schemaVersion": 0, + "manifest": "settings.plugin.json", + "description": "Translation files a host passes to plugins.declarative.settingsState as localizations, and the localization report every host must get back. l10n/zh-Hans.json next to this file is the packaged translation of the same manifest. A case names the interface language and the files the native adapter found; a file is a JSON value the host sends as its serialized text, or a string the host sends as is, for text that is not JSON or repeats a key. applied is the path whose text the state shows, or null; rejected lists every file Core did not apply, with the field of the first problem (null when the problem is the file as a whole). All rejections use plugin.settings.invalidLocalization.", + "cases": [ + { + "name": "a shorter tag serves a regional language", + "language": "zh-Hans-CN", + "files": { "l10n/zh-Hans.json": { "settings": { "dialyzer": { "title": "简体" } } } }, + "applied": "l10n/zh-Hans.json", + "rejected": [] + }, + { + "name": "an invalid specific file falls back to a valid shorter one", + "language": "zh-Hans", + "files": { + "l10n/zh-Hans.json": { "settings": { "dialyzer": { "description": "清单里没有说明" } } }, + "l10n/zh.json": { "settings": { "dialyzer": { "title": "中文" } } } + }, + "applied": "l10n/zh.json", + "rejected": [{ "path": "l10n/zh-Hans.json", "field": "settings.dialyzer.description" }] + }, + { + "name": "unknown setting", + "language": "zh", + "files": { "l10n/zh.json": { "settings": { "credo": { "title": "Credo" } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": "settings.credo" }] + }, + { + "name": "unknown option value", + "language": "zh", + "files": { "l10n/zh.json": { "settings": { "checks": { "options": { "lint": "检查" } } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": "settings.checks.options.lint" }] + }, + { + "name": "options of a setting without options", + "language": "zh", + "files": { "l10n/zh.json": { "settings": { "dialyzer": { "options": { "on": "开" } } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": "settings.dialyzer.options" }] + }, + { + "name": "unknown section heading", + "language": "zh", + "files": { "l10n/zh.json": { "sections": { "Client": "客户端" } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": "sections.Client" }] + }, + { + "name": "unknown action", + "language": "zh", + "files": { "l10n/zh.json": { "settingActions": { "clean": { "title": "清理" } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": "settingActions.clean" }] + }, + { + "name": "empty title", + "language": "zh", + "files": { "l10n/zh.json": { "settings": { "checks": { "title": " " } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": "settings.checks.title" }] + }, + { + "name": "unknown field inside a setting", + "language": "zh", + "files": { "l10n/zh.json": { "settings": { "checks": { "label": "检查项" } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": null }] + }, + { + "name": "null instead of text", + "language": "zh", + "files": { "l10n/zh.json": { "settings": { "checks": { "title": null } } } }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": null }] + }, + { + "name": "duplicate keys", + "language": "zh", + "files": { "l10n/zh.json": "{\"sections\":{\"Server\":\"一\",\"Server\":\"二\"}}" }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": null }] + }, + { + "name": "not JSON", + "language": "zh", + "files": { "l10n/zh.json": "settings: {}" }, + "applied": null, + "rejected": [{ "path": "l10n/zh.json", "field": null }] + }, + { + "name": "no file for the language", + "language": "fr", + "files": {}, + "applied": null, + "rejected": [] + } + ] +} diff --git a/shared/plugins/contract/v1/plugin-l10n.schema.json b/shared/plugins/contract/v1/plugin-l10n.schema.json new file mode 100644 index 00000000..771b708a --- /dev/null +++ b/shared/plugins/contract/v1/plugin-l10n.schema.json @@ -0,0 +1,71 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Lithe-IDEA/Lithe-core-rust/main/shared/plugins/contract/v1/plugin-l10n.schema.json", + "title": "Lithe plugin translation file (plugin contract v1)", + "description": "Shape of l10n/.json in a plugin package, such as l10n/zh-Hans.json: the plugin's own text in one interface language. Every member is optional, and text it leaves out stays as written in plugin.json. Rules that JSON Schema cannot express (every ID, option value and heading must exist in plugin.json, a field is translated only when plugin.json has it, no key repeats) are defined in README.md and enforced by plugins.declarative.settingsState, which reports a file that breaks one with plugin.settings.invalidLocalization and applies none of it. Core ignores top-level members it does not know, so a file that also translates text a later host supports stays usable on older hosts; this schema lists only the members Core reads today.", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { "description": "Editor hint only; Core ignores it.", "type": "string" }, + "settings": { + "description": "Text of settings[] entries, by setting ID.", + "type": "object", + "propertyNames": { "pattern": "^[a-z][A-Za-z0-9]*(?![\\s\\S])", "maxLength": 120 }, + "additionalProperties": { "$ref": "#/$defs/settingText" } + }, + "sections": { + "description": "Shown text of section headings, by the heading exactly as written in a setting's or action's section. Grouping and order still follow the original heading.", + "type": "object", + "propertyNames": { "minLength": 1, "maxLength": 120 }, + "additionalProperties": { "$ref": "#/$defs/label" } + }, + "settingActions": { + "description": "Text of settingActions[] entries, by action ID.", + "type": "object", + "propertyNames": { "pattern": "^[a-z][A-Za-z0-9]*(?![\\s\\S])", "maxLength": 120 }, + "additionalProperties": { "$ref": "#/$defs/actionText" } + } + }, + "$defs": { + "label": { + "description": "Short text: not only whitespace, at most 120 characters.", + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "\\S" + }, + "description": { + "description": "Not only whitespace, at most 480 characters.", + "type": "string", + "minLength": 1, + "maxLength": 480, + "pattern": "\\S" + }, + "settingText": { + "description": "Each member replaces the field of the same name; plugin.json must have that field.", + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "$ref": "#/$defs/label" }, + "description": { "$ref": "#/$defs/description" }, + "placeholder": { "$ref": "#/$defs/label" }, + "unit": { "$ref": "#/$defs/label" }, + "options": { + "description": "enum and multiSelect only: option titles, by option value. Values and their order stay as declared.", + "type": "object", + "propertyNames": { "pattern": "^[A-Za-z0-9._-]+(?![\\s\\S])", "maxLength": 120 }, + "additionalProperties": { "$ref": "#/$defs/label" } + } + } + }, + "actionText": { + "description": "Each member replaces the field of the same name; plugin.json must have that field.", + "type": "object", + "additionalProperties": false, + "properties": { + "title": { "$ref": "#/$defs/label" }, + "description": { "$ref": "#/$defs/description" } + } + } + } +} diff --git a/shared/plugins/reference/README.md b/shared/plugins/reference/README.md index c4887412..2e1ceb44 100644 --- a/shared/plugins/reference/README.md +++ b/shared/plugins/reference/README.md @@ -25,7 +25,7 @@ | [插件后端与插件函数](domains/backend.md) | 后端进程、函数协议、宿主方法、工作区上下文、文件、凭据、终端、跨插件调用 | API-01、04–08、20–22、24、35、37、40、41 | `backend`、`pluginFunctions` | | [编辑器、文档与预览](domains/editor.md) | 文档和选区、版本化编辑、打开与定位、HTML 预览、编辑器动作、编辑器语言功能 Provider、虚拟文档内容提供者 | API-09、10、12、15、16、25、27 | `backend` | | [界面](domains/ui.md) | 工具窗口、命令、包内网页、原生控件、后端命令、消息框 | API-02、03、13、14、17 | `ui` | -| [插件设置](domains/settings.md) | 声明式设置、放置位置、作用域、生效方式、设置页按钮 | API-19 | `settings` | +| [插件设置](domains/settings.md) | 声明式设置、放置位置、作用域、生效方式、设置页按钮、包内翻译 | API-19、API-37 | `settings` | | [声明式构建系统](domains/build-systems.md) | 构建检测、模型、命令规划、项目准备角色 | API-29 | `buildSystems` | | [构建提供者](domains/build-providers.md) | `litheBuild` 协议:同步、任务、测试、运行和调试准备、模型输入 | API-29 | `buildProviders`、`buildModelInputs` | | [调试适配器](domains/debug-adapters.md) | DAP 适配器声明、启动和传输 | API-30 | `debugAdapters` | @@ -71,7 +71,7 @@ This directory organizes the plugin contract by feature: what each numbered API | [Plugin backend and plugin functions](domains/backend.md) | Backend process, function protocol, host methods, workspace contexts, files, credentials, terminals, cross-plugin calls | API-01, 04–08, 20–22, 24, 35, 37, 40, 41 | `backend`, `pluginFunctions` | | [Editor, documents, and previews](domains/editor.md) | Documents and selections, versioned edits, open and reveal, HTML preview, editor actions, editor language-feature providers, virtual-document content providers | API-09, 10, 12, 15, 16, 25, 27 | `backend` | | [UI](domains/ui.md) | Tool windows, commands, package web content, native controls, backend commands, message boxes | API-02, 03, 13, 14, 17 | `ui` | -| [Plugin settings](domains/settings.md) | Declarative settings, placement, scope, apply behavior, settings page actions | API-19 | `settings` | +| [Plugin settings](domains/settings.md) | Declarative settings, placement, scope, apply behavior, settings page actions, packaged translations | API-19, API-37 | `settings` | | [Declarative build systems](domains/build-systems.md) | Build detection, models, command planning, project preparation roles | API-29 | `buildSystems` | | [Build providers](domains/build-providers.md) | The `litheBuild` protocol: sync, tasks, tests, run and debug preparation, model inputs | API-29 | `buildProviders`, `buildModelInputs` | | [Debug adapters](domains/debug-adapters.md) | DAP adapter declarations, launch, and transports | API-30 | `debugAdapters` | diff --git a/shared/plugins/reference/api-catalog.md b/shared/plugins/reference/api-catalog.md index efd35a78..a5708063 100644 --- a/shared/plugins/reference/api-catalog.md +++ b/shared/plugins/reference/api-catalog.md @@ -55,7 +55,7 @@ Lithe 的插件公共 API 按 [API 实施计划](https://github.com/1lck/Lithe-I | API-31 | VCS 提供者 | 既有能力 | [`vcsProviders`](../contract/v1/README.md#vcs-providers) | 不含内置 Git 的公共读取(见 API-42);Lithe 桌面端未声明 `vcsProviders` | [VCS](domains/vcs.md) | | API-33 | 项目生成器 | 既有能力 | [`projectGenerators`](../contract/v1/README.md#project-generators) | — | [项目生成器](domains/project-generators.md) | | API-35 | 插件间调用与依赖 | 部分完成 | 第一阶段:[`pluginFunctions`](../contract/v1/README.md#cross-plugin-calls)(清单 `exports`、`calls` 声明,不授予清单权限)与 [`functions/providers`、`functions/invoke`](../contract/v1/README.md#discovery-and-invocation):经 Core 中转,调用另一个已启用插件导出的函数;信任不传递,不是安装依赖(本机工作区) | 依赖与导出的安装闭包、版本求解、远程 Provider;在只于启动时注册后端插件的宿主上,新安装或更新的后端插件要重启 Lithe 后才会出现在 `providers` 里(见 [Hot activation](../contract/v1/README.md#hot-activation)) | [插件后端](domains/backend.md) | -| API-37 | 环境信息与语言包 | 部分完成 | `getCapabilities` 返回协议版本、可用方法、限制和后端运行位置(`backendAuthority`) | 语言包、环境和主题变化事件 | [插件后端](domains/backend.md) | +| API-37 | 环境信息与语言包 | 部分完成 | `getCapabilities` 返回协议版本、可用方法、限制和后端运行位置(`backendAuthority`);插件设置文字的[包内翻译 `l10n/<语言>.json`](../contract/v1/README.md#translations),按界面语言挑选,缺的回退到清单原文 | 设置以外插件文字的翻译、环境信息、语言和主题变化事件 | [插件后端](domains/backend.md)、[设置](domains/settings.md) | | API-40 | 工作区 authority | 部分完成 | 工作区上下文带 `local` 或 `remote`;文件和编辑器方法对远程上下文明确返回 `plugin.host.authorityUnsupported` | 远程执行 | [插件后端](domains/backend.md) | | API-41 | 激活事件与启动预算 | 部分完成 | [`onWorkspaceOpen`](../contract/v1/README.md#permissions-and-activation)、`onCommand`、后台两路并发启动、`initialize` 超时、有界通知队列 | 按工作区内容匹配激活、全局冷启动预算 | [插件后端](domains/backend.md) | @@ -227,7 +227,7 @@ Status meanings: | API-31 | VCS providers | Existing | [`vcsProviders`](../contract/v1/README.md#vcs-providers) | Public reads of built-in Git are not included (see API-42); the Lithe desktop client does not declare `vcsProviders` | [VCS](domains/vcs.md) | | API-33 | Project generators | Existing | [`projectGenerators`](../contract/v1/README.md#project-generators) | — | [Project generators](domains/project-generators.md) | | API-35 | Cross-plugin calls and dependencies | Partial | Phase one: [`pluginFunctions`](../contract/v1/README.md#cross-plugin-calls) (`exports` and `calls` in the manifest, granting no manifest permission) and [`functions/providers` and `functions/invoke`](../contract/v1/README.md#discovery-and-invocation): Core relays a call to a function another enabled plugin exports; trust is not transitive and this is not an install dependency (local workspaces) | The install closure of dependencies and exports, version solving, remote providers; on a host that registers backend plugins only when it launches, a newly installed or updated backend plugin appears in `providers` only after Lithe restarts (see [Hot activation](../contract/v1/README.md#hot-activation)) | [Plugin backend](domains/backend.md) | -| API-37 | Environment info and l10n | Partial | `getCapabilities` returns the protocol version, usable methods, limits, and where the backend runs (`backendAuthority`) | Language packs, environment and theme change events | [Plugin backend](domains/backend.md) | +| API-37 | Environment info and l10n | Partial | `getCapabilities` returns the protocol version, usable methods, limits, and where the backend runs (`backendAuthority`); [packaged translations `l10n/.json`](../contract/v1/README.md#translations) of plugin settings text, chosen by interface language and falling back to the manifest text | Translations of plugin text beyond settings, environment info, language and theme change events | [Plugin backend](domains/backend.md), [Settings](domains/settings.md) | | API-40 | Workspace authority | Partial | Workspace contexts carry `local` or `remote`; file and editor methods refuse a remote context with `plugin.host.authorityUnsupported` | Remote execution | [Plugin backend](domains/backend.md) | | API-41 | Activation events and startup budget | Partial | [`onWorkspaceOpen`](../contract/v1/README.md#permissions-and-activation), `onCommand`, two concurrent background starts, `initialize` timeout, bounded notification queues | Activation by workspace content, a global cold-start budget | [Plugin backend](domains/backend.md) | diff --git a/shared/plugins/reference/domains/settings.md b/shared/plugins/reference/domains/settings.md index 00a0ff74..06414749 100644 --- a/shared/plugins/reference/domains/settings.md +++ b/shared/plugins/reference/domains/settings.md @@ -11,10 +11,10 @@ | 清单字段 | `settings`、`settingActions` | | 能力 | `settings`,版本 `0`。`workspaceConfiguration` 的展开和推送见[语言](languages.md) | | 协议 | 没有专用协议;`settingActions` 调用的是插件自己的[插件函数](backend.md) | -| API 编号 | [API-19](../api-catalog.md#现在能用的-api):声明式设置。运行期读取、查看来源、修改设置和变化事件仍在计划中 | +| API 编号 | [API-19](../api-catalog.md#现在能用的-api):声明式设置。运行期读取、查看来源、修改设置和变化事件仍在计划中。[API-37](../api-catalog.md#现在能用的-api):设置文字的包内翻译 | | 权限 | 不需要额外权限。设置不会改变运行的程序,所以不进入安装审查的程序列表 | | 激活事件 | 不需要。值按设置的 `effect` 在下一次启动、模型重载、下一次运行或 `immediate` 推送时生效 | -| 桌面端 | 已声明 `settings`:按语言和构建系统分页、分组、全部控件类型、项目层、生效方式和页面按钮都已呈现 | +| 桌面端 | 已声明 `settings`:按语言和构建系统分页、分组、全部控件类型、项目层、生效方式和页面按钮都已呈现。还没有读取包内翻译 `l10n/`,始终显示清单原文 | ### 设置类型 @@ -27,7 +27,7 @@ | `text` | `pattern`(必填)、`maxLength`(可选)、`placeholder`(可选) | `pattern` 必须匹配整个值;`maxLength` 默认 256,最多 1024 个 Unicode 标量 | | `file`、`directory` | `placeholder`(可选) | 绝对路径,最多 1024 个 Unicode 标量;空值表示“未设置” | -所有类型的 `title`、`description` 都是宿主显示的文字。`text`、`file` 和 `directory` 的值不以 `-` 开头,也不含控制字符。`default` 的 JSON 类型必须符合 `type`,并且被这个声明接受。 +所有类型的 `title`、`description` 都是宿主显示的文字,用英文写;其他界面语言用包内翻译文件提供(见[翻译](#翻译))。`text`、`file` 和 `directory` 的值不以 `-` 开头,也不含控制字符。`default` 的 JSON 类型必须符合 `type`,并且被这个声明接受。 ```json "settings": [ @@ -78,12 +78,40 @@ `plugins.declarative.resolveSettings` 返回每个生效值及其 `source`(`project`、`global` 或 `default`)。 +### 翻译 + +插件包里的 `l10n/<语言>.json`(如 `l10n/zh-Hans.json`,[schema](../../contract/v1/plugin-l10n.schema.json))把设置文字翻译成别的界面语言,按 ID 对应清单里的条目。没有翻译的语言,以及文件里没写到的文字,都显示 `plugin.json` 的原文。 + +```json +{ + "settings": { + "dialyzer": { "title": "Dialyzer 静态分析" }, + "checks": { "title": "检查项", "options": { "format": "格式检查" } } + }, + "sections": { "Server": "服务器" }, + "settingActions": { "rebuildPlt": { "title": "重建 PLT" } } +} +``` + +| 成员 | 翻译的内容 | +| --- | --- | +| `settings.` | 设置的 `title`、`description`、`placeholder`、`unit`;`options.<值>` 是 `enum`、`multiSelect` 选项的标题 | +| `sections.<标题>` | 分组标题,键与 `section` 里写的原文完全一致 | +| `settingActions.` | 按钮的 `title`、`description` | + +- ID、选项值和顺序、保存的值、分组和分组顺序在所有语言下都不变,翻译只换显示的文字。 +- 界面语言是 `zh-Hans-CN` 时,依次找 `l10n/zh-Hans-CN.json`、`l10n/zh-Hans.json`、`l10n/zh.json`,显示第一个通过检查的文件,所以一份 `zh-Hans` 就能覆盖所有地区。 +- 一个文件有任何问题都整份不用,并以 `plugin.settings.invalidLocalization` 记进宿主日志,页面改用下一个候选或清单原文,不会因为翻译坏了而看不到设置。问题包括:不是 JSON 对象、超过 1 MiB、键重复、出现 `null`;条目里有格式没定义的字段;写了清单没有的设置、选项值、分组或按钮;翻译了清单没写的字段,或给没有选项的设置写 `options`;标题、占位符、单位、分组为空或超过 120 个字符,说明为空或超过 480 个字符。 +- 不需要声明能力或 `requires`;不读翻译的宿主照常显示清单原文。Core 忽略不认识的顶层成员,以后翻译更多插件文字时沿用同一份文件。 +- 翻译文件可以写 `"$schema"`,方便编辑器校验。 + ### 契约章节 | 章节 | 内容 | | --- | --- | | [Plugin settings](../../contract/v1/README.md#plugin-settings) | 设置的字段、类型、模板 `${setting:}` 和使用规则 | | [Placement, scope, effect, and page buttons](../../contract/v1/README.md#placement-scope-effect-and-page-buttons) | 放置位置、层级、生效时机、页面按钮和校验规则 | +| [Translations](../../contract/v1/README.md#translations) | 包内翻译文件的格式、语言候选顺序和拒绝规则 | | [Workspace configuration](../../contract/v1/README.md#workspace-configuration) | `workspaceConfiguration` 的展开和推送 | | [Executable resolution](../../contract/v1/README.md#executable-resolution) | `setting` 类型的可执行文件候选:引用全局 `file` 设置选择工具路径、候选顺序和严格选择 | | [Launch templates](../../contract/v1/README.md#launch-templates) | 设置值在模板里的替换规则 | @@ -93,6 +121,8 @@ | 文件 | 内容 | | --- | --- | | [`settings/settings.plugin.json`](../../contract/v1/fixtures/settings/settings.plugin.json) | 放在 Elixir 语言页和 Mix 构建系统页上的设置、`immediate` 设置、`settingActions` 按钮 | +| [`settings/l10n/zh-Hans.json`](../../contract/v1/fixtures/settings/l10n/zh-Hans.json) | 上面这份清单的简体中文翻译:设置标题、选项标题、分组标题和按钮 | +| [`settings/localization-cases.json`](../../contract/v1/fixtures/settings/localization-cases.json) | 13 个翻译用例:按语言挑选文件、坏文件回退,以及每种拒绝和它的字段 | | [`languages/node-runtime.plugin.json`](../../contract/v1/fixtures/languages/node-runtime.plugin.json) | 带插件设置的语言服务器(`node` 运行时) | | [`build-systems/build-tool-path.plugin.json`](../../contract/v1/fixtures/build-systems/build-tool-path.plugin.json) | 用 `file` 设置选择构建工具的可执行文件候选 | | [`debug-adapters/debug-adapter.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) | 调试适配器用 `file` 设置(`nextRun`)选择要调试的程序 | @@ -115,10 +145,10 @@ A plugin declares, in its manifest, the options a user may change. The host draw | Manifest fields | `settings`, `settingActions` | | Capability | `settings`, version `0`. Expanding and pushing `workspaceConfiguration` is described in [Languages](languages.md) | | Protocol | None of its own; `settingActions` call the plugin's own [plugin functions](backend.md) | -| API IDs | [API-19](../api-catalog.md#apis-usable-now): declarative settings. Runtime reads, source inspection, writes, and change events are still planned | +| API IDs | [API-19](../api-catalog.md#apis-usable-now): declarative settings. Runtime reads, source inspection, writes, and change events are still planned. [API-37](../api-catalog.md#apis-usable-now): packaged translations of settings text | | Permissions | None. Settings never change which program runs, so they are not in the install review's program list | | Activation | Not needed. Values take effect according to the setting's `effect`: at the next start, a model reload, the next run, or an `immediate` push | -| Desktop | Declares `settings`: pages per language and build system, sections, every control type, the project layer, effects, and page buttons are presented | +| Desktop | Declares `settings`: pages per language and build system, sections, every control type, the project layer, effects, and page buttons are presented. It does not read packaged translations `l10n/` yet and always shows the manifest text | ### Setting types @@ -131,7 +161,7 @@ A plugin declares, in its manifest, the options a user may change. The host draw | `text` | `pattern` (required), `maxLength` (optional), `placeholder` (optional) | `pattern` matches the whole value; `maxLength` defaults to 256 and is at most 1024 Unicode scalars | | `file`, `directory` | `placeholder` (optional) | An absolute path of at most 1024 Unicode scalars, or empty for "not set" | -`title` and `description` are text the host shows. Text and paths never start with `-` and contain no control characters. `default` must have the JSON type of `type` and must be accepted by the declaration. +`title` and `description` are text the host shows; write them in English and translate them for other interface languages in the package (see [Translations](#translations)). Text and paths never start with `-` and contain no control characters. `default` must have the JSON type of `type` and must be accepted by the declaration. ```json "settings": [ @@ -182,12 +212,40 @@ The host stores one global value per plugin and setting, and a project value in `plugins.declarative.resolveSettings` returns each effective value with its `source` (`project`, `global`, or `default`). +### Translations + +`l10n/.json` files in the package (such as `l10n/zh-Hans.json`, [schema](../../contract/v1/plugin-l10n.schema.json)) translate settings text into other interface languages, matched to manifest entries by ID. A language without a translation, and any text a file leaves out, shows the `plugin.json` text. + +```json +{ + "settings": { + "dialyzer": { "title": "Dialyzer 静态分析" }, + "checks": { "title": "检查项", "options": { "format": "格式检查" } } + }, + "sections": { "Server": "服务器" }, + "settingActions": { "rebuildPlt": { "title": "重建 PLT" } } +} +``` + +| Member | Translates | +| --- | --- | +| `settings.` | The setting's `title`, `description`, `placeholder`, and `unit`; `options.` is the title of an `enum` or `multiSelect` option | +| `sections.` | A section heading, keyed by the exact text `section` writes | +| `settingActions.` | The button's `title` and `description` | + +- IDs, option values and their order, stored values, and sections and their order are the same in every language; a translation changes only the shown text. +- For the interface language `zh-Hans-CN` the host looks for `l10n/zh-Hans-CN.json`, `l10n/zh-Hans.json`, and `l10n/zh.json`, and shows the first file that passes the checks, so one `zh-Hans` file serves every region. +- A file with any problem is not used at all and is logged by the host with `plugin.settings.invalidLocalization`; the page uses the next candidate or the manifest text, so a broken translation never hides a setting. Problems are: not a JSON object, larger than 1 MiB, a repeated key, or `null`; a field the format does not define inside an entry; a setting, option value, heading, or action the manifest does not declare; a translation of a field the manifest leaves out, or `options` for a setting without options; an empty title, placeholder, unit, or heading or one longer than 120 characters, or an empty description or one longer than 480. +- No capability or `requires` entry is needed; a host that does not read translations shows the manifest text. Core ignores top-level members it does not know, so translations of more plugin text will use the same files. +- A translation file may contain `"$schema"` for editor validation. + ### Contract sections | Section | Content | | --- | --- | | [Plugin settings](../../contract/v1/README.md#plugin-settings) | Setting fields, types, the `${setting:}` template, and use rules | | [Placement, scope, effect, and page buttons](../../contract/v1/README.md#placement-scope-effect-and-page-buttons) | Placement, layers, effect timing, page buttons, and validation rules | +| [Translations](../../contract/v1/README.md#translations) | Packaged translation files: format, language candidate order, and rejection rules | | [Workspace configuration](../../contract/v1/README.md#workspace-configuration) | Expanding and pushing `workspaceConfiguration` | | [Executable resolution](../../contract/v1/README.md#executable-resolution) | `setting` executable candidates: a global `file` setting selects the tool path, candidate order, and strict selection | | [Launch templates](../../contract/v1/README.md#launch-templates) | How setting values are substituted in templates | @@ -197,6 +255,8 @@ The host stores one global value per plugin and setting, and a project value in | File | Content | | --- | --- | | [`settings/settings.plugin.json`](../../contract/v1/fixtures/settings/settings.plugin.json) | Settings on the Elixir language page and the Mix build-system page, an `immediate` setting, and `settingActions` buttons | +| [`settings/l10n/zh-Hans.json`](../../contract/v1/fixtures/settings/l10n/zh-Hans.json) | Simplified Chinese translation of that manifest: setting, option, and section titles and a button | +| [`settings/localization-cases.json`](../../contract/v1/fixtures/settings/localization-cases.json) | 13 translation cases: choosing a file by language, falling back past a broken file, and each rejection with its field | | [`languages/node-runtime.plugin.json`](../../contract/v1/fixtures/languages/node-runtime.plugin.json) | A language server with plugin settings (`node` runtime) | | [`build-systems/build-tool-path.plugin.json`](../../contract/v1/fixtures/build-systems/build-tool-path.plugin.json) | A `file` setting selects the build tool's executable candidate | | [`debug-adapters/debug-adapter.plugin.json`](../../contract/v1/fixtures/debug-adapters/debug-adapter.plugin.json) | A debug adapter uses a `file` setting (`nextRun`) to pick the program to debug | diff --git a/shared/plugins/reference/protocol-changelog.md b/shared/plugins/reference/protocol-changelog.md index acbde07e..a6a0033c 100644 --- a/shared/plugins/reference/protocol-changelog.md +++ b/shared/plugins/reference/protocol-changelog.md @@ -133,6 +133,7 @@ | PR | 修改人 | API 编号 | 协议 / 能力 | 改了什么 | | --- | --- | --- | --- | --- | +| [#36](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/36) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | API-37 | `pluginAPIVersion` 0 内的补充 | 插件包可以带 `l10n/<语言>.json`(新增 [`plugin-l10n.schema.json`](../contract/v1/plugin-l10n.schema.json)),按设置 ID、选项值、分组原文和按钮 ID 翻译设置的标题、说明、占位符、单位、选项标题、分组标题和按钮文字;ID、值、分组和顺序不变,没翻译的文字显示清单原文。宿主命令 `plugins.declarative.settingsState` 新增可选 `language`、`localizations`,返回 `localization`(候选文件、选中的文件、被拒绝的文件);候选按语言标签逐级截短(`zh-Hans-CN` → `zh-Hans` → `zh`),选第一个通过检查的文件,有问题的文件整份不用,以新错误码 `plugin.settings.invalidLocalization` 报告。不需要新能力版本,不读翻译的宿主照常显示原文。 | | [#35](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/35) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的补充 | `build.detect`(`buildSystems[].detection`)还会跳过工作区的忽略路径:`.gitignore` 语法、相对工作区根、只对目录生效,存在团队构建选项 `.lithe/build/options.json` 或本机构建选项的 `ignoredPaths` 中,本机的整份替换团队的;都没有时默认 `[".*"]`,即所有名字以 `.` 开头的目录(如 `.claude/worktrees`)。这些规则先于 Git 规则判断,`!` 可以放回 Git 忽略的目录,共享扫描策略排除的缓存和构建输出仍然跳过。新增宿主命令 `build.editIgnoredPaths` 和错误码 `plugin.build.invalidIgnoredPath`(字段 `ignoredPaths[<序号>]`);`build.resolveStoredOptions` 返回 `ignoredPaths: { patterns, source }`。自动运行项发现、指纹扫描和 Node 清单发现不受影响。 | | [#33](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/33) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的补充 | `build.detect`(`buildSystems[].detection`)和自动运行项发现不再把被 Git 忽略的目录当作候选根目录。规则来自工作区内途经的 `.gitignore`,以及根目录在工作区内的仓库的 `info/exclude`(链接工作树读主仓库共享的那份),嵌套仓库另起一套规则;不读工作区根以上的目录、`core.excludesFile` 和索引,所以被忽略目录下已跟踪的清单文件也不再被发现。契约 README 同时写明检测最多向下 6 层。指纹扫描和 Node 清单发现仍然遍历被忽略的目录。 | | [#28](https://github.com/Lithe-IDEA/Lithe-core-rust/pull/28) | [@xiaoyumuxi](https://github.com/xiaoyumuxi) | — | `pluginAPIVersion` 0 内的说明修正 | 清单 schema 删除构建系统 `model`、`test.results`、`dependencies` 里不可达的 `source: "hostIntegration"` 分支,以及语言服务器 `hostIntegration` 下的 `capabilities`、`resources`。`hostIntegration` 字段仍是保留字段:构建系统和语言服务器写了任何值,都以 `plugin.integration.unsupported` 拒绝,字段路径不变;`model`、`test.results`、`dependencies` 写 `source: "hostIntegration"` 时按未知来源以 `plugin.manifest.invalid` 拒绝(原先同时写了 `hostIntegration` 名字时报 `plugin.integration.unsupported`)。README 与指南同步删掉这些来源和“社区插件”说法。能力表移到 `rust/crates/plugin-model/src/capabilities.rs`,插件函数协议移到 `rust/crates/plugin-rpc/src/`,跨插件调用规则移到 `rust/crates/plugin-function-relay/`,内容不变。 |