@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.
Files changed (69) hide show
  1. package/package.json +4 -2
  2. package/src/compile/index.js +3 -1
  3. package/src/compile/serialize.js +18 -15
  4. package/src/compile/skeleton-node.js +71 -5
  5. package/src/compile/types.ts +1 -0
  6. package/src/compile/utils/resolve-refs.js +5 -8
  7. package/src/compile/utils/x-i18n.js +15 -6
  8. package/src/state/index.js +46 -1
  9. package/src/state/state-node.js +36 -5
  10. package/src/state/types.ts +6 -0
  11. package/src/state/utils/urls.js +2 -0
  12. package/src/utils/json-pointer.js +29 -0
  13. package/src/webmcp/README.md +144 -0
  14. package/src/webmcp/index.js +88 -82
  15. package/src/webmcp/project.js +479 -111
  16. package/src/webmcp/resolve.js +37 -1
  17. package/src/webmcp/schema.js +169 -0
  18. package/src/webmcp/suggestions-store.js +121 -0
  19. package/src/webmcp/tools/describe-state.js +20 -64
  20. package/src/webmcp/tools/edit-array.js +51 -28
  21. package/src/webmcp/tools/fill-form-skill.js +17 -41
  22. package/src/webmcp/tools/get-data.js +66 -13
  23. package/src/webmcp/tools/get-field-suggestions.js +12 -23
  24. package/src/webmcp/tools/set-data.js +79 -28
  25. package/src/webmcp/tools/set-field-value.js +49 -39
  26. package/src/webmcp/variants-memo.js +53 -0
  27. package/types/compile/index.d.ts.map +1 -1
  28. package/types/compile/serialize.d.ts.map +1 -1
  29. package/types/compile/skeleton-node.d.ts +9 -2
  30. package/types/compile/skeleton-node.d.ts.map +1 -1
  31. package/types/compile/types.d.ts +1 -0
  32. package/types/compile/types.d.ts.map +1 -1
  33. package/types/compile/utils/resolve-refs.d.ts.map +1 -1
  34. package/types/compile/utils/x-i18n.d.ts +1 -1
  35. package/types/compile/utils/x-i18n.d.ts.map +1 -1
  36. package/types/state/index.d.ts +23 -0
  37. package/types/state/index.d.ts.map +1 -1
  38. package/types/state/state-node.d.ts.map +1 -1
  39. package/types/state/types.d.ts +2 -0
  40. package/types/state/types.d.ts.map +1 -1
  41. package/types/state/utils/urls.d.ts.map +1 -1
  42. package/types/utils/json-pointer.d.ts +22 -0
  43. package/types/utils/json-pointer.d.ts.map +1 -0
  44. package/types/webmcp/index.d.ts +23 -15
  45. package/types/webmcp/index.d.ts.map +1 -1
  46. package/types/webmcp/project.d.ts +159 -57
  47. package/types/webmcp/project.d.ts.map +1 -1
  48. package/types/webmcp/resolve.d.ts +7 -3
  49. package/types/webmcp/resolve.d.ts.map +1 -1
  50. package/types/webmcp/schema.d.ts +44 -0
  51. package/types/webmcp/schema.d.ts.map +1 -0
  52. package/types/webmcp/suggestions-store.d.ts +82 -0
  53. package/types/webmcp/suggestions-store.d.ts.map +1 -0
  54. package/types/webmcp/tools/describe-state.d.ts +5 -57
  55. package/types/webmcp/tools/describe-state.d.ts.map +1 -1
  56. package/types/webmcp/tools/edit-array.d.ts +8 -37
  57. package/types/webmcp/tools/edit-array.d.ts.map +1 -1
  58. package/types/webmcp/tools/fill-form-skill.d.ts +10 -13
  59. package/types/webmcp/tools/fill-form-skill.d.ts.map +1 -1
  60. package/types/webmcp/tools/get-data.d.ts +17 -15
  61. package/types/webmcp/tools/get-data.d.ts.map +1 -1
  62. package/types/webmcp/tools/get-field-suggestions.d.ts +4 -30
  63. package/types/webmcp/tools/get-field-suggestions.d.ts.map +1 -1
  64. package/types/webmcp/tools/set-data.d.ts +17 -34
  65. package/types/webmcp/tools/set-data.d.ts.map +1 -1
  66. package/types/webmcp/tools/set-field-value.d.ts +20 -57
  67. package/types/webmcp/tools/set-field-value.d.ts.map +1 -1
  68. package/types/webmcp/variants-memo.d.ts +42 -0
  69. 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.8.1",
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.1"
87
+ "@json-layout/vocabulary": "^2.13.2"
86
88
  },
87
89
  "dependencies": {
88
90
  "ajv": "^8.17.1",
@@ -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
- if (options.xI18n) resolveXI18n(schema, options.locale, options.defaultLocale)
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 = []
@@ -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
- const ast = parseModule(code)
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: ast.exports.exportLocalizeErrors
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 generatedCode = generateCode(ast).code.replace('export const compiledLayout = {', 'const compiledLayout = {')
120
+ const objectCode = generateCode(ast).code.replace('export const compiledLayout = {', 'const compiledLayout = {')
118
121
 
119
- return generatedCode
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 || required)) defaultData = schema.default
140
- else if (required) {
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 || required)) defaultData = schema.default
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
+ }
@@ -80,4 +80,5 @@ export interface SkeletonNode {
80
80
  discriminator?: string
81
81
  required?: boolean
82
82
  nullable?: boolean
83
+ constraints?: Record<string, unknown> // validation keywords a form filler has to respect, kept because the raw schema does not survive serialization
83
84
  }
@@ -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 pointerParts = pointer.split('/').filter(p => !!p)
16
- const { value: fragment } = pointerParts.reduce((a, pointerPart) => {
17
- a.path.push(pointerPart)
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
- if (typeof value !== 'object') console.error(`i18n property ${key} should be an object`)
13
- const realKey = key.replace('x-i18n-', '')
14
- schema[realKey] = value[locale] ?? value[defaultLocale] ?? schema[realKey]
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
  }
@@ -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
- item.value = item.value ?? item.key
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
  })
@@ -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 && !evalExpression(compiledLayout.expressions, normalizedLayout.if, data, options, display, normalizedLayout, compiledLayout.validates, rootData, parentContext)) {
333
- return noneComp
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 explicitly activated, just check if data validates against the active variant
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
- if (!(fullKey in context.activatedItems)) context.autoActivatedItems[fullKey] = activeChildTreeIndex
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)) {
@@ -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
@@ -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
+ }