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 diff --git a/docs/zh-CN/game/config-system.md b/docs/zh-CN/game/config-system.md index a133ff63..64eb7726 100644 --- a/docs/zh-CN/game/config-system.md +++ b/docs/zh-CN/game/config-system.md @@ -200,8 +200,12 @@ var hotReload = loader.EnableHotReload( - 浏览 `config/` 目录 - 打开 raw YAML 文件 - 打开匹配的 schema 文件 +- 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 - 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 - 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 +- 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释 +- 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 +- 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 - 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 diff --git a/tools/gframework-config-tool/README.md b/tools/gframework-config-tool/README.md index 8a7c9672..780518f4 100644 --- a/tools/gframework-config-tool/README.md +++ b/tools/gframework-config-tool/README.md @@ -7,9 +7,14 @@ 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 +- Render existing YAML comments in the form preview and edit per-field YAML comments directly from the form +- Jump from reference fields to the referenced schema, config domain, or direct config file when a reference value is + present +- Initialize empty config files from schema-derived example YAML - Batch edit one config domain across multiple files for top-level scalar and scalar-array fields - Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the lightweight editors diff --git a/tools/gframework-config-tool/package.json b/tools/gframework-config-tool/package.json index cbc751f3..cf9231ae 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.3", "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/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 301e3d67..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 @@ -95,16 +103,136 @@ function parseTopLevelYaml(text) { return parseBlock(tokens, state, tokens[0].indent); } +/** + * Extract comment text from a YAML document and map it to logical field paths. + * The extractor focuses on comment lines that appear immediately above one key + * or array item so the form preview can surface author intent near the field. + * + * @param {string} text YAML text. + * @returns {Record} Comment lookup keyed by logical path. + */ +function extractYamlComments(text) { + const lines = String(text).split(/\r?\n/u); + const comments = {}; + const stack = [{indent: -1, type: "object", path: "", nextIndex: 0}]; + let pendingComments = []; + + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + + if (trimmed.length === 0) { + pendingComments = []; + continue; + } + + const indent = countLeadingSpaces(line); + if (trimmed.startsWith("#")) { + pendingComments.push(trimmed.replace(/^#\s?/u, "")); + continue; + } + + while (stack.length > 1 && indent < stack[stack.length - 1].indent) { + stack.pop(); + } + + const currentContext = stack[stack.length - 1]; + if (trimmed.startsWith("-")) { + if (currentContext.type !== "array") { + pendingComments = []; + continue; + } + + const itemIndex = currentContext.nextIndex || 0; + currentContext.nextIndex = itemIndex + 1; + const itemPath = joinArrayIndexPath(currentContext.path, itemIndex); + assignPendingComments(comments, itemPath, pendingComments); + pendingComments = []; + + const rest = trimmed.slice(1).trim(); + if (rest.length === 0) { + const nextLine = findNextMeaningfulLine(lines, index + 1); + if (nextLine && nextLine.indent > indent) { + stack.push(createContextForChild(itemPath, nextLine)); + } + continue; + } + + const inlineObjectMapping = parseYamlMappingText(rest); + if (!inlineObjectMapping) { + continue; + } + + const itemObjectContext = {indent: indent + 2, type: "object", path: itemPath, nextIndex: 0}; + stack.push(itemObjectContext); + + const key = inlineObjectMapping.key; + const parsedValue = splitYamlValueAndInlineComment(inlineObjectMapping.rawValue.trim()); + if (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(joinPropertyPath(itemPath, key), nextLine)); + } + + continue; + } + + const mapping = parseYamlMappingText(trimmed); + if (!mapping) { + pendingComments = []; + continue; + } + + const key = mapping.key; + const valueInfo = splitYamlValueAndInlineComment(mapping.rawValue.trim()); + const currentPath = joinPropertyPath(currentContext.path, key); + assignPendingComments(comments, currentPath, pendingComments); + pendingComments = []; + + if (valueInfo.comment) { + comments[currentPath] = comments[currentPath] + ? `${comments[currentPath]}\n${valueInfo.comment}` + : valueInfo.comment; + } + + const nextLine = findNextMeaningfulLine(lines, index + 1); + if (valueInfo.value.length === 0 && nextLine && nextLine.indent > indent) { + stack.push(createContextForChild(currentPath, nextLine)); + } + } + + return comments; +} + +/** + * Create one example YAML config from a parsed schema tree. + * The sample includes schema descriptions as YAML comments so empty files can + * be bootstrapped into a readable starting point from the form preview. + * + * @param {{type: "object", required: string[], properties: Record}} schemaInfo Parsed schema. + * @returns {string} Example YAML text. + */ +function createSampleConfigYaml(schemaInfo) { + const sampleRoot = createSampleNodeFromSchema(schemaInfo); + const schemaComments = {}; + collectSchemaComments(schemaInfo, "", schemaComments); + return renderYaml(sampleRoot, 0, "", schemaComments).join("\n"); +} + /** * Produce extension-facing validation diagnostics from schema and parsed YAML. * * @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; } @@ -150,30 +278,42 @@ function isScalarCompatible(expectedType, scalarValue) { * from the parsed structure so nested object edits can be saved safely. * * @param {string} originalYaml Original YAML content. - * @param {{scalars?: Record, arrays?: Record, objectArrays?: Record>>}} updates Updated form values. + * @param {{scalars?: Record, arrays?: Record, objectArrays?: Record>>, comments?: Record}} updates Updated form values. * @returns {string} Updated YAML content. */ function applyFormUpdates(originalYaml, updates) { const root = normalizeRootNode(parseTopLevelYaml(originalYaml)); + const preservedComments = extractYamlComments(originalYaml); const scalarUpdates = updates.scalars || {}; const arrayUpdates = updates.arrays || {}; const objectArrayUpdates = updates.objectArrays || {}; + 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)))); } - return renderYaml(root).join("\n"); + for (const [path, comment] of Object.entries(commentUpdates)) { + const normalizedComment = String(comment || "").trim(); + if (normalizedComment.length === 0) { + delete preservedComments[path]; + continue; + } + + preservedComments[path] = normalizedComment; + } + + return renderYaml(root, 0, "", preservedComments).join("\n"); } /** @@ -308,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 { @@ -323,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, @@ -353,10 +493,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 +505,20 @@ 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(ValidationMessageKeys.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], + joinArrayIndexPath(displayPath, index), + diagnostics, + localizer); } return; } @@ -378,7 +526,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(ValidationMessageKeys.expectedScalarShape, localizer, { + displayPath, + schemaType: schemaNode.type, + yamlKind: yamlNode ? yamlNode.kind : "missing" + }) }); return; } @@ -386,7 +538,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(ValidationMessageKeys.expectedScalarValue, localizer, { + displayPath, + schemaType: schemaNode.type + }) }); return; } @@ -396,7 +551,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(ValidationMessageKeys.enumMismatch, localizer, { + displayPath, + values: schemaNode.enumValues.join(", ") + }) }); } } @@ -408,13 +566,23 @@ 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}'`; + 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: `${subject} is expected to be an object.` + message: localizeValidationMessage(ValidationMessageKeys.expectedObject, localizer, { + subject, + displayPath + }) }); return; } @@ -423,7 +591,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(ValidationMessageKeys.missingRequired, localizer, { + displayPath: joinPropertyPath(displayPath, requiredProperty) + }) }); } } @@ -432,7 +602,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(ValidationMessageKeys.unknownProperty, localizer, { + displayPath: joinPropertyPath(displayPath, entry.key) + }) }); continue; } @@ -440,8 +612,63 @@ function validateObjectNode(schemaNode, yamlNode, displayPath, diagnostics) { validateNode( schemaNode.properties[entry.key], entry.node, - combinePath(displayPath, entry.key), - diagnostics); + joinPropertyPath(displayPath, entry.key), + diagnostics, + localizer); + } +} + +/** + * Format one validation message in either English or Simplified Chinese. + * + * @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 ValidationMessageKeys.expectedArray: + return `属性“${params.displayPath}”应为数组。`; + case ValidationMessageKeys.expectedScalarShape: + return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前 YAML 结构是“${params.yamlKind}”。`; + case ValidationMessageKeys.expectedScalarValue: + return `属性“${params.displayPath}”应为“${params.schemaType}”,但当前标量值不兼容。`; + case ValidationMessageKeys.enumMismatch: + return `属性“${params.displayPath}”必须是以下值之一:${params.values}。`; + case ValidationMessageKeys.expectedObject: + return params.subject; + case ValidationMessageKeys.missingRequired: + return `缺少必填属性“${params.displayPath}”。`; + case ValidationMessageKeys.unknownProperty: + return `属性“${params.displayPath}”未在匹配的 schema 中声明。`; + default: + return key; + } + } + + switch (key) { + case ValidationMessageKeys.expectedArray: + return `Property '${params.displayPath}' is expected to be an array.`; + case ValidationMessageKeys.expectedScalarShape: + return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current YAML shape is '${params.yamlKind}'.`; + case ValidationMessageKeys.expectedScalarValue: + return `Property '${params.displayPath}' is expected to be '${params.schemaType}', but the current scalar value is incompatible.`; + case ValidationMessageKeys.enumMismatch: + return `Property '${params.displayPath}' must be one of: ${params.values}.`; + case ValidationMessageKeys.expectedObject: + return params.subject; + case ValidationMessageKeys.missingRequired: + return `Required property '${params.displayPath}' is missing.`; + case ValidationMessageKeys.unknownProperty: + return `Property '${params.displayPath}' is not declared in the matching schema.`; + default: + return key; } } @@ -513,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; @@ -568,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; } @@ -666,13 +893,13 @@ function setObjectEntry(objectNode, key, valueNode) { * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ -function renderYaml(node, indent = 0) { +function renderYaml(node, indent = 0, currentPath = "", commentMap = {}) { if (node.kind === "object") { - return renderObjectNode(node, indent); + return renderObjectNode(node, indent, currentPath, commentMap); } if (node.kind === "array") { - return renderArrayNode(node, indent); + return renderArrayNode(node, indent, currentPath, commentMap); } return [`${" ".repeat(indent)}${formatYamlScalar(node.value)}`]; @@ -685,9 +912,14 @@ function renderYaml(node, indent = 0) { * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ -function renderObjectNode(node, indent) { +function renderObjectNode(node, indent, currentPath, commentMap) { const lines = []; for (const entry of node.entries) { + const entryPath = joinPropertyPath(currentPath, entry.key); + if (commentMap[entryPath]) { + lines.push(...renderYamlComments(commentMap[entryPath], indent)); + } + if (entry.node.kind === "scalar") { lines.push(`${" ".repeat(indent)}${entry.key}: ${formatYamlScalar(entry.node.value)}`); continue; @@ -699,7 +931,7 @@ function renderObjectNode(node, indent) { } lines.push(`${" ".repeat(indent)}${entry.key}:`); - lines.push(...renderYaml(entry.node, indent + 2)); + lines.push(...renderYaml(entry.node, indent + 2, entryPath, commentMap)); } return lines; @@ -712,16 +944,22 @@ function renderObjectNode(node, indent) { * @param {number} indent Current indentation. * @returns {string[]} YAML lines. */ -function renderArrayNode(node, indent) { +function renderArrayNode(node, indent, currentPath, commentMap) { const lines = []; - for (const item of node.items) { + for (let index = 0; index < node.items.length; index += 1) { + const item = node.items[index]; + const itemPath = joinArrayIndexPath(currentPath, index); + if (commentMap[itemPath]) { + lines.push(...renderYamlComments(commentMap[itemPath], indent)); + } + if (item.kind === "scalar") { lines.push(`${" ".repeat(indent)}- ${formatYamlScalar(item.value)}`); continue; } lines.push(`${" ".repeat(indent)}-`); - lines.push(...renderYaml(item, indent + 2)); + lines.push(...renderYaml(item, indent + 2, itemPath, commentMap)); } return lines; @@ -783,19 +1021,274 @@ function createObjectNode() { } /** - * Combine a parent path with one child segment. + * Build one example node recursively from schema metadata. * - * @param {string} parentPath Parent path. - * @param {string} key Child key. - * @returns {string} Combined path. + * @param {SchemaNode} schemaNode Schema node. + * @returns {YamlNode} Example YAML node. */ -function combinePath(parentPath, key) { - return parentPath && parentPath !== "" ? `${parentPath}.${key}` : key; +function createSampleNodeFromSchema(schemaNode) { + if (!schemaNode || schemaNode.type === "object") { + const objectNode = createObjectNode(); + for (const [key, propertySchema] of Object.entries(schemaNode && schemaNode.properties ? schemaNode.properties : {})) { + const childNode = createSampleNodeFromSchema(propertySchema); + setObjectEntry(objectNode, key, childNode); + } + + return objectNode; + } + + if (schemaNode.type === "array") { + if (schemaNode.items.type === "object") { + return createArrayNode([createSampleNodeFromSchema(schemaNode.items)]); + } + + return createArrayNode([createScalarNode(getSampleScalarValue(schemaNode.items))]); + } + + return createScalarNode(getSampleScalarValue(schemaNode)); +} + +/** + * Collect schema descriptions into a YAML comment lookup so sample configs can + * start with human-readable guidance right above generated fields. + * + * @param {SchemaNode} schemaNode Schema node. + * @param {string} currentPath Current logical path. + * @param {Record} commentMap Comment lookup. + */ +function collectSchemaComments(schemaNode, currentPath, commentMap) { + if (!schemaNode || schemaNode.type !== "object") { + return; + } + + for (const [key, propertySchema] of Object.entries(schemaNode.properties || {})) { + const propertyPath = joinPropertyPath(currentPath, key); + if (propertySchema.description) { + commentMap[propertyPath] = propertySchema.description; + } + + if (propertySchema.type === "object") { + collectSchemaComments(propertySchema, propertyPath, commentMap); + continue; + } + + if (propertySchema.type === "array" && propertySchema.items.type === "object") { + collectSchemaComments(propertySchema.items, joinArrayIndexPath(propertyPath, 0), commentMap); + } + } +} + +/** + * Resolve one sample scalar value from schema metadata. + * + * @param {Extract} schemaNode Scalar schema node. + * @returns {string} Sample scalar value. + */ +function getSampleScalarValue(schemaNode) { + if (schemaNode.defaultValue !== undefined) { + return schemaNode.defaultValue; + } + + if (Array.isArray(schemaNode.enumValues) && schemaNode.enumValues.length > 0) { + return schemaNode.enumValues[0]; + } + + switch (schemaNode.type) { + case "integer": + return "0"; + case "number": + return "0"; + case "boolean": + return "false"; + case "string": + default: + return schemaNode.refTable + ? "example_id" + : "example"; + } +} + +/** + * Render one comment block to YAML lines. + * + * @param {string} commentText Comment text. + * @param {number} indent Current indentation. + * @returns {string[]} YAML comment lines. + */ +function renderYamlComments(commentText, indent) { + return String(commentText) + .split(/\r?\n/u) + .filter((line) => line.length > 0) + .map((line) => `${" ".repeat(indent)}# ${line}`); +} + +/** + * Assign pending comment lines to one logical path. + * + * @param {Record} commentMap Comment lookup. + * @param {string} path Logical path. + * @param {string[]} pendingComments Pending comment lines. + */ +function assignPendingComments(commentMap, path, pendingComments) { + if (!path || !Array.isArray(pendingComments) || pendingComments.length === 0) { + return; + } + + commentMap[path] = pendingComments.join("\n"); +} + +/** + * Count leading spaces in one source line. + * + * @param {string} line Source line. + * @returns {number} Leading-space count. + */ +function countLeadingSpaces(line) { + const indentMatch = /^(\s*)/u.exec(line); + return indentMatch ? indentMatch[1].length : 0; +} + +/** + * Find the next non-empty, non-comment source line. + * + * @param {string[]} lines Source lines. + * @param {number} startIndex Starting index. + * @returns {{indent: number, trimmed: string} | undefined} Next significant line. + */ +function findNextMeaningfulLine(lines, startIndex) { + for (let index = startIndex; index < lines.length; index += 1) { + const line = lines[index]; + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) { + continue; + } + + return { + indent: countLeadingSpaces(line), + trimmed + }; + } + + return undefined; +} + +/** + * Create one container context from the next meaningful line. + * + * @param {string} path Logical parent path. + * @param {{indent: number, trimmed: string}} nextLine Next meaningful line. + * @returns {{indent: number, type: "object" | "array", path: string, nextIndex: number}} Context model. + */ +function createContextForChild(path, nextLine) { + return { + indent: nextLine.indent, + type: nextLine.trimmed.startsWith("-") ? "array" : "object", + path, + nextIndex: 0 + }; +} + +/** + * Split a YAML value from one inline trailing comment. + * + * @param {string} rawValue Raw value segment after `key:`. + * @returns {{value: string, comment?: string}} Parsed value and optional comment. + */ +function splitYamlValueAndInlineComment(rawValue) { + let inSingleQuote = false; + let inDoubleQuote = false; + + for (let index = 0; index < rawValue.length; index += 1) { + const character = rawValue[index]; + if (character === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (character === "\"" && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (character === "#" && !inSingleQuote && !inDoubleQuote && (index === 0 || /\s/u.test(rawValue[index - 1]))) { + return { + value: rawValue.slice(0, index).trimEnd(), + comment: rawValue.slice(index + 1).trim() + }; + } + } + + return {value: rawValue}; +} + +/** + * Parse one YAML mapping entry such as `key: value` or `"complex key": value`. + * + * @param {string} text Raw YAML line text without leading indentation. + * @returns {{key: string, rawValue: string} | undefined} Parsed mapping entry. + */ +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 = { applyFormUpdates, applyScalarUpdates, + createSampleConfigYaml, + extractYamlComments, getEditableSchemaFields, isEditableScalarType, isScalarCompatible, diff --git a/tools/gframework-config-tool/src/extension.js b/tools/gframework-config-tool/src/extension.js index 22fceb82..9695074e 100644 --- a/tools/gframework-config-tool/src/extension.js +++ b/tools/gframework-config-tool/src/extension.js @@ -3,6 +3,8 @@ const path = require("path"); const vscode = require("vscode"); const { applyFormUpdates, + createSampleConfigYaml, + extractYamlComments, getEditableSchemaFields, parseBatchArrayValue, parseSchemaContent, @@ -10,6 +12,15 @@ const { unquoteScalar, validateParsedConfig } = require("./configValidation"); +const { + isTemplatePath, + joinArrayIndexPath, + joinArrayTemplatePath, + joinPropertyPath +} = require("./configPath"); +const {createLocalizer} = require("./localization"); + +const localizer = createLocalizer(vscode.env.language); /** * Activate the GFramework config extension. @@ -132,11 +143,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 +182,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 +194,7 @@ class ConfigTreeDataProvider { item.contextValue = "gframeworkConfigFile"; item.command = { command: "gframeworkConfig.openRaw", - title: "Open Raw", + title: localizer.t("command.openRaw.title"), arguments: [item] }; @@ -243,7 +254,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; } @@ -251,6 +262,87 @@ async function openSchemaFile(item) { await vscode.window.showTextDocument(document, {preview: false}); } +/** + * Open the schema file for a referenced config table. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @param {string | undefined} refTable Referenced table name. + * @returns {Promise} Async task. + */ +async function openReferenceSchemaFile(workspaceRoot, refTable) { + if (!workspaceRoot || !refTable) { + return; + } + + const schemaUri = vscode.Uri.joinPath(getSchemasRoot(workspaceRoot), `${refTable}.schema.json`); + if (!fs.existsSync(schemaUri.fsPath)) { + void vscode.window.showWarningMessage(localizer.t("message.referenceSchemaMissing", {refTable})); + return; + } + + const document = await vscode.workspace.openTextDocument(schemaUri); + await vscode.window.showTextDocument(document, {preview: false}); +} + +/** + * Reveal the referenced config domain directory in the Explorer. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @param {string | undefined} refTable Referenced table name. + * @returns {Promise} Async task. + */ +async function revealReferenceDomain(workspaceRoot, refTable) { + if (!workspaceRoot || !refTable) { + return; + } + + const domainUri = vscode.Uri.joinPath(getConfigRoot(workspaceRoot), refTable); + if (!fs.existsSync(domainUri.fsPath)) { + void vscode.window.showWarningMessage(localizer.t("message.referenceDomainMissing", {refTable})); + return; + } + + await vscode.commands.executeCommand("revealInExplorer", domainUri); +} + +/** + * Open the referenced config file when the current field already has a key + * value. If the direct file cannot be found, fall back to revealing the whole + * referenced domain. + * + * @param {vscode.WorkspaceFolder} workspaceRoot Workspace root. + * @param {string | undefined} refTable Referenced table name. + * @param {string | undefined} refValue Referenced config id or file stem. + * @returns {Promise} Async task. + */ +async function openReferenceValueFile(workspaceRoot, refTable, refValue) { + if (!workspaceRoot || !refTable || !refValue) { + return; + } + + const configRoot = getConfigRoot(workspaceRoot); + const domainUri = vscode.Uri.joinPath(configRoot, refTable); + const yamlCandidate = vscode.Uri.joinPath(domainUri, `${refValue}.yaml`); + const ymlCandidate = vscode.Uri.joinPath(domainUri, `${refValue}.yml`); + const targetUri = fs.existsSync(yamlCandidate.fsPath) + ? yamlCandidate + : fs.existsSync(ymlCandidate.fsPath) + ? ymlCandidate + : undefined; + + if (!targetUri) { + await revealReferenceDomain(workspaceRoot, refTable); + void vscode.window.showWarningMessage(localizer.t("message.referenceValueMissing", { + refTable, + refValue + })); + return; + } + + const document = await vscode.workspace.openTextDocument(targetUri); + await vscode.window.showTextDocument(document, {preview: false}); +} + /** * Open a lightweight form preview for schema-bound config fields. * The preview walks nested object structures recursively and now supports @@ -267,38 +359,97 @@ async function openFormPreview(item, diagnostics) { return; } - const yamlText = await fs.promises.readFile(configUri.fsPath, "utf8"); - const parsedYaml = parseTopLevelYaml(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 && latestYamlText.trim().length === 0; 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}); panel.webview.html = renderFormHtml( path.basename(configUri.fsPath), schemaInfo, - parsedYaml); + parsedYaml, + { + commentLookup, + canInitializeFromSchema + }); 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 || {}), - objectArrays: message.objectArrays || {} + objectArrays: message.objectArrays || {}, + comments: message.comments || {} }); await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8"); 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")); + return; } if (message.type === "openRaw") { await openRawFile({resourceUri: configUri}); + return; + } + + if (message.type === "initializeFromSchema") { + if (!schemaInfo.exists) { + void vscode.window.showWarningMessage(localizer.t("message.schemaNotFound")); + 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(latestYamlText), + { + commentLookup: extractYamlComments(latestYamlText), + canInitializeFromSchema: false + }); + void vscode.window.showInformationMessage(localizer.t("message.formInitialized")); + return; + } + + if (message.type === "openReferenceSchema") { + await openReferenceSchemaFile(workspaceRoot, message.refTable); + return; + } + + if (message.type === "openReferenceDomain") { + await revealReferenceDomain(workspaceRoot, message.refTable); + return; + } + + if (message.type === "openReferenceValue") { + await openReferenceValueFile(workspaceRoot, message.refTable, message.refValue); } }); } @@ -353,13 +504,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 +554,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 +569,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 +583,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 +642,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 +659,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) + })); } /** @@ -566,10 +720,18 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) { * @param {string} fileName File name. * @param {{exists: boolean, schemaPath: string, required: string[], properties: Record, type?: string}} schemaInfo Schema info. * @param {unknown} parsedYaml Parsed YAML data. + * @param {{commentLookup?: Record, canInitializeFromSchema?: boolean} | undefined} options Render options. * @returns {string} HTML string. */ -function renderFormHtml(fileName, schemaInfo, parsedYaml) { - const formModel = buildFormModel(schemaInfo, parsedYaml); +function renderFormHtml(fileName, schemaInfo, parsedYaml, options) { + const renderOptions = options || {}; + const formModel = buildFormModel(schemaInfo, parsedYaml, renderOptions.commentLookup || {}); + const saveButtonLabel = escapeHtml(localizer.t("webview.button.save")); + const openRawButtonLabel = escapeHtml(localizer.t("webview.button.openRaw")); + const objectArrayItemLabel = localizer.t("webview.objectArray.item"); + const initializeAction = renderOptions.canInitializeFromSchema + ? `` + : ""; const renderedFields = formModel.fields .map((field) => renderFormField(field)) .join("\n"); @@ -583,8 +745,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 +754,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 ` - + @@ -626,6 +788,12 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { margin-bottom: 16px; color: var(--vscode-descriptionForeground); } + .hint-banner { + padding: 10px 12px; + border: 1px solid var(--vscode-panel-border, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--vscode-editor-background) 92%, var(--vscode-panel-border, transparent)); + } .field { display: block; margin-bottom: 12px; @@ -692,6 +860,30 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) { margin-bottom: 10px; color: var(--vscode-descriptionForeground); } + .field-actions { + display: flex; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; + } + .link-button { + padding: 4px 8px; + font-size: 12px; + } + .yaml-comment { + display: block; + margin-bottom: 6px; + padding: 8px 10px; + border-left: 3px solid var(--vscode-textBlockQuote-border); + background: color-mix(in srgb, var(--vscode-editor-background) 90%, var(--vscode-textBlockQuote-border)); + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + font-family: var(--vscode-editor-font-family, var(--vscode-font-family)); + font-size: 12px; + } + .comment-editor { + margin-top: 8px; + } .object-array { margin-bottom: 18px; padding: 12px; @@ -739,16 +931,19 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
- - + + + ${initializeAction}
+
${escapeHtml(localizer.t("webview.help.summary"))}
-
File: ${escapeHtml(fileName)}
+
${escapeHtml(localizer.t("webview.meta.file", {fileName}))}
${schemaStatus}
${emptyState}
`; @@ -858,9 +1091,11 @@ function renderFormField(field) { if (field.kind === "section") { return `
-
${escapeHtml(field.label)} ${field.required ? "required" : ""}
+
${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)}
`; } @@ -870,36 +1105,42 @@ function renderFormField(field) { .map((item) => renderObjectArrayItem(item)) .join("\n"); const renderedTemplate = renderObjectArrayItem({ - title: "Item", + title: localizer.t("webview.objectArray.item"), fields: field.templateFields }); return `
-
${escapeHtml(field.label)} ${field.required ? "required" : ""}
+
${escapeHtml(field.label)} ${field.required ? `${escapeHtml(localizer.t("webview.badge.required"))}` : ""}
${escapeHtml(field.displayPath || field.path)}
- Each item uses the object schema below. + ${renderYamlCommentBlock(field)} + ${escapeHtml(localizer.t("webview.objectArray.hint"))} ${renderFieldHint(field.schema, true)} + ${renderReferenceActions(field)} + ${renderCommentEditor(field)}
${renderedItems}
- +
`; } if (field.kind === "array") { const itemType = field.itemType - ? `array<${escapeHtml(field.itemType)}>` + ? `array<${field.itemType}>` : "array"; const dataAttribute = field.itemMode ? `data-item-array-path="${escapeHtml(field.path)}"` : `data-array-path="${escapeHtml(field.path)}"`; return ` `; } @@ -922,14 +1163,78 @@ function renderFormField(field) { return ` `; } +/** + * 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 (isTemplatePath(commentPath)) { + 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. * @@ -941,7 +1246,7 @@ function renderObjectArrayItem(item) {
${escapeHtml(item.title)} - +
${item.fields.map((field) => renderFormField(field)).join("\n")}
@@ -953,16 +1258,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}; } @@ -975,8 +1281,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; } @@ -985,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); @@ -995,10 +1302,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; } @@ -1014,7 +1322,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; } @@ -1027,10 +1336,11 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns propertySchema.items, undefined, "", - `${propertyPath}[]`, + joinArrayTemplatePath(propertyPath), depth + 1, itemFieldsTemplate, - unsupported); + unsupported, + commentLookup); fields.push({ kind: "objectArray", path: propertyPath, @@ -1039,7 +1349,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; @@ -1054,7 +1371,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; } @@ -1062,8 +1380,8 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns unsupported.push({ path: propertyPath, message: propertySchema.type === "array" - ? "Unsupported array shapes are currently raw-YAML-only in the form preview." - : `${propertySchema.type} fields are currently raw-YAML-only.` + ? localizer.t("webview.unsupported.array") + : localizer.t("webview.unsupported.type", {type: propertySchema.type}) }); } } @@ -1076,9 +1394,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 []; } @@ -1086,11 +1405,11 @@ 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, - message: "Object-array items must be mappings. Use raw YAML if the current file mixes scalar and object items." + message: localizer.t("webview.unsupported.objectArrayMixed") }); continue; } @@ -1103,9 +1422,10 @@ function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, u itemPath, depth, fields, - unsupported); + unsupported, + commentLookup); items.push({ - title: `Item ${index + 1}`, + title: localizer.t("webview.objectArray.itemNumber", {index: index + 1}), fields }); } @@ -1125,8 +1445,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; } @@ -1135,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); @@ -1147,6 +1468,7 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa displayPath: itemDisplayPath, label, description: propertySchema.description, + comment: commentLookup[itemDisplayPath] || "", required: requiredSet.has(key), depth }); @@ -1157,7 +1479,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa itemDisplayPath, depth + 1, fields, - unsupported); + unsupported, + commentLookup); continue; } @@ -1174,7 +1497,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa itemType: propertySchema.items.type, value: getScalarArrayValue(propertyValue), schema: propertySchema, - itemMode: true + itemMode: true, + comment: commentLookup[itemDisplayPath] || "" }); continue; } @@ -1189,7 +1513,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa depth, value: getScalarFieldValue(propertyValue, propertySchema.defaultValue), schema: propertySchema, - itemMode: true + itemMode: true, + comment: commentLookup[itemDisplayPath] || "" }); continue; } @@ -1197,8 +1522,8 @@ function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPa unsupported.push({ path: itemDisplayPath, message: propertySchema.type === "array" - ? "Nested object-array fields are currently raw-YAML-only inside the object-array editor." - : `${propertySchema.type} fields are currently raw-YAML-only.` + ? localizer.t("webview.unsupported.nestedObjectArray") + : localizer.t("webview.unsupported.type", {type: propertySchema.type}) }); } } @@ -1261,7 +1586,7 @@ function renderFieldHint(propertySchema, isArrayField) { } if (propertySchema.defaultValue) { - hints.push(`Default: ${escapeHtml(propertySchema.defaultValue)}`); + hints.push(escapeHtml(localizer.t("webview.hint.default", {value: propertySchema.defaultValue}))); } const enumValues = isArrayField @@ -1270,11 +1595,11 @@ function renderFieldHint(propertySchema, isArrayField) { : [] : propertySchema.enumValues; if (Array.isArray(enumValues) && enumValues.length > 0) { - hints.push(`Allowed: ${escapeHtml(enumValues.join(", "))}`); + hints.push(escapeHtml(localizer.t("webview.hint.allowed", {values: enumValues.join(", ")}))); } if (propertySchema.refTable) { - hints.push(`Ref table: ${escapeHtml(propertySchema.refTable)}`); + hints.push(escapeHtml(localizer.t("webview.hint.refTable", {refTable: propertySchema.refTable}))); } if (hints.length === 0) { @@ -1294,16 +1619,21 @@ async function promptBatchFieldValue(field) { if (field.inputKind === "array") { const hintParts = []; if (field.itemEnumValues && field.itemEnumValues.length > 0) { - hintParts.push(`Allowed items: ${field.itemEnumValues.join(", ")}`); + hintParts.push(localizer.t("input.batchArray.placeholder.allowedItems", { + values: field.itemEnumValues.join(", ") + })); } if (field.defaultValue) { - hintParts.push(`Default: ${field.defaultValue}`); + hintParts.push(localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue})); } return vscode.window.showInputBox({ - title: `Batch Edit Array: ${field.title || field.key}`, - prompt: `Enter comma-separated items for '${field.key}' (expected array<${field.itemType}>). Leave empty to clear the array.`, + title: localizer.t("input.batchArray.title", {field: field.title || field.key}), + prompt: localizer.t("input.batchArray.prompt", { + fieldKey: field.key, + itemType: field.itemType + }), placeHolder: hintParts.join(" | "), ignoreFocusOut: true }); @@ -1313,22 +1643,27 @@ async function promptBatchFieldValue(field) { const picked = await vscode.window.showQuickPick( field.enumValues.map((value) => ({ label: value, - description: value === field.defaultValue ? "default" : undefined + description: value === field.defaultValue + ? localizer.t("detail.default") + : undefined })), { - title: `Batch Edit Field: ${field.title || field.key}`, - placeHolder: `Select a value for '${field.key}'.` + title: localizer.t("quickPick.batchField.title", {field: field.title || field.key}), + placeHolder: localizer.t("quickPick.batchField.placeholder", {fieldKey: field.key}) }); return picked ? picked.label : undefined; } return vscode.window.showInputBox({ - title: `Batch Edit Field: ${field.title || field.key}`, - prompt: `Enter the new value for '${field.key}' (expected ${field.type}).`, + title: localizer.t("input.batchField.title", {field: field.title || field.key}), + prompt: localizer.t("input.batchField.prompt", { + fieldKey: field.key, + type: field.type + }), placeHolder: [ field.description || "", - field.defaultValue ? `Default: ${field.defaultValue}` : "", - field.refTable ? `Ref table: ${field.refTable}` : "" + field.defaultValue ? localizer.t("input.batchArray.placeholder.default", {value: field.defaultValue}) : "", + field.refTable ? localizer.t("input.batchField.placeholder.refTable", {refTable: field.refTable}) : "" ].filter((part) => part.length > 0).join(" | ") || undefined, ignoreFocusOut: true }); diff --git a/tools/gframework-config-tool/src/localization.js b/tools/gframework-config-tool/src/localization.js new file mode 100644 index 00000000..3c8a74d7 --- /dev/null +++ b/tools/gframework-config-tool/src/localization.js @@ -0,0 +1,198 @@ +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 + * resolved here so the preview panel and prompts stay readable for both + * Simplified Chinese and English users. + * + * @param {string | undefined} language VS Code UI language. + * @returns {{languageTag: string, isChinese: boolean, t: (key: string, params?: Record) => string}} Localizer. + */ +function createLocalizer(language) { + const normalizedLanguage = String(language || "en").toLowerCase(); + const isChinese = normalizedLanguage.startsWith("zh"); + 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: isSimplifiedChinese, + t(key, params) { + const template = dictionary[key] || enMessages[key] || key; + return template.replace(/\{([A-Za-z0-9_]+)\}/gu, (match, token) => { + if (!params || !Object.prototype.hasOwnProperty.call(params, token)) { + return match; + } + + return String(params[token]); + }); + } + }; +} + +const enMessages = { + "tree.noConfigDirectory.label": "No config directory", + "tree.noConfigDirectory.description": "Set gframeworkConfig.configPath or create the directory.", + "tree.fileDescription.schema": "schema", + "tree.fileDescription.schemaMissing": "schema missing", + "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.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.", + "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.", + "quickPick.batchEditFields.title": "Batch Edit Fields: {domain}", + "quickPick.batchEditFields.placeholder": "Select the fields to apply across the chosen files.", + "detail.required": "required", + "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}", + "input.batchArray.placeholder.default": "Default: {value}", + "quickPick.batchField.title": "Batch Edit Field: {field}", + "quickPick.batchField.placeholder": "Select a value for '{fieldKey}'.", + "input.batchField.title": "Batch Edit Field: {field}", + "input.batchField.prompt": "Enter the new value for '{fieldKey}' (expected {type}).", + "input.batchField.placeholder.refTable": "Ref table: {refTable}", + "webview.panelTitle": "Config Form: {fileName}", + "webview.meta.file": "File: {fileName}", + "webview.meta.schema": "Schema: {schemaPath}", + "webview.meta.schemaMissing": "Schema missing: {schemaPath}", + "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.", + "webview.objectArray.add": "Add Item", + "webview.objectArray.remove": "Remove", + "webview.array.hint": "One item per line. Expected type: {itemType}", + "webview.hint.default": "Default: {value}", + "webview.hint.allowed": "Allowed: {values}", + "webview.hint.refTable": "Ref table: {refTable}", + "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.", + [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 = { + "tree.noConfigDirectory.label": "未找到配置目录", + "tree.noConfigDirectory.description": "请设置 gframeworkConfig.configPath,或先创建该目录。", + "tree.fileDescription.schema": "已匹配 schema", + "tree.fileDescription.schemaMissing": "缺少 schema", + "command.openRaw.title": "打开原始文件", + "message.schemaNotFound": "未找到匹配的 schema 文件。", + "message.formSaved": "已从表单预览保存配置文件。", + "message.formInitialized": "已根据 schema 初始化示例配置。", + "message.initializeFromSchemaConfirm": "从 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": "选择要更新的配置文件。", + "quickPick.batchEditFields.title": "批量编辑字段:{domain}", + "quickPick.batchEditFields.placeholder": "选择要应用到已选文件的字段。", + "detail.required": "必填", + "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}", + "input.batchArray.placeholder.default": "默认值:{value}", + "quickPick.batchField.title": "批量编辑字段:{field}", + "quickPick.batchField.placeholder": "为“{fieldKey}”选择一个值。", + "input.batchField.title": "批量编辑字段:{field}", + "input.batchField.prompt": "请输入“{fieldKey}”的新值(期望类型:{type})。", + "input.batchField.placeholder.refTable": "引用表:{refTable}", + "webview.panelTitle": "配置表单:{fileName}", + "webview.meta.file": "文件:{fileName}", + "webview.meta.schema": "Schema:{schemaPath}", + "webview.meta.schemaMissing": "缺少 Schema:{schemaPath}", + "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 编辑。", + "webview.objectArray.add": "新增对象项", + "webview.objectArray.remove": "删除", + "webview.array.hint": "每行一个元素。期望类型:{itemType}", + "webview.hint.default": "默认值:{value}", + "webview.hint.allowed": "允许值:{values}", + "webview.hint.refTable": "引用表:{refTable}", + "webview.unsupported.array": "当前表单预览暂不支持这种数组结构,请改用原始 YAML。", + "webview.unsupported.type": "当前表单预览暂不支持 {type} 字段,请改用原始 YAML。", + "webview.unsupported.objectArrayMixed": "对象数组中的每一项都必须是映射对象。如果当前文件混用了标量项和对象项,请改用原始 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 = { + createLocalizer +}; 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 ae8a2723..225be952 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, @@ -83,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(` { @@ -174,6 +190,27 @@ reward: assert.match(diagnostics[0].message, /coin, gem/u); }); +test("validateParsedConfig should localize diagnostics when Chinese UI is requested", () => { + const schema = parseSchemaContent(` + { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" } + } + } + `); + const yaml = parseTopLevelYaml(` +id: 1 +`); + + const diagnostics = validateParsedConfig(schema, yaml, {isChinese: true}); + + assert.equal(diagnostics.length, 2); + assert.match(diagnostics[0].message, /缺少必填属性/u); + assert.match(diagnostics[1].message, /未在匹配的 schema 中声明/u); +}); + test("applyFormUpdates should update nested scalar and scalar-array paths", () => { const updated = applyFormUpdates( [ @@ -269,6 +306,111 @@ 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("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( + [ + "# 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( [ diff --git a/tools/gframework-config-tool/test/localization.test.js b/tools/gframework-config-tool/test/localization.test.js new file mode 100644 index 00000000..503eae15 --- /dev/null +++ b/tools/gframework-config-tool/test/localization.test.js @@ -0,0 +1,36 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const {createLocalizer} = require("../src/localization"); + +test("createLocalizer should default to English strings", () => { + const localizer = createLocalizer("en"); + + assert.equal(localizer.languageTag, "en"); + 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'."); +}); + +test("createLocalizer should switch to Simplified Chinese for zh languages", () => { + const localizer = createLocalizer("zh-cn"); + + assert.equal(localizer.languageTag, "zh-CN"); + assert.equal(localizer.isChinese, true); + assert.equal(localizer.t("webview.button.save"), "保存表单"); + assert.equal( + 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'."); +});