@adecore/intelligent-ui 0.21.0 → 0.21.1

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/src/runtime.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { copyUiValue, evaluateUiExpression, type UiExpression, type UiValue } from './expression.ts';
1
+ import { copyUiValue, evaluateUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
2
2
  import { UiBudget, UiFailure, type UiLimits } from './budget.ts';
3
3
  import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.ts';
4
4
  import { type UiBlock, type UiNode } from './compiler.ts';
5
+ import { uiActionProp, uiActionTarget, uiLocalState } from './actions.ts';
6
+ import { uiCompiledNodes, uiVisibleInputs } from './inputs.ts';
5
7
  import { uiDiagnostic, type UiDiagnostic } from './syntax.ts';
6
8
 
7
9
  export interface UiBinding<Value> {
@@ -19,6 +21,8 @@ export interface UiViewNode<Props = Record<string, unknown>> {
19
21
  complete: boolean;
20
22
  fallback: string;
21
23
  error?: string;
24
+ // Runs the node's state action; present only on a component that has one.
25
+ onAction?(): void;
22
26
  }
23
27
 
24
28
  export interface UiEvaluation {
@@ -34,19 +38,21 @@ export class UiState {
34
38
  private querySignatures: Record<string, string> = Object.create(null);
35
39
  private readonly listeners = new Set<() => void>();
36
40
  private revision = 0;
41
+ private readonly limits: Partial<UiLimits>;
37
42
 
38
- constructor(block: UiBlock) {
43
+ constructor(block: UiBlock, limits: Partial<UiLimits> = {}) {
44
+ this.limits = limits;
39
45
  this.sync(block);
40
46
  }
41
47
 
42
48
  sync(block: UiBlock): void {
43
- const defaults = copyUiValue(block.defaults) as Record<string, UiValue>;
49
+ const defaults = copyUiValue(block.defaults, new UiBudget(this.limits)) as Record<string, UiValue>;
44
50
  if (this.id !== block.id) {
45
51
  this.values = Object.create(null);
46
52
  this.queries = Object.create(null);
47
53
  }
48
54
  for (const [name, value] of Object.entries(defaults)) {
49
- if (this.id !== block.id || !Object.hasOwn(this.defaults, name) || JSON.stringify(this.defaults[name]) !== JSON.stringify(value)) {
55
+ if (this.id !== block.id || !Object.hasOwn(this.defaults, name) || !sameUiValue(this.defaults[name], value)) {
50
56
  this.values[name] = value;
51
57
  }
52
58
  }
@@ -78,14 +84,14 @@ export class UiState {
78
84
  }
79
85
 
80
86
  scope(): Readonly<Record<string, UiValue>> {
81
- return copyUiValue({ ...this.values, ...this.queries }) as Record<string, UiValue>;
87
+ return copyUiValue({ ...this.values, ...this.queries }, new UiBudget(this.limits)) as Record<string, UiValue>;
82
88
  }
83
89
 
84
90
  set(name: string, value: unknown): void {
85
91
  if (!Object.hasOwn(this.defaults, name)) {
86
92
  throw new UiFailure('refused_binding', 'Only declared local state can be changed.');
87
93
  }
88
- const next = copyUiValue(value);
94
+ const next = copyUiValue(value, new UiBudget(this.limits));
89
95
  const initial = this.defaults[name];
90
96
  if (Array.isArray(next) !== Array.isArray(initial) || typeof next !== typeof initial || (next === null) !== (initial === null)) {
91
97
  throw new UiFailure('invalid_value', 'The state type cannot change.');
@@ -98,29 +104,19 @@ export class UiState {
98
104
  if (block.id !== this.id || !Object.hasOwn(block.queries, name) || this.querySignatures[name] !== JSON.stringify(block.queries[name])) {
99
105
  throw new UiFailure('refused_query', 'The query must belong to this block.');
100
106
  }
101
- this.queries[name] = copyUiValue(value);
107
+ this.queries[name] = copyUiValue(value, new UiBudget(this.limits));
102
108
  this.changed();
103
109
  }
104
110
 
105
111
  run(action: UiExpression): void {
106
- if (action.kind !== 'call' || !['Set', 'Reset'].includes(action.name)) {
107
- throw new UiFailure('refused_action', 'Expected @Set or @Reset.');
108
- }
109
- if (action.name === 'Reset' && action.args.length === 0) {
110
- this.values = copyUiValue(this.defaults) as Record<string, UiValue>;
112
+ const target = uiActionTarget(action, (name) => Object.hasOwn(this.defaults, name));
113
+ if (target === null) {
114
+ this.values = copyUiValue(this.defaults, new UiBudget(this.limits)) as Record<string, UiValue>;
111
115
  this.changed();
112
- return;
113
- }
114
- const reference = action.args[0];
115
- if (!reference || reference.kind !== 'reference' || !Object.hasOwn(this.defaults, reference.name)) {
116
- throw new UiFailure('refused_binding', 'The action needs a local state variable.');
117
- }
118
- if (action.name === 'Set' && action.args.length === 2) {
119
- this.set(reference.name, evaluateUiExpression(action.args[1], this.scope()));
120
- } else if (action.name === 'Reset' && action.args.length === 1) {
121
- this.set(reference.name, this.defaults[reference.name]);
116
+ } else if (action.kind === 'call' && action.name === 'Set') {
117
+ this.set(target, evaluateUiExpression(action.args[1], this.scope(), new UiBudget(this.limits)));
122
118
  } else {
123
- throw new UiFailure('refused_action', 'Invalid state action arguments.');
119
+ this.set(target, this.defaults[target]);
124
120
  }
125
121
  }
126
122
 
@@ -165,8 +161,11 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
165
161
  throw new UiFailure(node.error ?? 'unknown_catalog', 'This part uses an unsupported UI definition.');
166
162
  }
167
163
  const props: Record<string, UiValue> = copyUiValue(node.props, budget) as Record<string, UiValue>;
164
+ const actionProp = uiActionProp(node.type);
168
165
  for (const [key, expression] of Object.entries(node.expressions)) {
169
- props[key] = evaluateUiExpression(expression, variables, budget);
166
+ if (key !== actionProp) {
167
+ props[key] = evaluateUiExpression(expression, variables, budget);
168
+ }
170
169
  }
171
170
  if (node.type === '$text') {
172
171
  if (typeof props.text !== 'string' && typeof props.text !== 'number' && typeof props.text !== 'boolean' && props.text !== null) {
@@ -205,7 +204,7 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
205
204
  }
206
205
  const bindings: Record<string, UiBinding<unknown>> = Object.create(null);
207
206
  for (const [key, name] of Object.entries(node.bindings)) {
208
- if (!('binding' in entry) || entry.binding !== key || !Object.hasOwn(block.defaults, name) || Object.hasOwn(block.queries, name)) {
207
+ if (!('binding' in entry) || entry.binding !== key || !uiLocalState(block)(name)) {
209
208
  throw new UiFailure('refused_binding', 'The binding must target declared local input state.');
210
209
  }
211
210
  bindings[key] = {
@@ -222,6 +221,23 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
222
221
  };
223
222
  }
224
223
  view.bindings = bindings;
224
+ const action = actionProp === undefined ? undefined : node.expressions[actionProp];
225
+ if (action) {
226
+ uiActionTarget(action, uiLocalState(block));
227
+ view.onAction = () => {
228
+ if (!node.complete || props.disabled === true) {
229
+ return;
230
+ }
231
+ try {
232
+ state.run(action);
233
+ } catch (error) {
234
+ // An action that cannot apply to the current state leaves it as it was.
235
+ if (!(error instanceof UiFailure)) {
236
+ throw error;
237
+ }
238
+ }
239
+ };
240
+ }
225
241
  view.children = children(node.children, variables, depth + 1, suffix);
226
242
  return [view];
227
243
  } catch (error) {
@@ -252,15 +268,28 @@ export interface UiChoiceSelection {
252
268
  export function uiInputValues(block: UiBlock, state: UiState): Record<string, UiValue> {
253
269
  const scope = state.scope();
254
270
  const values: Record<string, UiValue> = Object.create(null);
271
+ const local = uiLocalState(block);
272
+ const actionTarget = (action: UiExpression): string | null => {
273
+ try {
274
+ return uiActionTarget(action, local);
275
+ } catch {
276
+ return null;
277
+ }
278
+ };
255
279
  const visit = (nodes: readonly UiNode[]) => {
256
280
  for (const node of nodes) {
257
281
  if (isUiComponent(node.type) && !node.error && node.complete) {
258
282
  const entry = UI_CATALOG[node.type];
259
283
  for (const [prop, name] of Object.entries(node.bindings)) {
260
- if ('binding' in entry && entry.binding === prop && Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name)) {
284
+ if ('binding' in entry && entry.binding === prop && local(name)) {
261
285
  values[name] = scope[name];
262
286
  }
263
287
  }
288
+ const actionProp = uiActionProp(node.type);
289
+ const target = actionProp && node.expressions[actionProp] ? actionTarget(node.expressions[actionProp]) : null;
290
+ if (target !== null) {
291
+ values[target] = scope[target];
292
+ }
264
293
  }
265
294
  visit(node.children);
266
295
  }
@@ -269,18 +298,19 @@ export function uiInputValues(block: UiBlock, state: UiState): Record<string, Ui
269
298
  return values;
270
299
  }
271
300
 
272
- /* The host supplies its stored block; client data can change only declared input bindings. */
301
+ /* The host supplies its stored block; client data can change only declared inputs and Button targets. */
273
302
  export function resolveUiChoice(
274
303
  block: UiBlock,
275
304
  choiceId: string,
276
305
  input: Readonly<Record<string, unknown>> = {},
277
- queries: Readonly<Record<string, unknown>> = {}
306
+ queries: Readonly<Record<string, unknown>> = {},
307
+ limits: Partial<UiLimits> = {}
278
308
  ): UiChoiceSelection {
279
309
  if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
280
310
  throw new UiFailure('refused_choice', 'Only a completed supported block can send a choice.');
281
311
  }
282
- const values = copyUiValue(input, new UiBudget()) as Record<string, UiValue>;
283
- const state = new UiState(block);
312
+ const values = copyUiValue(input, new UiBudget(limits)) as Record<string, UiValue>;
313
+ const state = new UiState(block, limits);
284
314
  for (const [name, value] of Object.entries(queries)) {
285
315
  state.setQuery(name, value, block);
286
316
  }
@@ -290,25 +320,17 @@ export function resolveUiChoice(
290
320
  if (!Object.hasOwn(allowed, name)) {
291
321
  throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
292
322
  }
293
- if (JSON.stringify(value) !== JSON.stringify(allowed[name])) {
323
+ if (!sameUiValue(value, allowed[name])) {
294
324
  state.set(name, value);
295
325
  changed.add(name);
296
326
  }
297
327
  }
298
- const evaluated = evaluateUiBlock(block, state);
328
+ const evaluated = evaluateUiBlock(block, state, limits);
299
329
  if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
300
330
  throw new UiFailure('budget_exceeded', 'The choice exceeded its evaluation budget.');
301
331
  }
302
- const validInputs = new Set<string>();
303
332
  let choice: UiViewNode | undefined;
304
- const written = new Map<string, UiNode>();
305
- const remember = (nodes: readonly UiNode[]) => {
306
- for (const node of nodes) {
307
- written.set(node.id, node);
308
- remember(node.children);
309
- }
310
- };
311
- remember(block.nodes);
333
+ const written = uiCompiledNodes(block.nodes);
312
334
  const visit = (nodes: readonly UiViewNode[], parent?: string) => {
313
335
  for (const node of nodes) {
314
336
  const source = written.get(node.sourceId ?? node.id);
@@ -318,18 +340,6 @@ export function resolveUiChoice(
318
340
  if (node.error || !node.complete) {
319
341
  continue;
320
342
  }
321
- let valid = true;
322
- if (node.type === 'Segmented') {
323
- valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
324
- } else if (node.type === 'Checklist') {
325
- const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
326
- valid = (node.props.value as unknown[]).every((value) => options.includes(value));
327
- }
328
- if (valid && source) {
329
- for (const prop of Object.keys(node.bindings)) {
330
- validInputs.add(source.bindings[prop]);
331
- }
332
- }
333
343
  if (node.id === choiceId && node.type === 'Choice' && parent === 'Choices' && node.props.disabled !== true) {
334
344
  choice = node;
335
345
  }
@@ -337,6 +347,7 @@ export function resolveUiChoice(
337
347
  }
338
348
  };
339
349
  visit(evaluated.nodes);
350
+ const validInputs = uiVisibleInputs(block, evaluated.nodes, state.scope());
340
351
  if ([...changed].some((name) => !validInputs.has(name))) {
341
352
  throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
342
353
  }
package/src/text.ts CHANGED
@@ -1,47 +1,54 @@
1
1
  import { UI_CATALOG, UI_GROUPS } from './catalog.ts';
2
+ import { uiPropList } from './prop-list.ts';
2
3
  import { UI_FENCE_LANGUAGE, type UiBlock } from './compiler.ts';
3
4
 
4
5
  export function uiCompactCatalog(): string {
5
6
  return Object.entries(UI_CATALOG)
6
7
  .map(([name, entry]) => {
7
- const props = Object.entries(entry.schema.shape)
8
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
9
- .join(',');
8
+ const props = uiPropList(entry).join(',');
10
9
  return `${name}${props ? `: ${props}` : ''}`;
11
10
  })
12
11
  .join(' · ');
13
12
  }
14
13
 
15
- const UI_SYNTAX = `Write XML-like component tags inside a fenced ${UI_FENCE_LANGUAGE} block. Put text between opening and closing tags; empty components end with />. Quote string props; put numbers, booleans and expressions in braces.`;
16
- const UI_EXAMPLE = [
17
- '```' + UI_FENCE_LANGUAGE,
18
- '$enabled = false',
19
- '<Summary badge="Draft">Release overview</Summary>',
20
- '<Stats><Stat label="Tests" value={42}/></Stats>',
21
- '<Switch value={$enabled}>Notify me</Switch>',
22
- '<Choices><Choice context="Review the release overview">Continue</Choice></Choices>',
23
- '```'
24
- ].join('\n');
14
+ export interface UiTextOptions {
15
+ // The info string the host's compiler looks for; `UI_FENCE_LANGUAGE` without one.
16
+ fenceLanguage?: string;
17
+ }
18
+
19
+ function uiSyntax(language: string): string {
20
+ return `Write XML-like component tags inside a fenced ${language} block. Put text between opening and closing tags; empty components end with />. Quote string props; put numbers, booleans and expressions in braces.`;
21
+ }
22
+
23
+ function uiExample(language: string): string {
24
+ return [
25
+ '```' + language,
26
+ '$enabled = false',
27
+ '<Summary badge="Draft">Release overview</Summary>',
28
+ '<Stats><Stat label="Tests" value={42}/></Stats>',
29
+ '<Switch value={$enabled}>Notify me</Switch>',
30
+ '<Choices><Choice context="Review the release overview">Continue</Choice></Choices>',
31
+ '```'
32
+ ].join('\n');
33
+ }
25
34
 
26
- export function uiSessionNote(): string {
27
- return `${UI_SYNTAX} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${UI_EXAMPLE}\n`;
35
+ export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
36
+ return `${uiSyntax(fenceLanguage)} Prose outside. Only named catalog props, no styling. $name = literal JSON; input value={$name}; Button action={@Set($name, literal)}, {@Reset($name)} or {@Reset()} changes local state only; braced expressions: @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show/Each control children. Choice labels and precise context stay visible and send once. Image: attachment or generated="latest", never URL/path. No code execution. State resets on reload. Catalog (? means optional props): ${uiCompactCatalog()}\nExample:\n${uiExample(fenceLanguage)}\n`;
28
37
  }
29
38
 
30
- export function uiReferenceText(): string {
39
+ export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
31
40
  const groups = Object.entries(UI_GROUPS)
32
41
  .map(([group, definition]) => {
33
42
  const components = Object.entries(UI_CATALOG)
34
43
  .filter(([, entry]) => entry.group === group)
35
44
  .map(([name, entry]) => {
36
- const props = Object.entries(entry.schema.shape)
37
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
38
- .join(', ');
45
+ const props = uiPropList(entry).join(', ');
39
46
  return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
40
47
  });
41
48
  return [definition.description, definition.rules, ...components].join('\n');
42
49
  })
43
50
  .join('\n\n');
44
- return `${UI_SYNTAX}\nExample:\n${UI_EXAMPLE}\n\n${groups}`;
51
+ return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
45
52
  }
46
53
 
47
54
  export function uiFallbackText(text: string, blocks: readonly UiBlock[]): string {