@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/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 +26 -1
- package/dist/compiler.js +180 -74
- 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 +7 -4
- package/dist/index.js +6 -3
- package/dist/inputs.d.ts +5 -0
- package/dist/inputs.js +69 -0
- package/dist/links.d.ts +49 -0
- package/dist/links.js +42 -0
- package/dist/prop-list.d.ts +7 -0
- package/dist/prop-list.js +5 -0
- package/dist/protocol.js +2 -1
- package/dist/query.d.ts +7 -0
- package/dist/query.js +52 -0
- package/dist/runtime.d.ts +11 -1
- package/dist/runtime.js +139 -25
- package/dist/stream.d.ts +28 -0
- package/dist/stream.js +56 -0
- package/dist/text.d.ts +5 -2
- package/dist/text.js +24 -12
- package/package.json +91 -76
- package/src/actions.ts +39 -0
- package/src/catalog.ts +12 -8
- package/src/compiler.ts +203 -71
- package/src/expression.ts +17 -0
- package/src/fallback.ts +26 -0
- package/src/index.ts +32 -4
- package/src/inputs.ts +74 -0
- package/src/links.ts +54 -0
- package/src/prop-list.ts +7 -0
- package/src/protocol.ts +2 -1
- package/src/query.ts +67 -0
- package/src/runtime.ts +155 -25
- package/src/stream.ts +78 -0
- package/src/text.ts +31 -12
package/src/compiler.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
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';
|
|
11
|
+
|
|
12
|
+
export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 } as const;
|
|
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
|
+
}
|
|
8
27
|
|
|
9
28
|
export interface UiNode {
|
|
10
29
|
id: string;
|
|
@@ -23,11 +42,13 @@ export interface UiNode {
|
|
|
23
42
|
export interface UiQuery {
|
|
24
43
|
source: string;
|
|
25
44
|
args: Record<string, UiValue>;
|
|
45
|
+
expression?: UiExpression;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export interface UiBlock {
|
|
29
49
|
id: string;
|
|
30
50
|
catalogVersion: number;
|
|
51
|
+
revision?: string;
|
|
31
52
|
start: number;
|
|
32
53
|
end: number;
|
|
33
54
|
complete: boolean;
|
|
@@ -44,6 +65,8 @@ export interface UiCompileOptions {
|
|
|
44
65
|
limits?: Partial<UiLimits>;
|
|
45
66
|
latestAttachment?: string;
|
|
46
67
|
querySchemas?: Readonly<Record<string, z.ZodType>>;
|
|
68
|
+
// The info string that marks a UI fence; `UI_FENCE_LANGUAGE` without one.
|
|
69
|
+
fenceLanguage?: string;
|
|
47
70
|
now?: () => number;
|
|
48
71
|
}
|
|
49
72
|
|
|
@@ -62,10 +85,6 @@ function isConstant(expression: UiExpression): boolean {
|
|
|
62
85
|
}
|
|
63
86
|
}
|
|
64
87
|
|
|
65
|
-
function plain(value: UiValue): string {
|
|
66
|
-
return value === null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
88
|
export function compileUiBlock(source: string, options: UiCompileOptions): UiBlock {
|
|
70
89
|
const budget = new UiBudget(options.limits, options.now);
|
|
71
90
|
const block: UiBlock = {
|
|
@@ -85,8 +104,16 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
85
104
|
block.diagnostics.push(uiDiagnostic(error, start, end, nodeId));
|
|
86
105
|
}
|
|
87
106
|
};
|
|
88
|
-
|
|
89
|
-
|
|
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);
|
|
90
117
|
const node: UiNode = {
|
|
91
118
|
id: syntax.id,
|
|
92
119
|
type: syntax.type,
|
|
@@ -105,7 +132,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
105
132
|
if (syntax.expression) {
|
|
106
133
|
node.expressions.text = syntax.expression;
|
|
107
134
|
try {
|
|
108
|
-
node.fallback =
|
|
135
|
+
node.fallback = uiPlainText(evaluateUiExpression(syntax.expression, block.defaults, budget));
|
|
109
136
|
} catch (error) {
|
|
110
137
|
if (error instanceof UiFailure && error.code === 'budget_exceeded') {
|
|
111
138
|
throw error;
|
|
@@ -135,8 +162,13 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
135
162
|
throw new UiFailure('invalid_child', `${syntax.type} is not a child of ${parent}.`);
|
|
136
163
|
}
|
|
137
164
|
}
|
|
165
|
+
const actionProp = uiActionProp(syntax.type);
|
|
138
166
|
for (const [key, expression] of Object.entries(syntax.props)) {
|
|
139
167
|
safeKey(key);
|
|
168
|
+
if (key === actionProp) {
|
|
169
|
+
node.expressions[key] = checkedAction(expression);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
140
172
|
if (!Object.hasOwn(entry.schema.shape, key)) {
|
|
141
173
|
diagnose(new UiFailure('refused_prop', `${syntax.type} does not accept ${key}.`), syntax.start, syntax.end, syntax.id);
|
|
142
174
|
continue;
|
|
@@ -145,13 +177,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
145
177
|
node.props[key] = evaluateUiExpression(expression, {}, budget);
|
|
146
178
|
} else {
|
|
147
179
|
node.expressions[key] = expression;
|
|
148
|
-
if (
|
|
149
|
-
'binding' in entry &&
|
|
150
|
-
entry.binding === key &&
|
|
151
|
-
expression.kind === 'reference' &&
|
|
152
|
-
Object.hasOwn(block.defaults, expression.name) &&
|
|
153
|
-
!Object.hasOwn(block.queries, expression.name)
|
|
154
|
-
) {
|
|
180
|
+
if ('binding' in entry && entry.binding === key && expression.kind === 'reference' && uiLocalState(block)(expression.name)) {
|
|
155
181
|
node.bindings[key] = expression.name;
|
|
156
182
|
}
|
|
157
183
|
}
|
|
@@ -160,10 +186,16 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
160
186
|
delete node.props.generated;
|
|
161
187
|
node.props.attachment = options.latestAttachment;
|
|
162
188
|
}
|
|
189
|
+
if (actionProp !== undefined && syntax.complete && !node.expressions[actionProp]) {
|
|
190
|
+
throw new UiFailure('invalid_props', `${syntax.type} needs ${actionProp}.`);
|
|
191
|
+
}
|
|
163
192
|
const evaluated: Record<string, UiValue> = { ...node.props };
|
|
164
193
|
fallbackProps = evaluated;
|
|
165
194
|
let unresolved = false;
|
|
166
195
|
for (const [key, expression] of Object.entries(node.expressions)) {
|
|
196
|
+
if (key === actionProp) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
167
199
|
try {
|
|
168
200
|
evaluated[key] = evaluateUiExpression(expression, block.defaults, budget);
|
|
169
201
|
} catch (error) {
|
|
@@ -188,27 +220,8 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
188
220
|
diagnose(error, syntax.start, syntax.end, syntax.id);
|
|
189
221
|
}
|
|
190
222
|
const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
|
|
191
|
-
node.children = syntax.children.map((child) => compile(child, effectiveParent));
|
|
192
|
-
|
|
193
|
-
const label = fallbackProps.label ?? fallbackProps.title;
|
|
194
|
-
const value = fallbackProps.value;
|
|
195
|
-
const target = fallbackProps.path ?? fallbackProps.sha ?? (syntax.type === 'Node' ? fallbackProps.id : undefined);
|
|
196
|
-
if (syntax.type === 'Stat') {
|
|
197
|
-
node.fallback = `${plain(label ?? '')}: ${plain(value ?? '')}${fallbackProps.unit ? ` ${plain(fallbackProps.unit)}` : ''}\n`;
|
|
198
|
-
} else if (syntax.type === 'Source') {
|
|
199
|
-
node.fallback = `${plain(label ?? '')}: ${plain(fallbackProps.url ?? '')}\n`;
|
|
200
|
-
} else if (syntax.type === 'Table') {
|
|
201
|
-
node.fallback = `${plain(fallbackProps.rows ?? [])}\n`;
|
|
202
|
-
} else if (syntax.type === 'Chart') {
|
|
203
|
-
node.fallback = `${plain(fallbackProps.data ?? [])}\n`;
|
|
204
|
-
} else if (syntax.type === 'Image') {
|
|
205
|
-
node.fallback = `[Image: ${plain(fallbackProps.alt ?? fallbackProps.attachment ?? fallbackProps.generated ?? 'unavailable')}]${children}\n`;
|
|
206
|
-
} else {
|
|
207
|
-
node.fallback = `${label !== undefined ? `${plain(label)}: ` : ''}${target !== undefined ? `${plain(target)} ` : ''}${children}${fallbackProps.context ? `\n${plain(fallbackProps.context)}` : ''}`;
|
|
208
|
-
if (syntax.type !== 'Tag' && !node.fallback.endsWith('\n')) {
|
|
209
|
-
node.fallback += '\n';
|
|
210
|
-
}
|
|
211
|
-
}
|
|
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(''));
|
|
212
225
|
if (!node.fallback.trim()) {
|
|
213
226
|
node.fallback = source.slice(syntax.start, Math.min(syntax.end, syntax.start + 1024));
|
|
214
227
|
}
|
|
@@ -217,14 +230,29 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
217
230
|
try {
|
|
218
231
|
const syntax = parseUiSyntax(source, options.id, options.final, budget);
|
|
219
232
|
block.diagnostics = syntax.diagnostics;
|
|
220
|
-
for (const [name, expression] of Object.entries(syntax.declarations)
|
|
233
|
+
for (const [name, expression] of Object.entries(syntax.declarations).sort(
|
|
234
|
+
([, a], [, b]) => Number(a.kind === 'call' && a.name === 'Query') - Number(b.kind === 'call' && b.name === 'Query')
|
|
235
|
+
)) {
|
|
221
236
|
try {
|
|
222
237
|
if (expression.kind === 'call' && expression.name === 'Query') {
|
|
223
|
-
if (expression.args.length !== 2 || !expression.args.
|
|
224
|
-
throw new UiFailure('invalid_query', '@Query needs a literal source and
|
|
238
|
+
if (expression.args.length !== 2 || !isConstant(expression.args[0]) || expression.args[1].kind !== 'object') {
|
|
239
|
+
throw new UiFailure('invalid_query', '@Query needs a literal source and an argument record.');
|
|
225
240
|
}
|
|
226
241
|
const querySource = evaluateUiExpression(expression.args[0], {}, budget);
|
|
227
|
-
const
|
|
242
|
+
const argument = expression.args[1];
|
|
243
|
+
const references = (part: unknown): void => {
|
|
244
|
+
if (!part || typeof part !== 'object') {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if ('kind' in part && part.kind === 'reference' && 'name' in part && !Object.hasOwn(block.defaults, String(part.name))) {
|
|
248
|
+
throw new UiFailure('invalid_query', 'Query arguments may reference only declared local state.');
|
|
249
|
+
}
|
|
250
|
+
for (const value of Object.values(part)) {
|
|
251
|
+
references(value);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
references(argument);
|
|
255
|
+
const args = evaluateUiExpression(argument, block.defaults, budget);
|
|
228
256
|
if (typeof querySource !== 'string' || !args || Array.isArray(args) || typeof args !== 'object') {
|
|
229
257
|
throw new UiFailure('invalid_query', 'Invalid query source or argument record.');
|
|
230
258
|
}
|
|
@@ -235,7 +263,10 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
235
263
|
if (!checked.success) {
|
|
236
264
|
throw new UiFailure('invalid_query', 'Query arguments do not match the host schema.');
|
|
237
265
|
}
|
|
238
|
-
block.queries
|
|
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.`);
|
|
268
|
+
}
|
|
269
|
+
block.queries[name] = { source: querySource, args, ...(!isConstant(argument) ? { expression: argument } : {}) };
|
|
239
270
|
} else {
|
|
240
271
|
if (!isConstant(expression)) {
|
|
241
272
|
throw new UiFailure('invalid_default', 'State defaults must be literal JSON values.');
|
|
@@ -249,7 +280,7 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
249
280
|
diagnose(error, 0, source.length);
|
|
250
281
|
}
|
|
251
282
|
}
|
|
252
|
-
block.nodes = syntax.nodes.map((node) => compile(node));
|
|
283
|
+
block.nodes = syntax.nodes.map((node) => compile(node, 0));
|
|
253
284
|
block.fallback = block.nodes
|
|
254
285
|
.map((node) => node.fallback)
|
|
255
286
|
.join('')
|
|
@@ -267,36 +298,137 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
267
298
|
return block;
|
|
268
299
|
}
|
|
269
300
|
|
|
270
|
-
export
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
export type UiCompilerOptions = Omit<UiCompileOptions, 'final' | 'latestAttachment'>;
|
|
302
|
+
export type UiCompileUpdate = Pick<UiCompileOptions, 'final' | 'latestAttachment'>;
|
|
303
|
+
|
|
304
|
+
interface CachedUiBlock {
|
|
305
|
+
source: string;
|
|
306
|
+
block: UiBlock;
|
|
307
|
+
latestAttachment?: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export class UiCompiler {
|
|
311
|
+
private readonly options: UiCompilerOptions;
|
|
312
|
+
private cache = new Map<number, CachedUiBlock>();
|
|
313
|
+
|
|
314
|
+
constructor(options: UiCompilerOptions) {
|
|
315
|
+
this.options = options;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
compile(text: string, update: UiCompileUpdate = {}): UiBlock[] {
|
|
319
|
+
const blocks: UiBlock[] = [];
|
|
320
|
+
const next = new Map<number, CachedUiBlock>();
|
|
321
|
+
let fence: { marker: string; length: number; start: number; codeStart: number; ui: boolean } | null = null;
|
|
322
|
+
let position = 0;
|
|
323
|
+
let characters = 0;
|
|
324
|
+
let nodes = 0;
|
|
325
|
+
let exhausted = false;
|
|
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;
|
|
329
|
+
const started = now();
|
|
330
|
+
const nodeCount = (items: readonly UiNode[]): number => items.reduce((count, node) => count + 1 + nodeCount(node.children), 0);
|
|
331
|
+
const rejected = (start: number, codeStart: number, codeEnd: number, end: number, complete: boolean): UiBlock => ({
|
|
332
|
+
id: `${this.options.id}:ui:${start}`,
|
|
333
|
+
catalogVersion: UI_CATALOG_VERSION,
|
|
334
|
+
start,
|
|
335
|
+
end,
|
|
336
|
+
complete,
|
|
337
|
+
defaults: {},
|
|
338
|
+
queries: {},
|
|
339
|
+
nodes: [],
|
|
340
|
+
fallback: text.slice(codeStart, Math.min(codeEnd, codeStart + 4096)),
|
|
341
|
+
diagnostics: [uiDiagnostic(new UiFailure('budget_exceeded', 'This reply exceeded its UI budget; remaining fences stay text.'), start, end)]
|
|
342
|
+
});
|
|
343
|
+
const append = (start: number, codeStart: number, codeEnd: number, end: number, closed: boolean) => {
|
|
344
|
+
if (exhausted) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (blocks.length >= UI_REPLY_LIMITS.blocks) {
|
|
348
|
+
const last = blocks.at(-1)!;
|
|
349
|
+
blocks[blocks.length - 1] = {
|
|
350
|
+
...last,
|
|
351
|
+
diagnostics: [
|
|
352
|
+
...last.diagnostics.slice(0, diagnostics - 1),
|
|
353
|
+
uiDiagnostic(new UiFailure('budget_exceeded', 'Further UI fences stay text because this reply has too many blocks.'), start, end)
|
|
354
|
+
]
|
|
355
|
+
};
|
|
356
|
+
exhausted = true;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const complete = closed || update.final === true;
|
|
360
|
+
characters += codeEnd - codeStart;
|
|
361
|
+
if (characters > UI_REPLY_LIMITS.characters || now() - started > UI_REPLY_LIMITS.milliseconds) {
|
|
362
|
+
blocks.push(rejected(start, codeStart, codeEnd, end, complete));
|
|
363
|
+
exhausted = true;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const source = text.slice(codeStart, codeEnd);
|
|
367
|
+
const cached = this.cache.get(start);
|
|
368
|
+
const unchanged =
|
|
369
|
+
!update.final &&
|
|
370
|
+
cached?.source === source &&
|
|
371
|
+
cached.block.end === end &&
|
|
372
|
+
cached.block.complete === complete &&
|
|
373
|
+
cached.latestAttachment === update.latestAttachment;
|
|
374
|
+
const block = unchanged
|
|
375
|
+
? cached.block
|
|
376
|
+
: compileUiBlock(source, {
|
|
377
|
+
...this.options,
|
|
378
|
+
...update,
|
|
379
|
+
id: `${this.options.id}:ui:${start}`,
|
|
380
|
+
final: complete
|
|
381
|
+
});
|
|
382
|
+
nodes += nodeCount(block.nodes);
|
|
383
|
+
if (nodes > UI_REPLY_LIMITS.nodes) {
|
|
384
|
+
blocks.push(rejected(start, codeStart, codeEnd, end, complete));
|
|
385
|
+
exhausted = true;
|
|
386
|
+
return;
|
|
290
387
|
}
|
|
291
|
-
|
|
388
|
+
block.start = start;
|
|
389
|
+
block.end = end;
|
|
390
|
+
blocks.push(block);
|
|
391
|
+
next.set(start, { source, block, latestAttachment: update.latestAttachment });
|
|
392
|
+
};
|
|
393
|
+
while (position < text.length && !exhausted) {
|
|
394
|
+
const newline = text.indexOf('\n', position);
|
|
395
|
+
const end = newline === -1 ? text.length : newline + 1;
|
|
396
|
+
const line = text.slice(position, end);
|
|
397
|
+
const opening = /^ {0,3}(`{3,}|~{3,})([^\r\n]*)/.exec(line);
|
|
398
|
+
if (!fence && opening) {
|
|
399
|
+
fence = {
|
|
400
|
+
marker: opening[1][0],
|
|
401
|
+
length: opening[1].length,
|
|
402
|
+
start: position,
|
|
403
|
+
codeStart: position + line.length,
|
|
404
|
+
ui: opening[2].trim() === language
|
|
405
|
+
};
|
|
406
|
+
} else if (fence && opening && opening[1][0] === fence.marker && opening[1].length >= fence.length && !opening[2].trim()) {
|
|
407
|
+
if (fence.ui) {
|
|
408
|
+
append(fence.start, fence.codeStart, position, position + line.length, true);
|
|
409
|
+
}
|
|
410
|
+
fence = null;
|
|
411
|
+
}
|
|
412
|
+
position = end;
|
|
292
413
|
}
|
|
293
|
-
|
|
414
|
+
if (fence?.ui) {
|
|
415
|
+
append(fence.start, fence.codeStart, text.length, text.length, false);
|
|
416
|
+
}
|
|
417
|
+
this.cache = next;
|
|
418
|
+
return blocks;
|
|
294
419
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
block.end = text.length;
|
|
299
|
-
blocks.push(block);
|
|
420
|
+
|
|
421
|
+
clear(): void {
|
|
422
|
+
this.cache.clear();
|
|
300
423
|
}
|
|
301
|
-
|
|
424
|
+
}
|
|
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
|
+
|
|
432
|
+
export function compileUi(text: string, options: UiCompileOptions): UiBlock[] {
|
|
433
|
+
return new UiCompiler(options).compile(text, options);
|
|
302
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,9 +10,37 @@ 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
|
-
export {
|
|
16
|
-
|
|
15
|
+
export {
|
|
16
|
+
UI_FENCE_LANGUAGE,
|
|
17
|
+
UI_HOST_LIMITS,
|
|
18
|
+
UI_REPLY_LIMITS,
|
|
19
|
+
UiCompiler,
|
|
20
|
+
compileUi,
|
|
21
|
+
compileUiBlock,
|
|
22
|
+
uiHasFence,
|
|
23
|
+
uiMayReferenceHost,
|
|
24
|
+
type UiCompilerOptions,
|
|
25
|
+
type UiCompileUpdate,
|
|
26
|
+
type UiBlock,
|
|
27
|
+
type UiCompileOptions,
|
|
28
|
+
type UiNode,
|
|
29
|
+
type UiQuery
|
|
30
|
+
} from './compiler.ts';
|
|
31
|
+
export {
|
|
32
|
+
evaluateUiBlock,
|
|
33
|
+
UiState,
|
|
34
|
+
uiInputValues,
|
|
35
|
+
resolveUiChoice,
|
|
36
|
+
type UiChoiceSelection,
|
|
37
|
+
type UiBinding,
|
|
38
|
+
type UiEvaluation,
|
|
39
|
+
type UiViewNode
|
|
40
|
+
} from './runtime.ts';
|
|
41
|
+
export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
|
|
17
42
|
export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
|
|
18
|
-
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.ts';
|
|
43
|
+
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
44
|
+
export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
|
|
45
|
+
|
|
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
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { UiLimits } from './budget.ts';
|
|
3
|
+
import type { UiBlock } from './compiler.ts';
|
|
4
|
+
import { evaluateUiBlock, type UiViewNode } from './runtime.ts';
|
|
5
|
+
import { uiValidatedState } from './query.ts';
|
|
6
|
+
|
|
7
|
+
export const UiLinkTargetSchema = z.discriminatedUnion('type', [
|
|
8
|
+
z.object({ type: z.literal('File'), path: z.string().min(1).max(4096), line: z.number().int().positive().optional() }).strict(),
|
|
9
|
+
z.object({ type: z.literal('Diff'), path: z.string().min(1).max(4096) }).strict(),
|
|
10
|
+
z.object({ type: z.literal('Commit'), sha: z.string().regex(/^[a-f\d]{7,64}$/i) }).strict(),
|
|
11
|
+
z.object({ type: z.literal('Node'), id: z.string().min(1).max(256) }).strict()
|
|
12
|
+
]);
|
|
13
|
+
export type UiLinkTarget = z.infer<typeof UiLinkTargetSchema>;
|
|
14
|
+
|
|
15
|
+
// `state` is a frozen set: native clients validate these payloads whole, so a new state would fail them.
|
|
16
|
+
export const UiLinkResolutionSchema = z.object({
|
|
17
|
+
state: z.enum(['chip', 'plain']),
|
|
18
|
+
target: UiLinkTargetSchema.optional(),
|
|
19
|
+
cwd: z.string().optional(),
|
|
20
|
+
viewId: z.string().optional(),
|
|
21
|
+
projectId: z.string().optional(),
|
|
22
|
+
staged: z.boolean().optional(),
|
|
23
|
+
conflicted: z.boolean().optional(),
|
|
24
|
+
relativePath: z.string().optional(),
|
|
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(),
|
|
28
|
+
reason: z.string().optional()
|
|
29
|
+
});
|
|
30
|
+
export type UiLinkResolution = z.infer<typeof UiLinkResolutionSchema>;
|
|
31
|
+
|
|
32
|
+
export function uiLinkTargets(
|
|
33
|
+
block: UiBlock,
|
|
34
|
+
input: Readonly<Record<string, unknown>> = {},
|
|
35
|
+
queries: Readonly<Record<string, unknown>> = {},
|
|
36
|
+
limits: Partial<UiLimits> = {}
|
|
37
|
+
): Record<string, UiLinkTarget> {
|
|
38
|
+
const state = uiValidatedState(block, input, queries, limits);
|
|
39
|
+
const targets: Record<string, UiLinkTarget> = Object.create(null);
|
|
40
|
+
const visit = (nodes: readonly UiViewNode[]) => {
|
|
41
|
+
for (const node of nodes) {
|
|
42
|
+
if (node.error || !node.complete) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const checked = UiLinkTargetSchema.safeParse({ type: node.type, ...node.props });
|
|
46
|
+
if (checked.success) {
|
|
47
|
+
targets[node.id] = checked.data;
|
|
48
|
+
}
|
|
49
|
+
visit(node.children);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
visit(evaluateUiBlock(block, state, limits).nodes);
|
|
53
|
+
return targets;
|
|
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/protocol.ts
CHANGED
|
@@ -32,11 +32,12 @@ export const UiDiagnosticSchema = z.object({
|
|
|
32
32
|
export const UiBlockSchema: z.ZodType<UiBlock> = z.object({
|
|
33
33
|
id: z.string(),
|
|
34
34
|
catalogVersion: position,
|
|
35
|
+
revision: z.string().min(1).optional(),
|
|
35
36
|
start: position,
|
|
36
37
|
end: position,
|
|
37
38
|
complete: z.boolean(),
|
|
38
39
|
defaults: values,
|
|
39
|
-
queries: z.record(z.string(), z.object({ source: z.string(), args: values })),
|
|
40
|
+
queries: z.record(z.string(), z.object({ source: z.string(), args: values, expression: z.unknown().optional() })),
|
|
40
41
|
nodes: z.array(UiNodeSchema),
|
|
41
42
|
diagnostics: z.array(UiDiagnosticSchema),
|
|
42
43
|
fallback: z.string()
|