mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-04-02 20:09:00 +08:00
feat(docs): 添加游戏内容配置系统文档和VSCode插件功能
- 新增游戏内容配置系统完整文档,介绍AI-First配表方案 - 实现YAML配置源文件和JSON Schema结构描述功能 - 添加运行时只读查询和Source Generator类型生成能力 - 集成VSCode插件提供配置浏览、校验和表单编辑功能 - 支持开发期热重载和跨表引用校验机制 - 提供批量编辑和嵌套对象安全表单入口
This commit is contained in:
parent
65a6e2c257
commit
38bd934779
@ -201,11 +201,20 @@ var hotReload = loader.EnableHotReload(
|
|||||||
- 打开 raw YAML 文件
|
- 打开 raw YAML 文件
|
||||||
- 打开匹配的 schema 文件
|
- 打开匹配的 schema 文件
|
||||||
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
|
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
|
||||||
- 对嵌套对象字段、顶层标量字段和顶层标量数组提供轻量表单入口
|
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
|
||||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||||
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
|
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
|
||||||
|
|
||||||
当前表单入口适合编辑嵌套对象中的标量字段和标量数组;对象数组仍建议放在 raw YAML 中完成。
|
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||||
|
|
||||||
|
对象数组编辑器当前支持:
|
||||||
|
|
||||||
|
- 新增和删除对象项
|
||||||
|
- 编辑对象项中的标量字段
|
||||||
|
- 编辑对象项中的标量数组
|
||||||
|
- 编辑对象项中的嵌套对象字段
|
||||||
|
|
||||||
|
如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。
|
||||||
|
|
||||||
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
|
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
|
||||||
|
|
||||||
@ -214,7 +223,7 @@ var hotReload = loader.EnableHotReload(
|
|||||||
以下能力尚未完全完成:
|
以下能力尚未完全完成:
|
||||||
|
|
||||||
- 更完整的 JSON Schema 支持
|
- 更完整的 JSON Schema 支持
|
||||||
- VS Code 中对象数组的安全表单编辑器
|
- VS Code 中更深层对象数组嵌套的安全表单编辑器
|
||||||
- 更强的复杂数组与更深 schema 关键字支持
|
- 更强的复杂数组与更深 schema 关键字支持
|
||||||
|
|
||||||
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。
|
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。
|
||||||
|
|||||||
@ -9,7 +9,7 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
|
|||||||
- Open matching schema files from `schemas/`
|
- Open matching schema files from `schemas/`
|
||||||
- Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and
|
- Run lightweight schema validation for nested required fields, unknown nested fields, scalar types, scalar arrays, and
|
||||||
arrays of objects
|
arrays of objects
|
||||||
- Open a lightweight form preview for nested object fields, top-level scalar fields, and scalar arrays
|
- Open a lightweight form preview for nested object fields, object arrays, top-level scalar fields, and scalar arrays
|
||||||
- Batch edit one config domain across multiple files for top-level scalar and scalar-array fields
|
- 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
|
- Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the
|
||||||
lightweight editors
|
lightweight editors
|
||||||
@ -25,8 +25,6 @@ The extension currently validates the repository's minimal config-schema subset:
|
|||||||
- arrays of objects whose items use the same supported subset recursively
|
- arrays of objects whose items use the same supported subset recursively
|
||||||
- scalar `enum` constraints and scalar-array item `enum` constraints
|
- scalar `enum` constraints and scalar-array item `enum` constraints
|
||||||
|
|
||||||
Object-array editing should still be reviewed in raw YAML.
|
|
||||||
|
|
||||||
## Local Testing
|
## Local Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -38,7 +36,7 @@ node --test ./test/*.test.js
|
|||||||
|
|
||||||
- Multi-root workspaces use the first workspace folder
|
- Multi-root workspaces use the first workspace folder
|
||||||
- Validation only covers a minimal subset of JSON Schema
|
- Validation only covers a minimal subset of JSON Schema
|
||||||
- Form preview supports nested objects and scalar arrays, but object arrays remain raw-YAML-only for edits
|
- Form preview supports object-array editing, but nested object arrays inside array items still fall back to raw YAML
|
||||||
- Batch editing remains limited to top-level scalar fields and top-level scalar arrays
|
- Batch editing remains limited to top-level scalar fields and top-level scalar arrays
|
||||||
|
|
||||||
## Workspace Settings
|
## Workspace Settings
|
||||||
|
|||||||
@ -150,13 +150,14 @@ function isScalarCompatible(expectedType, scalarValue) {
|
|||||||
* from the parsed structure so nested object edits can be saved safely.
|
* from the parsed structure so nested object edits can be saved safely.
|
||||||
*
|
*
|
||||||
* @param {string} originalYaml Original YAML content.
|
* @param {string} originalYaml Original YAML content.
|
||||||
* @param {{scalars?: Record<string, string>, arrays?: Record<string, string[]>}} updates Updated form values.
|
* @param {{scalars?: Record<string, string>, arrays?: Record<string, string[]>, objectArrays?: Record<string, Array<Record<string, unknown>>>}} updates Updated form values.
|
||||||
* @returns {string} Updated YAML content.
|
* @returns {string} Updated YAML content.
|
||||||
*/
|
*/
|
||||||
function applyFormUpdates(originalYaml, updates) {
|
function applyFormUpdates(originalYaml, updates) {
|
||||||
const root = normalizeRootNode(parseTopLevelYaml(originalYaml));
|
const root = normalizeRootNode(parseTopLevelYaml(originalYaml));
|
||||||
const scalarUpdates = updates.scalars || {};
|
const scalarUpdates = updates.scalars || {};
|
||||||
const arrayUpdates = updates.arrays || {};
|
const arrayUpdates = updates.arrays || {};
|
||||||
|
const objectArrayUpdates = updates.objectArrays || {};
|
||||||
|
|
||||||
for (const [path, value] of Object.entries(scalarUpdates)) {
|
for (const [path, value] of Object.entries(scalarUpdates)) {
|
||||||
setNodeAtPath(root, path.split("."), createScalarNode(String(value)));
|
setNodeAtPath(root, path.split("."), createScalarNode(String(value)));
|
||||||
@ -167,6 +168,11 @@ function applyFormUpdates(originalYaml, updates) {
|
|||||||
(values || []).map((item) => createScalarNode(String(item)))));
|
(values || []).map((item) => createScalarNode(String(item)))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [path, items] of Object.entries(objectArrayUpdates)) {
|
||||||
|
setNodeAtPath(root, path.split("."), createArrayNode(
|
||||||
|
(items || []).map((item) => createNodeFromFormValue(item))));
|
||||||
|
}
|
||||||
|
|
||||||
return renderYaml(root).join("\n");
|
return renderYaml(root).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -687,6 +693,11 @@ function renderObjectNode(node, indent) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry.node.kind === "array" && entry.node.items.length === 0) {
|
||||||
|
lines.push(`${" ".repeat(indent)}${entry.key}: []`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
lines.push(`${" ".repeat(indent)}${entry.key}:`);
|
lines.push(`${" ".repeat(indent)}${entry.key}:`);
|
||||||
lines.push(...renderYaml(entry.node, indent + 2));
|
lines.push(...renderYaml(entry.node, indent + 2));
|
||||||
}
|
}
|
||||||
@ -736,6 +747,32 @@ function createArrayNode(items) {
|
|||||||
return {kind: "array", items};
|
return {kind: "array", items};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert one structured form value back into a YAML node tree.
|
||||||
|
* Object-array editors submit plain JavaScript objects so the writer can
|
||||||
|
* rebuild the full array deterministically instead of patching item paths
|
||||||
|
* one by one.
|
||||||
|
*
|
||||||
|
* @param {unknown} value Structured form value.
|
||||||
|
* @returns {YamlNode} YAML node.
|
||||||
|
*/
|
||||||
|
function createNodeFromFormValue(value) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return createArrayNode(value.map((item) => createNodeFromFormValue(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
const objectNode = createObjectNode();
|
||||||
|
for (const [key, childValue] of Object.entries(value)) {
|
||||||
|
setObjectEntry(objectNode, key, createNodeFromFormValue(childValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
return objectNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createScalarNode(String(value ?? ""));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an object node.
|
* Create an object node.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -13,8 +13,8 @@ const {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Activate the GFramework config extension.
|
* Activate the GFramework config extension.
|
||||||
* The initial MVP focuses on workspace file navigation, lightweight validation,
|
* The current tool focuses on workspace file navigation, lightweight
|
||||||
* and a small form-preview entry for top-level scalar values.
|
* validation, and a schema-aware form preview for common editing workflows.
|
||||||
*
|
*
|
||||||
* @param {vscode.ExtensionContext} context Extension context.
|
* @param {vscode.ExtensionContext} context Extension context.
|
||||||
*/
|
*/
|
||||||
@ -253,8 +253,8 @@ async function openSchemaFile(item) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a lightweight form preview for schema-bound config fields.
|
* Open a lightweight form preview for schema-bound config fields.
|
||||||
* The preview now walks nested object structures recursively, while complex
|
* The preview walks nested object structures recursively and now supports
|
||||||
* object-array editing still falls back to raw YAML for safety.
|
* object-array editing for the repository's supported schema subset.
|
||||||
*
|
*
|
||||||
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
|
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
|
||||||
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
|
* @param {vscode.DiagnosticCollection} diagnostics Diagnostic collection.
|
||||||
@ -287,7 +287,8 @@ async function openFormPreview(item, diagnostics) {
|
|||||||
const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
|
const latestYamlText = await fs.promises.readFile(configUri.fsPath, "utf8");
|
||||||
const updatedYaml = applyFormUpdates(latestYamlText, {
|
const updatedYaml = applyFormUpdates(latestYamlText, {
|
||||||
scalars: message.scalars || {},
|
scalars: message.scalars || {},
|
||||||
arrays: parseArrayFieldPayload(message.arrays || {})
|
arrays: parseArrayFieldPayload(message.arrays || {}),
|
||||||
|
objectArrays: message.objectArrays || {}
|
||||||
});
|
});
|
||||||
await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8");
|
await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8");
|
||||||
const document = await vscode.workspace.openTextDocument(configUri);
|
const document = await vscode.workspace.openTextDocument(configUri);
|
||||||
@ -570,54 +571,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
|
|||||||
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||||
const formModel = buildFormModel(schemaInfo, parsedYaml);
|
const formModel = buildFormModel(schemaInfo, parsedYaml);
|
||||||
const renderedFields = formModel.fields
|
const renderedFields = formModel.fields
|
||||||
.map((field) => {
|
.map((field) => renderFormField(field))
|
||||||
if (field.kind === "section") {
|
|
||||||
return `
|
|
||||||
<div class="section depth-${field.depth}">
|
|
||||||
<div class="section-title">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
|
|
||||||
<div class="meta-key">${escapeHtml(field.path)}</div>
|
|
||||||
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field.kind === "array") {
|
|
||||||
const itemType = field.itemType
|
|
||||||
? `array<${escapeHtml(field.itemType)}>`
|
|
||||||
: "array";
|
|
||||||
return `
|
|
||||||
<label class="field depth-${field.depth}">
|
|
||||||
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
|
|
||||||
<span class="meta-key">${escapeHtml(field.path)}</span>
|
|
||||||
<span class="hint">One item per line. Expected type: ${itemType}</span>
|
|
||||||
${renderFieldHint(field.schema, true)}
|
|
||||||
<textarea data-array-path="${escapeHtml(field.path)}" rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : [];
|
|
||||||
const inputControl = enumValues.length > 0
|
|
||||||
? `
|
|
||||||
<select data-path="${escapeHtml(field.path)}">
|
|
||||||
${enumValues.map((value) => {
|
|
||||||
const escapedOption = escapeHtml(value);
|
|
||||||
const selected = value === field.value ? " selected" : "";
|
|
||||||
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
|
|
||||||
}).join("\n")}
|
|
||||||
</select>
|
|
||||||
`
|
|
||||||
: `<input data-path="${escapeHtml(field.path)}" value="${escapeHtml(field.value)}" />`;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<label class="field depth-${field.depth}">
|
|
||||||
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
|
|
||||||
<span class="meta-key">${escapeHtml(field.path)}</span>
|
|
||||||
${renderFieldHint(field.schema, false)}
|
|
||||||
${inputControl}
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
const unsupportedFields = formModel.unsupported
|
const unsupportedFields = formModel.unsupported
|
||||||
@ -664,6 +618,10 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.secondary-button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
}
|
||||||
.meta {
|
.meta {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
@ -734,6 +692,34 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
.object-array {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--vscode-panel-border, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.object-array-items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.object-array-item {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 88%, var(--vscode-panel-border, transparent));
|
||||||
|
}
|
||||||
|
.object-array-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.object-array-item-title {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.depth-1 {
|
.depth-1 {
|
||||||
margin-left: 12px;
|
margin-left: 12px;
|
||||||
}
|
}
|
||||||
@ -743,6 +729,12 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
|||||||
.depth-3 {
|
.depth-3 {
|
||||||
margin-left: 36px;
|
margin-left: 36px;
|
||||||
}
|
}
|
||||||
|
.depth-4 {
|
||||||
|
margin-left: 48px;
|
||||||
|
}
|
||||||
|
.depth-5 {
|
||||||
|
margin-left: 60px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -757,16 +749,96 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
|||||||
<div id="fields">${emptyState}</div>
|
<div id="fields">${emptyState}</div>
|
||||||
<script>
|
<script>
|
||||||
const vscode = acquireVsCodeApi();
|
const vscode = acquireVsCodeApi();
|
||||||
|
function parseArrayEditorValue(value) {
|
||||||
|
return String(value)
|
||||||
|
.split(/\\r?\\n/u)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
function setNestedObjectValue(target, path, value) {
|
||||||
|
const segments = path.split(".").filter((segment) => segment.length > 0);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = target;
|
||||||
|
for (let index = 0; index < segments.length; index += 1) {
|
||||||
|
const segment = segments[index];
|
||||||
|
if (index === segments.length - 1) {
|
||||||
|
current[segment] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current[segment] || typeof current[segment] !== "object" || Array.isArray(current[segment])) {
|
||||||
|
current[segment] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renumberObjectArrayItems(editor) {
|
||||||
|
const items = editor.querySelectorAll("[data-object-array-item]");
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
const title = item.querySelector(".object-array-item-title");
|
||||||
|
if (title) {
|
||||||
|
title.textContent = "Item " + (index + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const addButton = event.target.closest("[data-add-object-array-item]");
|
||||||
|
if (addButton) {
|
||||||
|
const editor = addButton.closest("[data-object-array-editor]");
|
||||||
|
const itemsHost = editor.querySelector("[data-object-array-items]");
|
||||||
|
const template = editor.querySelector("template[data-object-array-template]");
|
||||||
|
if (itemsHost && template) {
|
||||||
|
itemsHost.appendChild(template.content.cloneNode(true));
|
||||||
|
renumberObjectArrayItems(editor);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeButton = event.target.closest("[data-remove-object-array-item]");
|
||||||
|
if (removeButton) {
|
||||||
|
const item = removeButton.closest("[data-object-array-item]");
|
||||||
|
const editor = removeButton.closest("[data-object-array-editor]");
|
||||||
|
if (item) {
|
||||||
|
item.remove();
|
||||||
|
}
|
||||||
|
if (editor) {
|
||||||
|
renumberObjectArrayItems(editor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
document.getElementById("save").addEventListener("click", () => {
|
document.getElementById("save").addEventListener("click", () => {
|
||||||
const scalars = {};
|
const scalars = {};
|
||||||
const arrays = {};
|
const arrays = {};
|
||||||
|
const objectArrays = {};
|
||||||
for (const control of document.querySelectorAll("[data-path]")) {
|
for (const control of document.querySelectorAll("[data-path]")) {
|
||||||
scalars[control.dataset.path] = control.value;
|
scalars[control.dataset.path] = control.value;
|
||||||
}
|
}
|
||||||
for (const textarea of document.querySelectorAll("textarea[data-array-path]")) {
|
for (const textarea of document.querySelectorAll("textarea[data-array-path]")) {
|
||||||
arrays[textarea.dataset.arrayPath] = textarea.value;
|
arrays[textarea.dataset.arrayPath] = textarea.value;
|
||||||
}
|
}
|
||||||
vscode.postMessage({ type: "save", scalars, arrays });
|
for (const editor of document.querySelectorAll("[data-object-array-editor]")) {
|
||||||
|
const path = editor.dataset.objectArrayPath;
|
||||||
|
const items = [];
|
||||||
|
for (const item of editor.querySelectorAll("[data-object-array-items] > [data-object-array-item]")) {
|
||||||
|
const itemValue = {};
|
||||||
|
for (const control of item.querySelectorAll("[data-item-local-path]")) {
|
||||||
|
setNestedObjectValue(itemValue, control.dataset.itemLocalPath, control.value);
|
||||||
|
}
|
||||||
|
for (const textarea of item.querySelectorAll("textarea[data-item-array-path]")) {
|
||||||
|
setNestedObjectValue(
|
||||||
|
itemValue,
|
||||||
|
textarea.dataset.itemArrayPath,
|
||||||
|
parseArrayEditorValue(textarea.value));
|
||||||
|
}
|
||||||
|
items.push(itemValue);
|
||||||
|
}
|
||||||
|
objectArrays[path] = items;
|
||||||
|
}
|
||||||
|
vscode.postMessage({ type: "save", scalars, arrays, objectArrays });
|
||||||
});
|
});
|
||||||
document.getElementById("openRaw").addEventListener("click", () => {
|
document.getElementById("openRaw").addEventListener("click", () => {
|
||||||
vscode.postMessage({ type: "openRaw" });
|
vscode.postMessage({ type: "openRaw" });
|
||||||
@ -776,6 +848,106 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
|||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one form field.
|
||||||
|
*
|
||||||
|
* @param {Record<string, unknown>} field Form field descriptor.
|
||||||
|
* @returns {string} HTML fragment.
|
||||||
|
*/
|
||||||
|
function renderFormField(field) {
|
||||||
|
if (field.kind === "section") {
|
||||||
|
return `
|
||||||
|
<div class="section depth-${field.depth}">
|
||||||
|
<div class="section-title">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
|
||||||
|
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
|
||||||
|
${field.description ? `<span class="hint">${escapeHtml(field.description)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.kind === "objectArray") {
|
||||||
|
const renderedItems = field.items
|
||||||
|
.map((item) => renderObjectArrayItem(item))
|
||||||
|
.join("\n");
|
||||||
|
const renderedTemplate = renderObjectArrayItem({
|
||||||
|
title: "Item",
|
||||||
|
fields: field.templateFields
|
||||||
|
});
|
||||||
|
return `
|
||||||
|
<div class="object-array depth-${field.depth}" data-object-array-editor data-object-array-path="${escapeHtml(field.path)}">
|
||||||
|
<div class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</div>
|
||||||
|
<div class="meta-key">${escapeHtml(field.displayPath || field.path)}</div>
|
||||||
|
<span class="hint">Each item uses the object schema below.</span>
|
||||||
|
${renderFieldHint(field.schema, true)}
|
||||||
|
<div class="object-array-items" data-object-array-items>${renderedItems}</div>
|
||||||
|
<template data-object-array-template>${renderedTemplate}</template>
|
||||||
|
<button type="button" class="secondary-button" data-add-object-array-item>Add Item</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.kind === "array") {
|
||||||
|
const itemType = field.itemType
|
||||||
|
? `array<${escapeHtml(field.itemType)}>`
|
||||||
|
: "array";
|
||||||
|
const dataAttribute = field.itemMode
|
||||||
|
? `data-item-array-path="${escapeHtml(field.path)}"`
|
||||||
|
: `data-array-path="${escapeHtml(field.path)}"`;
|
||||||
|
return `
|
||||||
|
<label class="field depth-${field.depth}">
|
||||||
|
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
|
||||||
|
<span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span>
|
||||||
|
<span class="hint">One item per line. Expected type: ${itemType}</span>
|
||||||
|
${renderFieldHint(field.schema, true)}
|
||||||
|
<textarea ${dataAttribute} rows="5">${escapeHtml(field.value.join("\n"))}</textarea>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumValues = Array.isArray(field.schema.enumValues) ? field.schema.enumValues : [];
|
||||||
|
const dataAttribute = field.itemMode
|
||||||
|
? `data-item-local-path="${escapeHtml(field.path)}"`
|
||||||
|
: `data-path="${escapeHtml(field.path)}"`;
|
||||||
|
const inputControl = enumValues.length > 0
|
||||||
|
? `
|
||||||
|
<select ${dataAttribute}>
|
||||||
|
${enumValues.map((value) => {
|
||||||
|
const escapedOption = escapeHtml(value);
|
||||||
|
const selected = value === field.value ? " selected" : "";
|
||||||
|
return `<option value="${escapedOption}"${selected}>${escapedOption}</option>`;
|
||||||
|
}).join("\n")}
|
||||||
|
</select>
|
||||||
|
`
|
||||||
|
: `<input ${dataAttribute} value="${escapeHtml(field.value)}" />`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<label class="field depth-${field.depth}">
|
||||||
|
<span class="label">${escapeHtml(field.label)} ${field.required ? "<span class=\"badge\">required</span>" : ""}</span>
|
||||||
|
<span class="meta-key">${escapeHtml(field.displayPath || field.path)}</span>
|
||||||
|
${renderFieldHint(field.schema, false)}
|
||||||
|
${inputControl}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render one object-array item editor block.
|
||||||
|
*
|
||||||
|
* @param {{title: string, fields: Array<Record<string, unknown>>}} item Item model.
|
||||||
|
* @returns {string} HTML fragment.
|
||||||
|
*/
|
||||||
|
function renderObjectArrayItem(item) {
|
||||||
|
return `
|
||||||
|
<div class="object-array-item" data-object-array-item>
|
||||||
|
<div class="object-array-item-header">
|
||||||
|
<span class="object-array-item-title">${escapeHtml(item.title)}</span>
|
||||||
|
<button type="button" class="secondary-button" data-remove-object-array-item>Remove</button>
|
||||||
|
</div>
|
||||||
|
${item.fields.map((field) => renderFormField(field)).join("\n")}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a recursive form model from schema and parsed YAML.
|
* Build a recursive form model from schema and parsed YAML.
|
||||||
*
|
*
|
||||||
@ -795,7 +967,7 @@ function buildFormModel(schemaInfo, parsedYaml) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively collect form-editable fields.
|
* Recursively collect top-level form-editable fields.
|
||||||
*
|
*
|
||||||
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
|
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
|
||||||
* @param {unknown} yamlNode YAML node.
|
* @param {unknown} yamlNode YAML node.
|
||||||
@ -836,6 +1008,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
|||||||
fields.push({
|
fields.push({
|
||||||
kind: "array",
|
kind: "array",
|
||||||
path: propertyPath,
|
path: propertyPath,
|
||||||
|
displayPath: propertyPath,
|
||||||
label,
|
label,
|
||||||
required: requiredSet.has(key),
|
required: requiredSet.has(key),
|
||||||
depth,
|
depth,
|
||||||
@ -846,10 +1019,37 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (propertySchema.type === "array" &&
|
||||||
|
propertySchema.items &&
|
||||||
|
propertySchema.items.type === "object") {
|
||||||
|
const itemFieldsTemplate = [];
|
||||||
|
collectObjectArrayItemFields(
|
||||||
|
propertySchema.items,
|
||||||
|
undefined,
|
||||||
|
"",
|
||||||
|
`${propertyPath}[]`,
|
||||||
|
depth + 1,
|
||||||
|
itemFieldsTemplate,
|
||||||
|
unsupported);
|
||||||
|
fields.push({
|
||||||
|
kind: "objectArray",
|
||||||
|
path: propertyPath,
|
||||||
|
displayPath: propertyPath,
|
||||||
|
label,
|
||||||
|
required: requiredSet.has(key),
|
||||||
|
depth,
|
||||||
|
schema: propertySchema,
|
||||||
|
items: buildObjectArrayItemModels(propertySchema.items, propertyValue, propertyPath, depth + 1, unsupported),
|
||||||
|
templateFields: itemFieldsTemplate
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
|
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
|
||||||
fields.push({
|
fields.push({
|
||||||
kind: "scalar",
|
kind: "scalar",
|
||||||
path: propertyPath,
|
path: propertyPath,
|
||||||
|
displayPath: propertyPath,
|
||||||
label,
|
label,
|
||||||
required: requiredSet.has(key),
|
required: requiredSet.has(key),
|
||||||
depth,
|
depth,
|
||||||
@ -862,7 +1062,142 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
|||||||
unsupported.push({
|
unsupported.push({
|
||||||
path: propertyPath,
|
path: propertyPath,
|
||||||
message: propertySchema.type === "array"
|
message: propertySchema.type === "array"
|
||||||
? "Object-array fields are currently view-only in the form preview. Use raw YAML for edits."
|
? "Unsupported array shapes are currently raw-YAML-only in the form preview."
|
||||||
|
: `${propertySchema.type} fields are currently raw-YAML-only.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build object-array item models from the current YAML array value.
|
||||||
|
*
|
||||||
|
* @param {{type: string, required?: string[], properties?: Record<string, unknown>}} itemSchema Array item schema.
|
||||||
|
* @param {unknown} yamlNode YAML node.
|
||||||
|
* @param {string} propertyPath Top-level object-array path.
|
||||||
|
* @param {number} depth Current depth.
|
||||||
|
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
|
||||||
|
* @returns {Array<{title: string, fields: Array<Record<string, unknown>>}>} Item models.
|
||||||
|
*/
|
||||||
|
function buildObjectArrayItemModels(itemSchema, yamlNode, propertyPath, depth, unsupported) {
|
||||||
|
if (!yamlNode || yamlNode.kind !== "array") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [];
|
||||||
|
for (let index = 0; index < yamlNode.items.length; index += 1) {
|
||||||
|
const itemNode = yamlNode.items[index];
|
||||||
|
const itemPath = `${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."
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = [];
|
||||||
|
collectObjectArrayItemFields(
|
||||||
|
itemSchema,
|
||||||
|
itemNode,
|
||||||
|
"",
|
||||||
|
itemPath,
|
||||||
|
depth,
|
||||||
|
fields,
|
||||||
|
unsupported);
|
||||||
|
items.push({
|
||||||
|
title: `Item ${index + 1}`,
|
||||||
|
fields
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect editable fields inside one object-array item.
|
||||||
|
* Nested objects remain editable, while nested object arrays still fall back
|
||||||
|
* to raw YAML until a deeper editor model is added.
|
||||||
|
*
|
||||||
|
* @param {{type: string, required?: string[], properties?: Record<string, unknown>, title?: string, description?: string}} schemaNode Schema node.
|
||||||
|
* @param {unknown} yamlNode YAML node.
|
||||||
|
* @param {string} localPath Path inside the current array item.
|
||||||
|
* @param {string} displayPath Full logical path for UI display.
|
||||||
|
* @param {number} depth Current depth.
|
||||||
|
* @param {Array<Record<string, unknown>>} fields Field sink.
|
||||||
|
* @param {Array<{path: string, message: string}>} unsupported Unsupported sink.
|
||||||
|
*/
|
||||||
|
function collectObjectArrayItemFields(schemaNode, yamlNode, localPath, displayPath, depth, fields, unsupported) {
|
||||||
|
if (!schemaNode || schemaNode.type !== "object") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const yamlMap = getYamlObjectMap(yamlNode);
|
||||||
|
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 label = propertySchema.title || key;
|
||||||
|
const propertyValue = yamlMap.get(key);
|
||||||
|
|
||||||
|
if (propertySchema.type === "object") {
|
||||||
|
fields.push({
|
||||||
|
kind: "section",
|
||||||
|
path: itemLocalPath,
|
||||||
|
displayPath: itemDisplayPath,
|
||||||
|
label,
|
||||||
|
description: propertySchema.description,
|
||||||
|
required: requiredSet.has(key),
|
||||||
|
depth
|
||||||
|
});
|
||||||
|
collectObjectArrayItemFields(
|
||||||
|
propertySchema,
|
||||||
|
propertyValue,
|
||||||
|
itemLocalPath,
|
||||||
|
itemDisplayPath,
|
||||||
|
depth + 1,
|
||||||
|
fields,
|
||||||
|
unsupported);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (propertySchema.type === "array" &&
|
||||||
|
propertySchema.items &&
|
||||||
|
["string", "integer", "number", "boolean"].includes(propertySchema.items.type)) {
|
||||||
|
fields.push({
|
||||||
|
kind: "array",
|
||||||
|
path: itemLocalPath,
|
||||||
|
displayPath: itemDisplayPath,
|
||||||
|
label,
|
||||||
|
required: requiredSet.has(key),
|
||||||
|
depth,
|
||||||
|
itemType: propertySchema.items.type,
|
||||||
|
value: getScalarArrayValue(propertyValue),
|
||||||
|
schema: propertySchema,
|
||||||
|
itemMode: true
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["string", "integer", "number", "boolean"].includes(propertySchema.type)) {
|
||||||
|
fields.push({
|
||||||
|
kind: "scalar",
|
||||||
|
path: itemLocalPath,
|
||||||
|
displayPath: itemDisplayPath,
|
||||||
|
label,
|
||||||
|
required: requiredSet.has(key),
|
||||||
|
depth,
|
||||||
|
value: getScalarFieldValue(propertyValue, propertySchema.defaultValue),
|
||||||
|
schema: propertySchema,
|
||||||
|
itemMode: true
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`
|
: `${propertySchema.type} fields are currently raw-YAML-only.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -203,6 +203,72 @@ test("applyFormUpdates should update nested scalar and scalar-array paths", () =
|
|||||||
assert.match(updated, /^phases:$/mu);
|
assert.match(updated, /^phases:$/mu);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("applyFormUpdates should rewrite object-array items from structured form payloads", () => {
|
||||||
|
const updated = applyFormUpdates(
|
||||||
|
[
|
||||||
|
"id: 1",
|
||||||
|
"name: Slime",
|
||||||
|
"phases:",
|
||||||
|
" -",
|
||||||
|
" wave: 1",
|
||||||
|
" monsterId: slime"
|
||||||
|
].join("\n"),
|
||||||
|
{
|
||||||
|
objectArrays: {
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
wave: "1",
|
||||||
|
monsterId: "slime",
|
||||||
|
tags: ["starter", "melee"],
|
||||||
|
reward: {
|
||||||
|
gold: "10",
|
||||||
|
currency: "coin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wave: "2",
|
||||||
|
monsterId: "goblin"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(updated, /^id: 1$/mu);
|
||||||
|
assert.match(updated, /^name: Slime$/mu);
|
||||||
|
assert.match(updated, /^phases:$/mu);
|
||||||
|
assert.match(updated, /^ -$/mu);
|
||||||
|
assert.match(updated, /^ wave: 1$/mu);
|
||||||
|
assert.match(updated, /^ monsterId: slime$/mu);
|
||||||
|
assert.match(updated, /^ tags:$/mu);
|
||||||
|
assert.match(updated, /^ - starter$/mu);
|
||||||
|
assert.match(updated, /^ - melee$/mu);
|
||||||
|
assert.match(updated, /^ reward:$/mu);
|
||||||
|
assert.match(updated, /^ gold: 10$/mu);
|
||||||
|
assert.match(updated, /^ currency: coin$/mu);
|
||||||
|
assert.match(updated, /^ monsterId: goblin$/mu);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyFormUpdates should clear object arrays when the form removes all items", () => {
|
||||||
|
const updated = applyFormUpdates(
|
||||||
|
[
|
||||||
|
"id: 1",
|
||||||
|
"phases:",
|
||||||
|
" -",
|
||||||
|
" wave: 1",
|
||||||
|
" monsterId: slime"
|
||||||
|
].join("\n"),
|
||||||
|
{
|
||||||
|
objectArrays: {
|
||||||
|
phases: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(updated, [
|
||||||
|
"id: 1",
|
||||||
|
"phases: []"
|
||||||
|
].join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => {
|
test("applyScalarUpdates should preserve the scalar-only compatibility wrapper", () => {
|
||||||
const updated = applyScalarUpdates(
|
const updated = applyScalarUpdates(
|
||||||
[
|
[
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user