@adecore/intelligent-ui 0.21.0 → 0.21.2
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/README.md +29 -57
- package/dist/actions.d.ts +5 -0
- package/dist/actions.js +34 -0
- package/dist/catalog.d.ts +9 -1
- package/dist/catalog.js +12 -8
- package/dist/compiler.d.ts +9 -1
- package/dist/compiler.js +104 -87
- package/dist/expression.d.ts +1 -0
- package/dist/expression.js +16 -0
- package/dist/fallback.d.ts +2 -0
- package/dist/fallback.js +25 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/inputs.d.ts +5 -0
- package/dist/inputs.js +69 -0
- package/dist/links.d.ts +3 -1
- package/dist/links.js +6 -3
- package/dist/prop-list.d.ts +7 -0
- package/dist/prop-list.js +5 -0
- package/dist/query.d.ts +3 -2
- package/dist/query.js +14 -77
- package/dist/runtime.d.ts +4 -2
- package/dist/runtime.js +63 -54
- package/dist/text.d.ts +5 -2
- package/dist/text.js +21 -20
- package/package.json +1 -1
- package/src/actions.ts +39 -0
- package/src/catalog.ts +12 -8
- package/src/compiler.ts +118 -87
- package/src/expression.ts +17 -0
- package/src/fallback.ts +26 -0
- package/src/index.ts +5 -2
- package/src/inputs.ts +74 -0
- package/src/links.ts +8 -3
- package/src/prop-list.ts +7 -0
- package/src/query.ts +26 -80
- package/src/runtime.ts +64 -53
- package/src/text.ts +27 -20
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: {
|
|
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
|
}
|
package/src/compiler.ts
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
import { safeKey, UiBudget, UiFailure, type UiLimits } from './budget.ts';
|
|
1
|
+
import { safeKey, UI_LIMITS, UiBudget, UiFailure, type UiLimits } from './budget.ts';
|
|
2
2
|
import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.ts';
|
|
3
|
+
import { uiActionProp, uiActionTarget, uiLocalState } from './actions.ts';
|
|
4
|
+
import { uiNodeFallback, uiPlainText } from './fallback.ts';
|
|
3
5
|
import { evaluateUiExpression, type UiExpression, type UiValue } from './expression.ts';
|
|
4
6
|
import { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntaxNode } from './syntax.ts';
|
|
5
7
|
import { type z } from 'zod';
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
// The info string a fence carries when the host names none of its own.
|
|
10
|
+
export const UI_FENCE_LANGUAGE = 'ui';
|
|
8
11
|
|
|
9
12
|
export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 } as const;
|
|
10
13
|
|
|
14
|
+
// What a host reads for one block: its queries, its link targets and how often a visible block reads again.
|
|
15
|
+
export const UI_HOST_LIMITS = { queries: 8, links: 64, refreshMilliseconds: 10_000 } as const;
|
|
16
|
+
|
|
17
|
+
/* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
|
|
18
|
+
export function uiMayReferenceHost(text: string): boolean {
|
|
19
|
+
if (text.includes('@Query')) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
const links = Object.entries(UI_CATALOG)
|
|
23
|
+
.filter(([, entry]) => entry.group === 'links')
|
|
24
|
+
.map(([name]) => name);
|
|
25
|
+
return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
export interface UiNode {
|
|
12
29
|
id: string;
|
|
13
30
|
type: string;
|
|
@@ -48,9 +65,64 @@ export interface UiCompileOptions {
|
|
|
48
65
|
limits?: Partial<UiLimits>;
|
|
49
66
|
latestAttachment?: string;
|
|
50
67
|
querySchemas?: Readonly<Record<string, z.ZodType>>;
|
|
68
|
+
// The info string that marks a UI fence; `UI_FENCE_LANGUAGE` without one.
|
|
69
|
+
fenceLanguage?: string;
|
|
51
70
|
now?: () => number;
|
|
52
71
|
}
|
|
53
72
|
|
|
73
|
+
/* A spent budget ends the whole block; every other failure only marks the node or declaration it came from. */
|
|
74
|
+
function rethrowBudget(error: unknown): void {
|
|
75
|
+
if (error instanceof UiFailure && error.code === 'budget_exceeded') {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isQuery(expression: UiExpression): boolean {
|
|
81
|
+
return expression.kind === 'call' && expression.name === 'Query';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Throws when a query argument reads anything but declared local state. */
|
|
85
|
+
function checkQueryReferences(part: unknown, defaults: Readonly<Record<string, UiValue>>): void {
|
|
86
|
+
if (!part || typeof part !== 'object') {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(defaults, String(part.name))) {
|
|
90
|
+
throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
|
|
91
|
+
}
|
|
92
|
+
for (const value of Object.values(part)) {
|
|
93
|
+
checkQueryReferences(value, defaults);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function compileQuery(
|
|
98
|
+
expression: Extract<UiExpression, { kind: 'call' }>,
|
|
99
|
+
block: UiBlock,
|
|
100
|
+
budget: UiBudget,
|
|
101
|
+
querySchemas: UiCompileOptions['querySchemas']
|
|
102
|
+
): UiQuery {
|
|
103
|
+
if (expression.args.length !== 2 || !isConstant(expression.args[0]) || expression.args[1].kind !== 'object') {
|
|
104
|
+
throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
|
|
105
|
+
}
|
|
106
|
+
const querySource = evaluateUiExpression(expression.args[0], {}, budget);
|
|
107
|
+
const argument = expression.args[1];
|
|
108
|
+
checkQueryReferences(argument, block.defaults);
|
|
109
|
+
const args = evaluateUiExpression(argument, block.defaults, budget);
|
|
110
|
+
if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
|
|
111
|
+
throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
|
|
112
|
+
}
|
|
113
|
+
if (!querySchemas || !Object.hasOwn(querySchemas, querySource)) {
|
|
114
|
+
throw new UiFailure('unknown_query', 'The host did not register this query source.');
|
|
115
|
+
}
|
|
116
|
+
const checked = querySchemas[querySource].safeParse(args);
|
|
117
|
+
if (!checked.success) {
|
|
118
|
+
throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(block.queries).length >= UI_HOST_LIMITS.queries) {
|
|
121
|
+
throw new UiFailure('budget_exceeded', `A block may declare at most ${UI_HOST_LIMITS.queries} queries.`);
|
|
122
|
+
}
|
|
123
|
+
return { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
|
|
124
|
+
}
|
|
125
|
+
|
|
54
126
|
function isConstant(expression: UiExpression): boolean {
|
|
55
127
|
switch (expression.kind) {
|
|
56
128
|
case 'literal':
|
|
@@ -66,10 +138,6 @@ function isConstant(expression: UiExpression): boolean {
|
|
|
66
138
|
}
|
|
67
139
|
}
|
|
68
140
|
|
|
69
|
-
function plain(value: UiValue): string {
|
|
70
|
-
return value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
141
|
export function compileUiBlock(source: string, options: UiCompileOptions): UiBlock {
|
|
74
142
|
const budget = new UiBudget(options.limits, options.now);
|
|
75
143
|
const block: UiBlock = {
|
|
@@ -89,8 +157,16 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
89
157
|
block.diagnostics.push(uiDiagnostic(error, start, end, nodeId));
|
|
90
158
|
}
|
|
91
159
|
};
|
|
92
|
-
|
|
93
|
-
|
|
160
|
+
// A pressed action must set the same value whatever state it is pressed in, so a choice can check it.
|
|
161
|
+
const checkedAction = (action: UiExpression): UiExpression => {
|
|
162
|
+
uiActionTarget(action, uiLocalState(block));
|
|
163
|
+
if (action.kind === 'call' && action.name === 'Set' && !isConstant(action.args[1])) {
|
|
164
|
+
throw new UiFailure('refused_action', '@Set in an action takes a literal value.');
|
|
165
|
+
}
|
|
166
|
+
return action;
|
|
167
|
+
};
|
|
168
|
+
const compile = (syntax: UiSyntaxNode, depth: number, parent?: string): UiNode => {
|
|
169
|
+
budget.depth(depth);
|
|
94
170
|
const node: UiNode = {
|
|
95
171
|
id: syntax.id,
|
|
96
172
|
type: syntax.type,
|
|
@@ -109,11 +185,9 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
109
185
|
if (syntax.expression) {
|
|
110
186
|
node.expressions.text = syntax.expression;
|
|
111
187
|
try {
|
|
112
|
-
node.fallback =
|
|
188
|
+
node.fallback = uiPlainText(evaluateUiExpression(syntax.expression, block.defaults, budget));
|
|
113
189
|
} catch (error) {
|
|
114
|
-
|
|
115
|
-
throw error;
|
|
116
|
-
}
|
|
190
|
+
rethrowBudget(error);
|
|
117
191
|
}
|
|
118
192
|
} else {
|
|
119
193
|
node.props.text = syntax.text ?? '';
|
|
@@ -139,8 +213,13 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
139
213
|
throw new UiFailure('invalid_child', `${syntax.type} is not a child of ${parent}.`);
|
|
140
214
|
}
|
|
141
215
|
}
|
|
216
|
+
const actionProp = uiActionProp(syntax.type);
|
|
142
217
|
for (const [key, expression] of Object.entries(syntax.props)) {
|
|
143
218
|
safeKey(key);
|
|
219
|
+
if (key === actionProp) {
|
|
220
|
+
node.expressions[key] = checkedAction(expression);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
144
223
|
if (!Object.hasOwn(entry.schema.shape, key)) {
|
|
145
224
|
diagnose(new UiFailure('refused_prop', `${syntax.type} does not accept ${key}.`), syntax.start, syntax.end, syntax.id);
|
|
146
225
|
continue;
|
|
@@ -149,13 +228,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
149
228
|
node.props[key] = evaluateUiExpression(expression, {}, budget);
|
|
150
229
|
} else {
|
|
151
230
|
node.expressions[key] = expression;
|
|
152
|
-
if (
|
|
153
|
-
'binding' in entry &&
|
|
154
|
-
entry.binding === key &&
|
|
155
|
-
expression.kind === 'reference' &&
|
|
156
|
-
Object.hasOwn(block.defaults, expression.name) &&
|
|
157
|
-
!Object.hasOwn(block.queries, expression.name)
|
|
158
|
-
) {
|
|
231
|
+
if ('binding' in entry && entry.binding === key && expression.kind === 'reference' && uiLocalState(block)(expression.name)) {
|
|
159
232
|
node.bindings[key] = expression.name;
|
|
160
233
|
}
|
|
161
234
|
}
|
|
@@ -164,16 +237,20 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
164
237
|
delete node.props.generated;
|
|
165
238
|
node.props.attachment = options.latestAttachment;
|
|
166
239
|
}
|
|
240
|
+
if (actionProp !== undefined && syntax.complete && !node.expressions[actionProp]) {
|
|
241
|
+
throw new UiFailure('invalid_props', `${syntax.type} needs ${actionProp}.`);
|
|
242
|
+
}
|
|
167
243
|
const evaluated: Record<string, UiValue> = { ...node.props };
|
|
168
244
|
fallbackProps = evaluated;
|
|
169
245
|
let unresolved = false;
|
|
170
246
|
for (const [key, expression] of Object.entries(node.expressions)) {
|
|
247
|
+
if (key === actionProp) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
171
250
|
try {
|
|
172
251
|
evaluated[key] = evaluateUiExpression(expression, block.defaults, budget);
|
|
173
252
|
} catch (error) {
|
|
174
|
-
|
|
175
|
-
throw error;
|
|
176
|
-
}
|
|
253
|
+
rethrowBudget(error);
|
|
177
254
|
unresolved = true;
|
|
178
255
|
}
|
|
179
256
|
}
|
|
@@ -185,34 +262,13 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
185
262
|
}
|
|
186
263
|
}
|
|
187
264
|
} catch (error) {
|
|
188
|
-
|
|
189
|
-
throw error;
|
|
190
|
-
}
|
|
265
|
+
rethrowBudget(error);
|
|
191
266
|
node.error = error instanceof UiFailure ? error.code : 'invalid_props';
|
|
192
267
|
diagnose(error, syntax.start, syntax.end, syntax.id);
|
|
193
268
|
}
|
|
194
269
|
const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
|
|
195
|
-
node.children = syntax.children.map((child) => compile(child, effectiveParent));
|
|
196
|
-
|
|
197
|
-
const label = fallbackProps.label ?? fallbackProps.title;
|
|
198
|
-
const value = fallbackProps.value;
|
|
199
|
-
const target = fallbackProps.path ?? fallbackProps.sha ?? (syntax.type === 'Node' ? fallbackProps.id : undefined);
|
|
200
|
-
if (syntax.type === 'Stat') {
|
|
201
|
-
node.fallback = `${plain(label ?? '')}: ${plain(value ?? '')}${fallbackProps.unit ? ` ${plain(fallbackProps.unit)}` : ''}\n`;
|
|
202
|
-
} else if (syntax.type === 'Source') {
|
|
203
|
-
node.fallback = `${plain(label ?? '')}: ${plain(fallbackProps.url ?? '')}\n`;
|
|
204
|
-
} else if (syntax.type === 'Table') {
|
|
205
|
-
node.fallback = `${plain(fallbackProps.rows ?? [])}\n`;
|
|
206
|
-
} else if (syntax.type === 'Chart') {
|
|
207
|
-
node.fallback = `${plain(fallbackProps.data ?? [])}\n`;
|
|
208
|
-
} else if (syntax.type === 'Image') {
|
|
209
|
-
node.fallback = `[Image: ${plain(fallbackProps.alt ?? fallbackProps.attachment ?? fallbackProps.generated ?? 'unavailable')}]${children}\n`;
|
|
210
|
-
} else {
|
|
211
|
-
node.fallback = `${label !== undefined ? `${plain(label)}: ` : ''}${target !== undefined ? `${plain(target)} ` : ''}${children}${fallbackProps.context ? `\n${plain(fallbackProps.context)}` : ''}`;
|
|
212
|
-
if (syntax.type !== 'Tag' && !node.fallback.endsWith('\n')) {
|
|
213
|
-
node.fallback += '\n';
|
|
214
|
-
}
|
|
215
|
-
}
|
|
270
|
+
node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
|
|
271
|
+
node.fallback = uiNodeFallback(syntax.type, fallbackProps, node.children.map((child) => child.fallback).join(''));
|
|
216
272
|
if (!node.fallback.trim()) {
|
|
217
273
|
node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
|
|
218
274
|
}
|
|
@@ -221,43 +277,12 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
221
277
|
try {
|
|
222
278
|
const syntax = parseUiSyntax(source, options.id, options.final, budget);
|
|
223
279
|
block.diagnostics = syntax.diagnostics;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
280
|
+
// Defaults first, so a query argument can read every one of them.
|
|
281
|
+
const declarations = Object.entries(syntax.declarations).sort(([, left], [, right]) => Number(isQuery(left)) - Number(isQuery(right)));
|
|
282
|
+
for (const [name, expression] of declarations) {
|
|
227
283
|
try {
|
|
228
284
|
if (expression.kind === 'call' && expression.name === 'Query') {
|
|
229
|
-
|
|
230
|
-
throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
|
|
231
|
-
}
|
|
232
|
-
const querySource = evaluateUiExpression(expression.args[0], {}, budget);
|
|
233
|
-
const argument = expression.args[1];
|
|
234
|
-
const references = (part: unknown): void => {
|
|
235
|
-
if (!part || typeof part !== 'object') {
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(block.defaults, String(part.name))) {
|
|
239
|
-
throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
|
|
240
|
-
}
|
|
241
|
-
for (const value of Object.values(part)) {
|
|
242
|
-
references(value);
|
|
243
|
-
}
|
|
244
|
-
};
|
|
245
|
-
references(argument);
|
|
246
|
-
const args = evaluateUiExpression(argument, block.defaults, budget);
|
|
247
|
-
if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
|
|
248
|
-
throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
|
|
249
|
-
}
|
|
250
|
-
if (!options.querySchemas || !Object.hasOwn(options.querySchemas, querySource)) {
|
|
251
|
-
throw new UiFailure('unknown_query', 'The host did not register this query source.');
|
|
252
|
-
}
|
|
253
|
-
const checked = options.querySchemas[querySource].safeParse(args);
|
|
254
|
-
if (!checked.success) {
|
|
255
|
-
throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
|
|
256
|
-
}
|
|
257
|
-
if (Object.keys(block.queries).length >= 8) {
|
|
258
|
-
throw new UiFailure('budget_exceeded', 'A block may declare at most eight queries.');
|
|
259
|
-
}
|
|
260
|
-
block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
|
|
285
|
+
block.queries[name] = compileQuery(expression, block, budget, options.querySchemas);
|
|
261
286
|
} else {
|
|
262
287
|
if (!isConstant(expression)) {
|
|
263
288
|
throw new UiFailure('invalid_default', 'State defaults must be literal JSON values.');
|
|
@@ -265,13 +290,11 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
265
290
|
block.defaults[name] = evaluateUiExpression(expression, {}, budget);
|
|
266
291
|
}
|
|
267
292
|
} catch (error) {
|
|
268
|
-
|
|
269
|
-
throw error;
|
|
270
|
-
}
|
|
293
|
+
rethrowBudget(error);
|
|
271
294
|
diagnose(error, 0, source.length);
|
|
272
295
|
}
|
|
273
296
|
}
|
|
274
|
-
block.nodes = syntax.nodes.map((node) => compile(node));
|
|
297
|
+
block.nodes = syntax.nodes.map((node) => compile(node, 0));
|
|
275
298
|
block.fallback = block.nodes
|
|
276
299
|
.map((node) => node.fallback)
|
|
277
300
|
.join('')
|
|
@@ -315,6 +338,8 @@ export class UiCompiler {
|
|
|
315
338
|
let nodes = 0;
|
|
316
339
|
let exhausted = false;
|
|
317
340
|
const now = this.options.now ?? Date.now;
|
|
341
|
+
const language = this.options.fenceLanguage ?? UI_FENCE_LANGUAGE;
|
|
342
|
+
const diagnostics = this.options.limits?.diagnostics ?? UI_LIMITS.diagnostics;
|
|
318
343
|
const started = now();
|
|
319
344
|
const nodeCount = (items: readonly UiNode[]): number => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
|
|
320
345
|
const rejected = (start: number, codeStart: number, codeEnd: number, end: number, complete: boolean): UiBlock => ({
|
|
@@ -338,7 +363,7 @@ export class UiCompiler {
|
|
|
338
363
|
blocks[blocks.length - 1] = {
|
|
339
364
|
...last,
|
|
340
365
|
diagnostics: [
|
|
341
|
-
...last.diagnostics.slice(0,
|
|
366
|
+
...last.diagnostics.slice(0, diagnostics - 1),
|
|
342
367
|
uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
|
|
343
368
|
]
|
|
344
369
|
};
|
|
@@ -390,7 +415,7 @@ export class UiCompiler {
|
|
|
390
415
|
length: opening[1].length,
|
|
391
416
|
start: position,
|
|
392
417
|
codeStart: position + line.length,
|
|
393
|
-
ui: opening[2].trim() ===
|
|
418
|
+
ui: opening[2].trim() === language
|
|
394
419
|
};
|
|
395
420
|
} else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
|
|
396
421
|
if (fence.ui) {
|
|
@@ -412,6 +437,12 @@ export class UiCompiler {
|
|
|
412
437
|
}
|
|
413
438
|
}
|
|
414
439
|
|
|
440
|
+
/* A cheap test for whether `text` opens a UI fence at all, before a compiler is worth creating. */
|
|
441
|
+
export function uiHasFence(text: string, fenceLanguage: string = UI_FENCE_LANGUAGE): boolean {
|
|
442
|
+
const language = fenceLanguage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
443
|
+
return new RegExp(`^ {0,3}(?:\`{3,}|~{3,})[ \\t]*${language}[ \\t]*\\r?$`, 'm').test(text);
|
|
444
|
+
}
|
|
445
|
+
|
|
415
446
|
export function compileUi(text: string, options: UiCompileOptions): UiBlock[] {
|
|
416
447
|
return new UiCompiler(options).compile(text, options);
|
|
417
448
|
}
|
package/src/expression.ts
CHANGED
|
@@ -243,6 +243,23 @@ export function parseUiExpression(source: string, budget = new UiBudget()): UiEx
|
|
|
243
243
|
return new ExpressionParser(source, budget).parse();
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
+
/* Deep equality of two values, regardless of the order their record keys were written in. */
|
|
247
|
+
export function sameUiValue(first: UiValue, second: UiValue): boolean {
|
|
248
|
+
if (first === second) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (first === null || second === null || typeof first !== 'object' || typeof second !== 'object' || Array.isArray(first) !== Array.isArray(second)) {
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(first) && Array.isArray(second)) {
|
|
255
|
+
return first.length === second.length && first.every((item, i) => sameUiValue(item, second[i]));
|
|
256
|
+
}
|
|
257
|
+
const left = first as Record<string, UiValue>;
|
|
258
|
+
const right = second as Record<string, UiValue>;
|
|
259
|
+
const keys = Object.keys(left);
|
|
260
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && sameUiValue(left[key], right[key]));
|
|
261
|
+
}
|
|
262
|
+
|
|
246
263
|
export function copyUiValue(value: unknown, budget = new UiBudget(), depth = 0): UiValue {
|
|
247
264
|
budget.depth(depth);
|
|
248
265
|
if (value === null || typeof value === 'boolean') {
|
package/src/fallback.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function uiPlainText(value: unknown): string {
|
|
2
|
+
return value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/* The readable text a node leaves behind where it cannot be drawn, from its props and its children's text. */
|
|
6
|
+
export function uiNodeFallback(type: string, props: Readonly<Record<string, unknown>>, children: string): string {
|
|
7
|
+
const label = props.label ?? props.title;
|
|
8
|
+
switch (type) {
|
|
9
|
+
case '$text':
|
|
10
|
+
return uiPlainText(props.text);
|
|
11
|
+
case 'Stat':
|
|
12
|
+
return `${uiPlainText(label)}: ${uiPlainText(props.value)}${props.unit ? ` ${uiPlainText(props.unit)}` : ''}\n`;
|
|
13
|
+
case 'Source':
|
|
14
|
+
return `${uiPlainText(label)}: ${uiPlainText(props.url)}\n`;
|
|
15
|
+
case 'Table':
|
|
16
|
+
return `${uiPlainText(props.rows ?? [])}\n`;
|
|
17
|
+
case 'Chart':
|
|
18
|
+
return `${uiPlainText(props.data ?? [])}\n`;
|
|
19
|
+
case 'Image':
|
|
20
|
+
return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
|
|
21
|
+
}
|
|
22
|
+
const target = props.path ?? props.sha ?? (type === 'Node' ? props.id : undefined);
|
|
23
|
+
const context = props.context ? `\n${uiPlainText(props.context)}` : '';
|
|
24
|
+
const text = `${label !== undefined ? `${uiPlainText(label)}: ` : ''}${target !== undefined ? `${uiPlainText(target)} ` : ''}${children}${context}`;
|
|
25
|
+
return type === 'Tag' || text.endsWith('\n') ? text : `${text}\n`;
|
|
26
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -10,14 +10,17 @@ export {
|
|
|
10
10
|
type UiTone
|
|
11
11
|
} from './catalog.ts';
|
|
12
12
|
export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
|
|
13
|
-
export { copyUiValue, evaluateUiExpression, parseUiExpression, type UiExpression, type UiValue } from './expression.ts';
|
|
13
|
+
export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
|
|
14
14
|
export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
|
|
15
15
|
export {
|
|
16
16
|
UI_FENCE_LANGUAGE,
|
|
17
|
+
UI_HOST_LIMITS,
|
|
17
18
|
UI_REPLY_LIMITS,
|
|
18
19
|
UiCompiler,
|
|
19
20
|
compileUi,
|
|
20
21
|
compileUiBlock,
|
|
22
|
+
uiHasFence,
|
|
23
|
+
uiMayReferenceHost,
|
|
21
24
|
type UiCompilerOptions,
|
|
22
25
|
type UiCompileUpdate,
|
|
23
26
|
type UiBlock,
|
|
@@ -37,7 +40,7 @@ export {
|
|
|
37
40
|
} from './runtime.ts';
|
|
38
41
|
export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
|
|
39
42
|
export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
|
|
40
|
-
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.ts';
|
|
43
|
+
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
41
44
|
export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
|
|
42
45
|
|
|
43
46
|
export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
|
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { uiActionProp, uiActionTarget, uiLocalState } from './actions.ts';
|
|
2
|
+
import type { UiBlock, UiNode } from './compiler.ts';
|
|
3
|
+
import { evaluateUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
|
|
4
|
+
import type { UiViewNode } from './runtime.ts';
|
|
5
|
+
|
|
6
|
+
export function uiCompiledNodes(nodes: readonly UiNode[], into = new Map<string, UiNode>()): Map<string, UiNode> {
|
|
7
|
+
for (const node of nodes) {
|
|
8
|
+
into.set(node.id, node);
|
|
9
|
+
uiCompiledNodes(node.children, into);
|
|
10
|
+
}
|
|
11
|
+
return into;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* Whether a control's value is one its own visible options offer. */
|
|
15
|
+
function offered(node: UiViewNode): boolean {
|
|
16
|
+
if (node.type === 'Segmented') {
|
|
17
|
+
return node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
|
|
18
|
+
}
|
|
19
|
+
if (node.type === 'Checklist') {
|
|
20
|
+
const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
|
|
21
|
+
return (node.props.value as unknown[]).every((value) => options.includes(value));
|
|
22
|
+
}
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
* The value pressing a Button sets its target to, or undefined when it sets none. A Button's @Set takes
|
|
28
|
+
* a literal only, so the value never depends on the state it was pressed in.
|
|
29
|
+
*/
|
|
30
|
+
function actionValue(action: UiExpression, block: UiBlock): { target: string; value: UiValue } | undefined {
|
|
31
|
+
try {
|
|
32
|
+
const target = uiActionTarget(action, uiLocalState(block));
|
|
33
|
+
if (target === null || action.kind !== 'call') {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
return { target, value: action.name === 'Set' ? evaluateUiExpression(action.args[1], {}) : block.defaults[target] };
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* The variables whose current value a visible, complete control of the evaluated tree accounts for: a bound
|
|
44
|
+
* control holding it within what it offers, or an enabled Button that sets exactly that value. A submitted
|
|
45
|
+
* value outside this set, and unequal to its default, is one no person could have produced on the screen.
|
|
46
|
+
*/
|
|
47
|
+
export function uiVisibleInputs(block: UiBlock, nodes: readonly UiViewNode[], scope: Readonly<Record<string, UiValue>>): Set<string> {
|
|
48
|
+
const written = uiCompiledNodes(block.nodes);
|
|
49
|
+
const inputs = new Set<string>();
|
|
50
|
+
const visit = (views: readonly UiViewNode[]) => {
|
|
51
|
+
for (const node of views) {
|
|
52
|
+
if (node.error || !node.complete) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const source = written.get(node.sourceId ?? node.id);
|
|
56
|
+
if (source && offered(node)) {
|
|
57
|
+
for (const prop of Object.keys(node.bindings)) {
|
|
58
|
+
if (source.bindings[prop]) {
|
|
59
|
+
inputs.add(source.bindings[prop]);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const actionProp = uiActionProp(node.type);
|
|
64
|
+
const action = source && actionProp ? source.expressions[actionProp] : undefined;
|
|
65
|
+
const pressed = action && node.props.disabled !== true ? actionValue(action, block) : undefined;
|
|
66
|
+
if (pressed && sameUiValue(pressed.value, scope[pressed.target])) {
|
|
67
|
+
inputs.add(pressed.target);
|
|
68
|
+
}
|
|
69
|
+
visit(node.children);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
visit(nodes);
|
|
73
|
+
return inputs;
|
|
74
|
+
}
|
package/src/links.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import type { UiLimits } from './budget.ts';
|
|
2
3
|
import type { UiBlock } from './compiler.ts';
|
|
3
4
|
import { evaluateUiBlock, type UiViewNode } from './runtime.ts';
|
|
4
5
|
import { uiValidatedState } from './query.ts';
|
|
@@ -11,6 +12,7 @@ export const UiLinkTargetSchema = z.discriminatedUnion('type', [
|
|
|
11
12
|
]);
|
|
12
13
|
export type UiLinkTarget = z.infer<typeof UiLinkTargetSchema>;
|
|
13
14
|
|
|
15
|
+
// `state` is a frozen set: native clients validate these payloads whole, so a new state would fail them.
|
|
14
16
|
export const UiLinkResolutionSchema = z.object({
|
|
15
17
|
state: z.enum(['chip', 'plain']),
|
|
16
18
|
target: UiLinkTargetSchema.optional(),
|
|
@@ -21,6 +23,8 @@ export const UiLinkResolutionSchema = z.object({
|
|
|
21
23
|
conflicted: z.boolean().optional(),
|
|
22
24
|
relativePath: z.string().optional(),
|
|
23
25
|
label: z.string().optional(),
|
|
26
|
+
// A code that names the reason across versions; open, so a new one fails nobody.
|
|
27
|
+
code: z.string().optional(),
|
|
24
28
|
reason: z.string().optional()
|
|
25
29
|
});
|
|
26
30
|
export type UiLinkResolution = z.infer<typeof UiLinkResolutionSchema>;
|
|
@@ -28,9 +32,10 @@ export type UiLinkResolution = z.infer<typeof UiLinkResolutionSchema>;
|
|
|
28
32
|
export function uiLinkTargets(
|
|
29
33
|
block: UiBlock,
|
|
30
34
|
input: Readonly<Record<string, unknown>> = {},
|
|
31
|
-
queries: Readonly<Record<string, unknown>> = {}
|
|
35
|
+
queries: Readonly<Record<string, unknown>> = {},
|
|
36
|
+
limits: Partial<UiLimits> = {}
|
|
32
37
|
): Record<string, UiLinkTarget> {
|
|
33
|
-
const state = uiValidatedState(block, input, queries);
|
|
38
|
+
const state = uiValidatedState(block, input, queries, limits);
|
|
34
39
|
const targets: Record<string, UiLinkTarget> = Object.create(null);
|
|
35
40
|
const visit = (nodes: readonly UiViewNode[]) => {
|
|
36
41
|
for (const node of nodes) {
|
|
@@ -44,6 +49,6 @@ export function uiLinkTargets(
|
|
|
44
49
|
visit(node.children);
|
|
45
50
|
}
|
|
46
51
|
};
|
|
47
|
-
visit(evaluateUiBlock(block, state).nodes);
|
|
52
|
+
visit(evaluateUiBlock(block, state, limits).nodes);
|
|
48
53
|
return targets;
|
|
49
54
|
}
|
package/src/prop-list.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
/* The props a catalog entry accepts, its action first and an optional one marked with `?`. */
|
|
4
|
+
export function uiPropList(entry: { readonly schema: { readonly shape: Readonly<Record<string, z.ZodType>> }; readonly action?: string }): string[] {
|
|
5
|
+
const props = Object.entries(entry.schema.shape).map(([key, schema]) => `${key}${schema.isOptional() ? '?' : ''}`);
|
|
6
|
+
return entry.action ? [entry.action, ...props] : props;
|
|
7
|
+
}
|