@json-layout/core 2.8.1 → 2.9.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.
- package/package.json +4 -2
- package/src/compile/index.js +3 -1
- package/src/compile/serialize.js +18 -15
- package/src/compile/skeleton-node.js +71 -5
- package/src/compile/types.ts +1 -0
- package/src/compile/utils/resolve-refs.js +5 -8
- package/src/compile/utils/x-i18n.js +15 -6
- package/src/state/index.js +46 -1
- package/src/state/state-node.js +36 -5
- package/src/state/types.ts +6 -0
- package/src/state/utils/urls.js +2 -0
- package/src/utils/json-pointer.js +29 -0
- package/src/webmcp/README.md +144 -0
- package/src/webmcp/index.js +88 -82
- package/src/webmcp/project.js +479 -111
- package/src/webmcp/resolve.js +37 -1
- package/src/webmcp/schema.js +169 -0
- package/src/webmcp/suggestions-store.js +121 -0
- package/src/webmcp/tools/describe-state.js +20 -64
- package/src/webmcp/tools/edit-array.js +51 -28
- package/src/webmcp/tools/fill-form-skill.js +17 -41
- package/src/webmcp/tools/get-data.js +66 -13
- package/src/webmcp/tools/get-field-suggestions.js +12 -23
- package/src/webmcp/tools/set-data.js +79 -28
- package/src/webmcp/tools/set-field-value.js +49 -39
- package/src/webmcp/variants-memo.js +53 -0
- package/types/compile/index.d.ts.map +1 -1
- package/types/compile/serialize.d.ts.map +1 -1
- package/types/compile/skeleton-node.d.ts +9 -2
- package/types/compile/skeleton-node.d.ts.map +1 -1
- package/types/compile/types.d.ts +1 -0
- package/types/compile/types.d.ts.map +1 -1
- package/types/compile/utils/resolve-refs.d.ts.map +1 -1
- package/types/compile/utils/x-i18n.d.ts +1 -1
- package/types/compile/utils/x-i18n.d.ts.map +1 -1
- package/types/state/index.d.ts +23 -0
- package/types/state/index.d.ts.map +1 -1
- package/types/state/state-node.d.ts.map +1 -1
- package/types/state/types.d.ts +2 -0
- package/types/state/types.d.ts.map +1 -1
- package/types/state/utils/urls.d.ts.map +1 -1
- package/types/utils/json-pointer.d.ts +22 -0
- package/types/utils/json-pointer.d.ts.map +1 -0
- package/types/webmcp/index.d.ts +23 -15
- package/types/webmcp/index.d.ts.map +1 -1
- package/types/webmcp/project.d.ts +159 -57
- package/types/webmcp/project.d.ts.map +1 -1
- package/types/webmcp/resolve.d.ts +7 -3
- package/types/webmcp/resolve.d.ts.map +1 -1
- package/types/webmcp/schema.d.ts +44 -0
- package/types/webmcp/schema.d.ts.map +1 -0
- package/types/webmcp/suggestions-store.d.ts +82 -0
- package/types/webmcp/suggestions-store.d.ts.map +1 -0
- package/types/webmcp/tools/describe-state.d.ts +5 -57
- package/types/webmcp/tools/describe-state.d.ts.map +1 -1
- package/types/webmcp/tools/edit-array.d.ts +8 -37
- package/types/webmcp/tools/edit-array.d.ts.map +1 -1
- package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
- package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
- package/types/webmcp/tools/get-data.d.ts +17 -15
- package/types/webmcp/tools/get-data.d.ts.map +1 -1
- package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
- package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
- package/types/webmcp/tools/set-data.d.ts +17 -34
- package/types/webmcp/tools/set-data.d.ts.map +1 -1
- package/types/webmcp/tools/set-field-value.d.ts +20 -57
- package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
- package/types/webmcp/variants-memo.d.ts +42 -0
- package/types/webmcp/variants-memo.d.ts.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@json-layout/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Compilation and state management utilities for JSON Layout.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -61,6 +61,8 @@
|
|
|
61
61
|
"scripts": {
|
|
62
62
|
"test:only": "node --test --test-only test/*.spec.js",
|
|
63
63
|
"test": "mkdir -p tmp && node --test test/*.spec.js",
|
|
64
|
+
"webmcp-eval:report": "node webmcp-eval/report.js",
|
|
65
|
+
"webmcp-eval:run": "node webmcp-eval/run-case.js",
|
|
64
66
|
"build": "rm -rf ./types && tsc -p tsconfig.build.json",
|
|
65
67
|
"watch:build": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
66
68
|
"watch:test": "node --test --watch test/*.spec.js"
|
|
@@ -82,7 +84,7 @@
|
|
|
82
84
|
},
|
|
83
85
|
"homepage": "https://github.com/json-layout/json-layout#readme",
|
|
84
86
|
"peerDependencies": {
|
|
85
|
-
"@json-layout/vocabulary": "^2.13.
|
|
87
|
+
"@json-layout/vocabulary": "^2.13.2"
|
|
86
88
|
},
|
|
87
89
|
"dependencies": {
|
|
88
90
|
"ajv": "^8.17.1",
|
package/src/compile/index.js
CHANGED
|
@@ -37,7 +37,9 @@ export function compile (_schema, partialOptions = {}) {
|
|
|
37
37
|
const schema = /** @type {import('ajv').SchemaObject} */(clone(_schema))
|
|
38
38
|
schema.$id = schema.$id ?? '_jl'
|
|
39
39
|
const getJSONRef = resolveLocaleRefs(schema, options.ajv, options.locale, options.defaultLocale)
|
|
40
|
-
|
|
40
|
+
// always called: when xI18n is off this strips the annotations rather than applying
|
|
41
|
+
// them, so a schema carrying them stays valid instead of failing to normalize
|
|
42
|
+
resolveXI18n(schema, options.locale, options.defaultLocale, options.xI18n)
|
|
41
43
|
|
|
42
44
|
/** @type {string[]} */
|
|
43
45
|
const validatePointers = []
|
package/src/compile/serialize.js
CHANGED
|
@@ -51,6 +51,8 @@ export async function serialize (compiledLayout) {
|
|
|
51
51
|
let code = standaloneCode.default(ajv, validatesExports)
|
|
52
52
|
|
|
53
53
|
code = code.replace('"use strict";', '')
|
|
54
|
+
// make the exportN validates module-local so the object below can reference them
|
|
55
|
+
code = code.replace(/export const (export\d+) =/g, 'const $1 =')
|
|
54
56
|
|
|
55
57
|
// some internal imports to ajv are not translated to esm, we do it here
|
|
56
58
|
// cf https://github.com/ajv-validator/ajv-formats/pull/73
|
|
@@ -76,8 +78,7 @@ export async function serialize (compiledLayout) {
|
|
|
76
78
|
ajvI18nPath = 'ajv-i18n/localize/en/index.js'
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
code = `import localizeErrors from "${ajvI18nPath}"
|
|
80
|
-
export const exportLocalizeErrors = localizeErrors;\n` + code
|
|
81
|
+
code = `import localizeErrors from "${ajvI18nPath}";\n` + code
|
|
81
82
|
|
|
82
83
|
i = 0
|
|
83
84
|
const expressionsNodes = []
|
|
@@ -91,30 +92,32 @@ export const exportLocalizeErrors = localizeErrors;\n` + code
|
|
|
91
92
|
expressionsNodes.push(builders.raw(id))
|
|
92
93
|
}
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
// reference the validate functions by name, keyed by json-pointer
|
|
96
|
+
/** @type {Record<string, any>} */
|
|
97
|
+
const validatesNodes = {}
|
|
98
|
+
i = 0
|
|
99
|
+
for (const pointer of Object.keys(compiledLayout.validates)) {
|
|
100
|
+
validatesNodes[pointer] = builders.raw(`export${i++}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// run magicast only on the small object literal; keep the large generated `code` as
|
|
104
|
+
// a raw string prefix (parsing it was the serialization bottleneck)
|
|
105
|
+
const ast = parseModule('export const compiledLayout = {}')
|
|
95
106
|
ast.exports.compiledLayout = {
|
|
96
107
|
mainTree: compiledLayout.mainTree,
|
|
97
108
|
skeletonTrees: clone(compiledLayout.skeletonTrees),
|
|
98
109
|
skeletonNodes: clone(compiledLayout.skeletonNodes),
|
|
99
110
|
normalizedLayouts: clone(compiledLayout.normalizedLayouts),
|
|
100
|
-
validates:
|
|
111
|
+
validates: validatesNodes,
|
|
101
112
|
validationErrors: compiledLayout.validationErrors,
|
|
102
113
|
expressions: expressionsNodes,
|
|
103
114
|
locale: compiledLayout.locale,
|
|
104
115
|
messages: compiledLayout.messages,
|
|
105
116
|
components: compiledLayout.options.components,
|
|
106
|
-
localizeErrors:
|
|
107
|
-
}
|
|
108
|
-
delete ast.exports.exportLocalizeErrors
|
|
109
|
-
|
|
110
|
-
i = 0
|
|
111
|
-
for (const pointer of Object.keys(compiledLayout.validates)) {
|
|
112
|
-
const exportKey = `export${i++}`
|
|
113
|
-
ast.exports.compiledLayout.validates[pointer] = ast.exports[exportKey]
|
|
114
|
-
delete ast.exports[exportKey]
|
|
117
|
+
localizeErrors: builders.raw('localizeErrors')
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
const
|
|
120
|
+
const objectCode = generateCode(ast).code.replace('export const compiledLayout = {', 'const compiledLayout = {')
|
|
118
121
|
|
|
119
|
-
return
|
|
122
|
+
return `${code}\n${objectCode}`
|
|
120
123
|
}
|
|
@@ -4,6 +4,30 @@ import { normalizeLayoutFragment, mergeNullableSubSchema, getSchemaFragmentType
|
|
|
4
4
|
import { makeSkeletonTree } from './skeleton-tree.js'
|
|
5
5
|
import { partialResolveRefs } from './utils/resolve-refs.js'
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Validation keywords that change what a value has to look like, as opposed to how it is
|
|
9
|
+
* rendered. A build-time compiled layout does not carry the raw schema — `serialize` emits
|
|
10
|
+
* the skeleton, the layouts and the validators, and nothing else — so a form filler working
|
|
11
|
+
* against a precompiled layout could not learn that a field is an email, or matches a
|
|
12
|
+
* pattern, or that an array holds at most five items. Ajv still enforces every one of them,
|
|
13
|
+
* which made the omission worse than useless: the rule was invisible until it was violated.
|
|
14
|
+
* Numbers are left out because min/max/step already reach the layout.
|
|
15
|
+
*/
|
|
16
|
+
const CONSTRAINT_KEYWORDS = ['format', 'pattern', 'minLength', 'maxLength', 'minItems', 'maxItems', 'uniqueItems']
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {any} schema
|
|
20
|
+
* @returns {Record<string, unknown> | undefined}
|
|
21
|
+
*/
|
|
22
|
+
function collectConstraints (schema) {
|
|
23
|
+
/** @type {Record<string, unknown>} */
|
|
24
|
+
const constraints = {}
|
|
25
|
+
for (const keyword of CONSTRAINT_KEYWORDS) {
|
|
26
|
+
if (schema?.[keyword] !== undefined) constraints[keyword] = schema[keyword]
|
|
27
|
+
}
|
|
28
|
+
return Object.keys(constraints).length ? constraints : undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
7
31
|
/**
|
|
8
32
|
* @param {any} rawSchema
|
|
9
33
|
* @param {string} sourceSchemaId
|
|
@@ -17,10 +41,17 @@ import { partialResolveRefs } from './utils/resolve-refs.js'
|
|
|
17
41
|
* @param {import('@json-layout/vocabulary').Expression[]} expressions
|
|
18
42
|
* @param {string | number} key
|
|
19
43
|
* @param {string} pointer
|
|
20
|
-
* @param {boolean} required
|
|
44
|
+
* @param {boolean} required - the schema makes this node's value mandatory: omitting it is
|
|
45
|
+
* a validation error. This is what `node.required` reports and what a UI marks with an
|
|
46
|
+
* asterisk, so it must never be true for a node the schema is happy to see absent.
|
|
21
47
|
* @param {string} [condition]
|
|
22
48
|
* @param {boolean} [dependent]
|
|
23
49
|
* @param {string} [knownType]
|
|
50
|
+
* @param {boolean} [alwaysPresent] - the node's value is materialized whether or not the
|
|
51
|
+
* schema requires it, because its parent renders it as a fixed slot. Drives default and
|
|
52
|
+
* empty-container application only. Defaults to `required`, which is the same thing for
|
|
53
|
+
* every parent that builds its children from what the schema demands; tuple entries are
|
|
54
|
+
* the exception, always present but only required up to `minItems`.
|
|
24
55
|
* @returns {import('./types.js').SkeletonNode}
|
|
25
56
|
*/
|
|
26
57
|
export function makeSkeletonNode (
|
|
@@ -39,7 +70,8 @@ export function makeSkeletonNode (
|
|
|
39
70
|
required,
|
|
40
71
|
condition,
|
|
41
72
|
dependent,
|
|
42
|
-
knownType
|
|
73
|
+
knownType,
|
|
74
|
+
alwaysPresent = required
|
|
43
75
|
) {
|
|
44
76
|
let schemaId = sourceSchemaId
|
|
45
77
|
let schema = rawSchema
|
|
@@ -136,8 +168,8 @@ export function makeSkeletonNode (
|
|
|
136
168
|
if (compObject.getConstData) pushExpression(expressions, compObject.getConstData)
|
|
137
169
|
|
|
138
170
|
let defaultData
|
|
139
|
-
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true ||
|
|
140
|
-
else if (
|
|
171
|
+
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true || alwaysPresent)) defaultData = schema.default
|
|
172
|
+
else if (alwaysPresent) {
|
|
141
173
|
if (nullable) defaultData = null
|
|
142
174
|
else if (type === 'object' && isCompositeLayout(compObject, options.components)) defaultData = {}
|
|
143
175
|
else if (type === 'array') defaultData = []
|
|
@@ -198,6 +230,9 @@ export function makeSkeletonNode (
|
|
|
198
230
|
required: required && !nullable
|
|
199
231
|
}
|
|
200
232
|
|
|
233
|
+
const constraints = collectConstraints(schema)
|
|
234
|
+
if (constraints) node.constraints = constraints
|
|
235
|
+
|
|
201
236
|
if (condition) {
|
|
202
237
|
if (isSwitchStruct(normalizedLayout)) throw new Error('Switch struct not allowed in conditional schema')
|
|
203
238
|
node.condition = { type: 'js-eval', expr: condition, pure: true, dataAlias: 'value' }
|
|
@@ -305,6 +340,7 @@ export function makeSkeletonNode (
|
|
|
305
340
|
/** @type {string | undefined} */
|
|
306
341
|
let discriminator
|
|
307
342
|
if (schema.discriminator?.propertyName) discriminator = schema.discriminator?.propertyName
|
|
343
|
+
else discriminator = inferDiscriminator(schema.oneOf, schemaId, getJSONRef)
|
|
308
344
|
const oneOfPointer = `${refPointerPrefix}/oneOf`
|
|
309
345
|
if (!normalizedLayouts[oneOfPointer]) {
|
|
310
346
|
const normalizationResult = normalizeLayoutFragment(
|
|
@@ -319,7 +355,7 @@ export function makeSkeletonNode (
|
|
|
319
355
|
const compObjects = isSwitchStruct(normalizationResult.layout) ? normalizationResult.layout.switch : [normalizationResult.layout]
|
|
320
356
|
for (const compObject of compObjects) {
|
|
321
357
|
let defaultData
|
|
322
|
-
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true ||
|
|
358
|
+
if ('default' in schema && (options.useDefault === 'data' || options.useDefault === true || alwaysPresent)) defaultData = schema.default
|
|
323
359
|
else defaultData = nullable ? null : {}
|
|
324
360
|
if (compObject.defaultData === undefined) compObject.defaultData = defaultData
|
|
325
361
|
if (compObject.defaultData !== undefined && !compObject.getDefaultData) compObject.getDefaultData = { type: 'js-eval', expr: 'layout.defaultData', pure: true, dataAlias: 'value' }
|
|
@@ -548,6 +584,12 @@ export function makeSkeletonNode (
|
|
|
548
584
|
expressions,
|
|
549
585
|
i,
|
|
550
586
|
childPointer,
|
|
587
|
+
// a tuple entry is only mandatory while minItems still covers its position,
|
|
588
|
+
// but json-layout renders every entry, so all of them are always present
|
|
589
|
+
(schema.minItems ?? 0) > i,
|
|
590
|
+
undefined,
|
|
591
|
+
undefined,
|
|
592
|
+
undefined,
|
|
551
593
|
true
|
|
552
594
|
)
|
|
553
595
|
}
|
|
@@ -594,3 +636,27 @@ export function makeSkeletonNode (
|
|
|
594
636
|
|
|
595
637
|
return node
|
|
596
638
|
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* infer a discriminator when the keyword was not declared:
|
|
642
|
+
* a property that has a distinct const string value in every branch of the oneOf
|
|
643
|
+
* @param {any[]} oneOf
|
|
644
|
+
* @param {string} schemaId
|
|
645
|
+
* @param {(schemaId: string, ref: string) => [any, string, string]} getJSONRef
|
|
646
|
+
* @returns {string | undefined}
|
|
647
|
+
*/
|
|
648
|
+
function inferDiscriminator (oneOf, schemaId, getJSONRef) {
|
|
649
|
+
const fragments = oneOf.map(branch => branch.$ref ? getJSONRef(schemaId, branch.$ref)[0] : branch)
|
|
650
|
+
const candidateKeys = Object.keys(fragments[0]?.properties ?? {})
|
|
651
|
+
for (const key of candidateKeys) {
|
|
652
|
+
/** @type {string[]} */
|
|
653
|
+
const values = []
|
|
654
|
+
const distinct = fragments.every(fragment => {
|
|
655
|
+
const value = fragment?.properties?.[key]?.const
|
|
656
|
+
if (typeof value !== 'string' || values.includes(value)) return false
|
|
657
|
+
values.push(value)
|
|
658
|
+
return true
|
|
659
|
+
})
|
|
660
|
+
if (distinct) return key
|
|
661
|
+
}
|
|
662
|
+
}
|
package/src/compile/types.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { clone } from '@json-layout/vocabulary'
|
|
2
2
|
|
|
3
|
+
import { resolvePointerFragment } from '../../utils/json-pointer.js'
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* @param {Record<string, import('ajv').SchemaObject>} schemas
|
|
5
7
|
* @param {import('ajv/dist/2019.js').default} ajv
|
|
@@ -12,14 +14,9 @@ const prepareGetJSONRef = (schemas, ajv) => {
|
|
|
12
14
|
schemas[schemaId] = schemas[schemaId] ?? (ajv.getSchema(schemaId)?.schema)
|
|
13
15
|
if (!schemas[schemaId]) throw new Error(`reference not found ${schemaId}`)
|
|
14
16
|
if (!pointer) return [schemas[schemaId], schemaId, fullRef]
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (!(pointerPart in a.value)) throw new Error(`reference not found ${schemaId}#${a.path.join('/')}`)
|
|
19
|
-
a.value = a.value[pointerPart]
|
|
20
|
-
return a
|
|
21
|
-
}, { path: /** @type {string[]} */([]), value: schemas[schemaId] })
|
|
22
|
-
return [fragment, schemaId, fullRef]
|
|
17
|
+
const resolved = resolvePointerFragment(schemas[schemaId], pointer)
|
|
18
|
+
if (!resolved.found) throw new Error(`reference not found ${schemaId}#${resolved.path.join('/')}`)
|
|
19
|
+
return [resolved.value, schemaId, fullRef]
|
|
23
20
|
}
|
|
24
21
|
}
|
|
25
22
|
|
|
@@ -4,21 +4,30 @@
|
|
|
4
4
|
* @param {Record<string, any>} schema
|
|
5
5
|
* @param {string} locale
|
|
6
6
|
* @param {string} [defaultLocale]
|
|
7
|
+
* @param {boolean} [apply] - when false the annotations are stripped without being applied
|
|
7
8
|
*/
|
|
8
|
-
export const resolveXI18n = (schema, locale, defaultLocale = 'en') => {
|
|
9
|
+
export const resolveXI18n = (schema, locale, defaultLocale = 'en', apply = true) => {
|
|
9
10
|
if (!schema || typeof schema !== 'object') return
|
|
10
11
|
for (const [key, value] of Object.entries(schema)) {
|
|
11
12
|
if (key.startsWith('x-i18n-')) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Always removed, even when not applied: these keys belong to this vocabulary, and
|
|
14
|
+
// the option says whether to translate with them, not whether they are legal. Left
|
|
15
|
+
// in place they fail the component schemas' unevaluatedProperties, normalization
|
|
16
|
+
// falls back to the default component, and a one-of-select that stops being a
|
|
17
|
+
// variant selector no longer merges its branch into the parent — the data then
|
|
18
|
+
// never stabilises and updateState throws after 100 iterations.
|
|
19
|
+
if (apply) {
|
|
20
|
+
if (typeof value !== 'object') console.error(`i18n property ${key} should be an object`)
|
|
21
|
+
const realKey = key.replace('x-i18n-', '')
|
|
22
|
+
schema[realKey] = value[locale] ?? value[defaultLocale] ?? schema[realKey]
|
|
23
|
+
}
|
|
15
24
|
delete schema[key]
|
|
16
25
|
} else if (Array.isArray(value)) {
|
|
17
26
|
for (const child of value) {
|
|
18
|
-
resolveXI18n(child, locale, defaultLocale)
|
|
27
|
+
resolveXI18n(child, locale, defaultLocale, apply)
|
|
19
28
|
}
|
|
20
29
|
} else {
|
|
21
|
-
resolveXI18n(value, locale, defaultLocale)
|
|
30
|
+
resolveXI18n(value, locale, defaultLocale, apply)
|
|
22
31
|
}
|
|
23
32
|
}
|
|
24
33
|
}
|
package/src/state/index.js
CHANGED
|
@@ -139,6 +139,10 @@ export class StatefulLayout {
|
|
|
139
139
|
set data (data) {
|
|
140
140
|
logDataBinding('apply main data setter', data)
|
|
141
141
|
this._data = data
|
|
142
|
+
// data replaced from outside is authoritative, previously activated oneOf branches
|
|
143
|
+
// are re-resolved from the new data (discriminator based or validation based)
|
|
144
|
+
this._explicitActivatedItems.clear()
|
|
145
|
+
this._reResolveActivatedItems = true
|
|
142
146
|
this.updateState()
|
|
143
147
|
}
|
|
144
148
|
|
|
@@ -167,6 +171,19 @@ export class StatefulLayout {
|
|
|
167
171
|
// @ts-ignore
|
|
168
172
|
_lastCreateStateTreeContext
|
|
169
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The raw validation errors of the last state update, each carrying the data pointer
|
|
176
|
+
* (instancePath) it applies to.
|
|
177
|
+
*
|
|
178
|
+
* A node only carries an error when it is hydrated, so an error below an unhydrated
|
|
179
|
+
* subtree — a list item shown in summary mode, say — has no node to attach to and
|
|
180
|
+
* collapses onto the nearest ancestor. These keep the precise location.
|
|
181
|
+
* @returns {import('ajv').ErrorObject[]}
|
|
182
|
+
*/
|
|
183
|
+
get validationErrors () {
|
|
184
|
+
return this._lastCreateStateTreeContext?.allErrors ?? []
|
|
185
|
+
}
|
|
186
|
+
|
|
170
187
|
/**
|
|
171
188
|
* @private
|
|
172
189
|
* @type {string | null}
|
|
@@ -285,6 +302,10 @@ export class StatefulLayout {
|
|
|
285
302
|
/** @type {CreateStateTreeContext} */
|
|
286
303
|
const createStateTreeContext = {
|
|
287
304
|
activatedItems: this.activatedItems,
|
|
305
|
+
explicitActivatedItems: this._explicitActivatedItems,
|
|
306
|
+
// only the first tree creation after the data setter re-resolves activated branches,
|
|
307
|
+
// the following hydration iterations preserve them so that they cannot oscillate
|
|
308
|
+
reResolveActivatedItems: this._reResolveActivatedItems,
|
|
288
309
|
autoActivatedItems: {},
|
|
289
310
|
autofocusTarget: this._autofocusTarget,
|
|
290
311
|
currentInput: this._currentInput,
|
|
@@ -303,6 +324,8 @@ export class StatefulLayout {
|
|
|
303
324
|
// @ts-ignore
|
|
304
325
|
if (this._options._debugCache) createStateTreeContext._debugCache = this._lastCreateStateTreeContext?._debugCache ?? {}
|
|
305
326
|
|
|
327
|
+
this._reResolveActivatedItems = false
|
|
328
|
+
|
|
306
329
|
this._stateTree = createStateTree(
|
|
307
330
|
createStateTreeContext,
|
|
308
331
|
this._options,
|
|
@@ -453,6 +476,7 @@ export class StatefulLayout {
|
|
|
453
476
|
if (activateKey !== undefined) {
|
|
454
477
|
logActivatedItems(node.fullKey, 'activated item on input', activateKey)
|
|
455
478
|
this.activatedItems = produce(this.activatedItems, draft => { draft[node.fullKey] = activateKey })
|
|
479
|
+
this._explicitActivatedItems.add(node.fullKey)
|
|
456
480
|
this._autofocusTarget = node.fullKey + '/' + activateKey
|
|
457
481
|
}
|
|
458
482
|
if (node.parentFullKey === null) {
|
|
@@ -692,7 +716,11 @@ export class StatefulLayout {
|
|
|
692
716
|
item.value = layout.getItems?.itemValue ? this.evalNodeExpression(node, layout.getItems.itemValue, rawItem) : (layout.getItems?.returnObjects ? rawItem : rawItem.value)
|
|
693
717
|
item.key = layout.getItems?.itemKey ? this.evalNodeExpression(node, layout.getItems.itemKey, rawItem) : rawItem.key
|
|
694
718
|
item.title = layout.getItems?.itemTitle ? this.evalNodeExpression(node, layout.getItems.itemTitle, rawItem) : rawItem.title
|
|
695
|
-
|
|
719
|
+
// A legitimate null — a { const: null } branch, normalized to
|
|
720
|
+
// { key: "null", value: null } — is a value, not an absence. `??` would swallow
|
|
721
|
+
// it and keep the key, so the tool hands the agent the string "null" and the
|
|
722
|
+
// schema then rejects the very suggestion it offered.
|
|
723
|
+
if (item.value === undefined) item.value = item.key
|
|
696
724
|
item.key = item.key ?? item.value + ''
|
|
697
725
|
item.title = item.title ?? item.key
|
|
698
726
|
}
|
|
@@ -713,6 +741,21 @@ export class StatefulLayout {
|
|
|
713
741
|
*/
|
|
714
742
|
activatedItems
|
|
715
743
|
|
|
744
|
+
/**
|
|
745
|
+
* keys of activatedItems that were explicitly selected by the user,
|
|
746
|
+
* as opposed to auto-activated from the data
|
|
747
|
+
* @private
|
|
748
|
+
* @type {Set<string>}
|
|
749
|
+
*/
|
|
750
|
+
_explicitActivatedItems = new Set()
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* true after data was replaced from outside, consumed by the next state tree creation
|
|
754
|
+
* @private
|
|
755
|
+
* @type {boolean}
|
|
756
|
+
*/
|
|
757
|
+
_reResolveActivatedItems = false
|
|
758
|
+
|
|
716
759
|
/**
|
|
717
760
|
* @param {StateNode} node
|
|
718
761
|
* @param {number | string} key
|
|
@@ -720,6 +763,7 @@ export class StatefulLayout {
|
|
|
720
763
|
activateItem (node, key) {
|
|
721
764
|
logActivatedItems(node.fullKey, 'activate item explicitly', key)
|
|
722
765
|
this.activatedItems = produce(this.activatedItems, draft => { draft[node.fullKey] = key })
|
|
766
|
+
this._explicitActivatedItems.add(node.fullKey)
|
|
723
767
|
this._autofocusTarget = node.fullKey + '/' + key
|
|
724
768
|
if (node.key === '$oneOf') {
|
|
725
769
|
if (node.layout.emptyData && node.data && typeof node.data === 'object' && node.children?.[0]) {
|
|
@@ -764,6 +808,7 @@ export class StatefulLayout {
|
|
|
764
808
|
if (key.startsWith(node.fullKey)) {
|
|
765
809
|
logActivatedItems(node.fullKey, 'item deactivation deletes a key', key)
|
|
766
810
|
delete draft[key]
|
|
811
|
+
this._explicitActivatedItems.delete(key)
|
|
767
812
|
}
|
|
768
813
|
}
|
|
769
814
|
})
|
package/src/state/state-node.js
CHANGED
|
@@ -329,8 +329,17 @@ const getCompObject = (normalizedLayout, childDefinition, options, compiledLayou
|
|
|
329
329
|
if (childDefinition?.if && !evalExpression(compiledLayout.expressions, childDefinition.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)) {
|
|
330
330
|
return noneComp
|
|
331
331
|
}
|
|
332
|
-
if (normalizedLayout.if
|
|
333
|
-
|
|
332
|
+
if (normalizedLayout.if) {
|
|
333
|
+
let visible
|
|
334
|
+
try {
|
|
335
|
+
visible = evalExpression(compiledLayout.expressions, normalizedLayout.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)
|
|
336
|
+
} catch (err) {
|
|
337
|
+
// an "if" that cannot be evaluated (typically a rootData path through an empty
|
|
338
|
+
// array) hides the node, as an unresolvable getItems yields no items
|
|
339
|
+
console.warn(`json-layout: "if" expression threw, treating it as false: ${normalizedLayout.if.expr}`)
|
|
340
|
+
visible = false
|
|
341
|
+
}
|
|
342
|
+
if (!visible) return noneComp
|
|
334
343
|
}
|
|
335
344
|
return normalizedLayout
|
|
336
345
|
}
|
|
@@ -570,13 +579,34 @@ export function createStateNode (
|
|
|
570
579
|
// find the oneOf child that was either previously selected
|
|
571
580
|
// or the one matching the specified discriminator
|
|
572
581
|
// or the one that is valid with current data
|
|
573
|
-
let activeChildTreeIndex = /** @type {number} */(context.activatedItems[fullKey])
|
|
582
|
+
let activeChildTreeIndex = /** @type {number | undefined} */(context.activatedItems[fullKey])
|
|
574
583
|
let validChildTreeIndex = -1
|
|
584
|
+
if (
|
|
585
|
+
activeChildTreeIndex !== undefined &&
|
|
586
|
+
skeleton.discriminator !== undefined &&
|
|
587
|
+
!context.explicitActivatedItems.has(fullKey) &&
|
|
588
|
+
data?.[skeleton.discriminator] !== undefined
|
|
589
|
+
) {
|
|
590
|
+
// an auto-activated branch is only kept as long as the discriminator value in the data still matches it,
|
|
591
|
+
// so that data replaced from outside (main data setter) is re-resolved to the right branch
|
|
592
|
+
const activeTree = skeleton.childrenTrees[activeChildTreeIndex]
|
|
593
|
+
if (activeTree !== undefined && compiledLayout.skeletonTrees[activeTree].discriminatorValue !== data[skeleton.discriminator]) {
|
|
594
|
+
activeChildTreeIndex = undefined
|
|
595
|
+
}
|
|
596
|
+
}
|
|
575
597
|
if (activeChildTreeIndex !== undefined) {
|
|
576
|
-
// already
|
|
598
|
+
// already activated, just check if data validates against the active variant
|
|
577
599
|
const activeTree = skeleton.childrenTrees[activeChildTreeIndex]
|
|
578
600
|
if (activeTree !== undefined && compiledLayout.validates[compiledLayout.skeletonTrees[activeTree].refPointer](data)) {
|
|
579
601
|
validChildTreeIndex = activeChildTreeIndex
|
|
602
|
+
} else if (context.reResolveActivatedItems) {
|
|
603
|
+
// data was replaced from outside (main data setter) and does not validate against the active branch,
|
|
604
|
+
// switch to another branch that validates the new data, keep the active branch if none does
|
|
605
|
+
const reResolvedIndex = skeleton.childrenTrees.findIndex((childTree) => compiledLayout.validates[compiledLayout.skeletonTrees[childTree].refPointer](data))
|
|
606
|
+
if (reResolvedIndex !== -1) {
|
|
607
|
+
activeChildTreeIndex = reResolvedIndex
|
|
608
|
+
validChildTreeIndex = reResolvedIndex
|
|
609
|
+
}
|
|
580
610
|
}
|
|
581
611
|
} else {
|
|
582
612
|
// try discriminator-based resolution first (cheap string comparison)
|
|
@@ -599,7 +629,8 @@ export function createStateNode (
|
|
|
599
629
|
if (activeChildTreeIndex !== -1) {
|
|
600
630
|
const activeChildTree = compiledLayout.skeletonTrees[skeleton.childrenTrees[activeChildTreeIndex]]
|
|
601
631
|
const activeChildNode = compiledLayout.skeletonNodes[activeChildTree.root]
|
|
602
|
-
|
|
632
|
+
// record the auto-activated branch, also when it changed compared to a stale previously activated branch
|
|
633
|
+
if (context.activatedItems[fullKey] !== activeChildTreeIndex) context.autoActivatedItems[fullKey] = activeChildTreeIndex
|
|
603
634
|
context.errors = context.errors?.filter(error => {
|
|
604
635
|
// if an item was selected, remove the oneOf error
|
|
605
636
|
if (matchLocalError(error, skeleton, dataPath, parentDataPath)) {
|
package/src/state/types.ts
CHANGED
|
@@ -73,6 +73,12 @@ export interface CreateStateTreeContext {
|
|
|
73
73
|
additionalPropertiesErrors?: ErrorObject[]
|
|
74
74
|
files: FileRef[]
|
|
75
75
|
activatedItems: Record<string, number | string>
|
|
76
|
+
// keys of activatedItems that were explicitly selected by the user (activateItem / input with activateKey)
|
|
77
|
+
// as opposed to auto-activated from the data, they are not challenged by the discriminator value in the data
|
|
78
|
+
explicitActivatedItems: Set<string>
|
|
79
|
+
// true on the first state tree creation after data was replaced from outside (main data setter),
|
|
80
|
+
// activated oneOf branches that do not validate the new data are then re-resolved
|
|
81
|
+
reResolveActivatedItems: boolean
|
|
76
82
|
autoActivatedItems: Record<string, number | number>
|
|
77
83
|
autofocusTarget: string | null
|
|
78
84
|
currentInput: string | null
|
package/src/state/utils/urls.js
CHANGED
|
@@ -3,5 +3,7 @@ const origin = typeof window === 'undefined' ? 'http://test.com' : window.locati
|
|
|
3
3
|
export const pathURL = (/** @type {string} */url, /** @type {string} */baseURL) => {
|
|
4
4
|
if (url.startsWith('http://') || url.startsWith('https://')) return new URL(url)
|
|
5
5
|
if (url.startsWith('/')) return new URL(origin + url)
|
|
6
|
+
// an absolute base (a Node process pointed at a remote API) must not be glued onto the origin
|
|
7
|
+
if (baseURL.startsWith('http://') || baseURL.startsWith('https://')) return new URL(baseURL + url)
|
|
6
8
|
return new URL(origin + baseURL + url)
|
|
7
9
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file JSON pointer traversal, shared by the compilation step and the webmcp tools
|
|
3
|
+
* @description Pointers are produced by concatenation in the compilation step
|
|
4
|
+
* (`${refPointerPrefix}/properties/${propertyKey}` and similar), their segments are
|
|
5
|
+
* therefore consumed raw: they are deliberately not unescaped as RFC 6901 would
|
|
6
|
+
* prescribe, so that resolution stays symmetric with the way pointers are built.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve the fragment part of a JSON pointer (what follows the '#') in a schema or any object.
|
|
11
|
+
* @param {unknown} root
|
|
12
|
+
* @param {string} fragment - e.g. '/properties/address/items', leading and empty segments are ignored
|
|
13
|
+
* @returns {{found: true, value: unknown} | {found: false, path: string[]}} - the resolved value, or
|
|
14
|
+
* the segments consumed up to and including the missing one, to report where the resolution failed
|
|
15
|
+
*/
|
|
16
|
+
export function resolvePointerFragment (root, fragment) {
|
|
17
|
+
/** @type {string[]} */
|
|
18
|
+
const path = []
|
|
19
|
+
let current = root
|
|
20
|
+
for (const segment of fragment.split('/')) {
|
|
21
|
+
if (!segment) continue
|
|
22
|
+
path.push(segment)
|
|
23
|
+
if (current === null || typeof current !== 'object' || !(segment in current)) {
|
|
24
|
+
return { found: false, path }
|
|
25
|
+
}
|
|
26
|
+
current = /** @type {any} */(current)[segment]
|
|
27
|
+
}
|
|
28
|
+
return { found: true, value: current }
|
|
29
|
+
}
|