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 文件
|
||||
- 打开匹配的 schema 文件
|
||||
- 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验
|
||||
- 对嵌套对象字段、顶层标量字段和顶层标量数组提供轻量表单入口
|
||||
- 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口
|
||||
- 对同一配置域内的多份 YAML 文件执行批量字段更新
|
||||
- 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据
|
||||
|
||||
当前表单入口适合编辑嵌套对象中的标量字段和标量数组;对象数组仍建议放在 raw YAML 中完成。
|
||||
当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。
|
||||
|
||||
对象数组编辑器当前支持:
|
||||
|
||||
- 新增和删除对象项
|
||||
- 编辑对象项中的标量字段
|
||||
- 编辑对象项中的标量数组
|
||||
- 编辑对象项中的嵌套对象字段
|
||||
|
||||
如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。
|
||||
|
||||
当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。
|
||||
|
||||
@ -214,7 +223,7 @@ var hotReload = loader.EnableHotReload(
|
||||
以下能力尚未完全完成:
|
||||
|
||||
- 更完整的 JSON Schema 支持
|
||||
- VS Code 中对象数组的安全表单编辑器
|
||||
- VS Code 中更深层对象数组嵌套的安全表单编辑器
|
||||
- 更强的复杂数组与更深 schema 关键字支持
|
||||
|
||||
因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。
|
||||
|
||||
@ -9,7 +9,7 @@ Minimal VS Code extension scaffold for the GFramework AI-First config workflow.
|
||||
- Open matching schema files from `schemas/`
|
||||
- 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, 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
|
||||
- Surface schema metadata such as `title`, `description`, `default`, `enum`, and `x-gframework-ref-table` in the
|
||||
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
|
||||
- scalar `enum` constraints and scalar-array item `enum` constraints
|
||||
|
||||
Object-array editing should still be reviewed in raw YAML.
|
||||
|
||||
## Local Testing
|
||||
|
||||
```bash
|
||||
@ -38,7 +36,7 @@ node --test ./test/*.test.js
|
||||
|
||||
- Multi-root workspaces use the first workspace folder
|
||||
- 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
|
||||
|
||||
## Workspace Settings
|
||||
|
||||
@ -150,13 +150,14 @@ 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<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.
|
||||
*/
|
||||
function applyFormUpdates(originalYaml, updates) {
|
||||
const root = normalizeRootNode(parseTopLevelYaml(originalYaml));
|
||||
const scalarUpdates = updates.scalars || {};
|
||||
const arrayUpdates = updates.arrays || {};
|
||||
const objectArrayUpdates = updates.objectArrays || {};
|
||||
|
||||
for (const [path, value] of Object.entries(scalarUpdates)) {
|
||||
setNodeAtPath(root, path.split("."), createScalarNode(String(value)));
|
||||
@ -167,6 +168,11 @@ function applyFormUpdates(originalYaml, updates) {
|
||||
(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");
|
||||
}
|
||||
|
||||
@ -687,6 +693,11 @@ function renderObjectNode(node, indent) {
|
||||
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(...renderYaml(entry.node, indent + 2));
|
||||
}
|
||||
@ -736,6 +747,32 @@ function createArrayNode(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.
|
||||
*
|
||||
|
||||
@ -13,8 +13,8 @@ const {
|
||||
|
||||
/**
|
||||
* Activate the GFramework config extension.
|
||||
* The initial MVP focuses on workspace file navigation, lightweight validation,
|
||||
* and a small form-preview entry for top-level scalar values.
|
||||
* The current tool focuses on workspace file navigation, lightweight
|
||||
* validation, and a schema-aware form preview for common editing workflows.
|
||||
*
|
||||
* @param {vscode.ExtensionContext} context Extension context.
|
||||
*/
|
||||
@ -253,8 +253,8 @@ async function openSchemaFile(item) {
|
||||
|
||||
/**
|
||||
* Open a lightweight form preview for schema-bound config fields.
|
||||
* The preview now walks nested object structures recursively, while complex
|
||||
* object-array editing still falls back to raw YAML for safety.
|
||||
* The preview walks nested object structures recursively and now supports
|
||||
* object-array editing for the repository's supported schema subset.
|
||||
*
|
||||
* @param {ConfigTreeItem | { resourceUri?: vscode.Uri }} item Tree item.
|
||||
* @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 updatedYaml = applyFormUpdates(latestYamlText, {
|
||||
scalars: message.scalars || {},
|
||||
arrays: parseArrayFieldPayload(message.arrays || {})
|
||||
arrays: parseArrayFieldPayload(message.arrays || {}),
|
||||
objectArrays: message.objectArrays || {}
|
||||
});
|
||||
await fs.promises.writeFile(configUri.fsPath, updatedYaml, "utf8");
|
||||
const document = await vscode.workspace.openTextDocument(configUri);
|
||||
@ -570,54 +571,7 @@ async function loadSchemaInfoForConfig(configUri, workspaceRoot) {
|
||||
function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
const formModel = buildFormModel(schemaInfo, parsedYaml);
|
||||
const renderedFields = formModel.fields
|
||||
.map((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>
|
||||
`;
|
||||
})
|
||||
.map((field) => renderFormField(field))
|
||||
.join("\n");
|
||||
|
||||
const unsupportedFields = formModel.unsupported
|
||||
@ -664,6 +618,10 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.secondary-button {
|
||||
background: transparent;
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
.meta {
|
||||
margin-bottom: 16px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
@ -734,6 +692,34 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
margin-bottom: 10px;
|
||||
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 {
|
||||
margin-left: 12px;
|
||||
}
|
||||
@ -743,6 +729,12 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
.depth-3 {
|
||||
margin-left: 36px;
|
||||
}
|
||||
.depth-4 {
|
||||
margin-left: 48px;
|
||||
}
|
||||
.depth-5 {
|
||||
margin-left: 60px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -757,16 +749,96 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
<div id="fields">${emptyState}</div>
|
||||
<script>
|
||||
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", () => {
|
||||
const scalars = {};
|
||||
const arrays = {};
|
||||
const objectArrays = {};
|
||||
for (const control of document.querySelectorAll("[data-path]")) {
|
||||
scalars[control.dataset.path] = control.value;
|
||||
}
|
||||
for (const textarea of document.querySelectorAll("textarea[data-array-path]")) {
|
||||
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", () => {
|
||||
vscode.postMessage({ type: "openRaw" });
|
||||
@ -776,6 +848,106 @@ function renderFormHtml(fileName, schemaInfo, parsedYaml) {
|
||||
</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.
|
||||
*
|
||||
@ -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 {unknown} yamlNode YAML node.
|
||||
@ -836,6 +1008,7 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
||||
fields.push({
|
||||
kind: "array",
|
||||
path: propertyPath,
|
||||
displayPath: propertyPath,
|
||||
label,
|
||||
required: requiredSet.has(key),
|
||||
depth,
|
||||
@ -846,10 +1019,37 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
||||
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)) {
|
||||
fields.push({
|
||||
kind: "scalar",
|
||||
path: propertyPath,
|
||||
displayPath: propertyPath,
|
||||
label,
|
||||
required: requiredSet.has(key),
|
||||
depth,
|
||||
@ -862,7 +1062,142 @@ function collectFormFields(schemaNode, yamlNode, currentPath, depth, fields, uns
|
||||
unsupported.push({
|
||||
path: propertyPath,
|
||||
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.`
|
||||
});
|
||||
}
|
||||
|
||||
@ -203,6 +203,72 @@ test("applyFormUpdates should update nested scalar and scalar-array paths", () =
|
||||
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", () => {
|
||||
const updated = applyScalarUpdates(
|
||||
[
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user