@adecore/intelligent-ui 0.0.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/query.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { UI_CATALOG_VERSION } from './catalog.ts';
2
+ import { UiBudget, UiFailure, type UiLimits } from './budget.ts';
3
+ import type { UiBlock } from './compiler.ts';
4
+ import { uiVisibleInputs } from './inputs.ts';
5
+ import { uiNodeFallback } from './fallback.ts';
6
+ import { copyUiValue, evaluateUiExpression, sameUiValue, type UiValue } from './expression.ts';
7
+ import { evaluateUiBlock, uiInputValues, UiState, type UiViewNode } from './runtime.ts';
8
+
9
+ export function uiValidatedState(
10
+ block: UiBlock,
11
+ input: Readonly<Record<string, unknown>> = {},
12
+ queries: Readonly<Record<string, unknown>> = {},
13
+ limits: Partial<UiLimits> = {}
14
+ ): UiState {
15
+ if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
16
+ throw new UiFailure('invalid_block', 'Only completed supported blocks can resolve input.');
17
+ }
18
+ const values = copyUiValue(input, new UiBudget(limits)) as Record<string, UiValue>;
19
+ const state = new UiState(block, limits);
20
+ for (const [name, value] of Object.entries(queries)) {
21
+ state.setQuery(name, value, block);
22
+ }
23
+ const allowed = uiInputValues(block, state);
24
+ for (const [key, value] of Object.entries(values)) {
25
+ if (!Object.hasOwn(allowed, key)) {
26
+ throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
27
+ }
28
+ state.set(key, value);
29
+ }
30
+ const evaluated = evaluateUiBlock(block, state, limits);
31
+ if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
32
+ throw new UiFailure('budget_exceeded', 'The query inputs exceeded their evaluation budget.');
33
+ }
34
+ const visible = uiVisibleInputs(block, evaluated.nodes, state.scope());
35
+ for (const [key, value] of Object.entries(values)) {
36
+ if (!sameUiValue(value, allowed[key]) && !visible.has(key)) {
37
+ throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
38
+ }
39
+ }
40
+ return state;
41
+ }
42
+
43
+ export function uiQueryArguments(
44
+ block: UiBlock,
45
+ name: string,
46
+ input: Readonly<Record<string, unknown>> = {},
47
+ limits: Partial<UiLimits> = {}
48
+ ): Record<string, UiValue> {
49
+ if (!Object.hasOwn(block.queries, name)) {
50
+ throw new UiFailure('invalid_query', 'This block does not declare that query.');
51
+ }
52
+ const state = uiValidatedState(block, input, {}, limits);
53
+ const query = block.queries[name];
54
+ return query.expression
55
+ ? (evaluateUiExpression(query.expression, state.scope(), new UiBudget(limits)) as Record<string, UiValue>)
56
+ : (copyUiValue(query.args, new UiBudget(limits)) as Record<string, UiValue>);
57
+ }
58
+
59
+ export function uiQueryFallback(block: UiBlock, queries: Readonly<Record<string, unknown>>): string {
60
+ const state = new UiState(block);
61
+ for (const [name, value] of Object.entries(queries)) {
62
+ state.setQuery(name, value, block);
63
+ }
64
+ const text = (nodes: readonly UiViewNode[]): string =>
65
+ nodes.map((node) => (node.error ? node.fallback : uiNodeFallback(node.type, node.props, text(node.children)))).join('');
66
+ return text(evaluateUiBlock(block, state).nodes).trim() || block.fallback;
67
+ }
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> {
@@ -11,6 +13,7 @@ export interface UiBinding<Value> {
11
13
 
12
14
  export interface UiViewNode<Props = Record<string, unknown>> {
13
15
  id: string;
16
+ sourceId?: string;
14
17
  type: string;
15
18
  props: Props;
16
19
  bindings: Readonly<Record<string, UiBinding<unknown>>>;
@@ -18,6 +21,8 @@ export interface UiViewNode<Props = Record<string, unknown>> {
18
21
  complete: boolean;
19
22
  fallback: string;
20
23
  error?: string;
24
+ // Runs the node's state action; present only on a component that has one.
25
+ onAction?(): void;
21
26
  }
22
27
 
23
28
  export interface UiEvaluation {
@@ -33,19 +38,21 @@ export class UiState {
33
38
  private querySignatures: Record<string, string> = Object.create(null);
34
39
  private readonly listeners = new Set<() => void>();
35
40
  private revision = 0;
41
+ private readonly limits: Partial<UiLimits>;
36
42
 
37
- constructor(block: UiBlock) {
43
+ constructor(block: UiBlock, limits: Partial<UiLimits> = {}) {
44
+ this.limits = limits;
38
45
  this.sync(block);
39
46
  }
40
47
 
41
48
  sync(block: UiBlock): void {
42
- const defaults = copyUiValue(block.defaults) as Record<string, UiValue>;
49
+ const defaults = copyUiValue(block.defaults, new UiBudget(this.limits)) as Record<string, UiValue>;
43
50
  if (this.id !== block.id) {
44
51
  this.values = Object.create(null);
45
52
  this.queries = Object.create(null);
46
53
  }
47
54
  for (const [name, value] of Object.entries(defaults)) {
48
- 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)) {
49
56
  this.values[name] = value;
50
57
  }
51
58
  }
@@ -77,14 +84,14 @@ export class UiState {
77
84
  }
78
85
 
79
86
  scope(): Readonly<Record<string, UiValue>> {
80
- 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>;
81
88
  }
82
89
 
83
90
  set(name: string, value: unknown): void {
84
91
  if (!Object.hasOwn(this.defaults, name)) {
85
92
  throw new UiFailure('refused_binding', 'Only declared local state can be changed.');
86
93
  }
87
- const next = copyUiValue(value);
94
+ const next = copyUiValue(value, new UiBudget(this.limits));
88
95
  const initial = this.defaults[name];
89
96
  if (Array.isArray(next) !== Array.isArray(initial) || typeof next !== typeof initial || (next === null) !== (initial === null)) {
90
97
  throw new UiFailure('invalid_value', 'The state type cannot change.');
@@ -97,29 +104,19 @@ export class UiState {
97
104
  if (block.id !== this.id || !Object.hasOwn(block.queries, name) || this.querySignatures[name] !== JSON.stringify(block.queries[name])) {
98
105
  throw new UiFailure('refused_query', 'The query must belong to this block.');
99
106
  }
100
- this.queries[name] = copyUiValue(value);
107
+ this.queries[name] = copyUiValue(value, new UiBudget(this.limits));
101
108
  this.changed();
102
109
  }
103
110
 
104
111
  run(action: UiExpression): void {
105
- if (action.kind !== 'call' || !['Set', 'Reset'].includes(action.name)) {
106
- throw new UiFailure('refused_action', 'Expected @Set or @Reset.');
107
- }
108
- if (action.name === 'Reset' && action.args.length === 0) {
109
- 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>;
110
115
  this.changed();
111
- return;
112
- }
113
- const reference = action.args[0];
114
- if (!reference || reference.kind !== 'reference' || !Object.hasOwn(this.defaults, reference.name)) {
115
- throw new UiFailure('refused_binding', 'The action needs a local state variable.');
116
- }
117
- if (action.name === 'Set' && action.args.length === 2) {
118
- this.set(reference.name, evaluateUiExpression(action.args[1], this.scope()));
119
- } else if (action.name === 'Reset' && action.args.length === 1) {
120
- 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)));
121
118
  } else {
122
- throw new UiFailure('refused_action', 'Invalid state action arguments.');
119
+ this.set(target, this.defaults[target]);
123
120
  }
124
121
  }
125
122
 
@@ -149,6 +146,7 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
149
146
  const visit = (node: UiNode, variables: Readonly<Record<string, UiValue>>, depth: number, suffix = ''): UiViewNode[] => {
150
147
  const view: UiViewNode = {
151
148
  id: `${node.id}${suffix}`,
149
+ sourceId: node.id,
152
150
  type: node.type,
153
151
  props: {},
154
152
  bindings: {},
@@ -163,8 +161,11 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
163
161
  throw new UiFailure(node.error ?? 'unknown_catalog', 'This part uses an unsupported UI definition.');
164
162
  }
165
163
  const props: Record<string, UiValue> = copyUiValue(node.props, budget) as Record<string, UiValue>;
164
+ const actionProp = uiActionProp(node.type);
166
165
  for (const [key, expression] of Object.entries(node.expressions)) {
167
- props[key] = evaluateUiExpression(expression, variables, budget);
166
+ if (key !== actionProp) {
167
+ props[key] = evaluateUiExpression(expression, variables, budget);
168
+ }
168
169
  }
169
170
  if (node.type === '$text') {
170
171
  if (typeof props.text !== 'string' && typeof props.text !== 'number' && typeof props.text !== 'boolean' && props.text !== null) {
@@ -203,7 +204,7 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
203
204
  }
204
205
  const bindings: Record<string, UiBinding<unknown>> = Object.create(null);
205
206
  for (const [key, name] of Object.entries(node.bindings)) {
206
- 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)) {
207
208
  throw new UiFailure('refused_binding', 'The binding must target declared local input state.');
208
209
  }
209
210
  bindings[key] = {
@@ -220,6 +221,23 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
220
221
  };
221
222
  }
222
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
+ }
223
241
  view.children = children(node.children, variables, depth + 1, suffix);
224
242
  return [view];
225
243
  } catch (error) {
@@ -240,3 +258,115 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
240
258
  }
241
259
  return result;
242
260
  }
261
+
262
+ export interface UiChoiceSelection {
263
+ label: string;
264
+ context: string;
265
+ values: Record<string, UiValue>;
266
+ }
267
+
268
+ export function uiInputValues(block: UiBlock, state: UiState): Record<string, UiValue> {
269
+ const scope = state.scope();
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
+ };
279
+ const visit = (nodes: readonly UiNode[]) => {
280
+ for (const node of nodes) {
281
+ if (isUiComponent(node.type) && !node.error && node.complete) {
282
+ const entry = UI_CATALOG[node.type];
283
+ for (const [prop, name] of Object.entries(node.bindings)) {
284
+ if ('binding' in entry && entry.binding === prop && local(name)) {
285
+ values[name] = scope[name];
286
+ }
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
+ }
293
+ }
294
+ visit(node.children);
295
+ }
296
+ };
297
+ visit(block.nodes);
298
+ return values;
299
+ }
300
+
301
+ /* The host supplies its stored block; client data can change only declared inputs and Button targets. */
302
+ export function resolveUiChoice(
303
+ block: UiBlock,
304
+ choiceId: string,
305
+ input: Readonly<Record<string, unknown>> = {},
306
+ queries: Readonly<Record<string, unknown>> = {},
307
+ limits: Partial<UiLimits> = {}
308
+ ): UiChoiceSelection {
309
+ if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
310
+ throw new UiFailure('refused_choice', 'Only a completed supported block can send a choice.');
311
+ }
312
+ const values = copyUiValue(input, new UiBudget(limits)) as Record<string, UiValue>;
313
+ const state = new UiState(block, limits);
314
+ for (const [name, value] of Object.entries(queries)) {
315
+ state.setQuery(name, value, block);
316
+ }
317
+ const allowed = uiInputValues(block, state);
318
+ const changed = new Set<string>();
319
+ for (const [name, value] of Object.entries(values)) {
320
+ if (!Object.hasOwn(allowed, name)) {
321
+ throw new UiFailure('refused_binding', 'Only declared input bindings can be submitted.');
322
+ }
323
+ if (!sameUiValue(value, allowed[name])) {
324
+ state.set(name, value);
325
+ changed.add(name);
326
+ }
327
+ }
328
+ const evaluated = evaluateUiBlock(block, state, limits);
329
+ if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
330
+ throw new UiFailure('budget_exceeded', 'The choice exceeded its evaluation budget.');
331
+ }
332
+ let choice: UiViewNode | undefined;
333
+ const written = uiCompiledNodes(block.nodes);
334
+ const visit = (nodes: readonly UiViewNode[], parent?: string) => {
335
+ for (const node of nodes) {
336
+ const source = written.get(node.sourceId ?? node.id);
337
+ if (node.error && source && Object.values(source.bindings).some((name) => changed.has(name))) {
338
+ throw new UiFailure('invalid_value', 'An input value does not match its control.');
339
+ }
340
+ if (node.error || !node.complete) {
341
+ continue;
342
+ }
343
+ if (node.id === choiceId && node.type === 'Choice' && parent === 'Choices' && node.props.disabled !== true) {
344
+ choice = node;
345
+ }
346
+ visit(node.children, node.type);
347
+ }
348
+ };
349
+ visit(evaluated.nodes);
350
+ const validInputs = uiVisibleInputs(block, evaluated.nodes, state.scope());
351
+ if ([...changed].some((name) => !validInputs.has(name))) {
352
+ throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
353
+ }
354
+ if (!choice) {
355
+ throw new UiFailure('refused_choice', 'This choice is not available in the current block.');
356
+ }
357
+ const text = (nodes: readonly UiViewNode[]): string =>
358
+ nodes.map((node) => (node.type === '$text' ? String(node.props.text ?? '') : text(node.children))).join('');
359
+ const hasError = (nodes: readonly UiViewNode[]): boolean => nodes.some((node) => node.error !== undefined || hasError(node.children));
360
+ if (hasError(choice.children)) {
361
+ throw new UiFailure('refused_choice', 'The choice label could not be fully read.');
362
+ }
363
+ const label = text(choice.children).replace(/\s+/g, ' ').trim();
364
+ if (!label) {
365
+ throw new UiFailure('refused_choice', 'A choice needs a visible label.');
366
+ }
367
+ return {
368
+ label,
369
+ values: uiInputValues(block, state),
370
+ context: typeof choice.props.context === 'string' && choice.props.context.trim() ? choice.props.context : label
371
+ };
372
+ }
package/src/stream.ts ADDED
@@ -0,0 +1,78 @@
1
+ import { UiCompiler, type UiBlock, type UiCompilerOptions } from './compiler.ts';
2
+
3
+ export const UI_STREAM_INTERVAL_MS = 250;
4
+
5
+ export interface UiStreamClock {
6
+ now(): number;
7
+ setTimeout(run: () => void, ms: number): unknown;
8
+ clearTimeout(handle: unknown): void;
9
+ }
10
+
11
+ export interface UiStreamPreview {
12
+ textLength: number;
13
+ blocks: UiBlock[];
14
+ }
15
+
16
+ export interface UiStreamOptions extends UiCompilerOptions {
17
+ clock: UiStreamClock;
18
+ emit(preview: UiStreamPreview): void;
19
+ }
20
+
21
+ export class UiStream {
22
+ private readonly compiler: UiCompiler;
23
+ private readonly options: UiStreamOptions;
24
+ private pending: { text: string; latestAttachment?: string } | null = null;
25
+ private timer: { handle: unknown } | null = null;
26
+ private lastCompiledAt = -Infinity;
27
+ private closed = false;
28
+
29
+ constructor(options: UiStreamOptions) {
30
+ this.options = options;
31
+ this.compiler = new UiCompiler(options);
32
+ }
33
+
34
+ update(text: string, latestAttachment?: string): void {
35
+ if (this.closed) {
36
+ return;
37
+ }
38
+ this.pending = { text, latestAttachment };
39
+ if (this.timer !== null) {
40
+ return;
41
+ }
42
+ const delay = Math.max(0, UI_STREAM_INTERVAL_MS - (this.options.clock.now() - this.lastCompiledAt));
43
+ if (delay === 0) {
44
+ this.flush();
45
+ } else {
46
+ this.timer = { handle: this.options.clock.setTimeout(() => this.flush(), delay) };
47
+ }
48
+ }
49
+
50
+ finish(text: string, latestAttachment?: string): UiBlock[] {
51
+ this.dispose();
52
+ const blocks = this.compiler.compile(text, { final: true, latestAttachment });
53
+ this.compiler.clear();
54
+ return blocks;
55
+ }
56
+
57
+ dispose(): void {
58
+ this.closed = true;
59
+ if (this.timer !== null) {
60
+ this.options.clock.clearTimeout(this.timer.handle);
61
+ this.timer = null;
62
+ }
63
+ this.pending = null;
64
+ this.compiler.clear();
65
+ }
66
+
67
+ private flush(): void {
68
+ this.timer = null;
69
+ const pending = this.pending;
70
+ if (pending === null || this.closed) {
71
+ return;
72
+ }
73
+ this.pending = null;
74
+ this.lastCompiledAt = this.options.clock.now();
75
+ const blocks = this.compiler.compile(pending.text, { latestAttachment: pending.latestAttachment });
76
+ this.options.emit({ textLength: pending.text.length, blocks });
77
+ }
78
+ }
package/src/text.ts CHANGED
@@ -1,35 +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(',');
10
- return `${name}(${props})`;
8
+ const props = uiPropList(entry).join(',');
9
+ return `${name}${props ? `: ${props}` : ''}`;
11
10
  })
12
11
  .join(' · ');
13
12
  }
14
13
 
15
- export function uiSessionNote(): string {
16
- return `For a result, comparison or choice that benefits from an interface, put a fenced ${UI_FENCE_LANGUAGE} block in your final reply, with explanation outside it. Use catalog tags with named props; no layout, style or color props. State: $name = literal JSON on a line above the tags. Bind local inputs with value={$name}; use braced expressions and @Count, @Filter(list,row,predicate), @Sum, @Join, @Round. Show(when) and Each(items,as) control children. End decisions with Choices; each Choice has a short label and precise context shown to the person. Image takes a chat attachment or generated="latest", never a URL or path. No executable code. Local state resets on reload. ${uiCompactCatalog()}`;
14
+ export interface UiTextOptions {
15
+ // The info string the host's compiler looks for; `UI_FENCE_LANGUAGE` without one.
16
+ fenceLanguage?: string;
17
17
  }
18
18
 
19
- export function uiReferenceText(): string {
20
- return Object.entries(UI_GROUPS)
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
+ }
34
+
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`;
37
+ }
38
+
39
+ export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
40
+ const groups = Object.entries(UI_GROUPS)
21
41
  .map(([group, definition]) => {
22
42
  const components = Object.entries(UI_CATALOG)
23
43
  .filter(([, entry]) => entry.group === group)
24
44
  .map(([name, entry]) => {
25
- const props = Object.entries(entry.schema.shape)
26
- .map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`)
27
- .join(', ');
28
- return `${name}(${props}): ${entry.description}`;
45
+ const props = uiPropList(entry).join(', ');
46
+ return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
29
47
  });
30
48
  return [definition.description, definition.rules, ...components].join('\n');
31
49
  })
32
50
  .join('\n\n');
51
+ return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
33
52
  }
34
53
 
35
54
  export function uiFallbackText(text: string, blocks: readonly UiBlock[]): string {