@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/dist/links.js CHANGED
@@ -7,6 +7,7 @@ export const UiLinkTargetSchema = z.discriminatedUnion('type', [
7
7
  z.object({ type: z.literal('Commit'), sha: z.string().regex(/^[a-f\d]{7,64}$/i) }).strict(),
8
8
  z.object({ type: z.literal('Node'), id: z.string().min(1).max(256) }).strict()
9
9
  ]);
10
+ // `state` is a frozen set: native clients validate these payloads whole, so a new state would fail them.
10
11
  export const UiLinkResolutionSchema = z.object({
11
12
  state: z.enum(['chip', 'plain']),
12
13
  target: UiLinkTargetSchema.optional(),
@@ -17,10 +18,12 @@ export const UiLinkResolutionSchema = z.object({
17
18
  conflicted: z.boolean().optional(),
18
19
  relativePath: z.string().optional(),
19
20
  label: z.string().optional(),
21
+ // A code that names the reason across versions; open, so a new one fails nobody.
22
+ code: z.string().optional(),
20
23
  reason: z.string().optional()
21
24
  });
22
- export function uiLinkTargets(block, input = {}, queries = {}) {
23
- const state = uiValidatedState(block, input, queries);
25
+ export function uiLinkTargets(block, input = {}, queries = {}, limits = {}) {
26
+ const state = uiValidatedState(block, input, queries, limits);
24
27
  const targets = Object.create(null);
25
28
  const visit = (nodes) => {
26
29
  for (const node of nodes) {
@@ -34,6 +37,6 @@ export function uiLinkTargets(block, input = {}, queries = {}) {
34
37
  visit(node.children);
35
38
  }
36
39
  };
37
- visit(evaluateUiBlock(block, state).nodes);
40
+ visit(evaluateUiBlock(block, state, limits).nodes);
38
41
  return targets;
39
42
  }
@@ -0,0 +1,7 @@
1
+ import type { z } from 'zod';
2
+ export declare function uiPropList(entry: {
3
+ readonly schema: {
4
+ readonly shape: Readonly<Record<string, z.ZodType>>;
5
+ };
6
+ readonly action?: string;
7
+ }): string[];
@@ -0,0 +1,5 @@
1
+ /* The props a catalog entry accepts, its action first and an optional one marked with `?`. */
2
+ export function uiPropList(entry) {
3
+ const props = Object.entries(entry.schema.shape).map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`);
4
+ return entry.action ? [entry.action, ...props] : props;
5
+ }
package/dist/query.d.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import { type UiLimits } from './budget.ts';
1
2
  import type { UiBlock } from './compiler.ts';
2
3
  import { type UiValue } from './expression.ts';
3
4
  import { UiState } from './runtime.ts';
4
- export declare function uiValidatedState(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>): UiState;
5
- export declare function uiQueryArguments(block: UiBlock, name: string, input?: Readonly<Record<string, unknown>>): Record<string, UiValue>;
5
+ export declare function uiValidatedState(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): UiState;
6
+ export declare function uiQueryArguments(block: UiBlock, name: string, input?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): Record<string, UiValue>;
6
7
  export declare function uiQueryFallback(block: UiBlock, queries: Readonly<Record<string, unknown>>): string;
package/dist/query.js CHANGED
@@ -1,25 +1,15 @@
1
1
  import { UI_CATALOG_VERSION } from './catalog.js';
2
2
  import { UiBudget, UiFailure } from './budget.js';
3
- import { copyUiValue, evaluateUiExpression } from './expression.js';
3
+ import { uiVisibleInputs } from './inputs.js';
4
+ import { uiNodeFallback } from './fallback.js';
5
+ import { copyUiValue, evaluateUiExpression, sameUiValue } from './expression.js';
4
6
  import { evaluateUiBlock, uiInputValues, UiState } from './runtime.js';
5
- function findBinding(nodes, id, prop) {
6
- for (const node of nodes) {
7
- if (node.id === id) {
8
- return node.bindings[prop];
9
- }
10
- const found = findBinding(node.children, id, prop);
11
- if (found) {
12
- return found;
13
- }
14
- }
15
- return undefined;
16
- }
17
- export function uiValidatedState(block, input = {}, queries = {}) {
7
+ export function uiValidatedState(block, input = {}, queries = {}, limits = {}) {
18
8
  if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
19
9
  throw new UiFailure('invalid_block', 'Only completed supported blocks can resolve input.');
20
10
  }
21
- const values = copyUiValue(input, new UiBudget());
22
- const state = new UiState(block);
11
+ const values = copyUiValue(input, new UiBudget(limits));
12
+ const state = new UiState(block, limits);
23
13
  for (const [name, value] of Object.entries(queries)) {
24
14
  state.setQuery(name, value, block);
25
15
  }
@@ -30,86 +20,33 @@ export function uiValidatedState(block, input = {}, queries = {}) {
30
20
  }
31
21
  state.set(key, value);
32
22
  }
33
- const visible = new Set();
34
- const visit = (nodes) => {
35
- for (const node of nodes) {
36
- if (node.error || !node.complete) {
37
- continue;
38
- }
39
- let valid = true;
40
- if (node.type === 'Segmented') {
41
- valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
42
- }
43
- if (node.type === 'Checklist') {
44
- const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
45
- valid = node.props.value.every((value) => options.includes(value));
46
- }
47
- if (valid) {
48
- for (const [key, binding] of Object.entries(node.bindings)) {
49
- const original = findBinding(block.nodes, node.sourceId ?? node.id, key);
50
- if (original && binding) {
51
- visible.add(original);
52
- }
53
- }
54
- }
55
- visit(node.children);
56
- }
57
- };
58
- const evaluated = evaluateUiBlock(block, state);
23
+ const evaluated = evaluateUiBlock(block, state, limits);
59
24
  if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
60
25
  throw new UiFailure('budget_exceeded', 'The query inputs exceeded their evaluation budget.');
61
26
  }
62
- visit(evaluated.nodes);
27
+ const visible = uiVisibleInputs(block, evaluated.nodes, state.scope());
63
28
  for (const [key, value] of Object.entries(values)) {
64
- if (JSON.stringify(value) !== JSON.stringify(allowed[key]) && !visible.has(key)) {
29
+ if (!sameUiValue(value, allowed[key]) && !visible.has(key)) {
65
30
  throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
66
31
  }
67
32
  }
68
33
  return state;
69
34
  }
70
- export function uiQueryArguments(block, name, input = {}) {
35
+ export function uiQueryArguments(block, name, input = {}, limits = {}) {
71
36
  if (!Object.hasOwn(block.queries, name)) {
72
37
  throw new UiFailure('invalid_query', 'This block does not declare that query.');
73
38
  }
74
- const state = uiValidatedState(block, input);
39
+ const state = uiValidatedState(block, input, {}, limits);
75
40
  const query = block.queries[name];
76
41
  return query.expression
77
- ? evaluateUiExpression(query.expression, state.scope(), new UiBudget())
78
- : copyUiValue(query.args, new UiBudget());
42
+ ? evaluateUiExpression(query.expression, state.scope(), new UiBudget(limits))
43
+ : copyUiValue(query.args, new UiBudget(limits));
79
44
  }
80
45
  export function uiQueryFallback(block, queries) {
81
46
  const state = new UiState(block);
82
47
  for (const [name, value] of Object.entries(queries)) {
83
48
  state.setQuery(name, value, block);
84
49
  }
85
- const plain = (value) => (value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value));
86
- const text = (nodes) => nodes
87
- .map((node) => {
88
- if (node.error) {
89
- return node.fallback;
90
- }
91
- if (node.type === '$text') {
92
- return plain(node.props.text);
93
- }
94
- const props = node.props;
95
- const children = text(node.children);
96
- if (node.type === 'Stat') {
97
- return `${plain(props.label)}: ${plain(props.value)}${props.unit ? ` ${plain(props.unit)}` : ''}\n`;
98
- }
99
- if (node.type === 'Table') {
100
- return `${plain(props.rows)}\n`;
101
- }
102
- if (node.type === 'Chart') {
103
- return `${plain(props.data)}\n`;
104
- }
105
- if (node.type === 'Source') {
106
- return `${plain(props.label)}: ${plain(props.url)}\n`;
107
- }
108
- if (node.type === 'Image') {
109
- return node.fallback;
110
- }
111
- return `${(props.label ?? props.title) ? `${plain(props.label ?? props.title)}: ` : ''}${plain(props.path ?? props.sha ?? (node.type === 'Node' ? props.id : null))}${children}${props.context ? `\n${plain(props.context)}` : ''}${node.type === 'Tag' ? '' : '\n'}`;
112
- })
113
- .join('');
50
+ const text = (nodes) => nodes.map((node) => (node.error ? node.fallback : uiNodeFallback(node.type, node.props, text(node.children)))).join('');
114
51
  return text(evaluateUiBlock(block, state).nodes).trim() || block.fallback;
115
52
  }
package/dist/runtime.d.ts CHANGED
@@ -16,6 +16,7 @@ export interface UiViewNode<Props = Record<string, unknown>> {
16
16
  complete: boolean;
17
17
  fallback: string;
18
18
  error?: string;
19
+ onAction?(): void;
19
20
  }
20
21
  export interface UiEvaluation {
21
22
  nodes: UiViewNode[];
@@ -29,7 +30,8 @@ export declare class UiState {
29
30
  private querySignatures;
30
31
  private readonly listeners;
31
32
  private revision;
32
- constructor(block: UiBlock);
33
+ private readonly limits;
34
+ constructor(block: UiBlock, limits?: Partial<UiLimits>);
33
35
  sync(block: UiBlock): void;
34
36
  snapshot(): number;
35
37
  subscribe(listener: () => void): () => void;
@@ -46,4 +48,4 @@ export interface UiChoiceSelection {
46
48
  values: Record<string, UiValue>;
47
49
  }
48
50
  export declare function uiInputValues(block: UiBlock, state: UiState): Record<string, UiValue>;
49
- export declare function resolveUiChoice(block: UiBlock, choiceId: string, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>): UiChoiceSelection;
51
+ export declare function resolveUiChoice(block: UiBlock, choiceId: string, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): UiChoiceSelection;
package/dist/runtime.js CHANGED
@@ -1,7 +1,9 @@
1
- import { copyUiValue, evaluateUiExpression } from './expression.js';
1
+ import { copyUiValue, evaluateUiExpression, sameUiValue } from './expression.js';
2
2
  import { UiBudget, UiFailure } from './budget.js';
3
3
  import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.js';
4
4
  import {} from './compiler.js';
5
+ import { uiActionProp, uiActionTarget, uiLocalState } from './actions.js';
6
+ import { uiCompiledNodes, uiVisibleInputs } from './inputs.js';
5
7
  import { uiDiagnostic } from './syntax.js';
6
8
  export class UiState {
7
9
  id = '';
@@ -11,17 +13,19 @@ export class UiState {
11
13
  querySignatures = Object.create(null);
12
14
  listeners = new Set();
13
15
  revision = 0;
14
- constructor(block) {
16
+ limits;
17
+ constructor(block, limits = {}) {
18
+ this.limits = limits;
15
19
  this.sync(block);
16
20
  }
17
21
  sync(block) {
18
- const defaults = copyUiValue(block.defaults);
22
+ const defaults = copyUiValue(block.defaults, new UiBudget(this.limits));
19
23
  if (this.id !== block.id) {
20
24
  this.values = Object.create(null);
21
25
  this.queries = Object.create(null);
22
26
  }
23
27
  for (const [name, value] of Object.entries(defaults)) {
24
- if (this.id !== block.id || !Object.hasOwn(this.defaults, name) || JSON.stringify(this.defaults[name]) !== JSON.stringify(value)) {
28
+ if (this.id !== block.id || !Object.hasOwn(this.defaults, name) || !sameUiValue(this.defaults[name], value)) {
25
29
  this.values[name] = value;
26
30
  }
27
31
  }
@@ -50,13 +54,13 @@ export class UiState {
50
54
  return () => this.listeners.delete(listener);
51
55
  }
52
56
  scope() {
53
- return copyUiValue({ ...this.values, ...this.queries });
57
+ return copyUiValue({ ...this.values, ...this.queries }, new UiBudget(this.limits));
54
58
  }
55
59
  set(name, value) {
56
60
  if (!Object.hasOwn(this.defaults, name)) {
57
61
  throw new UiFailure('refused_binding', 'Only declared local state can be changed.');
58
62
  }
59
- const next = copyUiValue(value);
63
+ const next = copyUiValue(value, new UiBudget(this.limits));
60
64
  const initial = this.defaults[name];
61
65
  if (Array.isArray(next) !== Array.isArray(initial) || typeof next !== typeof initial || (next === null) !== (initial === null)) {
62
66
  throw new UiFailure('invalid_value', 'The state type cannot change.');
@@ -68,30 +72,20 @@ export class UiState {
68
72
  if (block.id !== this.id || !Object.hasOwn(block.queries, name) || this.querySignatures[name] !== JSON.stringify(block.queries[name])) {
69
73
  throw new UiFailure('refused_query', 'The query must belong to this block.');
70
74
  }
71
- this.queries[name] = copyUiValue(value);
75
+ this.queries[name] = copyUiValue(value, new UiBudget(this.limits));
72
76
  this.changed();
73
77
  }
74
78
  run(action) {
75
- if (action.kind !== 'call' || !['Set', 'Reset'].includes(action.name)) {
76
- throw new UiFailure('refused_action', 'Expected @Set or @Reset.');
77
- }
78
- if (action.name === 'Reset' && action.args.length === 0) {
79
- this.values = copyUiValue(this.defaults);
79
+ const target = uiActionTarget(action, (name) => Object.hasOwn(this.defaults, name));
80
+ if (target === null) {
81
+ this.values = copyUiValue(this.defaults, new UiBudget(this.limits));
80
82
  this.changed();
81
- return;
82
- }
83
- const reference = action.args[0];
84
- if (!reference || reference.kind !== 'reference' || !Object.hasOwn(this.defaults, reference.name)) {
85
- throw new UiFailure('refused_binding', 'The action needs a local state variable.');
86
- }
87
- if (action.name === 'Set' && action.args.length === 2) {
88
- this.set(reference.name, evaluateUiExpression(action.args[1], this.scope()));
89
83
  }
90
- else if (action.name === 'Reset' && action.args.length === 1) {
91
- this.set(reference.name, this.defaults[reference.name]);
84
+ else if (action.kind === 'call' && action.name === 'Set') {
85
+ this.set(target, evaluateUiExpression(action.args[1], this.scope(), new UiBudget(this.limits)));
92
86
  }
93
87
  else {
94
- throw new UiFailure('refused_action', 'Invalid state action arguments.');
88
+ this.set(target, this.defaults[target]);
95
89
  }
96
90
  }
97
91
  changed() {
@@ -134,8 +128,11 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
134
128
  throw new UiFailure(node.error ?? 'unknown_catalog', 'This part uses an unsupported UI definition.');
135
129
  }
136
130
  const props = copyUiValue(node.props, budget);
131
+ const actionProp = uiActionProp(node.type);
137
132
  for (const [key, expression] of Object.entries(node.expressions)) {
138
- props[key] = evaluateUiExpression(expression, variables, budget);
133
+ if (key !== actionProp) {
134
+ props[key] = evaluateUiExpression(expression, variables, budget);
135
+ }
139
136
  }
140
137
  if (node.type === '$text') {
141
138
  if (typeof props.text !== 'string' && typeof props.text !== 'number' && typeof props.text !== 'boolean' && props.text !== null) {
@@ -174,7 +171,7 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
174
171
  }
175
172
  const bindings = Object.create(null);
176
173
  for (const [key, name] of Object.entries(node.bindings)) {
177
- if (!('binding' in entry) || entry.binding !== key || !Object.hasOwn(block.defaults, name) || Object.hasOwn(block.queries, name)) {
174
+ if (!('binding' in entry) || entry.binding !== key || !uiLocalState(block)(name)) {
178
175
  throw new UiFailure('refused_binding', 'The binding must target declared local input state.');
179
176
  }
180
177
  bindings[key] = {
@@ -191,6 +188,24 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
191
188
  };
192
189
  }
193
190
  view.bindings = bindings;
191
+ const action = actionProp === undefined ? undefined : node.expressions[actionProp];
192
+ if (action) {
193
+ uiActionTarget(action, uiLocalState(block));
194
+ view.onAction = () => {
195
+ if (!node.complete || props.disabled === true) {
196
+ return;
197
+ }
198
+ try {
199
+ state.run(action);
200
+ }
201
+ catch (error) {
202
+ // An action that cannot apply to the current state leaves it as it was.
203
+ if (!(error instanceof UiFailure)) {
204
+ throw error;
205
+ }
206
+ }
207
+ };
208
+ }
194
209
  view.children = children(node.children, variables, depth + 1, suffix);
195
210
  return [view];
196
211
  }
@@ -216,15 +231,29 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
216
231
  export function uiInputValues(block, state) {
217
232
  const scope = state.scope();
218
233
  const values = Object.create(null);
234
+ const local = uiLocalState(block);
235
+ const actionTarget = (action) => {
236
+ try {
237
+ return uiActionTarget(action, local);
238
+ }
239
+ catch {
240
+ return null;
241
+ }
242
+ };
219
243
  const visit = (nodes) => {
220
244
  for (const node of nodes) {
221
245
  if (isUiComponent(node.type) && !node.error && node.complete) {
222
246
  const entry = UI_CATALOG[node.type];
223
247
  for (const [prop, name] of Object.entries(node.bindings)) {
224
- if ('binding' in entry && entry.binding === prop && Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name)) {
248
+ if ('binding' in entry && entry.binding === prop && local(name)) {
225
249
  values[name] = scope[name];
226
250
  }
227
251
  }
252
+ const actionProp = uiActionProp(node.type);
253
+ const target = actionProp && node.expressions[actionProp] ? actionTarget(node.expressions[actionProp]) : null;
254
+ if (target !== null) {
255
+ values[target] = scope[target];
256
+ }
228
257
  }
229
258
  visit(node.children);
230
259
  }
@@ -232,13 +261,13 @@ export function uiInputValues(block, state) {
232
261
  visit(block.nodes);
233
262
  return values;
234
263
  }
235
- /* The host supplies its stored block; client data can change only declared input bindings. */
236
- export function resolveUiChoice(block, choiceId, input = {}, queries = {}) {
264
+ /* The host supplies its stored block; client data can change only declared inputs and Button targets. */
265
+ export function resolveUiChoice(block, choiceId, input = {}, queries = {}, limits = {}) {
237
266
  if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
238
267
  throw new UiFailure('refused_choice', 'Only a completed supported block can send a choice.');
239
268
  }
240
- const values = copyUiValue(input, new UiBudget());
241
- const state = new UiState(block);
269
+ const values = copyUiValue(input, new UiBudget(limits));
270
+ const state = new UiState(block, limits);
242
271
  for (const [name, value] of Object.entries(queries)) {
243
272
  state.setQuery(name, value, block);
244
273
  }
@@ -248,25 +277,17 @@ export function resolveUiChoice(block, choiceId, input = {}, queries = {}) {
248
277
  if (!Object.hasOwn(allowed, name)) {
249
278
  throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
250
279
  }
251
- if (JSON.stringify(value) !== JSON.stringify(allowed[name])) {
280
+ if (!sameUiValue(value, allowed[name])) {
252
281
  state.set(name, value);
253
282
  changed.add(name);
254
283
  }
255
284
  }
256
- const evaluated = evaluateUiBlock(block, state);
285
+ const evaluated = evaluateUiBlock(block, state, limits);
257
286
  if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
258
287
  throw new UiFailure('budget_exceeded', 'The choice exceeded its evaluation budget.');
259
288
  }
260
- const validInputs = new Set();
261
289
  let choice;
262
- const written = new Map();
263
- const remember = (nodes) => {
264
- for (const node of nodes) {
265
- written.set(node.id, node);
266
- remember(node.children);
267
- }
268
- };
269
- remember(block.nodes);
290
+ const written = uiCompiledNodes(block.nodes);
270
291
  const visit = (nodes, parent) => {
271
292
  for (const node of nodes) {
272
293
  const source = written.get(node.sourceId ?? node.id);
@@ -276,19 +297,6 @@ export function resolveUiChoice(block, choiceId, input = {}, queries = {}) {
276
297
  if (node.error || !node.complete) {
277
298
  continue;
278
299
  }
279
- let valid = true;
280
- if (node.type === 'Segmented') {
281
- valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
282
- }
283
- else if (node.type === 'Checklist') {
284
- const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
285
- valid = node.props.value.every((value) => options.includes(value));
286
- }
287
- if (valid && source) {
288
- for (const prop of Object.keys(node.bindings)) {
289
- validInputs.add(source.bindings[prop]);
290
- }
291
- }
292
300
  if (node.id === choiceId && node.type === 'Choice' && parent === 'Choices' && node.props.disabled !== true) {
293
301
  choice = node;
294
302
  }
@@ -296,6 +304,7 @@ export function resolveUiChoice(block, choiceId, input = {}, queries = {}) {
296
304
  }
297
305
  };
298
306
  visit(evaluated.nodes);
307
+ const validInputs = uiVisibleInputs(block, evaluated.nodes, state.scope());
299
308
  if ([...changed].some((name) => !validInputs.has(name))) {
300
309
  throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
301
310
  }
package/dist/text.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { type UiBlock } from './compiler.ts';
2
2
  export declare function uiCompactCatalog(): string;
3
- export declare function uiSessionNote(): string;
4
- export declare function uiReferenceText(): string;
3
+ export interface UiTextOptions {
4
+ fenceLanguage?: string;
5
+ }
6
+ export declare function uiSessionNote({ fenceLanguage }?: UiTextOptions): string;
7
+ export declare function uiReferenceText({ fenceLanguage }?: UiTextOptions): string;
5
8
  export declare function uiFallbackText(text: string, blocks: readonly UiBlock[]): string;
package/dist/text.js CHANGED
@@ -1,43 +1,44 @@
1
1
  import { UI_CATALOG, UI_GROUPS } from './catalog.js';
2
+ import { uiPropList } from './prop-list.js';
2
3
  import { UI_FENCE_LANGUAGE } from './compiler.js';
3
4
  export function uiCompactCatalog() {
4
5
  return Object.entries(UI_CATALOG)
5
6
  .map(([name, entry]) => {
6
- const props = Object.entries(entry.schema.shape)
7
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
8
- .join(',');
7
+ const props = uiPropList(entry).join(',');
9
8
  return `${name}${props ? `: ${props}` : ''}`;
10
9
  })
11
10
  .join(' · ');
12
11
  }
13
- 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.`;
14
- const UI_EXAMPLE = [
15
- '```' + UI_FENCE_LANGUAGE,
16
- '$enabled = false',
17
- '<Summary badge="Draft">Release overview</Summary>',
18
- '<Stats><Stat label="Tests" value={42}/></Stats>',
19
- '<Switch value={$enabled}>Notify me</Switch>',
20
- '<Choices><Choice context="Review the release overview">Continue</Choice></Choices>',
21
- '```'
22
- ].join('\n');
23
- export function uiSessionNote() {
24
- 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`;
12
+ function uiSyntax(language) {
13
+ 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.`;
25
14
  }
26
- export function uiReferenceText() {
15
+ function uiExample(language) {
16
+ return [
17
+ '```' + 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');
25
+ }
26
+ export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
27
+ 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
+ }
29
+ export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
27
30
  const groups = Object.entries(UI_GROUPS)
28
31
  .map(([group, definition]) => {
29
32
  const components = Object.entries(UI_CATALOG)
30
33
  .filter(([, entry]) => entry.group === group)
31
34
  .map(([name, entry]) => {
32
- const props = Object.entries(entry.schema.shape)
33
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
34
- .join(', ');
35
+ const props = uiPropList(entry).join(', ');
35
36
  return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
36
37
  });
37
38
  return [definition.description, definition.rules, ...components].join('\n');
38
39
  })
39
40
  .join('\n\n');
40
- return `${UI_SYNTAX}\nExample:\n${UI_EXAMPLE}\n\n${groups}`;
41
+ return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
41
42
  }
42
43
  export function uiFallbackText(text, blocks) {
43
44
  let result = '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/intelligent-ui",
3
- "version": "0.21.0",
3
+ "version": "0.21.1",
4
4
  "description": "A streaming UI catalog, compiler and bounded expression interpreter without a framework or DOM.",
5
5
  "license": "FSL-1.1-MIT",
6
6
  "author": "Bas Milius",
package/src/actions.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { UiFailure } from './budget.ts';
2
+ import { isUiComponent, UI_CATALOG } from './catalog.ts';
3
+ import type { UiBlock } from './compiler.ts';
4
+ import type { UiExpression } from './expression.ts';
5
+
6
+ /* The prop of a component that holds a state action, kept unevaluated, instead of a value. */
7
+ export function uiActionProp(type: string): string | undefined {
8
+ if (!isUiComponent(type)) {
9
+ return undefined;
10
+ }
11
+ const entry = UI_CATALOG[type];
12
+ return 'action' in entry ? entry.action : undefined;
13
+ }
14
+
15
+ /*
16
+ * The variable an action writes, or null for `@Reset()`, which restores them all. Throws for anything
17
+ * but `@Set($name, value)`, `@Reset($name)` and `@Reset()` on a variable that `local` accepts.
18
+ */
19
+ export function uiActionTarget(action: UiExpression, local: (name: string) => boolean): string | null {
20
+ if (action.kind !== 'call' || (action.name !== 'Set' && action.name !== 'Reset')) {
21
+ throw new UiFailure('refused_action', 'Expected @Set or @Reset.');
22
+ }
23
+ if (action.name === 'Reset' && action.args.length === 0) {
24
+ return null;
25
+ }
26
+ if (action.args.length !== (action.name === 'Set' ? 2 : 1)) {
27
+ throw new UiFailure('refused_action', 'Invalid state action arguments.');
28
+ }
29
+ const reference = action.args[0];
30
+ if (reference.kind !== 'reference' || !local(reference.name)) {
31
+ throw new UiFailure('refused_binding', 'The action needs a local state variable.');
32
+ }
33
+ return reference.name;
34
+ }
35
+
36
+ /* Whether `name` is local state of the block: declared with a default and never a query. */
37
+ export function uiLocalState(block: Pick<UiBlock, 'defaults' | 'queries'>): (name: string) => boolean {
38
+ return (name) => Object.hasOwn(block.defaults, name) && !Object.hasOwn(block.queries, name);
39
+ }
package/src/catalog.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { uiPropList } from './prop-list.ts';
2
3
 
3
4
  export const UI_CATALOG_VERSION = 1;
4
5
  export const UiToneSchema = z.enum(['neutral', 'info', 'success', 'warning', 'danger']);
@@ -21,7 +22,10 @@ export const UI_GROUPS = {
21
22
  rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
22
23
  },
23
24
  links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
24
- inputs: { description: 'Change local block state.', rules: 'Bind value={$name}. State stays local until a Choice sends visible context.' },
25
+ inputs: {
26
+ description: 'Change local block state.',
27
+ rules: 'Bind value={$name}. A Button sets a literal with action={@Set($name, value)} or restores a default with {@Reset($name)} or {@Reset()}. State stays local until a Choice sends visible context.'
28
+ },
25
29
  choices: {
26
30
  description: 'Ask the person to pick a next step.',
27
31
  rules: 'Give a short label and precise visible context. A Choice sends once after streaming ends.'
@@ -120,6 +124,12 @@ export const UI_CATALOG = {
120
124
  },
121
125
  Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
122
126
  Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: 'Segmented' },
127
+ Button: {
128
+ group: 'inputs',
129
+ description: 'Run one local state action when pressed; children are the label.',
130
+ schema: z.strictObject({ disabled: z.boolean().optional() }),
131
+ action: 'action'
132
+ },
123
133
  Show: { group: 'inputs', description: 'Show children when the condition is true.', schema: z.strictObject({ when: z.boolean() }) },
124
134
  Each: {
125
135
  group: 'inputs',
@@ -144,12 +154,6 @@ export function isUiComponent(name: string): name is UiComponentName {
144
154
 
145
155
  export function uiCatalogText(): string {
146
156
  return Object.entries(UI_CATALOG)
147
- .map(([name, entry]) => {
148
- const shape = entry.schema.shape;
149
- const props = Object.entries(shape)
150
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
151
- .join(', ');
152
- return `${name}(${props}): ${entry.description}`;
153
- })
157
+ .map(([name, entry]) => `${name}(${uiPropList(entry).join(', ')}): ${entry.description}`)
154
158
  .join('\n');
155
159
  }