@nekro-nxt/contracts 0.0.0-stage → 0.1.0

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,351 @@
1
+ import { z } from 'zod'
2
+ import { JsonValueSchema, type JsonValue } from './domain.js'
3
+
4
+ /**
5
+ * The single configuration format: serialized Schemastery (`Schema.toJSON()`), restricted to the subset the
6
+ * product form can render and the Host can validate without executing Schema callbacks.
7
+ *
8
+ * Supported nodes: object (dict), string, number, natural, percent, boolean, const, union of consts (enum) and
9
+ * array of primitives. `meta.role: 'secret'` marks a write-only credential. NekroNXT reads three extra meta keys:
10
+ * `hint` (help text), `advanced` (collapsed by default) and `visibleWhen` (show only when a sibling equals a value).
11
+ */
12
+ export const CONFIG_SCHEMA_MAX_BYTES = 64 * 1024
13
+
14
+ const DescriptionSchema = z.union([z.string(), z.record(z.string(), z.string())])
15
+
16
+ export const ConfigSchemaMetaSchema = z
17
+ .object({
18
+ description: DescriptionSchema.optional(),
19
+ hint: z.string().max(500).optional(),
20
+ required: z.boolean().optional(),
21
+ default: JsonValueSchema.optional(),
22
+ role: z.string().max(32).optional(),
23
+ hidden: z.boolean().optional(),
24
+ min: z.number().finite().optional(),
25
+ max: z.number().finite().optional(),
26
+ step: z.number().finite().positive().optional(),
27
+ pattern: z.object({ source: z.string().max(500), flags: z.string().max(8).optional() }).optional(),
28
+ advanced: z.boolean().optional(),
29
+ group: z.string().max(40).optional(),
30
+ visibleWhen: z
31
+ .object({ key: z.string().min(1).max(64), equals: JsonValueSchema })
32
+ .strict()
33
+ .optional(),
34
+ })
35
+ .loose()
36
+
37
+ export type ConfigSchemaMeta = z.output<typeof ConfigSchemaMetaSchema>
38
+
39
+ export type ConfigSchemaNode =
40
+ | {
41
+ readonly type: 'object'
42
+ readonly dict: Readonly<Record<string, ConfigSchemaNode>>
43
+ readonly meta?: ConfigSchemaMeta | undefined
44
+ }
45
+ | {
46
+ readonly type: 'string' | 'number' | 'natural' | 'percent' | 'boolean'
47
+ readonly meta?: ConfigSchemaMeta | undefined
48
+ }
49
+ | { readonly type: 'const'; readonly value: JsonValue; readonly meta?: ConfigSchemaMeta | undefined }
50
+ | { readonly type: 'union'; readonly list: readonly ConfigSchemaNode[]; readonly meta?: ConfigSchemaMeta | undefined }
51
+ | { readonly type: 'array'; readonly inner: ConfigSchemaNode; readonly meta?: ConfigSchemaMeta | undefined }
52
+
53
+ const SAFE_KEY = /^(?!__proto__$|prototype$|constructor$)[A-Za-z_$][\w$-]{0,63}$/u
54
+
55
+ export const ConfigSchemaNodeSchema: z.ZodType<ConfigSchemaNode> = z.lazy(() =>
56
+ z.union([
57
+ z
58
+ .object({
59
+ type: z.literal('object'),
60
+ dict: z.record(z.string().regex(SAFE_KEY), ConfigSchemaNodeSchema),
61
+ meta: ConfigSchemaMetaSchema.optional(),
62
+ })
63
+ .loose(),
64
+ z
65
+ .object({
66
+ type: z.enum(['string', 'number', 'natural', 'percent', 'boolean']),
67
+ meta: ConfigSchemaMetaSchema.optional(),
68
+ })
69
+ .loose(),
70
+ z.object({ type: z.literal('const'), value: JsonValueSchema, meta: ConfigSchemaMetaSchema.optional() }).loose(),
71
+ z
72
+ .object({
73
+ type: z.literal('union'),
74
+ list: z.array(ConfigSchemaNodeSchema).min(1).max(64),
75
+ meta: ConfigSchemaMetaSchema.optional(),
76
+ })
77
+ .loose(),
78
+ z
79
+ .object({ type: z.literal('array'), inner: ConfigSchemaNodeSchema, meta: ConfigSchemaMetaSchema.optional() })
80
+ .loose(),
81
+ ]),
82
+ )
83
+
84
+ /** A whole configuration document: always an object at the root. */
85
+ export const ConfigSchemaDocumentSchema = ConfigSchemaNodeSchema.refine(
86
+ (node) => node.type === 'object',
87
+ '配置 Schema 的根节点必须是 object。',
88
+ ).refine((node) => new TextEncoder().encode(JSON.stringify(node)).length <= CONFIG_SCHEMA_MAX_BYTES, {
89
+ message: `配置 Schema 不能超过 ${CONFIG_SCHEMA_MAX_BYTES} 字节。`,
90
+ })
91
+
92
+ export type ConfigSchemaDocument = Extract<ConfigSchemaNode, { readonly type: 'object' }>
93
+
94
+ export const EMPTY_CONFIG_SCHEMA: ConfigSchemaDocument = { type: 'object', dict: {} }
95
+
96
+ export type ConfigFieldKind = 'string' | 'number' | 'boolean' | 'enum' | 'secret' | 'array' | 'object'
97
+
98
+ export interface ConfigField {
99
+ readonly key: string
100
+ readonly kind: ConfigFieldKind
101
+ readonly title: string
102
+ readonly hint?: string
103
+ readonly required: boolean
104
+ readonly default?: JsonValue
105
+ readonly advanced: boolean
106
+ readonly group?: string
107
+ readonly options?: readonly { readonly value: JsonValue; readonly label: string }[]
108
+ readonly node: ConfigSchemaNode
109
+ }
110
+
111
+ export const configDescription = (meta: ConfigSchemaMeta | undefined): string | undefined => {
112
+ const description = meta?.description
113
+ if (typeof description === 'string') return description
114
+ if (description) return description['zh-CN'] ?? description['zh'] ?? Object.values(description)[0]
115
+ return undefined
116
+ }
117
+
118
+ const isEnumUnion = (node: ConfigSchemaNode): node is Extract<ConfigSchemaNode, { readonly type: 'union' }> =>
119
+ node.type === 'union' && node.list.every((option) => option.type === 'const')
120
+
121
+ export const configFieldKind = (node: ConfigSchemaNode): ConfigFieldKind => {
122
+ if (node.meta?.role === 'secret') return 'secret'
123
+ if (node.type === 'string') return 'string'
124
+ if (node.type === 'number' || node.type === 'natural' || node.type === 'percent') return 'number'
125
+ if (node.type === 'boolean') return 'boolean'
126
+ if (node.type === 'array') return 'array'
127
+ if (node.type === 'object') return 'object'
128
+ if (node.type === 'const' || isEnumUnion(node)) return 'enum'
129
+ throw new TypeError('配置 Schema 只支持常量组成的 union。')
130
+ }
131
+
132
+ /** Flattens the top level of a document into product form fields (order preserved). */
133
+ const jsonLabel = (value: JsonValue): string => (typeof value === 'string' ? value : JSON.stringify(value))
134
+
135
+ export const configFields = (document: ConfigSchemaDocument): readonly ConfigField[] =>
136
+ Object.entries(document.dict).map(([key, node]) => {
137
+ const kind = configFieldKind(node)
138
+ const title = configDescription(node.meta) ?? key
139
+ const options =
140
+ node.type === 'union' && isEnumUnion(node)
141
+ ? node.list.map((option) => ({
142
+ value: option.type === 'const' ? option.value : null,
143
+ label: configDescription(option.meta) ?? (option.type === 'const' ? jsonLabel(option.value) : ''),
144
+ }))
145
+ : node.type === 'const'
146
+ ? [{ value: node.value, label: configDescription(node.meta) ?? jsonLabel(node.value) }]
147
+ : undefined
148
+ return {
149
+ key,
150
+ kind,
151
+ title,
152
+ ...(node.meta?.hint === undefined ? {} : { hint: node.meta.hint }),
153
+ required: node.meta?.required === true,
154
+ ...(node.meta?.default === undefined ? {} : { default: node.meta.default }),
155
+ advanced: node.meta?.advanced === true,
156
+ ...(node.meta?.group === undefined ? {} : { group: node.meta.group }),
157
+ ...(options === undefined ? {} : { options }),
158
+ node,
159
+ }
160
+ })
161
+
162
+ export const configSecretKeys = (document: ConfigSchemaDocument): readonly string[] =>
163
+ configFields(document)
164
+ .filter((field) => field.kind === 'secret')
165
+ .map((field) => field.key)
166
+
167
+ export interface ConfigValidationIssue {
168
+ readonly path: string
169
+ readonly message: string
170
+ }
171
+
172
+ const isRecord = (value: unknown): value is Readonly<Record<string, JsonValue>> =>
173
+ typeof value === 'object' && value !== null && !Array.isArray(value)
174
+
175
+ const sameJson = (left: JsonValue, right: JsonValue): boolean => JSON.stringify(left) === JSON.stringify(right)
176
+
177
+ const validateNode = (
178
+ node: ConfigSchemaNode,
179
+ value: JsonValue | undefined,
180
+ path: string,
181
+ label: string,
182
+ issues: ConfigValidationIssue[],
183
+ ): JsonValue | undefined => {
184
+ const resolved = value ?? node.meta?.default
185
+ if (resolved === undefined || resolved === null) {
186
+ if (node.meta?.required === true) issues.push({ path, message: `请填写${label}。` })
187
+ if (node.type === 'object') return validateNode(node, {}, path, label, issues)
188
+ return undefined
189
+ }
190
+ switch (node.type) {
191
+ case 'object': {
192
+ if (!isRecord(resolved)) {
193
+ issues.push({ path, message: `${label}必须是对象。` })
194
+ return undefined
195
+ }
196
+ const output: Record<string, JsonValue> = {}
197
+ for (const key of Object.keys(resolved)) {
198
+ if (!Object.hasOwn(node.dict, key))
199
+ issues.push({ path: path ? `${path}.${key}` : key, message: `未知配置项:${key}` })
200
+ }
201
+ for (const [key, child] of Object.entries(node.dict)) {
202
+ const childPath = path ? `${path}.${key}` : key
203
+ const next = validateNode(child, resolved[key], childPath, configDescription(child.meta) ?? key, issues)
204
+ if (next !== undefined) output[key] = next
205
+ }
206
+ return output
207
+ }
208
+ case 'string': {
209
+ if (typeof resolved !== 'string') {
210
+ issues.push({ path, message: `${label}必须是文本。` })
211
+ return undefined
212
+ }
213
+ if (node.meta?.required === true && resolved.trim().length === 0)
214
+ issues.push({ path, message: `请填写${label}。` })
215
+ const pattern = node.meta?.pattern
216
+ if (pattern && resolved.length > 0 && !new RegExp(pattern.source, pattern.flags ?? '').test(resolved)) {
217
+ issues.push({ path, message: `${label}的格式不正确。` })
218
+ }
219
+ return resolved
220
+ }
221
+ case 'number':
222
+ case 'natural':
223
+ case 'percent': {
224
+ if (typeof resolved !== 'number' || !Number.isFinite(resolved)) {
225
+ issues.push({ path, message: `${label}必须是数字。` })
226
+ return undefined
227
+ }
228
+ if (node.type === 'natural' && (!Number.isInteger(resolved) || resolved < 0)) {
229
+ issues.push({ path, message: `${label}必须是非负整数。` })
230
+ }
231
+ if (node.type === 'percent' && (resolved < 0 || resolved > 1))
232
+ issues.push({ path, message: `${label}必须在 0 到 1 之间。` })
233
+ if (node.meta?.min !== undefined && resolved < node.meta.min)
234
+ issues.push({ path, message: `${label}不能小于 ${node.meta.min}。` })
235
+ if (node.meta?.max !== undefined && resolved > node.meta.max)
236
+ issues.push({ path, message: `${label}不能大于 ${node.meta.max}。` })
237
+ return resolved
238
+ }
239
+ case 'boolean':
240
+ if (typeof resolved !== 'boolean') {
241
+ issues.push({ path, message: `${label}必须是开关值。` })
242
+ return undefined
243
+ }
244
+ return resolved
245
+ case 'const':
246
+ if (!sameJson(resolved, node.value)) issues.push({ path, message: `${label}的取值无效。` })
247
+ return resolved
248
+ case 'union':
249
+ if (!node.list.some((option) => option.type === 'const' && sameJson(option.value, resolved))) {
250
+ issues.push({ path, message: `${label}的取值无效。` })
251
+ }
252
+ return resolved
253
+ case 'array': {
254
+ if (!Array.isArray(resolved)) {
255
+ issues.push({ path, message: `${label}必须是列表。` })
256
+ return undefined
257
+ }
258
+ return resolved.flatMap((item, index) => {
259
+ const next = validateNode(node.inner, item, `${path}[${index}]`, `${label}第 ${index + 1} 项`, issues)
260
+ return next === undefined ? [] : [next]
261
+ })
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Validates a configuration value against a document and fills declared defaults. Secret fields are not part of the
268
+ * stored value; callers pass them through the credential store instead.
269
+ */
270
+ export const validateConfigValue = (
271
+ document: ConfigSchemaDocument,
272
+ value: JsonValue | undefined,
273
+ options: { readonly skipKeys?: readonly string[] } = {},
274
+ ): { readonly value: Readonly<Record<string, JsonValue>>; readonly issues: readonly ConfigValidationIssue[] } => {
275
+ const skip = new Set(options.skipKeys ?? [])
276
+ const issues: ConfigValidationIssue[] = []
277
+ const input = isRecord(value) ? value : value === undefined || value === null ? {} : undefined
278
+ if (input === undefined) return { value: {}, issues: [{ path: '', message: '配置必须是对象。' }] }
279
+ const filtered: ConfigSchemaDocument = {
280
+ ...document,
281
+ dict: Object.fromEntries(Object.entries(document.dict).filter(([key]) => !skip.has(key))),
282
+ }
283
+ const output = validateNode(filtered, input, '', '配置', issues)
284
+ return { value: isRecord(output) ? output : {}, issues }
285
+ }
286
+
287
+ /** Throws the first validation issue as a user-readable error. */
288
+ export const parseConfigValue = (
289
+ document: ConfigSchemaDocument,
290
+ value: JsonValue | undefined,
291
+ options: { readonly skipKeys?: readonly string[] } = {},
292
+ ): Readonly<Record<string, JsonValue>> => {
293
+ const result = validateConfigValue(document, value, options)
294
+ const first = result.issues[0]
295
+ if (first) throw new TypeError(first.message)
296
+ return result.value
297
+ }
298
+
299
+ interface FieldOptions<Value extends JsonValue> {
300
+ readonly hint?: string
301
+ readonly default?: Value
302
+ readonly required?: boolean
303
+ readonly advanced?: boolean
304
+ readonly group?: string
305
+ }
306
+
307
+ const fieldMeta = <Value extends JsonValue>(title: string, options: FieldOptions<Value> = {}): ConfigSchemaMeta => ({
308
+ description: title,
309
+ ...(options.hint === undefined ? {} : { hint: options.hint }),
310
+ ...(options.default === undefined ? {} : { default: options.default }),
311
+ ...(options.required === undefined ? {} : { required: options.required }),
312
+ ...(options.advanced === undefined ? {} : { advanced: options.advanced }),
313
+ ...(options.group === undefined ? {} : { group: options.group }),
314
+ })
315
+
316
+ /** Builds serialized Schemastery documents without depending on the Schemastery runtime. */
317
+ export const configSchema = {
318
+ object: (dict: Readonly<Record<string, ConfigSchemaNode>>): ConfigSchemaDocument => ({ type: 'object', dict }),
319
+ string: (title: string, options: FieldOptions<string> & { readonly multiline?: boolean } = {}): ConfigSchemaNode => ({
320
+ type: 'string',
321
+ meta: { ...fieldMeta(title, options), ...(options.multiline ? { role: 'textarea' } : {}) },
322
+ }),
323
+ secret: (title: string, options: Omit<FieldOptions<string>, 'default'> = {}): ConfigSchemaNode => ({
324
+ type: 'string',
325
+ meta: { ...fieldMeta(title, options), role: 'secret' },
326
+ }),
327
+ number: (
328
+ title: string,
329
+ options: FieldOptions<number> & { readonly min?: number; readonly max?: number; readonly integer?: boolean } = {},
330
+ ): ConfigSchemaNode => ({
331
+ type: options.integer ? 'natural' : 'number',
332
+ meta: {
333
+ ...fieldMeta(title, options),
334
+ ...(options.min === undefined ? {} : { min: options.min }),
335
+ ...(options.max === undefined ? {} : { max: options.max }),
336
+ },
337
+ }),
338
+ boolean: (title: string, options: FieldOptions<boolean> = {}): ConfigSchemaNode => ({
339
+ type: 'boolean',
340
+ meta: fieldMeta(title, options),
341
+ }),
342
+ enum: (
343
+ title: string,
344
+ choices: readonly { readonly value: string | number; readonly label: string }[],
345
+ options: FieldOptions<string | number> = {},
346
+ ): ConfigSchemaNode => ({
347
+ type: 'union',
348
+ list: choices.map((choice) => ({ type: 'const', value: choice.value, meta: { description: choice.label } })),
349
+ meta: fieldMeta(title, options),
350
+ }),
351
+ } as const