@changfenhuang/dsh-genui 0.9.8 → 0.9.9

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +13 -8
  3. package/README.zh-CN.md +13 -8
  4. package/SKILL.md +5 -5
  5. package/lib/client.js +3 -3
  6. package/lib/index.js +1273 -338
  7. package/lib/types/client/blocks/advanced.d.ts +1 -1
  8. package/lib/types/client/blocks/checkbox.d.ts +17 -0
  9. package/lib/types/client/blocks/forms.d.ts +3 -8
  10. package/lib/types/client/blocks/state.d.ts +18 -14
  11. package/lib/types/client/dom-fence.d.ts +1 -1
  12. package/lib/types/client/genui-runtime/diagnostics.d.ts +18 -0
  13. package/lib/types/client/genui-runtime/index.d.ts +1 -0
  14. package/lib/types/client/genui-runtime/limits.d.ts +54 -0
  15. package/lib/types/client/genui-runtime/normalize.d.ts +15 -0
  16. package/lib/types/client/genui-runtime/schema.d.ts +73 -0
  17. package/lib/types/client/genui-runtime/value-utils.d.ts +18 -0
  18. package/lib/types/client/guard.d.ts +60 -71
  19. package/lib/types/client/interaction-store.d.ts +7 -2
  20. package/lib/types/client/panel-command.d.ts +1 -1
  21. package/lib/types/client/primitive-labels.d.ts +11 -0
  22. package/lib/types/client/spec.d.ts +17 -17
  23. package/lib/types/plugin/chart-contract.d.ts +6 -4
  24. package/lib/types/plugin/index.d.ts +1 -4
  25. package/package.json +74 -13
  26. package/src/client/GenuiBlock.tsx +27 -11
  27. package/src/client/blocks/advanced.tsx +6 -5
  28. package/src/client/blocks/charts.tsx +1 -1
  29. package/src/client/blocks/checkbox.tsx +60 -0
  30. package/src/client/blocks/diagram/index.tsx +1 -1
  31. package/src/client/blocks/forms.tsx +51 -28
  32. package/src/client/blocks/render-node.tsx +3 -16
  33. package/src/client/blocks/state.ts +18 -14
  34. package/src/client/dom-fence.tsx +2 -1
  35. package/src/client/fence-render.tsx +26 -15
  36. package/src/client/genui-runtime/diagnostics.ts +143 -0
  37. package/src/client/genui-runtime/index.ts +1 -0
  38. package/src/client/genui-runtime/limits.ts +54 -0
  39. package/src/client/genui-runtime/normalize.ts +115 -0
  40. package/src/client/genui-runtime/schema.ts +291 -0
  41. package/src/client/genui-runtime/value-utils.ts +67 -0
  42. package/src/client/guard.ts +314 -169
  43. package/src/client/index.tsx +3 -2
  44. package/src/client/interaction-store.ts +7 -2
  45. package/src/client/panel-command.ts +2 -1
  46. package/src/client/panel.tsx +1 -1
  47. package/src/client/primitive-labels.ts +37 -0
  48. package/src/client/spec.ts +17 -17
  49. package/src/client/toolview.tsx +2 -1
  50. package/src/index.ts +3 -6
  51. package/src/plugin/chart-contract.ts +7 -97
  52. package/src/plugin/index.ts +2 -6
  53. package/src/plugin/tool.ts +60 -40
@@ -0,0 +1,291 @@
1
+ /** Runtime metadata shared by GenUI normalization and diagnostics. */
2
+
3
+ export type ComponentFieldKind =
4
+ | 'string'
5
+ | 'string-or-null'
6
+ | 'number'
7
+ | 'boolean'
8
+ | 'nodes'
9
+ | 'array'
10
+ | 'object'
11
+ | 'unknown'
12
+
13
+ /** A conditional rule for fields whose presence depends on another field. */
14
+ export interface ComponentConditionalRule {
15
+ readonly kind: 'required-if'
16
+ readonly when: { readonly field: string; readonly equals: unknown }
17
+ readonly required: readonly string[]
18
+ readonly message?: string
19
+ }
20
+
21
+ /** A field rule that requires at least one member of a field group. */
22
+ export interface ComponentOneOfRule {
23
+ readonly kind: 'one-of-required'
24
+ readonly fields: readonly string[]
25
+ readonly message?: string
26
+ }
27
+
28
+ /** Runtime metadata naming a component-specific semantic validator. */
29
+ export interface ComponentValidatorMetadata {
30
+ readonly name: string
31
+ readonly [key: string]: unknown
32
+ }
33
+
34
+ /** Runtime schema for an object nested inside a native component field. */
35
+ export interface ComponentRecordSchema {
36
+ readonly required: readonly string[]
37
+ readonly fields: Readonly<Record<string, ComponentFieldKind>>
38
+ readonly enums: Readonly<Record<string, readonly string[]>>
39
+ readonly nested: Readonly<Record<string, ComponentRecordSchema>>
40
+ }
41
+
42
+ export interface ComponentSchema {
43
+ readonly required: readonly string[]
44
+ readonly fields: Readonly<Record<string, ComponentFieldKind>>
45
+ readonly enums: Readonly<Record<string, readonly string[]>>
46
+ /** Explicit optional field kinds; `fields` remains the complete field map. */
47
+ readonly optional: Readonly<Record<string, ComponentFieldKind>>
48
+ readonly aliases: Readonly<Record<string, string>>
49
+ readonly oneOfRequired: readonly (readonly string[])[]
50
+ readonly conditionalRequired: readonly ComponentConditionalRule[]
51
+ readonly rules: readonly (ComponentOneOfRule | ComponentConditionalRule)[]
52
+ readonly nested: Readonly<Record<string, ComponentRecordSchema>>
53
+ readonly validator?: ComponentValidatorMetadata
54
+ }
55
+
56
+
57
+ /** Canonical enum domains shared by schema validation and repair. */
58
+ export const TEXT_SIZES = ['h1', 'h2', 'h3', 'body', 'muted', 'caption'] as const
59
+ export const BUTTON_TONES = ['primary', 'danger', 'success', 'ghost'] as const
60
+ export const BADGE_TONES = ['success', 'warn', 'danger', 'accent'] as const
61
+ export const INPUT_TYPES = ['text', 'email', 'password', 'color'] as const
62
+ export const CALLOUT_TONES = ['info', 'success', 'warning', 'error'] as const
63
+ export const CHART_KINDS = ['bars', 'line', 'donut'] as const
64
+ export const PLOT_KINDS = ['line', 'area', 'scatter'] as const
65
+ export const MEDIA_ASPECT_RATIOS = ['16:9', '4:3', '1:1', '9:16'] as const
66
+ export const MESH_SHAPES = ['box', 'sphere', 'cone', 'cylinder', 'torus'] as const
67
+ export const FILE_TYPES = ['file', 'dir'] as const
68
+ export const DIAGRAM_KINDS = [
69
+ 'architecture', 'it-state', 'flowchart', 'sequence', 'state', 'er', 'timeline',
70
+ 'swimlane', 'quadrant', 'radar', 'loop', 'nested', 'tree', 'org-chart', 'layers',
71
+ 'venn', 'pyramid', 'bar', 'line', 'gantt', 'scatter', 'high-level', 'process',
72
+ 'medallion', 'data-flow', 'dp-integration', 'dp-security-matrix',
73
+ ] as const
74
+ export const DIAGRAM_NODE_TYPES = ['focal', 'backend', 'store', 'external', 'input', 'optional', 'security'] as const
75
+ export const DIAGRAM_VARIANTS = ['light', 'dark', 'editorial'] as const
76
+ export const DIAGRAM_EDGE_KINDS = ['solid', 'dashed', 'accent', 'link'] as const
77
+ export const DIAGRAM_ROUTES = ['auto', 'orthogonal', 'straight'] as const
78
+ export const ECHART_PRESETS = ['bar', 'line', 'area', 'pie', 'scatter'] as const
79
+
80
+ const schema = (
81
+ required: readonly string[],
82
+ fields: Readonly<Record<string, ComponentFieldKind>>,
83
+ aliases: Readonly<Record<string, string>> = {},
84
+ options: {
85
+ oneOfRequired?: readonly (readonly string[])[]
86
+ conditionalRequired?: readonly ComponentConditionalRule[]
87
+ nested?: Readonly<Record<string, ComponentRecordSchema>>
88
+ enums?: Readonly<Record<string, readonly string[]>>
89
+ validator?: ComponentValidatorMetadata
90
+ } = {},
91
+ ): ComponentSchema => {
92
+ const optional = Object.fromEntries(
93
+ Object.entries(fields).filter(([field]) => field !== 'type' && !required.includes(field)),
94
+ ) as Record<string, ComponentFieldKind>
95
+ const oneOfRequired = options.oneOfRequired ?? []
96
+ const conditionalRequired = options.conditionalRequired ?? []
97
+ const rules: Array<ComponentOneOfRule | ComponentConditionalRule> = [
98
+ ...oneOfRequired.map(fieldsInRule => ({ kind: 'one-of-required' as const, fields: fieldsInRule })),
99
+ ...conditionalRequired,
100
+ ]
101
+ return {
102
+ required,
103
+ fields,
104
+ optional,
105
+ aliases,
106
+ oneOfRequired,
107
+ conditionalRequired,
108
+ rules,
109
+ enums: options.enums ?? {},
110
+ nested: options.nested ?? {},
111
+ ...(options.validator === undefined ? {} : { validator: options.validator }),
112
+ }
113
+ }
114
+
115
+ const recordSchema = (
116
+ required: readonly string[],
117
+ fields: Readonly<Record<string, ComponentFieldKind>>,
118
+ nested: Readonly<Record<string, ComponentRecordSchema>> = {},
119
+ enums: Readonly<Record<string, readonly string[]>> = {},
120
+ ): ComponentRecordSchema => ({ required, fields, enums, nested })
121
+
122
+ const nodeFields = { type: 'string' } as const
123
+
124
+ const chartDatumSchema = recordSchema(['label', 'value'], {
125
+ label: 'string',
126
+ value: 'number',
127
+ color: 'string',
128
+ })
129
+
130
+ const chartSeriesSchema = recordSchema(['label', 'data'], {
131
+ label: 'string',
132
+ color: 'string',
133
+ data: 'array',
134
+ }, { data: chartDatumSchema })
135
+
136
+ const stepsRecordSchema = recordSchema(['title'], { title: 'string', desc: 'string' })
137
+ const keyValueRecordSchema = recordSchema(['key', 'value'], { key: 'string', value: 'string' })
138
+ const timelineRecordSchema = recordSchema(['title'], { title: 'string', desc: 'string', time: 'string' })
139
+ const diffRecordSchema = recordSchema(['path', 'newText'], { path: 'string', oldText: 'string-or-null', newText: 'string' })
140
+ const plotParamSchema = recordSchema(['name', 'value'], {
141
+ name: 'string', value: 'number', min: 'number', max: 'number', step: 'number', animateTo: 'number', durationMs: 'number', loop: 'boolean',
142
+ })
143
+ const plotSeriesSchema = recordSchema(['expr'], {
144
+ expr: 'string', label: 'string', color: 'string', kind: 'string', params: 'array',
145
+ }, { params: plotParamSchema }, { kind: PLOT_KINDS })
146
+ const sceneMeshSchema = recordSchema(['shape'], {
147
+ shape: 'string', color: 'string', position: 'array', rotation: 'array', scale: 'unknown', size: 'unknown',
148
+ }, {}, { shape: MESH_SHAPES })
149
+
150
+ function fileTreeRecordSchema(depth: number): ComponentRecordSchema {
151
+ return recordSchema(['name'], {
152
+ name: 'string', type: 'string', children: 'array',
153
+ }, depth > 0 ? { children: fileTreeRecordSchema(depth - 1) } : {}, { type: FILE_TYPES })
154
+ }
155
+
156
+ const fileTreeNodeSchema = fileTreeRecordSchema(6)
157
+
158
+ const tabHolderSchema = recordSchema(['label', 'items'], {
159
+ label: 'string',
160
+ items: 'nodes',
161
+ content: 'nodes',
162
+ })
163
+
164
+ const accordionHolderSchema = recordSchema(['title', 'items'], {
165
+ title: 'string',
166
+ items: 'nodes',
167
+ })
168
+
169
+ const diagramNodeSchema = recordSchema(['id', 'label'], {
170
+ id: 'string',
171
+ label: 'string',
172
+ sub: 'string',
173
+ type: 'string',
174
+ x: 'number',
175
+ y: 'number',
176
+ w: 'number',
177
+ h: 'number',
178
+ tag: 'string',
179
+ }, {}, { type: DIAGRAM_NODE_TYPES })
180
+
181
+ const diagramEdgeSchema = recordSchema(['from', 'to'], {
182
+ from: 'string',
183
+ to: 'string',
184
+ label: 'string',
185
+ kind: 'string',
186
+ route: 'string',
187
+ }, {}, { kind: DIAGRAM_EDGE_KINDS, route: DIAGRAM_ROUTES })
188
+
189
+ const diagramZoneSchema = recordSchema(['label'], {
190
+ label: 'string',
191
+ x: 'number',
192
+ y: 'number',
193
+ w: 'number',
194
+ h: 'number',
195
+ })
196
+
197
+ const diagramThemeSchema = recordSchema([], {
198
+ paper: 'string',
199
+ 'paper-2': 'string',
200
+ ink: 'string',
201
+ muted: 'string',
202
+ soft: 'string',
203
+ rule: 'string',
204
+ accent: 'string',
205
+ 'accent-tint': 'string',
206
+ link: 'string',
207
+ })
208
+
209
+ /** Root GenUI specification metadata used by diagnostics. */
210
+ export const GENUI_SPEC_SCHEMA = schema(['items'], {
211
+ title: 'string',
212
+ gap: 'number',
213
+ panel: 'boolean',
214
+ append: 'boolean',
215
+ items: 'nodes',
216
+ })
217
+
218
+ /** Backwards-friendly short alias for the root specification schema. */
219
+ export const SPEC_SCHEMA = GENUI_SPEC_SCHEMA
220
+
221
+ /**
222
+ * Native component field metadata.
223
+ *
224
+ * This is intentionally explicit rather than inferred from TypeScript
225
+ * interfaces: the registry is also consumed at runtime by normalization and
226
+ * diagnostics, where erased interfaces are unavailable.
227
+ */
228
+ export const COMPONENT_SCHEMAS: Readonly<Record<string, ComponentSchema>> = {
229
+ accordion: schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: accordionHolderSchema } }),
230
+ audio: schema(['src'], { ...nodeFields, src: 'string', alt: 'string', loop: 'boolean' }),
231
+ avatar: schema(['name'], { ...nodeFields, name: 'string', color: 'string' }),
232
+ badge: schema(['label'], { ...nodeFields, label: 'string', tone: 'string', icon: 'string' }, { text: 'label', value: 'label' }, { enums: { tone: BADGE_TONES } }),
233
+ breadcrumb: schema(['items'], { ...nodeFields, items: 'array' }),
234
+ button: schema(['label'], { ...nodeFields, label: 'string', tone: 'string', full: 'boolean', small: 'boolean', icon: 'string', action: 'string' }, {}, { enums: { tone: BUTTON_TONES } }),
235
+ callout: schema(['content'], { ...nodeFields, title: 'string', content: 'string', tone: 'string' }, { kind: 'tone' }, { enums: { tone: CALLOUT_TONES } }),
236
+ card: schema(['items'], { ...nodeFields, title: 'string', items: 'nodes' }, { label: 'title', content: 'items' }),
237
+ chart: schema([], { ...nodeFields, kind: 'string', data: 'array', series: 'array' }, {}, {
238
+ oneOfRequired: [['data', 'series']],
239
+ conditionalRequired: [
240
+ { kind: 'required-if', when: { field: 'kind', equals: 'line' }, required: ['data'] },
241
+ { kind: 'required-if', when: { field: 'kind', equals: 'donut' }, required: ['data'] },
242
+ ],
243
+ nested: { data: chartDatumSchema, series: chartSeriesSchema },
244
+ enums: { kind: CHART_KINDS },
245
+ validator: { name: 'chart-renderability' },
246
+ }),
247
+ checkbox: schema(['label'], { ...nodeFields, label: 'string', checked: 'boolean', action: 'string', group: 'string' }),
248
+ code: schema(['code'], { ...nodeFields, lang: 'string', code: 'string' }),
249
+ col: schema(['items'], { ...nodeFields, items: 'nodes', gap: 'number' }),
250
+ copy: schema(['text'], { ...nodeFields, label: 'string', text: 'string' }),
251
+ diagram: schema(['kind', 'nodes'], { ...nodeFields, kind: 'string', variant: 'string', title: 'string', nodes: 'array', edges: 'array', zones: 'array', theme: 'object' }, {}, {
252
+ nested: { nodes: diagramNodeSchema, edges: diagramEdgeSchema, zones: diagramZoneSchema, theme: diagramThemeSchema },
253
+ enums: { kind: DIAGRAM_KINDS, variant: DIAGRAM_VARIANTS },
254
+ }),
255
+ diff: schema(['diffs'], { ...nodeFields, diffs: 'array' }, {}, { nested: { diffs: diffRecordSchema } }),
256
+ divider: schema([], nodeFields),
257
+ echart: schema([], { ...nodeFields, title: 'string', height: 'number', preset: 'string', data: 'array', series: 'array', option: 'object' }, {}, {
258
+ oneOfRequired: [['option', 'data', 'series']],
259
+ enums: { preset: ECHART_PRESETS },
260
+ }),
261
+ 'file-tree': schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: fileTreeNodeSchema } }),
262
+ grid: schema(['items'], { ...nodeFields, cols: 'number', items: 'nodes' }),
263
+ image: schema(['src'], { ...nodeFields, src: 'string', alt: 'string' }),
264
+ input: schema([], { ...nodeFields, label: 'string', placeholder: 'string', value: 'string', inputType: 'string', action: 'string', id: 'string' }, {}, { enums: { inputType: INPUT_TYPES } }),
265
+ json: schema(['value'], { ...nodeFields, value: 'unknown' }),
266
+ keyvalue: schema(['pairs'], { ...nodeFields, pairs: 'array' }, {}, { nested: { pairs: keyValueRecordSchema } }),
267
+ link: schema(['label'], { ...nodeFields, label: 'string', href: 'string' }),
268
+ list: schema(['items'], { ...nodeFields, items: 'array' }),
269
+ mermaid: schema(['code'], { ...nodeFields, code: 'string' }),
270
+ plot: schema(['series'], { ...nodeFields, series: 'array', xMin: 'number', xMax: 'number', yMin: 'number', yMax: 'number', title: 'string' }, {}, { nested: { series: plotSeriesSchema } }),
271
+ progress: schema(['value'], { ...nodeFields, value: 'number', label: 'string', valueLabel: 'string' }),
272
+ quiz: schema(['question', 'options'], { ...nodeFields, question: 'string', options: 'array', explanation: 'string', id: 'string', action: 'string' }),
273
+ radio: schema(['options'], { ...nodeFields, label: 'string', options: 'array', selected: 'number', action: 'string', group: 'string', answer: 'unknown', explanation: 'string' }),
274
+ row: schema(['items'], { ...nodeFields, items: 'nodes', wrap: 'boolean', spacer: 'boolean' }),
275
+ scene3d: schema(['meshes'], { ...nodeFields, title: 'string', meshes: 'array', ambient: 'number', background: 'string' }, {}, { nested: { meshes: sceneMeshSchema } }),
276
+ select: schema(['options'], { ...nodeFields, label: 'string', options: 'array', action: 'string', selected: 'number', id: 'string' }),
277
+ slider: schema([], { ...nodeFields, label: 'string', min: 'number', max: 'number', step: 'number', value: 'number', action: 'string', id: 'string' }),
278
+ spacer: schema([], nodeFields),
279
+ stat: schema(['label', 'value'], { ...nodeFields, label: 'string', value: 'string', delta: 'string' }),
280
+ steps: schema(['steps'], { ...nodeFields, steps: 'array', current: 'number' }, { items: 'steps' }, { nested: { steps: stepsRecordSchema } }),
281
+ submit: schema(['label'], { ...nodeFields, label: 'string', action: 'string', resetAction: 'string', groups: 'array' }),
282
+ switch: schema(['label'], { ...nodeFields, label: 'string', checked: 'boolean', action: 'string' }),
283
+ table: schema(['columns', 'rows'], { ...nodeFields, columns: 'array', rows: 'array' }, { headers: 'columns', data: 'rows' }),
284
+ tabs: schema(['tabs'], { ...nodeFields, tabs: 'array' }, {}, { nested: { tabs: tabHolderSchema } }),
285
+ text: schema(['content'], { ...nodeFields, content: 'string', size: 'string', center: 'boolean' }, { text: 'content' }, { enums: { size: TEXT_SIZES } }),
286
+ textarea: schema([], { ...nodeFields, label: 'string', placeholder: 'string', rows: 'number', value: 'string', action: 'string', id: 'string' }),
287
+ timeline: schema(['items'], { ...nodeFields, items: 'array' }, {}, { nested: { items: timelineRecordSchema } }),
288
+ video: schema(['src'], { ...nodeFields, src: 'string', alt: 'string', poster: 'string', loop: 'boolean', muted: 'boolean', aspectRatio: 'string' }, {}, { enums: { aspectRatio: MEDIA_ASPECT_RATIOS } }),
289
+ } as const
290
+
291
+ export const GENUI_NATIVE_TYPES: ReadonlySet<string> = new Set(Object.keys(COMPONENT_SCHEMAS))
@@ -0,0 +1,67 @@
1
+ /** Shared primitive sanitizers used by the GenUI guard. */
2
+
3
+ /** Is `value` one of `values`? */
4
+ function inEnum<T extends string>(value: unknown, values: readonly T[]): value is T {
5
+ return typeof value === 'string' && (values as readonly string[]).includes(value)
6
+ }
7
+
8
+ /** String field: truncate a string to `cap`, or undefined when not a string. */
9
+ export function str(value: unknown, cap: number): string | undefined {
10
+ return typeof value === 'string' ? value.slice(0, cap) : undefined
11
+ }
12
+
13
+ /**
14
+ * Color field: the value lands in an inline `style` (background/stroke) or
15
+ * THREE.Color. Arbitrary CSS values are an exfiltration channel, so only
16
+ * literal color formats and host design tokens are accepted.
17
+ */
18
+ const SAFE_COLOR_RE = /^(?:#[\da-fA-F]{3,8}|rgba?\([^)]{0,64}\)|hsla?\([^)]{0,64}\)|var\(--dsw-[\w-]+(?:,\s*#[0-9a-fA-F]{3,8})?\))$/
19
+
20
+ export function color(value: unknown): string | undefined {
21
+ if (typeof value !== 'string') return undefined
22
+ const normalized = value.trim()
23
+ return normalized.length <= 64 && SAFE_COLOR_RE.test(normalized) ? normalized : undefined
24
+ }
25
+
26
+ /** Keep only http(s) and mailto link targets. */
27
+ export function safeHref(value: unknown): string | undefined {
28
+ if (typeof value !== 'string') return undefined
29
+ const normalized = value.trim()
30
+ if (normalized.length > 2048) return undefined
31
+ return /^https?:\/\//i.test(normalized) || /^mailto:[^@\s]+@[^@\s]+$/i.test(normalized) ? normalized : undefined
32
+ }
33
+
34
+ /** Keep browser-reachable http(s) or same-origin relative media paths. */
35
+ export function safeMediaSrc(value: unknown): string | undefined {
36
+ if (typeof value !== 'string') return undefined
37
+ const normalized = value.trim()
38
+ if (normalized === '' || normalized.length > 2048) return undefined
39
+ if (/^https?:\/\//i.test(normalized)) return normalized
40
+ if (/^[a-z][a-z0-9+.-]*:/i.test(normalized) || /^[/\\]{2}/.test(normalized)) return undefined
41
+ return normalized
42
+ }
43
+
44
+ /** Finite-number field: clamp into [min, max], or undefined when not finite. */
45
+ export function num(value: unknown, min: number, max: number): number | undefined {
46
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : undefined
47
+ }
48
+
49
+ /** Integer field: clamp into [min, max], or undefined when not a finite number. */
50
+ export function int(value: unknown, min: number, max: number): number | undefined {
51
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : undefined
52
+ }
53
+
54
+ /** Optional enum field: the value when it matches, otherwise undefined. */
55
+ export function enu<T extends string>(value: unknown, values: readonly T[]): T | undefined {
56
+ return inEnum(value, values) ? value : undefined
57
+ }
58
+
59
+ /** Plain object (not array, not null). */
60
+ export function obj(value: unknown): Record<string, unknown> | undefined {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
62
+ }
63
+
64
+ /** Preserve an optional field only when its value exists. */
65
+ export function opt<K extends string, V>(key: K, value: V | undefined): Partial<Record<K, V>> {
66
+ return value === undefined ? {} : { [key]: value } as Partial<Record<K, V>>
67
+ }