@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/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 +54 -44
- 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 +59 -42
- 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/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,6 +65,8 @@ 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
|
|
|
@@ -66,10 +85,6 @@ function isConstant(expression: UiExpression): boolean {
|
|
|
66
85
|
}
|
|
67
86
|
}
|
|
68
87
|
|
|
69
|
-
function plain(value: UiValue): string {
|
|
70
|
-
return value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
88
|
export function compileUiBlock(source: string, options: UiCompileOptions): UiBlock {
|
|
74
89
|
const budget = new UiBudget(options.limits, options.now);
|
|
75
90
|
const block: UiBlock = {
|
|
@@ -89,8 +104,16 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
89
104
|
block.diagnostics.push(uiDiagnostic(error, start, end, nodeId));
|
|
90
105
|
}
|
|
91
106
|
};
|
|
92
|
-
|
|
93
|
-
|
|
107
|
+
// A pressed action must set the same value whatever state it is pressed in, so a choice can check it.
|
|
108
|
+
const checkedAction = (action: UiExpression): UiExpression => {
|
|
109
|
+
uiActionTarget(action, uiLocalState(block));
|
|
110
|
+
if (action.kind === 'call' && action.name === 'Set' && !isConstant(action.args[1])) {
|
|
111
|
+
throw new UiFailure('refused_action', '@Set in an action takes a literal value.');
|
|
112
|
+
}
|
|
113
|
+
return action;
|
|
114
|
+
};
|
|
115
|
+
const compile = (syntax: UiSyntaxNode, depth: number, parent?: string): UiNode => {
|
|
116
|
+
budget.depth(depth);
|
|
94
117
|
const node: UiNode = {
|
|
95
118
|
id: syntax.id,
|
|
96
119
|
type: syntax.type,
|
|
@@ -109,7 +132,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
109
132
|
if (syntax.expression) {
|
|
110
133
|
node.expressions.text = syntax.expression;
|
|
111
134
|
try {
|
|
112
|
-
node.fallback =
|
|
135
|
+
node.fallback = uiPlainText(evaluateUiExpression(syntax.expression, block.defaults, budget));
|
|
113
136
|
} catch (error) {
|
|
114
137
|
if (error instanceof UiFailure && error.code === 'budget_exceeded') {
|
|
115
138
|
throw error;
|
|
@@ -139,8 +162,13 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
139
162
|
throw new UiFailure('invalid_child', `${syntax.type} is not a child of ${parent}.`);
|
|
140
163
|
}
|
|
141
164
|
}
|
|
165
|
+
const actionProp = uiActionProp(syntax.type);
|
|
142
166
|
for (const [key, expression] of Object.entries(syntax.props)) {
|
|
143
167
|
safeKey(key);
|
|
168
|
+
if (key === actionProp) {
|
|
169
|
+
node.expressions[key] = checkedAction(expression);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
144
172
|
if (!Object.hasOwn(entry.schema.shape, key)) {
|
|
145
173
|
diagnose(new UiFailure('refused_prop', `${syntax.type} does not accept ${key}.`), syntax.start, syntax.end, syntax.id);
|
|
146
174
|
continue;
|
|
@@ -149,13 +177,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
149
177
|
node.props[key] = evaluateUiExpression(expression, {}, budget);
|
|
150
178
|
} else {
|
|
151
179
|
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
|
-
) {
|
|
180
|
+
if ('binding' in entry && entry.binding === key && expression.kind === 'reference' && uiLocalState(block)(expression.name)) {
|
|
159
181
|
node.bindings[key] = expression.name;
|
|
160
182
|
}
|
|
161
183
|
}
|
|
@@ -164,10 +186,16 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
164
186
|
delete node.props.generated;
|
|
165
187
|
node.props.attachment = options.latestAttachment;
|
|
166
188
|
}
|
|
189
|
+
if (actionProp !== undefined && syntax.complete && !node.expressions[actionProp]) {
|
|
190
|
+
throw new UiFailure('invalid_props', `${syntax.type} needs ${actionProp}.`);
|
|
191
|
+
}
|
|
167
192
|
const evaluated: Record<string, UiValue> = { ...node.props };
|
|
168
193
|
fallbackProps = evaluated;
|
|
169
194
|
let unresolved = false;
|
|
170
195
|
for (const [key, expression] of Object.entries(node.expressions)) {
|
|
196
|
+
if (key === actionProp) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
171
199
|
try {
|
|
172
200
|
evaluated[key] = evaluateUiExpression(expression, block.defaults, budget);
|
|
173
201
|
} catch (error) {
|
|
@@ -192,27 +220,8 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
192
220
|
diagnose(error, syntax.start, syntax.end, syntax.id);
|
|
193
221
|
}
|
|
194
222
|
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
|
-
}
|
|
223
|
+
node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
|
|
224
|
+
node.fallback = uiNodeFallback(syntax.type, fallbackProps, node.children.map((child) => child.fallback).join(''));
|
|
216
225
|
if (!node.fallback.trim()) {
|
|
217
226
|
node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
|
|
218
227
|
}
|
|
@@ -254,8 +263,8 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
254
263
|
if (!checked.success) {
|
|
255
264
|
throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
|
|
256
265
|
}
|
|
257
|
-
if (Object.keys(block.queries).length >=
|
|
258
|
-
throw new UiFailure('budget_exceeded',
|
|
266
|
+
if (Object.keys(block.queries).length >= UI_HOST_LIMITS.queries) {
|
|
267
|
+
throw new UiFailure('budget_exceeded', `A block may declare at most ${UI_HOST_LIMITS.queries} queries.`);
|
|
259
268
|
}
|
|
260
269
|
block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
|
|
261
270
|
} else {
|
|
@@ -271,7 +280,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
271
280
|
diagnose(error, 0, source.length);
|
|
272
281
|
}
|
|
273
282
|
}
|
|
274
|
-
block.nodes = syntax.nodes.map((node) => compile(node));
|
|
283
|
+
block.nodes = syntax.nodes.map((node) => compile(node, 0));
|
|
275
284
|
block.fallback = block.nodes
|
|
276
285
|
.map((node) => node.fallback)
|
|
277
286
|
.join('')
|
|
@@ -315,6 +324,8 @@ export class UiCompiler {
|
|
|
315
324
|
let nodes = 0;
|
|
316
325
|
let exhausted = false;
|
|
317
326
|
const now = this.options.now ?? Date.now;
|
|
327
|
+
const language = this.options.fenceLanguage ?? UI_FENCE_LANGUAGE;
|
|
328
|
+
const diagnostics = this.options.limits?.diagnostics ?? UI_LIMITS.diagnostics;
|
|
318
329
|
const started = now();
|
|
319
330
|
const nodeCount = (items: readonly UiNode[]): number => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
|
|
320
331
|
const rejected = (start: number, codeStart: number, codeEnd: number, end: number, complete: boolean): UiBlock => ({
|
|
@@ -338,7 +349,7 @@ export class UiCompiler {
|
|
|
338
349
|
blocks[blocks.length - 1] = {
|
|
339
350
|
...last,
|
|
340
351
|
diagnostics: [
|
|
341
|
-
...last.diagnostics.slice(0,
|
|
352
|
+
...last.diagnostics.slice(0, diagnostics - 1),
|
|
342
353
|
uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
|
|
343
354
|
]
|
|
344
355
|
};
|
|
@@ -390,7 +401,7 @@ export class UiCompiler {
|
|
|
390
401
|
length: opening[1].length,
|
|
391
402
|
start: position,
|
|
392
403
|
codeStart: position + line.length,
|
|
393
|
-
ui: opening[2].trim() ===
|
|
404
|
+
ui: opening[2].trim() === language
|
|
394
405
|
};
|
|
395
406
|
} else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
|
|
396
407
|
if (fence.ui) {
|
|
@@ -412,6 +423,12 @@ export class UiCompiler {
|
|
|
412
423
|
}
|
|
413
424
|
}
|
|
414
425
|
|
|
426
|
+
/* A cheap test for whether `text` opens a UI fence at all, before a compiler is worth creating. */
|
|
427
|
+
export function uiHasFence(text: string, fenceLanguage: string = UI_FENCE_LANGUAGE): boolean {
|
|
428
|
+
const language = fenceLanguage.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
429
|
+
return new RegExp(`^ {0,3}(?:\`{3,}|~{3,})[ \\t]*${language}[ \\t]*\\r?$`, 'm').test(text);
|
|
430
|
+
}
|
|
431
|
+
|
|
415
432
|
export function compileUi(text: string, options: UiCompileOptions): UiBlock[] {
|
|
416
433
|
return new UiCompiler(options).compile(text, options);
|
|
417
434
|
}
|
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
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -1,28 +1,22 @@
|
|
|
1
1
|
import { UI_CATALOG_VERSION } from './catalog.ts';
|
|
2
|
-
import { UiBudget, UiFailure } from './budget.ts';
|
|
3
|
-
import type { UiBlock
|
|
4
|
-
import {
|
|
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';
|
|
5
7
|
import { evaluateUiBlock, uiInputValues, UiState, type UiViewNode } from './runtime.ts';
|
|
6
8
|
|
|
7
|
-
function
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (found) {
|
|
14
|
-
return found;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function uiValidatedState(block: UiBlock, input: Readonly<Record<string, unknown>> = {}, queries: Readonly<Record<string, unknown>> = {}): UiState {
|
|
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 {
|
|
21
15
|
if (!block.complete || block.catalogVersion !== UI_CATALOG_VERSION) {
|
|
22
16
|
throw new UiFailure('invalid_block', 'Only completed supported blocks can resolve input.');
|
|
23
17
|
}
|
|
24
|
-
const values = copyUiValue(input, new UiBudget()) as Record<string, UiValue>;
|
|
25
|
-
const state = new UiState(block);
|
|
18
|
+
const values = copyUiValue(input, new UiBudget(limits)) as Record<string, UiValue>;
|
|
19
|
+
const state = new UiState(block, limits);
|
|
26
20
|
for (const [name, value] of Object.entries(queries)) {
|
|
27
21
|
state.setQuery(name, value, block);
|
|
28
22
|
}
|
|
@@ -33,53 +27,33 @@ export function uiValidatedState(block: UiBlock, input: Readonly<Record<string,
|
|
|
33
27
|
}
|
|
34
28
|
state.set(key, value);
|
|
35
29
|
}
|
|
36
|
-
const
|
|
37
|
-
const visit = (nodes: readonly UiViewNode[]) => {
|
|
38
|
-
for (const node of nodes) {
|
|
39
|
-
if (node.error || !node.complete) {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
let valid = true;
|
|
43
|
-
if (node.type === 'Segmented') {
|
|
44
|
-
valid = node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
|
|
45
|
-
}
|
|
46
|
-
if (node.type === 'Checklist') {
|
|
47
|
-
const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
|
|
48
|
-
valid = (node.props.value as unknown[]).every((value) => options.includes(value));
|
|
49
|
-
}
|
|
50
|
-
if (valid) {
|
|
51
|
-
for (const [key, binding] of Object.entries(node.bindings)) {
|
|
52
|
-
const original = findBinding(block.nodes, node.sourceId ?? node.id, key);
|
|
53
|
-
if (original && binding) {
|
|
54
|
-
visible.add(original);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
visit(node.children);
|
|
59
|
-
}
|
|
60
|
-
};
|
|
61
|
-
const evaluated = evaluateUiBlock(block, state);
|
|
30
|
+
const evaluated = evaluateUiBlock(block, state, limits);
|
|
62
31
|
if (evaluated.diagnostics.some((diagnostic) => diagnostic.code === 'budget_exceeded')) {
|
|
63
32
|
throw new UiFailure('budget_exceeded', 'The query inputs exceeded their evaluation budget.');
|
|
64
33
|
}
|
|
65
|
-
|
|
34
|
+
const visible = uiVisibleInputs(block, evaluated.nodes, state.scope());
|
|
66
35
|
for (const [key, value] of Object.entries(values)) {
|
|
67
|
-
if (
|
|
36
|
+
if (!sameUiValue(value, allowed[key]) && !visible.has(key)) {
|
|
68
37
|
throw new UiFailure('invalid_value', 'An input value is outside the visible control’s allowed values.');
|
|
69
38
|
}
|
|
70
39
|
}
|
|
71
40
|
return state;
|
|
72
41
|
}
|
|
73
42
|
|
|
74
|
-
export function uiQueryArguments(
|
|
43
|
+
export function uiQueryArguments(
|
|
44
|
+
block: UiBlock,
|
|
45
|
+
name: string,
|
|
46
|
+
input: Readonly<Record<string, unknown>> = {},
|
|
47
|
+
limits: Partial<UiLimits> = {}
|
|
48
|
+
): Record<string, UiValue> {
|
|
75
49
|
if (!Object.hasOwn(block.queries, name)) {
|
|
76
50
|
throw new UiFailure('invalid_query', 'This block does not declare that query.');
|
|
77
51
|
}
|
|
78
|
-
const state = uiValidatedState(block, input);
|
|
52
|
+
const state = uiValidatedState(block, input, {}, limits);
|
|
79
53
|
const query = block.queries[name];
|
|
80
54
|
return query.expression
|
|
81
|
-
? (evaluateUiExpression(query.expression, state.scope(), new UiBudget()) as Record<string, UiValue>)
|
|
82
|
-
: (copyUiValue(query.args, new UiBudget()) as Record<string, UiValue>);
|
|
55
|
+
? (evaluateUiExpression(query.expression, state.scope(), new UiBudget(limits)) as Record<string, UiValue>)
|
|
56
|
+
: (copyUiValue(query.args, new UiBudget(limits)) as Record<string, UiValue>);
|
|
83
57
|
}
|
|
84
58
|
|
|
85
59
|
export function uiQueryFallback(block: UiBlock, queries: Readonly<Record<string, unknown>>): string {
|
|
@@ -87,35 +61,7 @@ export function uiQueryFallback(block: UiBlock, queries: Readonly<Record<string,
|
|
|
87
61
|
for (const [name, value] of Object.entries(queries)) {
|
|
88
62
|
state.setQuery(name, value, block);
|
|
89
63
|
}
|
|
90
|
-
const plain = (value: unknown) => (value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
91
64
|
const text = (nodes: readonly UiViewNode[]): string =>
|
|
92
|
-
nodes
|
|
93
|
-
.map((node) => {
|
|
94
|
-
if (node.error) {
|
|
95
|
-
return node.fallback;
|
|
96
|
-
}
|
|
97
|
-
if (node.type === '$text') {
|
|
98
|
-
return plain(node.props.text);
|
|
99
|
-
}
|
|
100
|
-
const props = node.props;
|
|
101
|
-
const children = text(node.children);
|
|
102
|
-
if (node.type === 'Stat') {
|
|
103
|
-
return `${plain(props.label)}: ${plain(props.value)}${props.unit ? ` ${plain(props.unit)}` : ''}\n`;
|
|
104
|
-
}
|
|
105
|
-
if (node.type === 'Table') {
|
|
106
|
-
return `${plain(props.rows)}\n`;
|
|
107
|
-
}
|
|
108
|
-
if (node.type === 'Chart') {
|
|
109
|
-
return `${plain(props.data)}\n`;
|
|
110
|
-
}
|
|
111
|
-
if (node.type === 'Source') {
|
|
112
|
-
return `${plain(props.label)}: ${plain(props.url)}\n`;
|
|
113
|
-
}
|
|
114
|
-
if (node.type === 'Image') {
|
|
115
|
-
return node.fallback;
|
|
116
|
-
}
|
|
117
|
-
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'}`;
|
|
118
|
-
})
|
|
119
|
-
.join('');
|
|
65
|
+
nodes.map((node) => (node.error ? node.fallback : uiNodeFallback(node.type, node.props, text(node.children)))).join('');
|
|
120
66
|
return text(evaluateUiBlock(block, state).nodes).trim() || block.fallback;
|
|
121
67
|
}
|