From 6df348fb4ee5c9854a92624229c6bbf1b8ce47af Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:44:34 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(config-tool):=20=E6=B7=BB=E5=8A=A0=20V?= =?UTF-8?q?S=20Code=20=E6=89=A9=E5=B1=95=E5=AE=9E=E7=8E=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 - 实现配置验证消息的本地化支持,包括数组、标量、枚举等类型的错误提示 - 添加完整的 VS Code 扩展框架,支持配置文件浏览、验证和表单预览 - 实现批量编辑功能,支持对同一配置域内的多个 YAML 文件执行字段更新 - 集成诊断功能,在编辑器中显示配置验证错误和警告 - 提供树形视图展示配置目录结构和文件列表 --- docs/zh-CN/game/config-system.md | 1 + tools/gframework-config-tool/README.md | 1 + tools/gframework-config-tool/package.json | 26 ++-- tools/gframework-config-tool/package.nls.json | 14 ++ .../package.nls.zh-cn.json | 14 ++ .../src/configValidation.js | 103 ++++++++++-- tools/gframework-config-tool/src/extension.js | 146 ++++++++++-------- .../src/localization.js | 142 +++++++++++++++++ .../test/configValidation.test.js | 21 +++ .../test/localization.test.js | 25 +++ 10 files changed, 403 insertions(+), 90 deletions(-) create mode 100644 tools/gframework-config-tool/package.nls.json create mode 100644 tools/gframework-config-tool/package.nls.zh-cn.json create mode 100644 tools/gframework-config-tool/src/localization.js create mode 100644 tools/gframework-config-tool/test/localization.test.js diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index a133ff63..2f25fcb4 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -200,6 +200,7 @@ var hotReload = loader.EnableHotReload( - 浏览 `config/` 目录 - 打开 raw YAML 文件 - 打开匹配的 schema 文件 +- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 - 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 - 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 diff --git a/tools/gframework-config-tool/README.md b/tools/gframework-config-tool/README.md index 8a7c9672..9a316fee 100644 --- a/tools/gframework-config-tool/README.md +++ b/tools/gframework-config-tool/README.md @@ -7,6 +7,7 @@ VS Code extension for the GFramework AI-First config workflow. - Browse config files from the workspace `config/` directory - Open raw YAML files - Open matching schema files from `schemas/` +- Localize extension UI text in English and Simplified Chinese, including the form preview, prompts, and notifications - Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and arrays of objects - Open a lightweight form preview for nested object fields, object arrays, top-level scalar fields, and scalar arrays diff --git a/tools/gframework-config-tool/package.json b/tools/gframework-config-tool/package.json index cbc751f3..688c9601 100644 --- a/tools/gframework-config-tool/package.json +++ b/tools/gframework-config-tool/package.json @@ -1,8 +1,8 @@ { "name": "gframework-config-tool", - "displayName": "GFramework Config Tool", - "description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.", - "version": "0.0.1", + "displayName": "%extension.displayName%", + "description": "%extension.description%", + "version": "0.0.2", "publisher": "GeWuYou", "license": "Apache-2.0", "repository": { @@ -54,34 +54,34 @@ "explorer": [ { "id": "gframeworkConfigExplorer", - "name": "GFramework Config" + "name": "%view.gframeworkConfig.name%" } ] }, "commands": [ { "command": "gframeworkConfig.refresh", - "title": "GFramework Config: Refresh" + "title": "%command.refresh.title%" }, { "command": "gframeworkConfig.openRaw", - "title": "GFramework Config: Open Raw File" + "title": "%command.openRaw.title%" }, { "command": "gframeworkConfig.openSchema", - "title": "GFramework Config: Open Schema" + "title": "%command.openSchema.title%" }, { "command": "gframeworkConfig.openFormPreview", - "title": "GFramework Config: Open Form Preview" + "title": "%command.openFormPreview.title%" }, { "command": "gframeworkConfig.batchEditDomain", - "title": "GFramework Config: Batch Edit Domain" + "title": "%command.batchEditDomain.title%" }, { "command": "gframeworkConfig.validateAll", - "title": "GFramework Config: Validate All" + "title": "%command.validateAll.title%" } ], "menus": { @@ -121,17 +121,17 @@ ] }, "configuration": { - "title": "GFramework Config", + "title": "%configuration.title%", "properties": { "gframeworkConfig.configPath": { "type": "string", "default": "config", - "description": "Relative path from the workspace root to the config directory." + "description": "%configuration.configPath.description%" }, "gframeworkConfig.schemasPath": { "type": "string", "default": "schemas", - "description": "Relative path from the workspace root to the schema directory." + "description": "%configuration.schemasPath.description%" } } } diff --git a/tools/gframework-config-tool/package.nls.json b/tools/gframework-config-tool/package.nls.json new file mode 100644 index 00000000..a7acaa0f --- /dev/null +++ b/tools/gframework-config-tool/package.nls.json @@ -0,0 +1,14 @@ +{ + "extension.displayName": "GFramework Config Tool", + "extension.description": "VS Code tooling for browsing, validating, and editing AI-First config files in GFramework projects.", + "view.gframeworkConfig.name": "GFramework Config", + "command.refresh.title": "GFramework Config: Refresh", + "command.openRaw.title": "GFramework Config: Open Raw File", + "command.openSchema.title": "GFramework Config: Open Schema", + "command.openFormPreview.title": "GFramework Config: Open Form Preview", + "command.batchEditDomain.title": "GFramework Config: Batch Edit Domain", + "command.validateAll.title": "GFramework Config: Validate All", + "configuration.title": "GFramework Config", + "configuration.configPath.description": "Relative path from the workspace root to the config directory.", + "configuration.schemasPath.description": "Relative path from the workspace root to the schema directory." +} diff --git a/tools/gframework-config-tool/package.nls.zh-cn.json b/tools/gframework-config-tool/package.nls.zh-cn.json new file mode 100644 index 00000000..bd067a59 --- /dev/null +++ b/tools/gframework-config-tool/package.nls.zh-cn.json @@ -0,0 +1,14 @@ +{ + "extension.displayName": "GFramework 配置工具", + "extension.description": "为 GFramework 项目中的 AI-First 配置文件提供浏览、校验和编辑能力的 VS Code 扩展。", + "view.gframeworkConfig.name": "GFramework 配置", + "command.refresh.title": "GFramework 配置:刷新", + "command.openRaw.title": "GFramework 配置:打开原始文件", + "command.openSchema.title": "GFramework 配置:打开 Schema", + "command.openFormPreview.title": "GFramework 配置:打开表单预览", + "command.batchEditDomain.title": "GFramework 配置:批量编辑配置域", + "command.validateAll.title": "GFramework 配置:校验全部", + "configuration.title": "GFramework 配置", + "configuration.configPath.description": "从工作区根目录到配置目录的相对路径。", + "configuration.schemasPath.description": "从工作区根目录到 Schema 目录的相对路径。" +} diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index 301e3d67..17ee2816 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -100,11 +100,12 @@ function parseTopLevelYaml(text) { * * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. * @param {YamlNode} parsedYaml Parsed YAML tree. + * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. * @returns {Array<{severity: "error" | "warning", message: string}>} Validation diagnostics. */ -function validateParsedConfig(schemaInfo, parsedYaml) { +function validateParsedConfig(schemaInfo, parsedYaml, localizer) { const diagnostics = []; - validateNode(schemaInfo, parsedYaml, "", diagnostics); + validateNode(schemaInfo, parsedYaml, "", diagnostics, localizer); return diagnostics; } @@ -353,10 +354,11 @@ function parseSchemaNode(rawNode, displayPath) { * @param {YamlNode} yamlNode YAML node. * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. + * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. */ -function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { +function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (schemaNode.type === "object") { - validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics); + validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer); return; } @@ -364,13 +366,15 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { if (!yamlNode || yamlNode.kind !== "array") { diagnostics.push({ severity: "error", - message: `Property '${displayPath}' is expected to be an array.` + message: localizeValidationMessage("expectedArray", localizer, { + displayPath + }) }); return; } for (let index = 0; index < yamlNode.items.length; index += 1) { - validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics); + validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics, localizer); } return; } @@ -378,7 +382,11 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { if (!yamlNode || yamlNode.kind !== "scalar") { diagnostics.push({ severity: "error", - message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current YAML shape is '${yamlNode ? yamlNode.kind : "missing"}'.` + message: localizeValidationMessage("expectedScalarShape", localizer, { + displayPath, + schemaType: schemaNode.type, + yamlKind: yamlNode ? yamlNode.kind : "missing" + }) }); return; } @@ -386,7 +394,10 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { diagnostics.push({ severity: "error", - message: `Property '${displayPath}' is expected to be '${schemaNode.type}', but the current scalar value is incompatible.` + message: localizeValidationMessage("expectedScalarValue", localizer, { + displayPath, + schemaType: schemaNode.type + }) }); return; } @@ -396,7 +407,10 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { diagnostics.push({ severity: "error", - message: `Property '${displayPath}' must be one of: ${schemaNode.enumValues.join(", ")}.` + message: localizeValidationMessage("enumMismatch", localizer, { + displayPath, + values: schemaNode.enumValues.join(", ") + }) }); } } @@ -408,13 +422,17 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics) { * @param {YamlNode} yamlNode YAML node. * @param {string} displayPath Current logical path. * @param {Array<{severity: "error" | "warning", message: string}>} diagnostics Diagnostic sink. + * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. */ -function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { +function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (!yamlNode || yamlNode.kind !== "object") { const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`; diagnostics.push({ severity: "error", - message: `${subject} is expected to be an object.` + message: localizeValidationMessage("expectedObject", localizer, { + subject, + displayPath + }) }); return; } @@ -423,7 +441,9 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { if (!yamlNode.map.has(requiredProperty)) { diagnostics.push({ severity: "error", - message: `Required property '${combinePath(displayPath, requiredProperty)}' is missing.` + message: localizeValidationMessage("missingRequired", localizer, { + displayPath: combinePath(displayPath, requiredProperty) + }) }); } } @@ -432,7 +452,9 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { diagnostics.push({ severity: "error", - message: `Property '${combinePath(displayPath, entry.key)}' is not declared in the matching schema.` + message: localizeValidationMessage("unknownProperty", localizer, { + displayPath: combinePath(displayPath, entry.key) + }) }); continue; } @@ -441,7 +463,60 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { schemaNode.properties[entry.key], entry.node, combinePath(displayPath, entry.key), - diagnostics); + diagnostics, + localizer); + } +} + +/** + * Format one validation message in either English or Simplified Chinese. + * + * @param {"expectedArray" | "expectedScalarShape" | "expectedScalarValue" | "enumMismatch" | "expectedObject" | "missingRequired" | "unknownProperty"} key Message key. + * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. + * @param {Record} params Message parameters. + * @returns {string} Localized validation message. + */ +function localizeValidationMessage(key, localizer, params) { + if (localizer && localizer.isChinese) { + switch (key) { + case "expectedArray": + return `属性“${params.displayPath}”应为数组。`; + case "expectedScalarShape": + return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`; + case "expectedScalarValue": + return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; + case "enumMismatch": + return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; + case "expectedObject": + return params.displayPath && params.displayPath.length > 0 + ? `属性“${params.displayPath}”应为对象。` + : "根对象应为对象。"; + case "missingRequired": + return `缺少必填属性“${params.displayPath}”。`; + case "unknownProperty": + return `属性“${params.displayPath}”未在匹配的 schema 中声明。`; + default: + return key; + } + } + + switch (key) { + case "expectedArray": + return `Property '${params.displayPath}' is expected to be an array.`; + case "expectedScalarShape": + return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`; + case "expectedScalarValue": + return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; + case "enumMismatch": + return `Property '${params.displayPath}' must be one of: ${params.values}.`; + case "expectedObject": + return `${params.subject} is expected to be an object.`; + case "missingRequired": + return `Required property '${params.displayPath}' is missing.`; + case "unknownProperty": + return `Property '${params.displayPath}' is not declared in the matching schema.`; + default: + return key; } } diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 22fceb82..62eb2b7f 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -10,6 +10,9 @@ const { unquoteScalar, validateParsedConfig } = require("./configValidation"); +const {createLocalizer} = require("./localization"); + +const localizer = createLocalizer(vscode.env.language); /** * Activate the GFramework config extension. @@ -132,11 +135,11 @@ class ConfigTreeDataProvider { if (!configRoot || !fs.existsSync(configRoot.fsPath)) { return [ new ConfigTreeItem( - "No config directory", + localizer.t("tree.noConfigDirectory.label"), "info", vscode.TreeItemCollapsibleState.None, undefined, - "Set gframeworkConfig.configPath or create the directory.") + localizer.t("tree.noConfigDirectory.description")) ]; } @@ -171,8 +174,8 @@ class ConfigTreeDataProvider { const fileUri = vscode.Uri.joinPath(domainUri, entry.name); const schemaUri = getSchemaUriForConfigFile(fileUri, workspaceRoot); const description = schemaUri && fs.existsSync(schemaUri.fsPath) - ? "schema" - : "schema missing"; + ? localizer.t("tree.fileDescription.schema") + : localizer.t("tree.fileDescription.schemaMissing"); const item = new ConfigTreeItem( entry.name, "file", @@ -183,7 +186,7 @@ class ConfigTreeDataProvider { item.contextValue = "gframeworkConfigFile"; item.command = { command: "gframeworkConfig.openRaw", - title: "Open Raw", + title: localizer.t("command.openRaw.title"), arguments: [item] }; @@ -243,7 +246,7 @@ async function openSchemaFile(item) { const schemaUri = getSchemaUriForConfigFile(configUri, workspaceRoot); if (!schemaUri || !fs.existsSync(schemaUri.fsPath)) { - void vscode.window.showWarningMessage("Matching schema file was not found."); + void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound")); return; } @@ -273,7 +276,7 @@ async function openFormPreview(item, diagnostics) { const panel = vscode.window.createWebviewPanel( "gframeworkConfigFormPreview", - `Config Form: ${path.basename(configUri.fsPath)}`, + localizer.t("webview.panelTitle", {fileName: path.basename(configUri.fsPath)}), vscode.ViewColumn.Beside, {enableScripts: true}); @@ -294,7 +297,7 @@ async function openFormPreview(item, diagnostics) { const document = await vscode.workspace.openTextDocument(configUri); await document.save(); await validateConfigFile(configUri, diagnostics); - void vscode.window.showInformationMessage("Config file saved from form preview."); + void vscode.window.showInformationMessage(localizer.t("message.formSaved")); } if (message.type === "openRaw") { @@ -353,13 +356,13 @@ async function validateConfigFile(configUri, diagnostics) { if (!schemaInfo.exists) { fileDiagnostics.push(new vscode.Diagnostic( new vscode.Range(0, 0, 0, 1), - `Matching schema file not found: ${schemaInfo.schemaPath}`, + localizer.t("diagnostic.schemaMissing", {schemaPath: schemaInfo.schemaPath}), vscode.DiagnosticSeverity.Warning)); diagnostics.set(configUri, fileDiagnostics); return; } - for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml)) { + for (const diagnostic of validateParsedConfig(schemaInfo, parsedYaml, localizer)) { fileDiagnostics.push(new vscode.Diagnostic( new vscode.Range(0, 0, 0, 1), diagnostic.message, @@ -403,14 +406,14 @@ async function openBatchEdit(item, diagnostics, provider) { }); if (fileItems.length === 0) { - void vscode.window.showWarningMessage("No YAML config files were found in the selected domain."); + void vscode.window.showWarningMessage(localizer.t("message.noYamlFilesInDomain")); return; } const selectedFiles = await vscode.window.showQuickPick(fileItems, { canPickMany: true, - title: `Batch Edit: ${path.basename(domainUri.fsPath)}`, - placeHolder: "Select the config files to update." + title: localizer.t("quickPick.batchEdit.title", {domain: path.basename(domainUri.fsPath)}), + placeHolder: localizer.t("quickPick.batchEdit.placeholder") }); if (!selectedFiles || selectedFiles.length === 0) { return; @@ -418,14 +421,13 @@ async function openBatchEdit(item, diagnostics, provider) { const schemaInfo = await loadSchemaInfoForConfig(selectedFiles[0].fileUri, workspaceRoot); if (!schemaInfo.exists) { - void vscode.window.showWarningMessage("Batch edit requires a matching schema file for the selected domain."); + void vscode.window.showWarningMessage(localizer.t("message.batchEditNeedsSchema")); return; } const editableFields = getEditableSchemaFields(schemaInfo); if (editableFields.length === 0) { - void vscode.window.showWarningMessage( - "No top-level scalar or scalar-array fields were found in the matching schema."); + void vscode.window.showWarningMessage(localizer.t("message.batchEditNoEditableFields")); return; } @@ -433,19 +435,19 @@ async function openBatchEdit(item, diagnostics, provider) { editableFields.map((field) => ({ label: field.title || field.key, description: field.inputKind === "array" - ? `array<${field.itemType}>` + ? localizer.t("detail.arrayType", {itemType: field.itemType}) : field.type, detail: [ - field.required ? "required" : "", + field.required ? localizer.t("detail.required") : "", field.description || "", - field.refTable ? `ref: ${field.refTable}` : "" + field.refTable ? localizer.t("detail.refTable", {refTable: field.refTable}) : "" ].filter((part) => part.length > 0).join(" · ") || undefined, field })), { canPickMany: true, - title: `Batch Edit Fields: ${path.basename(domainUri.fsPath)}`, - placeHolder: "Select the fields to apply across the chosen files." + title: localizer.t("quickPick.batchEditFields.title", {domain: path.basename(domainUri.fsPath)}), + placeHolder: localizer.t("quickPick.batchEditFields.placeholder") }); if (!selectedFields || selectedFields.length === 0) { return; @@ -492,13 +494,15 @@ async function openBatchEdit(item, diagnostics, provider) { } if (changedFileCount === 0) { - void vscode.window.showInformationMessage("Batch edit did not change any selected config files."); + void vscode.window.showInformationMessage(localizer.t("message.batchEditNoChanges")); return; } const applied = await vscode.workspace.applyEdit(edit); if (!applied) { - throw new Error("VS Code rejected the batch edit workspace update."); + throw new Error(localizer.isChinese + ? "VS Code 拒绝了这次批量编辑工作区更新。" + : "VS Code rejected the batch edit workspace update."); } for (const document of touchedDocuments) { @@ -507,8 +511,10 @@ async function openBatchEdit(item, diagnostics, provider) { } provider.refresh(); - void vscode.window.showInformationMessage( - `Batch updated ${changedFileCount} config file(s) in '${path.basename(domainUri.fsPath)}'.`); + void vscode.window.showInformationMessage(localizer.t("message.batchEditUpdated", { + count: changedFileCount, + domain: path.basename(domainUri.fsPath) + })); } /** @@ -570,6 +576,9 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { */ function renderFormHtml(fileName, schemaInfo, parsedYaml) { const formModel = buildFormModel(schemaInfo, parsedYaml); + const saveButtonLabel = escapeHtml(localizer.t("webview.button.save")); + const openRawButtonLabel = escapeHtml(localizer.t("webview.button.openRaw")); + const objectArrayItemLabel = localizer.t("webview.objectArray.item"); const renderedFields = formModel.fields .map((field) => renderFormField(field)) .join("\n"); @@ -583,8 +592,8 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { .join("\n"); const schemaStatus = schemaInfo.exists - ? `Schema: ${escapeHtml(schemaInfo.schemaPath)}` - : `Schema missing: ${escapeHtml(schemaInfo.schemaPath)}`; + ? escapeHtml(localizer.t("webview.meta.schema", {schemaPath: schemaInfo.schemaPath})) + : escapeHtml(localizer.t("webview.meta.schemaMissing", {schemaPath: schemaInfo.schemaPath})); const editableContent = renderedFields; const unsupportedSection = unsupportedFields.length > 0 @@ -592,10 +601,10 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { : ""; const emptyState = editableContent.length > 0 ? `${editableContent}${unsupportedSection}` - : "

No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.

"; + : `

${escapeHtml(localizer.t("webview.emptyState"))}

`; return ` - + @@ -739,16 +748,17 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
- - + +
-
File: ${escapeHtml(fileName)}
+
${escapeHtml(localizer.t("webview.meta.file", {fileName}))}
${schemaStatus}
${emptyState}
`; @@ -870,7 +1074,9 @@ function renderFormField(field) {
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
+ ${renderYamlCommentBlock(field)} ${field.description ? `${escapeHtml(field.description)}` : ""} + ${renderCommentEditor(field)}
`; } @@ -887,8 +1093,11 @@ function renderFormField(field) {
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
+ ${renderYamlCommentBlock(field)} ${escapeHtml(localizer.t("webview.objectArray.hint"))} ${renderFieldHint(field.schema, true)} + ${renderReferenceActions(field)} + ${renderCommentEditor(field)}
${renderedItems}
@@ -907,9 +1116,12 @@ function renderFormField(field) { `; } @@ -934,12 +1146,76 @@ function renderFormField(field) { `; } +/** + * Render one existing YAML comment block for a field. + * + * @param {{comment?: string}} field Form field descriptor. + * @returns {string} HTML fragment. + */ +function renderYamlCommentBlock(field) { + if (!field.comment) { + return ""; + } + + return `${escapeHtml(field.comment)}`; +} + +/** + * Render one comment editor so users can add or update YAML comments directly + * from the structured form without dropping down to raw YAML first. + * + * @param {{displayPath?: string, path: string, comment?: string}} field Form field descriptor. + * @returns {string} HTML fragment. + */ +function renderCommentEditor(field) { + const commentPath = field.displayPath || field.path; + if (commentPath.includes("[]")) { + return ""; + } + + return ` +
+ ${escapeHtml(localizer.t("webview.comment.label"))} + +
+ `; +} + +/** + * Render lightweight reference-navigation actions for fields that point to + * another config table. + * + * @param {{schema?: {refTable?: string}, value?: string, kind?: string, displayPath?: string}} field Form field descriptor. + * @returns {string} HTML fragment. + */ +function renderReferenceActions(field) { + if (!field.schema || !field.schema.refTable) { + return ""; + } + + const refTable = escapeHtml(field.schema.refTable); + const actions = [ + ``, + `` + ]; + + if (field.kind === "scalar" && field.value) { + actions.push( + ``); + } + + return `
${actions.join("")}
`; +} + /** * Render one object-array item editor block. * @@ -963,16 +1239,17 @@ function renderObjectArrayItem(item) { * * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record, type?: string}} schemaInfo Schema info. * @param {unknown} parsedYaml Parsed YAML data. + * @param {Record} commentLookup YAML comment lookup. * @returns {{fields: Array>, unsupported: Array<{path: string, message: string}>}} Form model. */ -function buildFormModel(schemaInfo, parsedYaml) { +function buildFormModel(schemaInfo, parsedYaml, commentLookup) { if (!schemaInfo || schemaInfo.type !== "object") { return {fields: [], unsupported: []}; } const fields = []; const unsupported = []; - collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported); + collectFormFields(schemaInfo, parsedYaml, "", 0, fields, unsupported, commentLookup || {}); return {fields, unsupported}; } @@ -985,8 +1262,9 @@ function buildFormModel(schemaInfo, parsedYaml) { * @param {number} depth Current depth. * @param {Array>} fields Field sink. * @param {Array<{path: string, message: string}>} unsupported Unsupported sink. + * @param {Record} commentLookup YAML comment lookup. */ -function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, unsupported) { +function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, unsupported, commentLookup) { if (!schemaNode || schemaNode.type !== "object") { return; } @@ -1005,10 +1283,11 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns path: propertyPath, label, description: propertySchema.description, + comment: commentLookup[propertyPath] || "", required: requiredSet.has(key), depth }); - collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported); + collectFormFields(propertySchema, propertyValue, propertyPath, depth + 1, fields, unsupported, commentLookup); continue; } @@ -1024,7 +1303,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns depth, itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), - schema: propertySchema + schema: propertySchema, + comment: commentLookup[propertyPath] || "" }); continue; } @@ -1040,7 +1320,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns `${propertyPath}[]`, depth + 1, itemFieldsTemplate, - unsupported); + unsupported, + commentLookup); fields.push({ kind: "objectArray", path: propertyPath, @@ -1049,7 +1330,14 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns required: requiredSet.has(key), depth, schema: propertySchema, - items: buildObjectArrayItemModels(propertySchema.items, propertyValue, propertyPath, depth + 1, unsupported), + comment: commentLookup[propertyPath] || "", + items: buildObjectArrayItemModels( + propertySchema.items, + propertyValue, + propertyPath, + depth + 1, + unsupported, + commentLookup), templateFields: itemFieldsTemplate }); continue; @@ -1064,7 +1352,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns required: requiredSet.has(key), depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), - schema: propertySchema + schema: propertySchema, + comment: commentLookup[propertyPath] || "" }); continue; } @@ -1086,9 +1375,10 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns * @param {string} propertyPath Top-level object-array path. * @param {number} depth Current depth. * @param {Array<{path: string, message: string}>} unsupported Unsupported sink. + * @param {Record} commentLookup YAML comment lookup. * @returns {Array<{title: string, fields: Array>}>} Item models. */ -function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, unsupported) { +function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, unsupported, commentLookup) { if (!yamlNode || yamlNode.kind !== "array") { return []; } @@ -1113,7 +1403,8 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u itemPath, depth, fields, - unsupported); + unsupported, + commentLookup); items.push({ title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}), fields @@ -1135,8 +1426,9 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u * @param {number} depth Current depth. * @param {Array>} fields Field sink. * @param {Array<{path: string, message: string}>} unsupported Unsupported sink. + * @param {Record} commentLookup YAML comment lookup. */ -function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPath, depth, fields, unsupported) { +function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPath, depth, fields, unsupported, commentLookup) { if (!schemaNode || schemaNode.type !== "object") { return; } @@ -1157,6 +1449,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa displayPath: itemDisplayPath, label, description: propertySchema.description, + comment: commentLookup[itemDisplayPath] || "", required: requiredSet.has(key), depth }); @@ -1167,7 +1460,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa itemDisplayPath, depth + 1, fields, - unsupported); + unsupported, + commentLookup); continue; } @@ -1184,7 +1478,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), schema: propertySchema, - itemMode: true + itemMode: true, + comment: commentLookup[itemDisplayPath] || "" }); continue; } @@ -1199,7 +1494,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), schema: propertySchema, - itemMode: true + itemMode: true, + comment: commentLookup[itemDisplayPath] || "" }); continue; } diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 0fd1297f..4cb43ad5 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -37,11 +37,15 @@ const enMessages = { "command.openRaw.title": "Open Raw", "message.schemaNotFound": "Matching schema file was not found.", "message.formSaved": "Config file saved from form preview.", + "message.formInitialized": "Example config initialized from the schema.", "message.noYamlFilesInDomain": "No YAML config files were found in the selected domain.", "message.batchEditNeedsSchema": "Batch edit requires a matching schema file for the selected domain.", "message.batchEditNoEditableFields": "No top-level scalar or scalar-array fields were found in the matching schema.", "message.batchEditNoChanges": "Batch edit did not change any selected config files.", "message.batchEditUpdated": "Batch updated {count} config file(s) in '{domain}'.", + "message.referenceSchemaMissing": "The referenced schema '{refTable}.schema.json' was not found.", + "message.referenceDomainMissing": "The referenced config domain '{refTable}' was not found.", + "message.referenceValueMissing": "The referenced config '{refValue}' was not found in '{refTable}'.", "diagnostic.schemaMissing": "Matching schema file not found: {schemaPath}", "quickPick.batchEdit.title": "Batch Edit: {domain}", "quickPick.batchEdit.placeholder": "Select the config files to update.", @@ -67,7 +71,13 @@ const enMessages = { "webview.emptyState": "No editable schema-bound fields were detected. Use raw YAML for unsupported shapes.", "webview.button.save": "Save Form", "webview.button.openRaw": "Open Raw YAML", + "webview.button.initialize": "Initialize Example", "webview.badge.required": "required", + "webview.help.summary": "Edit values, comments, and references here. Use raw YAML when you need unsupported structures or exact formatting control.", + "webview.comment.label": "YAML comment", + "webview.ref.openSchema": "Open Ref Schema", + "webview.ref.openDomain": "Open Ref Domain", + "webview.ref.openValue": "Open Ref File", "webview.objectArray.item": "Item", "webview.objectArray.itemNumber": "Item {index}", "webview.objectArray.hint": "Each item uses the object schema below.", @@ -91,11 +101,15 @@ const zhCnMessages = { "command.openRaw.title": "打开原始文件", "message.schemaNotFound": "未找到匹配的 schema 文件。", "message.formSaved": "已从表单预览保存配置文件。", + "message.formInitialized": "已根据 schema 初始化示例配置。", "message.noYamlFilesInDomain": "所选配置域中没有找到 YAML 配置文件。", "message.batchEditNeedsSchema": "批量编辑要求该配置域存在匹配的 schema 文件。", "message.batchEditNoEditableFields": "匹配的 schema 中没有可批量编辑的顶层标量字段或标量数组字段。", "message.batchEditNoChanges": "批量编辑未修改任何已选配置文件。", "message.batchEditUpdated": "已在“{domain}”中批量更新 {count} 个配置文件。", + "message.referenceSchemaMissing": "未找到引用的 schema 文件“{refTable}.schema.json”。", + "message.referenceDomainMissing": "未找到引用的配置域“{refTable}”。", + "message.referenceValueMissing": "在“{refTable}”中未找到引用配置“{refValue}”。", "diagnostic.schemaMissing": "未找到匹配的 schema 文件:{schemaPath}", "quickPick.batchEdit.title": "批量编辑:{domain}", "quickPick.batchEdit.placeholder": "选择要更新的配置文件。", @@ -121,7 +135,13 @@ const zhCnMessages = { "webview.emptyState": "当前没有可编辑的 schema 绑定字段。对于暂不支持的结构,请回退到原始 YAML 编辑。", "webview.button.save": "保存表单", "webview.button.openRaw": "打开原始 YAML", + "webview.button.initialize": "初始化示例", "webview.badge.required": "必填", + "webview.help.summary": "你可以在这里直接编辑字段值、YAML 注释和关联跳转。遇到暂不支持的复杂结构或需要精确保留排版时,请回退到原始 YAML。", + "webview.comment.label": "YAML 注释", + "webview.ref.openSchema": "打开引用 Schema", + "webview.ref.openDomain": "打开引用配置域", + "webview.ref.openValue": "打开引用文件", "webview.objectArray.item": "对象项", "webview.objectArray.itemNumber": "对象项 {index}", "webview.objectArray.hint": "每一项都按下面的对象 schema 编辑。", diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index bde5079f..e62415dc 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -3,6 +3,8 @@ const assert = require("node:assert/strict"); const { applyFormUpdates, applyScalarUpdates, + createSampleConfigYaml, + extractYamlComments, getEditableSchemaFields, parseBatchArrayValue, parseSchemaContent, @@ -290,6 +292,95 @@ test("applyFormUpdates should clear object arrays when the form removes all item ].join("\n")); }); +test("extractYamlComments should map nested comments to logical paths", () => { + const comments = extractYamlComments(` +# Monster display name +name: Slime +stats: + # Current hp value + hp: 10 +skills: + # First skill entry + - + # Skill id note + id: jump +`); + + assert.equal(comments.name, "Monster display name"); + assert.equal(comments["stats.hp"], "Current hp value"); + assert.equal(comments["skills[0]"], "First skill entry"); + assert.equal(comments["skills[0].id"], "Skill id note"); +}); + +test("applyFormUpdates should preserve and update YAML comments", () => { + const updated = applyFormUpdates( + [ + "# Monster display name", + "name: Slime", + "stats:", + " # Current hp value", + " hp: 10" + ].join("\n"), + { + scalars: { + name: "Slime King" + }, + comments: { + name: "Localized display name", + "stats.hp": "Health points after rebalance" + } + }); + + assert.match(updated, /^# Localized display name$/mu); + assert.match(updated, /^name: Slime King$/mu); + assert.match(updated, /^ # Health points after rebalance$/mu); + assert.match(updated, /^ hp: 10$/mu); +}); + +test("createSampleConfigYaml should bootstrap comments and placeholder values from schema", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Monster display name." + }, + "rarity": { + "type": "string", + "description": "Monster rarity.", + "enum": ["common", "rare"] + }, + "skills": { + "type": "array", + "description": "Skill entries.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Skill id." + } + } + } + } + } + } + `); + + const sample = createSampleConfigYaml(schema); + + assert.match(sample, /^# Monster display name\.$/mu); + assert.match(sample, /^name: example$/mu); + assert.match(sample, /^# Monster rarity\.$/mu); + assert.match(sample, /^rarity: common$/mu); + assert.match(sample, /^# Skill entries\.$/mu); + assert.match(sample, /^skills:$/mu); + assert.match(sample, /^ -$/mu); + assert.match(sample, /^ # Skill id\.$/mu); + assert.match(sample, /^ id: example$/mu); +}); + test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => { const updated = applyScalarUpdates( [ From 55602b16f2cccb4168df53124aff42ebfa204573 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:42:46 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore(workflow):=20=E6=BF=80=E6=B4=BB=20VS?= =?UTF-8?q?=20Code=20=E6=89=A9=E5=B1=95=E5=8F=91=E5=B8=83=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除注释符号以启用工作流定义 - 恢复完整的 GitHub Actions 工作流配置 - 保留所有扩展版本发布和包管理功能 - 维持 Node.js 和 Bun 环境设置步骤 - 保持 VSIX 包生成和市场发布的完整流程 --- .../workflows/publish-vscode-extension.yml | 188 +++++++++--------- 1 file changed, 94 insertions(+), 94 deletions(-) diff --git a/.github/workflows/publish-vscode-extension.yml b/.github/workflows/publish-vscode-extension.yml index faf5379d..e83d0612 100644 --- a/.github/workflows/publish-vscode-extension.yml +++ b/.github/workflows/publish-vscode-extension.yml @@ -1,94 +1,94 @@ -#name: Publish VS Code Extension -# -#on: -# workflow_dispatch: -# inputs: -# version: -# description: Extension version to publish, for example 0.1.0. Leave empty to use package.json or the pushed tag. -# required: false -# type: string -# publish_to_marketplace: -# description: Publish to the Visual Studio Marketplace after packaging. -# required: true -# type: boolean -# default: true -# push: -# tags: -# - 'gframework-config-tool-v*' -# -#permissions: -# contents: read -# -#jobs: -# publish: -# name: Package And Publish Marketplace Extension -# runs-on: ubuntu-latest -# -# defaults: -# run: -# working-directory: tools/gframework-config-tool -# -# steps: -# - name: Checkout repository -# uses: actions/checkout@v6 -# with: -# fetch-depth: 0 -# -# - name: Setup Node.js 20 -# uses: actions/setup-node@v5 -# with: -# node-version: 20 -# -# - name: Setup Bun -# uses: oven-sh/setup-bun@v2 -# with: -# bun-version: 1.2.15 -# -# - name: Determine extension version -# id: version -# shell: bash -# run: | -# set -euo pipefail -# -# PACKAGE_VERSION=$(node -p "require('./package.json').version") -# VERSION="${PACKAGE_VERSION}" -# -# if [[ "${GITHUB_REF:-}" == refs/tags/gframework-config-tool-v* ]]; then -# VERSION="${GITHUB_REF#refs/tags/gframework-config-tool-v}" -# elif [[ -n "${{ inputs.version || '' }}" ]]; then -# VERSION="${{ inputs.version }}" -# fi -# -# echo "Resolved extension version: ${VERSION}" -# echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" -# -# - name: Install extension dependencies -# run: bun install -# -# - name: Synchronize package.json version -# shell: bash -# run: | -# set -euo pipefail -# node -e "const fs=require('fs'); const path='package.json'; const data=JSON.parse(fs.readFileSync(path,'utf8')); data.version='${{ steps.version.outputs.version }}'; fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n');" -# -# - name: Run extension tests -# run: bun run test -# -# - name: Package VSIX -# run: | -# set -euo pipefail -# mkdir -p ../../artifacts -# bun run package:vsix -- --out "../../artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix" -# -# - name: Upload VSIX artifact -# uses: actions/upload-artifact@v7 -# with: -# name: gframework-config-tool-vsix -# path: artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix -# if-no-files-found: error -# -# - name: Publish to Visual Studio Marketplace -# if: github.event_name == 'push' || inputs.publish_to_marketplace -# env: -# VSCE_PAT: ${{ secrets.VSCE_PAT }} -# run: bun run publish:marketplace +name: Publish VS Code Extension + +on: + workflow_dispatch: + inputs: + version: + description: Extension version to publish, for example 0.1.0. Leave empty to use package.json or the pushed tag. + required: false + type: string + publish_to_marketplace: + description: Publish to the Visual Studio Marketplace after packaging. + required: true + type: boolean + default: true + push: + tags: + - 'gframework-config-tool-v*' + +permissions: + contents: read + +jobs: + publish: + name: Package And Publish Marketplace Extension + runs-on: ubuntu-latest + + defaults: + run: + working-directory: tools/gframework-config-tool + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup Node.js 20 + uses: actions/setup-node@v5 + with: + node-version: 20 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.15 + + - name: Determine extension version + id: version + shell: bash + run: | + set -euo pipefail + + PACKAGE_VERSION=$(node -p "require('./package.json').version") + VERSION="${PACKAGE_VERSION}" + + if [[ "${GITHUB_REF:-}" == refs/tags/gframework-config-tool-v* ]]; then + VERSION="${GITHUB_REF#refs/tags/gframework-config-tool-v}" + elif [[ -n "${{ inputs.version || '' }}" ]]; then + VERSION="${{ inputs.version }}" + fi + + echo "Resolved extension version: ${VERSION}" + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + + - name: Install extension dependencies + run: bun install + + - name: Synchronize package.json version + shell: bash + run: | + set -euo pipefail + node -e "const fs=require('fs'); const path='package.json'; const data=JSON.parse(fs.readFileSync(path,'utf8')); data.version='${{ steps.version.outputs.version }}'; fs.writeFileSync(path, JSON.stringify(data, null, 2) + '\n');" + + - name: Run extension tests + run: bun run test + + - name: Package VSIX + run: | + set -euo pipefail + mkdir -p ../../artifacts + bun run package:vsix -- --out "../../artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix" + + - name: Upload VSIX artifact + uses: actions/upload-artifact@v7 + with: + name: gframework-config-tool-vsix + path: artifacts/gframework-config-tool-${{ steps.version.outputs.version }}.vsix + if-no-files-found: error + + - name: Publish to Visual Studio Marketplace + if: github.event_name == 'push' || inputs.publish_to_marketplace + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + run: bun run publish:marketplace From 3c52c8c1eac56a4033b7174aa9bd4314e7425093 Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:22:46 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(config):=20=E6=B7=BB=E5=8A=A0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=AA=8C=E8=AF=81=E5=92=8CYAML=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了配置模式解析器,支持递归对象/数组/标量树结构 - 添加了可编辑字段收集功能,支持标量和数组类型的批量编辑 - 集成了YAML解析器,支持嵌套对象、数组和注释提取 - 实现了配置验证诊断,支持中英文本地化错误消息 - 添加了表单更新应用功能,支持安全的嵌套对象编辑 - 实现了示例配置生成功能,包含模式描述作为YAML注释 - 提供了批量数组值解析和枚举值标准化工具函数 - 集成了多语言支持,包含中英文验证消息本地化 --- .../gframework-config-tool/src/configPath.js | 64 ++++++ .../src/configValidation.js | 199 ++++++++++++------ tools/gframework-config-tool/src/extension.js | 45 ++-- .../src/localization.js | 46 +++- .../src/localizationKeys.js | 13 ++ .../test/configValidation.test.js | 30 +++ .../test/localization.test.js | 11 + 7 files changed, 327 insertions(+), 81 deletions(-) create mode 100644 tools/gframework-config-tool/src/configPath.js create mode 100644 tools/gframework-config-tool/src/localizationKeys.js diff --git a/tools/gframework-config-tool/src/configPath.js b/tools/gframework-config-tool/src/configPath.js new file mode 100644 index 00000000..b85651e2 --- /dev/null +++ b/tools/gframework-config-tool/src/configPath.js @@ -0,0 +1,64 @@ +/** + * Join one object property onto a logical config path. + * + * @param {string} parentPath Parent logical path. + * @param {string} propertyName Property name. + * @returns {string} Combined logical path. + */ +function joinPropertyPath(parentPath, propertyName) { + return parentPath ? `${parentPath}.${propertyName}` : propertyName; +} + +/** + * Join one indexed array item onto a logical config path. + * + * @param {string} arrayPath Array logical path. + * @param {number} itemIndex Zero-based item index. + * @returns {string} Indexed logical path. + */ +function joinArrayIndexPath(arrayPath, itemIndex) { + return `${arrayPath}[${itemIndex}]`; +} + +/** + * Join one array-item template marker onto a logical config path. + * + * @param {string} arrayPath Array logical path. + * @returns {string} Template logical path. + */ +function joinArrayTemplatePath(arrayPath) { + return `${arrayPath}[]`; +} + +/** + * Check whether a logical path still contains one template array marker. + * + * @param {string} path Logical path. + * @returns {boolean} True when the path contains a template array segment. + */ +function isTemplatePath(path) { + return String(path).includes("[]"); +} + +/** + * Split one logical object path into individual property segments. + * The current form model only supports dotted object paths here and keeps + * array indexing as part of other dedicated helpers. + * + * @param {string} path Logical path. + * @returns {string[]} Property segments. + */ +function splitObjectPath(path) { + return String(path) + .split(".") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} + +module.exports = { + isTemplatePath, + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath, + splitObjectPath +}; diff --git a/tools/gframework-config-tool/src/configValidation.js b/tools/gframework-config-tool/src/configValidation.js index c233eabb..832cc909 100644 --- a/tools/gframework-config-tool/src/configValidation.js +++ b/tools/gframework-config-tool/src/configValidation.js @@ -1,3 +1,11 @@ +const { + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath, + splitObjectPath +} = require("./configPath"); +const {ValidationMessageKeys} = require("./localizationKeys"); + /** * Parse the repository's minimal config-schema subset into a recursive tree. * The parser intentionally mirrors the same high-level contract used by the @@ -137,7 +145,7 @@ function extractYamlComments(text) { const itemIndex = currentContext.nextIndex || 0; currentContext.nextIndex = itemIndex + 1; - const itemPath = `${currentContext.path}[${itemIndex}]`; + const itemPath = joinArrayIndexPath(currentContext.path, itemIndex); assignPendingComments(comments, itemPath, pendingComments); pendingComments = []; @@ -150,37 +158,37 @@ function extractYamlComments(text) { continue; } - const inlineObjectMatch = /^([A-Za-z0-9_]+):(.*)$/u.exec(rest); - if (!inlineObjectMatch) { + const inlineObjectMapping = parseYamlMappingText(rest); + if (!inlineObjectMapping) { continue; } const itemObjectContext = {indent: indent + 2, type: "object", path: itemPath, nextIndex: 0}; stack.push(itemObjectContext); - const key = inlineObjectMatch[1]; - const parsedValue = splitYamlValueAndInlineComment(inlineObjectMatch[2].trim()); + const key = inlineObjectMapping.key; + const parsedValue = splitYamlValueAndInlineComment(inlineObjectMapping.rawValue.trim()); if (parsedValue.comment) { - comments[`${itemPath}.${key}`] = parsedValue.comment; + comments[joinPropertyPath(itemPath, key)] = parsedValue.comment; } const nextLine = findNextMeaningfulLine(lines, index + 1); if (parsedValue.value.length === 0 && nextLine && nextLine.indent > indent) { - stack.push(createContextForChild(`${itemPath}.${key}`, nextLine)); + stack.push(createContextForChild(joinPropertyPath(itemPath, key), nextLine)); } continue; } - const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(trimmed); - if (!match) { + const mapping = parseYamlMappingText(trimmed); + if (!mapping) { pendingComments = []; continue; } - const key = match[1]; - const valueInfo = splitYamlValueAndInlineComment(match[2].trim()); - const currentPath = currentContext.path ? `${currentContext.path}.${key}` : key; + const key = mapping.key; + const valueInfo = splitYamlValueAndInlineComment(mapping.rawValue.trim()); + const currentPath = joinPropertyPath(currentContext.path, key); assignPendingComments(comments, currentPath, pendingComments); pendingComments = []; @@ -282,16 +290,16 @@ function applyFormUpdates(originalYaml, updates) { const commentUpdates = updates.comments || {}; for (const [path, value] of Object.entries(scalarUpdates)) { - setNodeAtPath(root, path.split("."), createScalarNode(String(value))); + setNodeAtPath(root, splitObjectPath(path), createScalarNode(String(value))); } for (const [path, values] of Object.entries(arrayUpdates)) { - setNodeAtPath(root, path.split("."), createArrayNode( + setNodeAtPath(root, splitObjectPath(path), createArrayNode( (values || []).map((item) => createScalarNode(String(item))))); } for (const [path, items] of Object.entries(objectArrayUpdates)) { - setNodeAtPath(root, path.split("."), createArrayNode( + setNodeAtPath(root, splitObjectPath(path), createArrayNode( (items || []).map((item) => createNodeFromFormValue(item)))); } @@ -440,7 +448,7 @@ function parseSchemaNode(rawNode, displayPath) { : []; const properties = {}; for (const [key, propertyNode] of Object.entries(value.properties || {})) { - properties[key] = parseSchemaNode(propertyNode, combinePath(displayPath, key)); + properties[key] = parseSchemaNode(propertyNode, joinPropertyPath(displayPath, key)); } return { @@ -455,7 +463,7 @@ function parseSchemaNode(rawNode, displayPath) { } if (type === "array") { - const itemNode = parseSchemaNode(value.items || {}, `${displayPath}[]`); + const itemNode = parseSchemaNode(value.items || {}, joinArrayTemplatePath(displayPath)); return { type: "array", displayPath, @@ -497,7 +505,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!yamlNode || yamlNode.kind !== "array") { diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedArray", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedArray, localizer, { displayPath }) }); @@ -505,7 +513,12 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) } for (let index = 0; index < yamlNode.items.length; index += 1) { - validateNode(schemaNode.items, yamlNode.items[index], `${displayPath}[${index}]`, diagnostics, localizer); + validateNode( + schemaNode.items, + yamlNode.items[index], + joinArrayIndexPath(displayPath, index), + diagnostics, + localizer); } return; } @@ -513,7 +526,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!yamlNode || yamlNode.kind !== "scalar") { diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedScalarShape", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedScalarShape, localizer, { displayPath, schemaType: schemaNode.type, yamlKind: yamlNode ? yamlNode.kind : "missing" @@ -525,7 +538,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) if (!isScalarCompatible(schemaNode.type, yamlNode.value)) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedScalarValue", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedScalarValue, localizer, { displayPath, schemaType: schemaNode.type }) @@ -538,7 +551,7 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) !schemaNode.enumValues.includes(unquoteScalar(yamlNode.value))) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("enumMismatch", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.enumMismatch, localizer, { displayPath, values: schemaNode.enumValues.join(", ") }) @@ -557,10 +570,16 @@ function validateNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) */ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, localizer) { if (!yamlNode || yamlNode.kind !== "object") { - const subject = displayPath.length === 0 ? "Root object" : `Property '${displayPath}'`; + const subject = displayPath.length === 0 + ? localizer && localizer.isChinese + ? "根对象应为对象。" + : "Root object is expected to be an object." + : localizer && localizer.isChinese + ? `属性“${displayPath}”应为对象。` + : `Property '${displayPath}' is expected to be an object.`; diagnostics.push({ severity: "error", - message: localizeValidationMessage("expectedObject", localizer, { + message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, { subject, displayPath }) @@ -572,8 +591,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca if (!yamlNode.map.has(requiredProperty)) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("missingRequired", localizer, { - displayPath: combinePath(displayPath, requiredProperty) + message: localizeValidationMessage(ValidationMessageKeys.missingRequired, localizer, { + displayPath: joinPropertyPath(displayPath, requiredProperty) }) }); } @@ -583,8 +602,8 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca if (!Object.prototype.hasOwnProperty.call(schemaNode.properties, entry.key)) { diagnostics.push({ severity: "error", - message: localizeValidationMessage("unknownProperty", localizer, { - displayPath: combinePath(displayPath, entry.key) + message: localizeValidationMessage(ValidationMessageKeys.unknownProperty, localizer, { + displayPath: joinPropertyPath(displayPath, entry.key) }) }); continue; @@ -593,7 +612,7 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca validateNode( schemaNode.properties[entry.key], entry.node, - combinePath(displayPath, entry.key), + joinPropertyPath(displayPath, entry.key), diagnostics, localizer); } @@ -602,29 +621,31 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics, loca /** * Format one validation message in either English or Simplified Chinese. * - * @param {"expectedArray" | "expectedScalarShape" | "expectedScalarValue" | "enumMismatch" | "expectedObject" | "missingRequired" | "unknownProperty"} key Message key. + * @param {string} key Message key. * @param {{isChinese?: boolean} | undefined} localizer Optional runtime localizer. * @param {Record} params Message parameters. * @returns {string} Localized validation message. */ function localizeValidationMessage(key, localizer, params) { + if (localizer && typeof localizer.t === "function") { + return localizer.t(key, params); + } + if (localizer && localizer.isChinese) { switch (key) { - case "expectedArray": + case ValidationMessageKeys.expectedArray: return `属性“${params.displayPath}”应为数组。`; - case "expectedScalarShape": + case ValidationMessageKeys.expectedScalarShape: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`; - case "expectedScalarValue": + case ValidationMessageKeys.expectedScalarValue: return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; - case "enumMismatch": + case ValidationMessageKeys.enumMismatch: return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; - case "expectedObject": - return params.displayPath && params.displayPath.length > 0 - ? `属性“${params.displayPath}”应为对象。` - : "根对象应为对象。"; - case "missingRequired": + case ValidationMessageKeys.expectedObject: + return params.subject; + case ValidationMessageKeys.missingRequired: return `缺少必填属性“${params.displayPath}”。`; - case "unknownProperty": + case ValidationMessageKeys.unknownProperty: return `属性“${params.displayPath}”未在匹配的 schema 中声明。`; default: return key; @@ -632,19 +653,19 @@ function localizeValidationMessage(key, localizer, params) { } switch (key) { - case "expectedArray": + case ValidationMessageKeys.expectedArray: return `Property '${params.displayPath}' is expected to be an array.`; - case "expectedScalarShape": + case ValidationMessageKeys.expectedScalarShape: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`; - case "expectedScalarValue": + case ValidationMessageKeys.expectedScalarValue: return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; - case "enumMismatch": + case ValidationMessageKeys.enumMismatch: return `Property '${params.displayPath}' must be one of: ${params.values}.`; - case "expectedObject": - return `${params.subject} is expected to be an object.`; - case "missingRequired": + case ValidationMessageKeys.expectedObject: + return params.subject; + case ValidationMessageKeys.missingRequired: return `Required property '${params.displayPath}' is missing.`; - case "unknownProperty": + case ValidationMessageKeys.unknownProperty: return `Property '${params.displayPath}' is not declared in the matching schema.`; default: return key; @@ -719,14 +740,14 @@ function parseMapping(tokens, state, indent) { continue; } - const match = /^([A-Za-z0-9_]+):(.*)$/u.exec(token.text); - if (!match) { + const mapping = parseYamlMappingText(token.text); + if (!mapping) { state.index += 1; continue; } - const key = match[1]; - const rawValue = match[2].trim(); + const key = mapping.key; + const rawValue = mapping.rawValue.trim(); state.index += 1; let node; @@ -774,7 +795,7 @@ function parseSequence(tokens, state, indent) { continue; } - if (/^[A-Za-z0-9_]+:/u.test(rest)) { + if (parseYamlMappingText(rest)) { items.push(parseInlineObjectItem(tokens, state, indent, rest)); continue; } @@ -894,7 +915,7 @@ function renderYaml(node, indent = 0, currentPath = "", commentMap = {}) { function renderObjectNode(node, indent, currentPath, commentMap) { const lines = []; for (const entry of node.entries) { - const entryPath = currentPath ? `${currentPath}.${entry.key}` : entry.key; + const entryPath = joinPropertyPath(currentPath, entry.key); if (commentMap[entryPath]) { lines.push(...renderYamlComments(commentMap[entryPath], indent)); } @@ -927,7 +948,7 @@ function renderArrayNode(node, indent, currentPath, commentMap) { const lines = []; for (let index = 0; index < node.items.length; index += 1) { const item = node.items[index]; - const itemPath = `${currentPath}[${index}]`; + const itemPath = joinArrayIndexPath(currentPath, index); if (commentMap[itemPath]) { lines.push(...renderYamlComments(commentMap[itemPath], indent)); } @@ -1041,7 +1062,7 @@ function collectSchemaComments(schemaNode, currentPath, commentMap) { } for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { - const propertyPath = currentPath ? `${currentPath}.${key}` : key; + const propertyPath = joinPropertyPath(currentPath, key); if (propertySchema.description) { commentMap[propertyPath] = propertySchema.description; } @@ -1052,7 +1073,7 @@ function collectSchemaComments(schemaNode, currentPath, commentMap) { } if (propertySchema.type === "array" && propertySchema.items.type === "object") { - collectSchemaComments(propertySchema.items, `${propertyPath}[0]`, commentMap); + collectSchemaComments(propertySchema.items, joinArrayIndexPath(propertyPath, 0), commentMap); } } } @@ -1201,14 +1222,66 @@ function splitYamlValueAndInlineComment(rawValue) { } /** - * Combine a parent path with one child segment. + * Parse one YAML mapping entry such as `key: value` or `"complex key": value`. * - * @param {string} parentPath Parent path. - * @param {string} key Child key. - * @returns {string} Combined path. + * @param {string} text Raw YAML line text without leading indentation. + * @returns {{key: string, rawValue: string} | undefined} Parsed mapping entry. */ -function combinePath(parentPath, key) { - return parentPath && parentPath !== "" ? `${parentPath}.${key}` : key; +function parseYamlMappingText(text) { + const separatorIndex = findYamlKeyValueSeparator(text); + if (separatorIndex < 0) { + return undefined; + } + + const rawKey = text.slice(0, separatorIndex).trim(); + if (rawKey.length === 0) { + return undefined; + } + + return { + key: normalizeYamlKey(rawKey), + rawValue: text.slice(separatorIndex + 1) + }; +} + +/** + * Find the first `:` that acts as a YAML key/value separator. + * + * @param {string} text Raw YAML line text without leading indentation. + * @returns {number} Separator index, or -1 when not found. + */ +function findYamlKeyValueSeparator(text) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (character === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (character === "\"" && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (character === ":" && !inSingleQuote && !inDoubleQuote) { + return index; + } + } + + return -1; +} + +/** + * Normalize a YAML key token into the logical key name used in the form model. + * + * @param {string} rawKey Raw YAML key token. + * @returns {string} Normalized key name. + */ +function normalizeYamlKey(rawKey) { + return unquoteScalar(rawKey.trim()); } module.exports = { diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 56a3b99b..9695074e 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -12,6 +12,12 @@ const { unquoteScalar, validateParsedConfig } = require("./configValidation"); +const { + isTemplatePath, + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath +} = require("./configPath"); const {createLocalizer} = require("./localization"); const localizer = createLocalizer(vscode.env.language); @@ -353,11 +359,11 @@ async function openFormPreview(item, diagnostics) { return; } - const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); - const parsedYaml = parseTopLevelYaml(yamlText); - const commentLookup = extractYamlComments(yamlText); + let latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + const parsedYaml = parseTopLevelYaml(latestYamlText); + const commentLookup = extractYamlComments(latestYamlText); const schemaInfo = await loadSchemaInfoForConfig(configUri, workspaceRoot); - const canInitializeFromSchema = schemaInfo.exists && yamlText.trim().length === 0; + const canInitializeFromSchema = schemaInfo.exists && latestYamlText.trim().length === 0; const panel = vscode.window.createWebviewPanel( "gframeworkConfigFormPreview", @@ -376,7 +382,7 @@ async function openFormPreview(item, diagnostics) { panel.webview.onDidReceiveMessage(async (message) => { if (message.type === "save") { - const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); + latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); const updatedYaml = applyFormUpdates(latestYamlText, { scalars: message.scalars || {}, arrays: parseArrayFieldPayload(message.arrays || {}), @@ -402,17 +408,30 @@ async function openFormPreview(item, diagnostics) { return; } + const confirmLabel = localizer.t("button.initializeFromSchemaConfirm"); + const cancelLabel = localizer.t("button.cancel"); + const userChoice = await vscode.window.showWarningMessage( + localizer.t("message.initializeFromSchemaConfirm"), + {modal: true}, + confirmLabel, + cancelLabel); + + if (userChoice !== confirmLabel) { + return; + } + const sampleYaml = createSampleConfigYaml(schemaInfo); await fs.promises.writeFile(configUri.fsPath, sampleYaml, "utf8"); const document = await vscode.workspace.openTextDocument(configUri); await document.save(); + latestYamlText = sampleYaml; await validateConfigFile(configUri, diagnostics); panel.webview.html = renderFormHtml( path.basename(configUri.fsPath), schemaInfo, - parseTopLevelYaml(sampleYaml), + parseTopLevelYaml(latestYamlText), { - commentLookup: extractYamlComments(sampleYaml), + commentLookup: extractYamlComments(latestYamlText), canInitializeFromSchema: false }); void vscode.window.showInformationMessage(localizer.t("message.formInitialized")); @@ -1178,7 +1197,7 @@ function renderYamlCommentBlock(field) { */ function renderCommentEditor(field) { const commentPath = field.displayPath || field.path; - if (commentPath.includes("[]")) { + if (isTemplatePath(commentPath)) { return ""; } @@ -1273,7 +1292,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []); for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { - const propertyPath = currentPath ? `${currentPath}.${key}` : key; + const propertyPath = joinPropertyPath(currentPath, key); const label = propertySchema.title || key; const propertyValue = yamlMap.get(key); @@ -1317,7 +1336,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns propertySchema.items, undefined, "", - `${propertyPath}[]`, + joinArrayTemplatePath(propertyPath), depth + 1, itemFieldsTemplate, unsupported, @@ -1386,7 +1405,7 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u const items = []; for (let index = 0; index < yamlNode.items.length; index += 1) { const itemNode = yamlNode.items[index]; - const itemPath = `${propertyPath}[${index}]`; + const itemPath = joinArrayIndexPath(propertyPath, index); if (!itemNode || itemNode.kind !== "object") { unsupported.push({ path: itemPath, @@ -1437,8 +1456,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa const requiredSet = new Set(Array.isArray(schemaNode.required) ? schemaNode.required : []); for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { - const itemLocalPath = localPath ? `${localPath}.${key}` : key; - const itemDisplayPath = `${displayPath}.${key}`; + const itemLocalPath = joinPropertyPath(localPath, key); + const itemDisplayPath = joinPropertyPath(displayPath, key); const label = propertySchema.title || key; const propertyValue = yamlMap.get(key); diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js index 4cb43ad5..3c8a74d7 100644 --- a/tools/gframework-config-tool/src/localization.js +++ b/tools/gframework-config-tool/src/localization.js @@ -1,3 +1,5 @@ +const {ValidationMessageKeys} = require("./localizationKeys"); + /** * Create a tiny in-process localizer for the extension runtime and webview. * VS Code contribution points use package.nls files, while runtime strings are @@ -10,12 +12,26 @@ function createLocalizer(language) { const normalizedLanguage = String(language || "en").toLowerCase(); const isChinese = normalizedLanguage.startsWith("zh"); - const languageTag = isChinese ? "zh-CN" : "en"; - const dictionary = isChinese ? zhCnMessages : enMessages; + const isTraditionalChinese = + normalizedLanguage === "zh-tw" || + normalizedLanguage === "zh-hk" || + normalizedLanguage === "zh-mo" || + normalizedLanguage.startsWith("zh-hant"); + const isSimplifiedChinese = isChinese && !isTraditionalChinese; + const languageTag = isTraditionalChinese + ? normalizedLanguage + : isSimplifiedChinese + ? "zh-CN" + : "en"; + const dictionary = isTraditionalChinese + ? enMessages + : isSimplifiedChinese + ? zhCnMessages + : enMessages; return { languageTag, - isChinese, + isChinese: isSimplifiedChinese, t(key, params) { const template = dictionary[key] || enMessages[key] || key; return template.replace(/\{([A-Za-z0-9_]+)\}/gu, (match, token) => { @@ -38,6 +54,7 @@ const enMessages = { "message.schemaNotFound": "Matching schema file was not found.", "message.formSaved": "Config file saved from form preview.", "message.formInitialized": "Example config initialized from the schema.", + "message.initializeFromSchemaConfirm": "Initializing from the schema will replace the current configuration and may discard unsaved form changes. Do you want to continue?", "message.noYamlFilesInDomain": "No YAML config files were found in the selected domain.", "message.batchEditNeedsSchema": "Batch edit requires a matching schema file for the selected domain.", "message.batchEditNoEditableFields": "No top-level scalar or scalar-array fields were found in the matching schema.", @@ -55,6 +72,8 @@ const enMessages = { "detail.refTable": "ref: {refTable}", "detail.arrayType": "array<{itemType}>", "detail.default": "default", + "button.cancel": "Cancel", + "button.initializeFromSchemaConfirm": "Initialize from schema", "input.batchArray.title": "Batch Edit Array: {field}", "input.batchArray.prompt": "Enter comma-separated items for '{fieldKey}' (expected array<{itemType}>). Leave empty to clear the array.", "input.batchArray.placeholder.allowedItems": "Allowed items: {values}", @@ -90,7 +109,14 @@ const enMessages = { "webview.unsupported.array": "Unsupported array shapes are currently raw-YAML-only in the form preview.", "webview.unsupported.type": "{type} fields are currently raw-YAML-only.", "webview.unsupported.objectArrayMixed": "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items.", - "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor." + "webview.unsupported.nestedObjectArray": "Nested object-array fields are currently raw-YAML-only inside the object-array editor.", + [ValidationMessageKeys.enumMismatch]: "Property '{displayPath}' must be one of: {values}.", + [ValidationMessageKeys.expectedArray]: "Property '{displayPath}' is expected to be an array.", + [ValidationMessageKeys.expectedObject]: "{subject} is expected to be an object.", + [ValidationMessageKeys.expectedScalarShape]: "Property '{displayPath}' is expected to be '{schemaType}', but the current YAML shape is '{yamlKind}'.", + [ValidationMessageKeys.expectedScalarValue]: "Property '{displayPath}' is expected to be '{schemaType}', but the current scalar value is incompatible.", + [ValidationMessageKeys.missingRequired]: "Required property '{displayPath}' is missing.", + [ValidationMessageKeys.unknownProperty]: "Property '{displayPath}' is not declared in the matching schema." }; const zhCnMessages = { @@ -102,6 +128,7 @@ const zhCnMessages = { "message.schemaNotFound": "未找到匹配的 schema 文件。", "message.formSaved": "已从表单预览保存配置文件。", "message.formInitialized": "已根据 schema 初始化示例配置。", + "message.initializeFromSchemaConfirm": "从 schema 初始化会替换当前配置,并且可能丢失尚未保存的表单修改。是否继续?", "message.noYamlFilesInDomain": "所选配置域中没有找到 YAML 配置文件。", "message.batchEditNeedsSchema": "批量编辑要求该配置域存在匹配的 schema 文件。", "message.batchEditNoEditableFields": "匹配的 schema 中没有可批量编辑的顶层标量字段或标量数组字段。", @@ -119,6 +146,8 @@ const zhCnMessages = { "detail.refTable": "引用表:{refTable}", "detail.arrayType": "数组<{itemType}>", "detail.default": "默认值", + "button.cancel": "取消", + "button.initializeFromSchemaConfirm": "从 schema 初始化", "input.batchArray.title": "批量编辑数组:{field}", "input.batchArray.prompt": "请输入“{fieldKey}”的逗号分隔项(期望类型:数组<{itemType}>)。留空表示清空数组。", "input.batchArray.placeholder.allowedItems": "允许项:{values}", @@ -154,7 +183,14 @@ const zhCnMessages = { "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 YAML。", - "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。" + "webview.unsupported.nestedObjectArray": "对象数组编辑器内暂不支持更深层的对象数组字段,请改用原始 YAML。", + [ValidationMessageKeys.enumMismatch]: "属性“{displayPath}”必须是以下值之一:{values}。", + [ValidationMessageKeys.expectedArray]: "属性“{displayPath}”应为数组。", + [ValidationMessageKeys.expectedObject]: "{subject}", + [ValidationMessageKeys.expectedScalarShape]: "属性“{displayPath}”应为“{schemaType}”,但当前 YAML 结构是“{yamlKind}”。", + [ValidationMessageKeys.expectedScalarValue]: "属性“{displayPath}”应为“{schemaType}”,但当前标量值不兼容。", + [ValidationMessageKeys.missingRequired]: "缺少必填属性“{displayPath}”。", + [ValidationMessageKeys.unknownProperty]: "属性“{displayPath}”未在匹配的 schema 中声明。" }; module.exports = { diff --git a/tools/gframework-config-tool/src/localizationKeys.js b/tools/gframework-config-tool/src/localizationKeys.js new file mode 100644 index 00000000..caf2f635 --- /dev/null +++ b/tools/gframework-config-tool/src/localizationKeys.js @@ -0,0 +1,13 @@ +const ValidationMessageKeys = Object.freeze({ + enumMismatch: "validation.enumMismatch", + expectedArray: "validation.expectedArray", + expectedObject: "validation.expectedObject", + expectedScalarShape: "validation.expectedScalarShape", + expectedScalarValue: "validation.expectedScalarValue", + missingRequired: "validation.missingRequired", + unknownProperty: "validation.unknownProperty" +}); + +module.exports = { + ValidationMessageKeys +}; diff --git a/tools/gframework-config-tool/test/configValidation.test.js b/tools/gframework-config-tool/test/configValidation.test.js index e62415dc..225be952 100644 --- a/tools/gframework-config-tool/test/configValidation.test.js +++ b/tools/gframework-config-tool/test/configValidation.test.js @@ -85,6 +85,20 @@ phases: assert.equal(yaml.map.get("phases").items[0].map.get("wave").value, "1"); }); +test("parseTopLevelYaml should keep complex mapping keys", () => { + const yaml = parseTopLevelYaml(` +my-key: slime +"complex key": value +root: + item.id: potion +`); + + assert.equal(yaml.kind, "object"); + assert.equal(yaml.map.get("my-key").value, "slime"); + assert.equal(yaml.map.get("complex key").value, "value"); + assert.equal(yaml.map.get("root").map.get("item.id").value, "potion"); +}); + test("validateParsedConfig should report missing and unknown nested properties", () => { const schema = parseSchemaContent(` { @@ -312,6 +326,22 @@ skills: assert.equal(comments["skills[0].id"], "Skill id note"); }); +test("extractYamlComments should keep comments for complex YAML keys", () => { + const comments = extractYamlComments(` +# Dashed key comment +my-key: Slime +# Quoted key comment +"complex key": value +root: + # Dotted key comment + item.id: potion +`); + + assert.equal(comments["my-key"], "Dashed key comment"); + assert.equal(comments["complex key"], "Quoted key comment"); + assert.equal(comments["root.item.id"], "Dotted key comment"); +}); + test("applyFormUpdates should preserve and update YAML comments", () => { const updated = applyFormUpdates( [ diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js index 30d2bf18..503eae15 100644 --- a/tools/gframework-config-tool/test/localization.test.js +++ b/tools/gframework-config-tool/test/localization.test.js @@ -23,3 +23,14 @@ test("createLocalizer should switch to Simplified Chinese for zh languages", () localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}), "已在“monster”中批量更新 2 个配置文件。"); }); + +test("createLocalizer should fall back to English for Traditional Chinese locales", () => { + const localizer = createLocalizer("zh-TW"); + + assert.equal(localizer.languageTag, "zh-tw"); + assert.equal(localizer.isChinese, false); + assert.equal(localizer.t("webview.button.save"), "Save Form"); + assert.equal( + localizer.t("message.batchEditUpdated", {count: 2, domain: "monster"}), + "Batch updated 2 config file(s) in 'monster'."); +});