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