@morlay/dsh-client-ui-primitives 0.0.2-alpha.8 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,812 @@
1
+ /**
2
+ * schema 投影:把 host 发来的 volatile 表单 schema 变成渲染层要的字段树。
3
+ *
4
+ * 投影只描述**结构**(字段、路径、meta、只读原因),不读值、不做本地化、不碰 React:值在 `draft.ts`,
5
+ * 文案在 `labels.ts`,控件在 `fields/`。动态子节点(dict 的键与值、array 的项)的路径段用
6
+ * {@link DYNAMIC_SEGMENT} 占位,渲染层按真实键/索引替换后再交给字段槽。
7
+ */
8
+
9
+ import type { SchemaNode } from "@deepseek-ai/dsh-client-ui-settings/client";
10
+
11
+ /** 动态子节点的路径占位段(dict 的键与值、array 的项):渲染层按真实键或索引替换。 */
12
+ export const DYNAMIC_SEGMENT = "*";
13
+
14
+ /** 节点不可编辑的原因;`null` 表示可编辑。 */
15
+ export type ReadOnlyReason = "transform" | "function" | "constructor";
16
+
17
+ /** 归一化后的字段元数据(schemastery `meta` 的可用子集)。 */
18
+ export interface FieldMeta {
19
+ /** `description`(可能是本地化字典):渲染时经 locale 解析。 */
20
+ description: string | Readonly<Record<string, string>> | undefined;
21
+ /** `comment`:纯文本补充说明。 */
22
+ comment: string | undefined;
23
+ /** `link`:外部文档。 */
24
+ link: string | undefined;
25
+ /** `role`:控件提示(`secret` / `slider` / `datetime` …)。 */
26
+ role: string | undefined;
27
+ /** `role === 'secret'`:值不回传,只能写。 */
28
+ secret: boolean;
29
+ /** `required()`。 */
30
+ required: boolean;
31
+ /** `disabled()`:控件禁用。 */
32
+ disabled: boolean;
33
+ /** `collapse()`:嵌套分组默认折叠。 */
34
+ collapse: boolean;
35
+ /** `deprecated()` / `experimental()` 的徽标文本。 */
36
+ badges: readonly string[];
37
+ /** `pattern()` 的正则来源。 */
38
+ pattern: string | undefined;
39
+ /** `min()`。 */
40
+ min: number | undefined;
41
+ /** `max()`。 */
42
+ max: number | undefined;
43
+ /** `step()`:`1` 表示整数。 */
44
+ step: number | undefined;
45
+ /** 是否声明了默认值(与「默认值是 undefined」区分开)。 */
46
+ hasDefault: boolean;
47
+ /** 声明的默认值。 */
48
+ defaultValue: unknown;
49
+ /** `extra()`:角色附带的任意数据,字段槽的自定义控件可用。 */
50
+ extra: unknown;
51
+ }
52
+
53
+ /** 每种节点共有的部分。 */
54
+ export interface FieldNodeBase {
55
+ /** 这一层的名字:对象字段名、tuple 的索引、动态子节点的占位段。 */
56
+ key: string;
57
+ /** 从段根起的路径;动态段是 {@link DYNAMIC_SEGMENT}。 */
58
+ path: readonly string[];
59
+ /**
60
+ * 按值展开时算好的显示名:数组成员的**项身份**(见 {@link FieldNodeBase} 所在的 array 节点)。
61
+ * 投影时没有(`undefined`),渲染层用它优先于字段名。
62
+ */
63
+ label: string | undefined;
64
+ /** 归一化后的元数据。 */
65
+ meta: FieldMeta;
66
+ /** 不可编辑的原因;`null` 表示可编辑。 */
67
+ readOnly: ReadOnlyReason | null;
68
+ }
69
+
70
+ /** 位集的一位。 */
71
+ export interface BitsEntry {
72
+ name: string;
73
+ bit: number;
74
+ }
75
+
76
+ /** 渲染层要的一个字段节点。 */
77
+ export type FieldNode =
78
+ | (FieldNodeBase & { type: "object"; fields: readonly FieldNode[] })
79
+ | (FieldNodeBase & { type: "dict"; keyNode: FieldNode; valueNode: FieldNode })
80
+ | (FieldNodeBase & {
81
+ type: "array";
82
+ item: FieldNode;
83
+ /**
84
+ * 项的**身份键**:这一项的「名字」取自它自己的哪个字段。显式声明优先
85
+ * (`z.array(inner).role('items', { mergeKey: 'id' })`),否则 item 上有 `id` 字段就用 `id`。
86
+ * 它只决定显示名与「同名即冲突」的判据——写盘仍按索引(host 的 path op 就是索引语义)。
87
+ */
88
+ mergeKey: string | undefined;
89
+ })
90
+ | (FieldNodeBase & { type: "tuple"; items: readonly FieldNode[] })
91
+ | (FieldNodeBase & {
92
+ type: "union";
93
+ /** 成员全是字面量时的可选项;否则 `undefined`。 */
94
+ choices: readonly unknown[] | undefined;
95
+ variants: readonly FieldNode[];
96
+ /**
97
+ * 判别标签:按哪个字段的值挑变体。显式声明优先
98
+ * (`z.union([...]).role('union', { tag: 'kind' })`),否则取所有对象成员共有的**唯一**常量字段。
99
+ * schemastery 本身没有 tag 机制(union 按声明顺序逐个试),所以这只是渲染与校验的选支依据。
100
+ */
101
+ tagKey: string | undefined;
102
+ })
103
+ | (FieldNodeBase & { type: "intersect"; members: readonly FieldNode[] })
104
+ | (FieldNodeBase & { type: "bitset"; bits: readonly BitsEntry[] })
105
+ | (FieldNodeBase & { type: "const"; value: unknown })
106
+ | (FieldNodeBase & { type: "lazy"; inner: FieldNode })
107
+ | (FieldNodeBase & { type: "string" | "number" | "boolean" | "any" | "never" | "function" })
108
+ /** 自引用 schema 回到祖先处:值这一层以下不再描述,由渲染层展开或只读呈现。 */
109
+ | (FieldNodeBase & { type: "recursive"; origin: string });
110
+
111
+ /** 空的元数据:动态子节点(无声明 schema 的 dict 键)与空字段树用它。 */
112
+ export function emptyMeta(): FieldMeta {
113
+ return {
114
+ description: undefined,
115
+ comment: undefined,
116
+ link: undefined,
117
+ role: undefined,
118
+ secret: false,
119
+ required: false,
120
+ disabled: false,
121
+ collapse: false,
122
+ badges: [],
123
+ pattern: undefined,
124
+ min: undefined,
125
+ max: undefined,
126
+ step: undefined,
127
+ hasDefault: false,
128
+ defaultValue: undefined,
129
+ extra: undefined,
130
+ };
131
+ }
132
+
133
+ /** `badges` 只留文本:配色与文案由字典决定。 */
134
+ function badgesOf(meta: SchemaNode["meta"]): string[] {
135
+ return (meta.badges ?? []).map((badge) => badge.text);
136
+ }
137
+
138
+ /** 归一化一个节点的 meta。 */
139
+ function metaOf(schema: SchemaNode): FieldMeta {
140
+ const meta = schema.meta ?? {};
141
+ return {
142
+ description: meta.description,
143
+ comment: meta.comment,
144
+ link: meta.link,
145
+ role: meta.role,
146
+ secret: meta.role === "secret",
147
+ required: meta.required === true,
148
+ disabled: meta.disabled === true,
149
+ collapse: meta.collapse === true,
150
+ badges: badgesOf(meta),
151
+ // `toJSON()` 之后 pattern 已经是字符串(source),只有直接传 schema 时才是 RegExp。
152
+ pattern: typeof meta.pattern === "string" ? meta.pattern : (meta.pattern?.source ?? undefined),
153
+ min: meta.min,
154
+ max: meta.max,
155
+ step: meta.step,
156
+ hasDefault: meta.default !== undefined,
157
+ defaultValue: meta.default,
158
+ extra: meta.extra,
159
+ };
160
+ }
161
+
162
+ /** 外层 meta 与内层 meta 合并:外层的 undefined 不覆盖内层(transform 的 inner 带着自己的角色)。 */
163
+ function mergeMeta(inner: FieldMeta, outer: FieldMeta): FieldMeta {
164
+ const merged: Record<string, unknown> = { ...inner };
165
+ for (const [key, value] of Object.entries(outer)) {
166
+ if (value !== undefined) merged[key] = value;
167
+ }
168
+ return merged as unknown as FieldMeta;
169
+ }
170
+
171
+ /** 节点自己的路径末段:动态段保持占位,根节点为空串。 */
172
+ function keyOf(path: readonly string[]): string {
173
+ return path[path.length - 1] ?? "";
174
+ }
175
+
176
+ /** 一个纯字符串叶子(dict 没有声明键 schema 时用它)。 */
177
+ function stringLeaf(path: readonly string[], meta: FieldMeta): FieldNode {
178
+ return { key: keyOf(path), path, label: undefined, meta, readOnly: null, type: "string" };
179
+ }
180
+
181
+ /** 把 schema 的类型名收口成渲染层认识的那几种。 */
182
+ function leafTypeOf(type: string): "string" | "number" | "boolean" | "any" | "never" | "function" {
183
+ switch (type) {
184
+ case "string":
185
+ return "string";
186
+ case "number":
187
+ return "number";
188
+ case "boolean":
189
+ return "boolean";
190
+ case "function":
191
+ return "function";
192
+ case "never":
193
+ return "never";
194
+ default:
195
+ return "any";
196
+ }
197
+ }
198
+
199
+ /**
200
+ * 投影一个 schema 节点。
201
+ * @param schema - rehydrate 之后的 volatile 表单 schema(或它的子节点)。
202
+ * @param path - 该节点相对段根的路径;动态子节点由调用方给占位段。
203
+ * @returns 渲染层要的字段树。
204
+ */
205
+ export function projectNode(schema: SchemaNode, path: readonly string[] = []): FieldNode {
206
+ return project(schema, path, []);
207
+ }
208
+
209
+ function project(
210
+ schema: SchemaNode,
211
+ path: readonly string[],
212
+ ancestors: readonly SchemaNode[],
213
+ ): FieldNode {
214
+ const base: FieldNodeBase = {
215
+ key: keyOf(path),
216
+ path,
217
+ label: undefined,
218
+ meta: metaOf(schema),
219
+ readOnly: null,
220
+ };
221
+ if (ancestors.includes(schema)) {
222
+ return { ...base, type: "recursive", origin: schema.type };
223
+ }
224
+ const chain = [...ancestors, schema];
225
+ switch (schema.type) {
226
+ case "object": {
227
+ const fields = Object.entries(schema.dict ?? {})
228
+ .filter(([, child]) => child.meta?.hidden !== true)
229
+ .map(([key, child]) => project(child, [...path, key], chain));
230
+ return { ...base, type: "object", fields };
231
+ }
232
+ case "dict": {
233
+ const segment = [...path, DYNAMIC_SEGMENT];
234
+ const keyNode =
235
+ schema.sKey === undefined
236
+ ? stringLeaf(segment, emptyMeta())
237
+ : project(schema.sKey, segment, chain);
238
+ return {
239
+ ...base,
240
+ type: "dict",
241
+ keyNode,
242
+ valueNode: project(schema.inner ?? schema, segment, chain),
243
+ };
244
+ }
245
+ case "array": {
246
+ const item = project(schema.inner ?? schema, [...path, DYNAMIC_SEGMENT], chain);
247
+ return { ...base, type: "array", item, mergeKey: mergeKeyOf(schema, item) };
248
+ }
249
+ case "tuple": {
250
+ const items = (schema.list ?? []).map((child, index) =>
251
+ project(child, [...path, String(index)], chain),
252
+ );
253
+ return { ...base, type: "tuple", items };
254
+ }
255
+ case "union": {
256
+ const members = schema.list ?? [];
257
+ const variants = members.map((child) => project(child, path, chain));
258
+ const literals = members.every((child) => child.type === "const")
259
+ ? members.map((child) => child.value)
260
+ : undefined;
261
+ return {
262
+ ...base,
263
+ type: "union",
264
+ choices: literals,
265
+ variants,
266
+ tagKey: tagKeyOf(schema, variants),
267
+ };
268
+ }
269
+ case "intersect": {
270
+ return {
271
+ ...base,
272
+ type: "intersect",
273
+ members: (schema.list ?? []).map((child) => project(child, path, chain)),
274
+ };
275
+ }
276
+ case "bitset": {
277
+ const bits = Object.entries(schema.bits ?? {}).map(([name, bit]) => ({ name, bit }));
278
+ return { ...base, type: "bitset", bits };
279
+ }
280
+ case "const": {
281
+ return { ...base, type: "const", value: schema.value };
282
+ }
283
+ case "transform": {
284
+ const projected = project(schema.inner ?? schema, path, chain);
285
+ return { ...projected, meta: mergeMeta(projected.meta, base.meta), readOnly: "transform" };
286
+ }
287
+ case "lazy": {
288
+ const inner = schema.inner;
289
+ return inner === undefined
290
+ ? { ...base, type: "any" }
291
+ : { ...base, type: "lazy", inner: project(inner, path, chain) };
292
+ }
293
+ case "is": {
294
+ return { ...base, type: "any", readOnly: "constructor" };
295
+ }
296
+ default: {
297
+ return {
298
+ ...base,
299
+ type: leafTypeOf(schema.type),
300
+ readOnly: schema.type === "function" ? "function" : null,
301
+ };
302
+ }
303
+ }
304
+ }
305
+
306
+ /** 展开 `lazy` 包装:投影里 lazy 只是一层,值仍在同一路径上。 */
307
+ export function unwrapLazy(node: FieldNode | undefined): FieldNode | undefined {
308
+ let current = node;
309
+ while (current?.type === "lazy") current = current.inner;
310
+ return current;
311
+ }
312
+
313
+ /** 某一层可见的字段:object 直接给;union / intersect 把各变体的对象字段并起来。 */
314
+ export function fieldsOf(node: FieldNode | undefined): readonly FieldNode[] {
315
+ const current = unwrapLazy(node);
316
+ switch (current?.type) {
317
+ case "object":
318
+ return current.fields;
319
+ case "union":
320
+ return current.variants.flatMap((variant) => fieldsOf(variant));
321
+ case "intersect":
322
+ return current.members.flatMap((member) => fieldsOf(member));
323
+ default:
324
+ return [];
325
+ }
326
+ }
327
+
328
+ /**
329
+ * 按具体路径找回投影节点(动态段匹配真实键或索引)。
330
+ * @param root - 段根的字段树。
331
+ * @param path - 具体路径。
332
+ * @returns 命中的节点,或 `undefined`。
333
+ */
334
+ export function seekNode(root: FieldNode, path: readonly string[]): FieldNode | undefined {
335
+ let current: FieldNode | undefined = root;
336
+ for (const segment of path) {
337
+ const node = unwrapLazy(current);
338
+ if (node === undefined) return undefined;
339
+ switch (node.type) {
340
+ case "object":
341
+ case "union":
342
+ case "intersect":
343
+ current = fieldsOf(node).find((field) => field.key === segment);
344
+ break;
345
+ case "dict":
346
+ current = node.valueNode;
347
+ break;
348
+ case "array":
349
+ current = node.item;
350
+ break;
351
+ case "tuple":
352
+ current = node.items[Number(segment)];
353
+ break;
354
+ default:
355
+ return undefined;
356
+ }
357
+ }
358
+ return unwrapLazy(current);
359
+ }
360
+
361
+ /** 按当前值展开出的一个字段:路径与名字已按真实键与索引替换。 */
362
+ export interface WalkedField {
363
+ path: readonly string[];
364
+ node: FieldNode;
365
+ /**
366
+ * 这一行是某个动态容器的**成员本身**(dict 的键 / array 的项)时给出它的身份:容器路径、键、以及数组
367
+ * 项的位置。渲染层用它画成员行(名字 + 移除);成员的子字段不带这个标记。
368
+ */
369
+ member?: {
370
+ parent: readonly string[];
371
+ key: string;
372
+ index: number | undefined;
373
+ };
374
+ }
375
+
376
+ /** 候选键的来源(业务注册;见 `hints.ts`):dict 的「可以加哪些键」。 */
377
+ export interface SuggestedKeys {
378
+ /**
379
+ * 读某个 dict 字段的候选键。
380
+ * @param path - 该字段的具体路径。
381
+ * @returns 候选键;没有就是空数组。
382
+ */
383
+ keysFor(path: readonly string[]): readonly string[];
384
+ }
385
+
386
+ /**
387
+ * 按当前值把字段树展开成渲染顺序的扁平列表(前序):容器在前、子项紧随其后。
388
+ *
389
+ * 出现的**只有值里有的东西,加上必填的**:对象的必填字段始终占行(值是空的也要让用户看见"必须填"),非必填的
390
+ * 按值出现——值里没有就不占行,它在这一层的「添加属性」候选里,选中才加。数组按索引、字典按键展开;字面量
391
+ * union 是叶子控件,对象 union 只展开与当前值匹配的那个变体,intersect 的各成员在同一层展开。
392
+ * @param root - 段根的字段树。
393
+ * @param value - 生效值(或草稿后的值)。
394
+ * @returns 渲染顺序的字段列表。
395
+ */
396
+ export function walkFields(root: FieldNode, value: unknown): WalkedField[] {
397
+ const out: WalkedField[] = [];
398
+ visit(root, root.path, value, out, true, undefined, new Set(), undefined);
399
+ return out;
400
+ }
401
+
402
+ function visit(
403
+ node: FieldNode,
404
+ path: readonly string[],
405
+ value: unknown,
406
+ out: WalkedField[],
407
+ emit: boolean,
408
+ label: string | undefined,
409
+ emitted: Set<string>,
410
+ member: WalkedField["member"],
411
+ ): void {
412
+ const current = unwrapLazy(node);
413
+ if (current === undefined) return;
414
+ // 动态成员:名字换成真实键(投影时那里是占位段),数组项优先用项身份当显示名。
415
+ const positioned = {
416
+ ...current,
417
+ path,
418
+ ...(member === undefined ? {} : { key: member.key }),
419
+ ...(label === undefined ? {} : { label }),
420
+ } as FieldNode;
421
+ const key = JSON.stringify(path);
422
+ // 相交成员常描述同一个字段(共享层的选择器与分支里的 const 标记):同一路径只发一次,
423
+ // 先出现的那个留下(声明顺序上它就是共享层那份),子节点照常展开。
424
+ if (emit && !emitted.has(key)) {
425
+ emitted.add(key);
426
+ out.push({ path, node: positioned, ...(member === undefined ? {} : { member }) });
427
+ }
428
+ switch (positioned.type) {
429
+ case "object": {
430
+ for (const field of positioned.fields) {
431
+ const child = memberOf(value, field.key);
432
+ // 必填字段始终占行(值还没有也要看得见"必须填");非必填的没值就不占行,它进「添加属性」候选
433
+ // (见 `controller.ts` 的 `addableAt`)。
434
+ if (child === undefined && field.meta.required !== true) continue;
435
+ visit(field, [...path, field.key], child, out, true, undefined, emitted, undefined);
436
+ }
437
+ return;
438
+ }
439
+ case "dict": {
440
+ for (const [key, child] of entriesOf(value)) {
441
+ visit(positioned.valueNode, [...path, key], child, out, true, undefined, emitted, {
442
+ parent: path,
443
+ key,
444
+ index: undefined,
445
+ });
446
+ }
447
+ return;
448
+ }
449
+ case "array": {
450
+ for (const [key, child] of entriesOf(value)) {
451
+ const label =
452
+ positioned.mergeKey === undefined ? undefined : memberLabel(child, positioned.mergeKey);
453
+ visit(positioned.item, [...path, key], child, out, true, label, emitted, {
454
+ parent: path,
455
+ key,
456
+ index: Number(key),
457
+ });
458
+ }
459
+ return;
460
+ }
461
+ case "tuple": {
462
+ positioned.items.forEach((item, index) => {
463
+ visit(
464
+ item,
465
+ [...path, String(index)],
466
+ memberOf(value, index),
467
+ out,
468
+ true,
469
+ undefined,
470
+ emitted,
471
+ undefined,
472
+ );
473
+ });
474
+ return;
475
+ }
476
+ case "union": {
477
+ if (positioned.choices !== undefined) return;
478
+ const variant = pickVariant(positioned, value);
479
+ if (variant !== undefined)
480
+ visit(variant, path, value, out, false, undefined, emitted, member);
481
+ return;
482
+ }
483
+ case "intersect": {
484
+ for (const part of positioned.members) {
485
+ visit(part, path, value, out, false, undefined, emitted, member);
486
+ }
487
+ return;
488
+ }
489
+ default:
490
+ return;
491
+ }
492
+ }
493
+
494
+ /** 数组的项身份:显式声明优先,否则 item 上的 `id` 字段。 */
495
+ function mergeKeyOf(schema: SchemaNode, item: FieldNode): string | undefined {
496
+ const declared = declaredExtra(schema, "mergeKey");
497
+ if (declared !== undefined) return declared;
498
+ const current = unwrapLazy(item);
499
+ return current?.type === "object" && current.fields.some((field) => field.key === "id")
500
+ ? "id"
501
+ : undefined;
502
+ }
503
+
504
+ /** union 的判别标签:显式声明优先,否则所有对象成员共有的唯一常量字段。 */
505
+ function tagKeyOf(schema: SchemaNode, variants: readonly FieldNode[]): string | undefined {
506
+ const declared = declaredExtra(schema, "tag");
507
+ if (declared !== undefined) return declared;
508
+ const members = variants.map((variant) => unwrapLazy(variant));
509
+ if (members.length === 0 || members.some((member) => member?.type !== "object")) return undefined;
510
+ const objects = members as (FieldNode & { type: "object" })[];
511
+ const candidates = (objects[0]?.fields ?? [])
512
+ .map((field) => unwrapLazy(field))
513
+ .filter((field): field is FieldNode & { type: "const" } => field?.type === "const")
514
+ .map((field) => field.key)
515
+ .filter((key) =>
516
+ objects.every(
517
+ (object) => unwrapLazy(object.fields.find((field) => field.key === key))?.type === "const",
518
+ ),
519
+ );
520
+ return candidates.length === 1 ? candidates[0] : undefined;
521
+ }
522
+
523
+ /** `role(text, { … })` 里声明的字符串约定(项身份 `mergeKey`、判别标签 `tag`)。 */
524
+ function declaredExtra(schema: SchemaNode, name: string): string | undefined {
525
+ const extra = schema.meta?.extra;
526
+ if (typeof extra !== "object" || extra === null) return undefined;
527
+ const value: unknown = Reflect.get(extra, name);
528
+ return typeof value === "string" && value.length > 0 ? value : undefined;
529
+ }
530
+
531
+ /** 一项的项身份值(渲染与冲突检查共用的读法)。 */
532
+ function memberLabel(value: unknown, mergeKey: string): string | undefined {
533
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
534
+ const member: unknown = Reflect.get(value, mergeKey);
535
+ if (typeof member === "string") return member;
536
+ if (typeof member === "number" && Number.isFinite(member)) return String(member);
537
+ return undefined;
538
+ }
539
+
540
+ /** 项身份的一处冲突。 */
541
+ export interface MergeKeyConflict {
542
+ /** `duplicate`:两个成员同名;`missing`:成员没有名字。 */
543
+ kind: "duplicate" | "missing";
544
+ /** 冲突成员的具体路径。 */
545
+ path: readonly string[];
546
+ /** 判据用的字段名。 */
547
+ mergeKey: string;
548
+ /** 那个名字(缺名字时是空串)。 */
549
+ value: string;
550
+ }
551
+
552
+ /**
553
+ * 项身份冲突:同一层里两个成员同名,或某个成员没有名字。
554
+ *
555
+ * 名字是成员的「身份」,写盘仍按索引——所以这只是保存前的本地校验:重名的数组在页面上是非法草稿,
556
+ * 而不是悄悄写到 host 上。
557
+ * @param root - 段根的字段树。
558
+ * @param value - 草稿后的段值。
559
+ * @returns 第一处冲突,或 `undefined`。
560
+ */
561
+ export function mergeKeyConflict(root: FieldNode, value: unknown): MergeKeyConflict | undefined {
562
+ return scanConflict(root, root.path, value);
563
+ }
564
+
565
+ function scanConflict(
566
+ node: FieldNode,
567
+ path: readonly string[],
568
+ value: unknown,
569
+ ): MergeKeyConflict | undefined {
570
+ const current = unwrapLazy(node);
571
+ if (current === undefined) return undefined;
572
+ switch (current.type) {
573
+ case "object": {
574
+ for (const field of current.fields) {
575
+ const conflict = scanConflict(field, [...path, field.key], memberOf(value, field.key));
576
+ if (conflict !== undefined) return conflict;
577
+ }
578
+ return undefined;
579
+ }
580
+ case "dict": {
581
+ for (const [key, child] of entriesOf(value)) {
582
+ const conflict = scanConflict(current.valueNode, [...path, key], child);
583
+ if (conflict !== undefined) return conflict;
584
+ }
585
+ return undefined;
586
+ }
587
+ case "array": {
588
+ const names = new Set<string>();
589
+ for (const [key, child] of entriesOf(value)) {
590
+ const itemPath = [...path, key];
591
+ if (current.mergeKey !== undefined) {
592
+ const label = memberLabel(child, current.mergeKey);
593
+ if (label === undefined || label.trim() === "") {
594
+ return { kind: "missing", path: itemPath, mergeKey: current.mergeKey, value: "" };
595
+ }
596
+ if (names.has(label)) {
597
+ return { kind: "duplicate", path: itemPath, mergeKey: current.mergeKey, value: label };
598
+ }
599
+ names.add(label);
600
+ }
601
+ const conflict = scanConflict(current.item, itemPath, child);
602
+ if (conflict !== undefined) return conflict;
603
+ }
604
+ return undefined;
605
+ }
606
+ case "tuple": {
607
+ for (const [index, item] of current.items.entries()) {
608
+ const conflict = scanConflict(item, [...path, String(index)], memberOf(value, index));
609
+ if (conflict !== undefined) return conflict;
610
+ }
611
+ return undefined;
612
+ }
613
+ case "union": {
614
+ if (current.choices !== undefined) return undefined;
615
+ const variant = pickVariant(current, value);
616
+ return variant === undefined ? undefined : scanConflict(variant, path, value);
617
+ }
618
+ case "intersect": {
619
+ for (const member of current.members) {
620
+ const conflict = scanConflict(member, path, value);
621
+ if (conflict !== undefined) return conflict;
622
+ }
623
+ return undefined;
624
+ }
625
+ default:
626
+ return undefined;
627
+ }
628
+ }
629
+
630
+ /** 一个容器值里的成员(对象键或数组索引)。 */
631
+ function entriesOf(value: unknown): [string, unknown][] {
632
+ if (Array.isArray(value)) return value.map((item, index) => [String(index), item]);
633
+ if (typeof value === "object" && value !== null) return Object.entries(value);
634
+ return [];
635
+ }
636
+
637
+ /** 读一个成员的值。 */
638
+ function memberOf(value: unknown, key: string | number): unknown {
639
+ if (Array.isArray(value)) return value[Number(key)];
640
+ if (typeof value === "object" && value !== null)
641
+ return (value as Record<string, unknown>)[String(key)];
642
+ return undefined;
643
+ }
644
+
645
+ /** union 的变体切换:一个选项要写的值。 */
646
+ export interface VariantChoice {
647
+ /** 页面上显示的名字:判别标签的值,或变体的形状。 */
648
+ label: string;
649
+ /** 写这个值就等于选中这一支。 */
650
+ value: unknown;
651
+ }
652
+
653
+ /** 把节点收成 union:渲染层判形状与选支都从这里进。 */
654
+ export function unionOf(node: FieldNode | undefined): (FieldNode & { type: "union" }) | undefined {
655
+ const current = unwrapLazy(node);
656
+ return current?.type === "union" ? current : undefined;
657
+ }
658
+
659
+ /**
660
+ * 当前值落在哪一支:与 {@link walkFields} 的展开同一判据(标签值优先,其次值的形状,最后第一支)。
661
+ * @param node - 字段节点(不是 union 就原样返回)。
662
+ * @param value - 这一层的值。
663
+ * @returns 选中变体的节点。
664
+ */
665
+ export function variantOf(node: FieldNode, value: unknown): FieldNode {
666
+ const union = unionOf(node);
667
+ if (union === undefined) return unwrapLazy(node) ?? node;
668
+ return pickVariant(union, value) ?? union;
669
+ }
670
+
671
+ /** 变体里那个判别标签字段的常量值(不是 `const` 就没有)。 */
672
+ function tagValueOf(variant: FieldNode, tagKey: string): { found: boolean; value: unknown } {
673
+ const current = unwrapLazy(variant);
674
+ if (current?.type !== "object" && current?.type !== "intersect") {
675
+ return { found: false, value: undefined };
676
+ }
677
+ for (const field of fieldsOf(current)) {
678
+ if (field.key !== tagKey) continue;
679
+ const candidate = unwrapLazy(field);
680
+ if (candidate?.type === "const") return { found: true, value: candidate.value };
681
+ }
682
+ return { found: false, value: undefined };
683
+ }
684
+
685
+ /** 一个变体在页面上的名字:能看出"这一支是什么",而不是内部类型名。 */
686
+ function variantLabel(variant: FieldNode, tag: { found: boolean; value: unknown }): string {
687
+ if (tag.found) return JSON.stringify(tag.value);
688
+ switch (unwrapLazy(variant)?.type) {
689
+ case "string":
690
+ return '""';
691
+ case "number":
692
+ return "0";
693
+ case "boolean":
694
+ return "true";
695
+ case "array":
696
+ case "tuple":
697
+ return "[ ]";
698
+ case "object":
699
+ case "dict":
700
+ case "intersect":
701
+ return "{ }";
702
+ default:
703
+ return unwrapLazy(variant)?.type ?? "any";
704
+ }
705
+ }
706
+
707
+ /**
708
+ * 变体切换的选项。
709
+ *
710
+ * 带判别标签的 union 写**标签值**(`session-rdb` 的 `type: sqlite` ↔ `type: postgres`);没有标签的写目标变体的
711
+ * 空值(`access` 在「一段文本」与「一组文本」之间切)。
712
+ * @param union - 投影出来的 union 节点。
713
+ * @returns 每个变体一项,顺序即声明顺序。
714
+ */
715
+ export function variantChoices(union: FieldNode & { type: "union" }): readonly VariantChoice[] {
716
+ return union.variants.map((variant) => {
717
+ const tag =
718
+ union.tagKey === undefined
719
+ ? { found: false, value: undefined }
720
+ : tagValueOf(variant, union.tagKey);
721
+ return {
722
+ label: variantLabel(variant, tag),
723
+ value: tag.found ? tag.value : emptyValueOf(variant),
724
+ };
725
+ });
726
+ }
727
+
728
+ /** 按路径读一个值:对象键与数组下标都认(值的读法只有这一处)。 */
729
+ export function readPath(root: unknown, path: readonly string[]): unknown {
730
+ return path.reduce<unknown>((node, segment) => {
731
+ if (Array.isArray(node)) return node[Number(segment)];
732
+ if (typeof node !== "object" || node === null) return undefined;
733
+ return (node as Record<string, unknown>)[segment];
734
+ }, root);
735
+ }
736
+
737
+ /**
738
+ * 这个"默认值"其实不是值——它不该用来当新加进来的那一项的初值。
739
+ *
740
+ * 两种:`null`("没有值"的判断位,例如 `defaultModel` 用 `.default(null)` 表达"没配就没有这个键"),以及
741
+ * schemastery 给容器类型自动塞的空壳(`{}`)。两种都该让容器把字段摆出来。
742
+ * @param node - 字段节点。
743
+ * @returns 是不是空壳。
744
+ */
745
+ function isBlankDefault(node: FieldNode): boolean {
746
+ const value = node.meta.defaultValue;
747
+ if (value === null) return true;
748
+ if (node.type !== "object" && node.type !== "intersect") return false;
749
+ return typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0;
750
+ }
751
+
752
+ /**
753
+ * 一个新子节点的初值:声明的默认值优先;对象把字段摆出来(见下);字典/数组给空的容器;其余标量给 **`null`**。
754
+ *
755
+ * `null` 在这里是"还没有值"的判断位(`null` / `undefined` 都算),页面据此把它画成一个等着输入的位子——
756
+ * 而不是 `""`、`0` 这种看着像已经填过的值。host 侧也认这个语义:非必填字段拿到 `null` 回退到默认值,
757
+ * 必填字段拿到 `null` 就是"必须填"。
758
+ * @param node - 子节点的字段树。
759
+ * @returns 初值。
760
+ */
761
+ export function emptyValueOf(node: FieldNode): unknown {
762
+ if (node.meta.hasDefault && !isBlankDefault(node)) {
763
+ return structuredClone(node.meta.defaultValue);
764
+ }
765
+ switch (node.type) {
766
+ case "array":
767
+ return [];
768
+ case "tuple":
769
+ return node.items.map((item) => emptyValueOf(item));
770
+ case "dict":
771
+ return {};
772
+ case "object":
773
+ case "intersect":
774
+ // 新加进来的对象:把它的字段都摆出来(标量给 `null` = "还没填",容器给空的容器)。加成之后直接看到要填
775
+ // 什么——否则是一个空对象,用户还得逐个从"添加属性"候选里再挑一遍。
776
+ return Object.fromEntries(fieldsOf(node).map((field) => [field.key, emptyValueOf(field)]));
777
+ case "const":
778
+ return node.value;
779
+ case "bitset":
780
+ return 0;
781
+ case "lazy":
782
+ return emptyValueOf(node.inner);
783
+ default:
784
+ return null;
785
+ }
786
+ }
787
+
788
+ /** 选中与当前值匹配的 union 变体:对象按判别标签的值判,叶子按值类型判。 */
789
+ function pickVariant(node: FieldNode & { type: "union" }, value: unknown): FieldNode | undefined {
790
+ if (
791
+ node.tagKey !== undefined &&
792
+ typeof value === "object" &&
793
+ value !== null &&
794
+ !Array.isArray(value)
795
+ ) {
796
+ const tagValue: unknown = Reflect.get(value, node.tagKey);
797
+ for (const variant of node.variants) {
798
+ const current = unwrapLazy(variant);
799
+ if (current?.type !== "object") continue;
800
+ const field = unwrapLazy(current.fields.find((candidate) => candidate.key === node.tagKey));
801
+ if (field?.type === "const" && field.value === tagValue) return variant;
802
+ }
803
+ }
804
+ for (const variant of node.variants) {
805
+ const current = unwrapLazy(variant);
806
+ if (current?.type === "string" && typeof value === "string") return variant;
807
+ if (current?.type === "number" && typeof value === "number") return variant;
808
+ if (current?.type === "boolean" && typeof value === "boolean") return variant;
809
+ if (current?.type === "array" && Array.isArray(value)) return variant;
810
+ }
811
+ return node.variants[0];
812
+ }