@adecore/intelligent-ui 0.24.1 → 0.25.0
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/dist/catalog.d.ts +96 -2
- package/dist/catalog.js +61 -2
- package/dist/compiler.d.ts +1 -0
- package/dist/compiler.js +47 -7
- package/dist/event-time.d.ts +6 -0
- package/dist/event-time.js +44 -0
- package/dist/fallback.js +14 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/dist/inputs.js +4 -1
- package/dist/places.d.ts +7 -0
- package/dist/places.js +32 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +17 -0
- package/dist/text.d.ts +5 -1
- package/dist/text.js +22 -13
- package/package.json +11 -1
- package/src/catalog.ts +66 -2
- package/src/compiler.ts +63 -7
- package/src/event-time.ts +54 -0
- package/src/fallback.ts +14 -0
- package/src/index.ts +4 -1
- package/src/inputs.ts +4 -1
- package/src/places.ts +47 -0
- package/src/runtime.ts +26 -0
- package/src/text.ts +33 -14
package/dist/catalog.d.ts
CHANGED
|
@@ -23,7 +23,11 @@ export declare const UI_GROUPS: {
|
|
|
23
23
|
};
|
|
24
24
|
readonly content: {
|
|
25
25
|
readonly description: 'Include code, attachments and sources.';
|
|
26
|
-
readonly rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.';
|
|
26
|
+
readonly rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path. Gallery and Compare take chat attachments too. The host says how to attach a file of your own.';
|
|
27
|
+
};
|
|
28
|
+
readonly places: {
|
|
29
|
+
readonly description: 'Show places on a map the host draws.';
|
|
30
|
+
readonly rules: 'Give lat and lon in decimal degrees, or an address the host looks up. Never a tile or map URL. The host chooses the map.';
|
|
27
31
|
};
|
|
28
32
|
readonly links: {
|
|
29
33
|
readonly description: 'Refer to a host-owned resource.';
|
|
@@ -109,6 +113,31 @@ export declare const UI_CATALOG: {
|
|
|
109
113
|
}, z.core.$strict>;
|
|
110
114
|
readonly parent: 'Steps';
|
|
111
115
|
};
|
|
116
|
+
readonly Timeline: {
|
|
117
|
+
readonly group: 'status';
|
|
118
|
+
readonly description: 'Events in time order.';
|
|
119
|
+
readonly schema: z.ZodObject<{}, z.core.$strict>;
|
|
120
|
+
readonly children: readonly ['Event'];
|
|
121
|
+
readonly count: {
|
|
122
|
+
readonly min: 1;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
readonly Event: {
|
|
126
|
+
readonly group: 'status';
|
|
127
|
+
readonly description: 'One event at a time or date; children are the title.';
|
|
128
|
+
readonly schema: z.ZodObject<{
|
|
129
|
+
at: z.ZodString;
|
|
130
|
+
tone: z.ZodOptional<z.ZodEnum<{
|
|
131
|
+
danger: "danger";
|
|
132
|
+
info: "info";
|
|
133
|
+
neutral: "neutral";
|
|
134
|
+
success: "success";
|
|
135
|
+
warning: "warning";
|
|
136
|
+
}>>;
|
|
137
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
138
|
+
}, z.core.$strict>;
|
|
139
|
+
readonly parent: 'Timeline';
|
|
140
|
+
};
|
|
112
141
|
readonly Stats: {
|
|
113
142
|
readonly group: 'data';
|
|
114
143
|
readonly description: 'A small set of key numbers.';
|
|
@@ -232,6 +261,51 @@ export declare const UI_CATALOG: {
|
|
|
232
261
|
alt: z.ZodOptional<z.ZodString>;
|
|
233
262
|
}, z.core.$strict>;
|
|
234
263
|
};
|
|
264
|
+
readonly Gallery: {
|
|
265
|
+
readonly group: 'content';
|
|
266
|
+
readonly description: 'A grid of images with captions; at most 24.';
|
|
267
|
+
readonly schema: z.ZodObject<{}, z.core.$strict>;
|
|
268
|
+
readonly children: readonly ['Image'];
|
|
269
|
+
readonly count: {
|
|
270
|
+
readonly min: 1;
|
|
271
|
+
readonly max: 24;
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
readonly Compare: {
|
|
275
|
+
readonly group: 'content';
|
|
276
|
+
readonly description: 'Two chat attachments in one frame with a handle to wipe between them; children are the caption.';
|
|
277
|
+
readonly schema: z.ZodObject<{
|
|
278
|
+
before: z.ZodString;
|
|
279
|
+
after: z.ZodString;
|
|
280
|
+
beforeLabel: z.ZodOptional<z.ZodString>;
|
|
281
|
+
afterLabel: z.ZodOptional<z.ZodString>;
|
|
282
|
+
}, z.core.$strict>;
|
|
283
|
+
};
|
|
284
|
+
readonly Map: {
|
|
285
|
+
readonly group: 'places';
|
|
286
|
+
readonly description: 'One to 50 places on a map the host draws, with a numbered list under it; bind value to let a person pick a place.';
|
|
287
|
+
readonly schema: z.ZodObject<{
|
|
288
|
+
value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
|
|
289
|
+
}, z.core.$strict>;
|
|
290
|
+
readonly children: readonly ['Place'];
|
|
291
|
+
readonly binding: 'value';
|
|
292
|
+
readonly count: {
|
|
293
|
+
readonly min: 1;
|
|
294
|
+
readonly max: 50;
|
|
295
|
+
};
|
|
296
|
+
};
|
|
297
|
+
readonly Place: {
|
|
298
|
+
readonly group: 'places';
|
|
299
|
+
readonly description: 'One place by lat and lon, or by an address the host looks up; with both, the point the host finds for the address wins and lat and lon stand when it finds none. Children are the name.';
|
|
300
|
+
readonly schema: z.ZodObject<{
|
|
301
|
+
lat: z.ZodOptional<z.ZodNumber>;
|
|
302
|
+
lon: z.ZodOptional<z.ZodNumber>;
|
|
303
|
+
address: z.ZodOptional<z.ZodString>;
|
|
304
|
+
detail: z.ZodOptional<z.ZodString>;
|
|
305
|
+
value: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>>;
|
|
306
|
+
}, z.core.$strict>;
|
|
307
|
+
readonly parent: 'Map';
|
|
308
|
+
};
|
|
235
309
|
readonly Sources: {
|
|
236
310
|
readonly group: 'content';
|
|
237
311
|
readonly description: 'Numbered sources without preloading.';
|
|
@@ -322,13 +396,32 @@ export declare const UI_CATALOG: {
|
|
|
322
396
|
readonly children: readonly ['Option'];
|
|
323
397
|
readonly binding: 'value';
|
|
324
398
|
};
|
|
399
|
+
readonly Select: {
|
|
400
|
+
readonly group: 'inputs';
|
|
401
|
+
readonly description: 'Choose one local value from a dropdown, for more or longer options than Segmented holds; the text beside its Options is its label.';
|
|
402
|
+
readonly schema: z.ZodObject<{
|
|
403
|
+
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
404
|
+
}, z.core.$strict>;
|
|
405
|
+
readonly children: readonly ['Option'];
|
|
406
|
+
readonly binding: 'value';
|
|
407
|
+
};
|
|
325
408
|
readonly Option: {
|
|
326
409
|
readonly group: 'inputs';
|
|
327
410
|
readonly description: 'One local alternative.';
|
|
328
411
|
readonly schema: z.ZodObject<{
|
|
329
412
|
value: z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>;
|
|
330
413
|
}, z.core.$strict>;
|
|
331
|
-
readonly parent: 'Segmented';
|
|
414
|
+
readonly parent: readonly ['Segmented', 'Select'];
|
|
415
|
+
};
|
|
416
|
+
readonly TextField: {
|
|
417
|
+
readonly group: 'inputs';
|
|
418
|
+
readonly description: 'A line of local text up to maxLength characters (256 without one, at most 1024); children are the label.';
|
|
419
|
+
readonly schema: z.ZodObject<{
|
|
420
|
+
value: z.ZodString;
|
|
421
|
+
maxLength: z.ZodOptional<z.ZodNumber>;
|
|
422
|
+
placeholder: z.ZodOptional<z.ZodString>;
|
|
423
|
+
}, z.core.$strict>;
|
|
424
|
+
readonly binding: 'value';
|
|
332
425
|
};
|
|
333
426
|
readonly Button: {
|
|
334
427
|
readonly group: 'inputs';
|
|
@@ -370,6 +463,7 @@ export declare const UI_CATALOG: {
|
|
|
370
463
|
readonly parent: 'Choices';
|
|
371
464
|
};
|
|
372
465
|
};
|
|
466
|
+
export type UiGroupName = keyof typeof UI_GROUPS;
|
|
373
467
|
export type UiComponentName = keyof typeof UI_CATALOG;
|
|
374
468
|
export type UiProps<Name extends UiComponentName> = z.infer<(typeof UI_CATALOG)[Name]['schema']>;
|
|
375
469
|
export declare function isUiComponent(name: string): name is UiComponentName;
|
package/dist/catalog.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { parseUiEventTime } from './event-time.js';
|
|
2
3
|
import { uiPropList } from './prop-list.js';
|
|
3
4
|
export const UI_CATALOG_VERSION = 1;
|
|
4
5
|
export const UiToneSchema = z.enum(['neutral', 'info', 'success', 'warning', 'danger']);
|
|
@@ -15,7 +16,11 @@ export const UI_GROUPS = {
|
|
|
15
16
|
structure: { description: 'Organize distinct topics.', rules: 'Use Tabs for alternatives and Sections for independent topics.' },
|
|
16
17
|
content: {
|
|
17
18
|
description: 'Include code, attachments and sources.',
|
|
18
|
-
rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
|
|
19
|
+
rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path. Gallery and Compare take chat attachments too. The host says how to attach a file of your own.'
|
|
20
|
+
},
|
|
21
|
+
places: {
|
|
22
|
+
description: 'Show places on a map the host draws.',
|
|
23
|
+
rules: 'Give lat and lon in decimal degrees, or an address the host looks up. Never a tile or map URL. The host chooses the map.'
|
|
19
24
|
},
|
|
20
25
|
links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
|
|
21
26
|
inputs: {
|
|
@@ -43,6 +48,17 @@ export const UI_CATALOG = {
|
|
|
43
48
|
schema: z.strictObject({ state: z.enum(['done', 'running', 'pending', 'failed', 'skipped']), detail: text.optional() }),
|
|
44
49
|
parent: 'Steps'
|
|
45
50
|
},
|
|
51
|
+
Timeline: { group: 'status', description: 'Events in time order.', schema: empty, children: ['Event'], count: { min: 1 } },
|
|
52
|
+
Event: {
|
|
53
|
+
group: 'status',
|
|
54
|
+
description: 'One event at a time or date; children are the title.',
|
|
55
|
+
schema: z.strictObject({
|
|
56
|
+
at: text.refine((value) => parseUiEventTime(value) !== null, 'At must be HH:MM, YYYY-MM-DD or an ISO date-time.'),
|
|
57
|
+
tone,
|
|
58
|
+
detail: text.optional()
|
|
59
|
+
}),
|
|
60
|
+
parent: 'Timeline'
|
|
61
|
+
},
|
|
46
62
|
Stats: { group: 'data', description: 'A small set of key numbers.', schema: empty, children: ['Stat'] },
|
|
47
63
|
Stat: {
|
|
48
64
|
group: 'data',
|
|
@@ -85,6 +101,34 @@ export const UI_CATALOG = {
|
|
|
85
101
|
.strictObject({ attachment: name.optional(), generated: z.literal('latest').optional(), alt: text.optional() })
|
|
86
102
|
.refine((props) => Boolean(props.attachment) !== Boolean(props.generated), 'Choose attachment or generated, exclusively.')
|
|
87
103
|
},
|
|
104
|
+
Gallery: { group: 'content', description: 'A grid of images with captions; at most 24.', schema: empty, children: ['Image'], count: { min: 1, max: 24 } },
|
|
105
|
+
Compare: {
|
|
106
|
+
group: 'content',
|
|
107
|
+
description: 'Two chat attachments in one frame with a handle to wipe between them; children are the caption.',
|
|
108
|
+
schema: z.strictObject({ before: name, after: name, beforeLabel: text.optional(), afterLabel: text.optional() })
|
|
109
|
+
},
|
|
110
|
+
Map: {
|
|
111
|
+
group: 'places',
|
|
112
|
+
description: 'One to 50 places on a map the host draws, with a numbered list under it; bind value to let a person pick a place.',
|
|
113
|
+
schema: z.strictObject({ value: scalar.optional() }),
|
|
114
|
+
children: ['Place'],
|
|
115
|
+
binding: 'value',
|
|
116
|
+
count: { min: 1, max: 50 }
|
|
117
|
+
},
|
|
118
|
+
Place: {
|
|
119
|
+
group: 'places',
|
|
120
|
+
description: 'One place by lat and lon, or by an address the host looks up; with both, the point the host finds for the address wins and lat and lon stand when it finds none. Children are the name.',
|
|
121
|
+
schema: z
|
|
122
|
+
.strictObject({
|
|
123
|
+
lat: number.min(-90).max(90).optional(),
|
|
124
|
+
lon: number.min(-180).max(180).optional(),
|
|
125
|
+
address: text.max(512).optional(),
|
|
126
|
+
detail: text.optional(),
|
|
127
|
+
value: scalar.optional()
|
|
128
|
+
})
|
|
129
|
+
.refine((props) => (props.lat === undefined) === (props.lon === undefined) && (props.lat !== undefined || props.address !== undefined), 'Give lat with lon, or an address.'),
|
|
130
|
+
parent: 'Map'
|
|
131
|
+
},
|
|
88
132
|
Sources: { group: 'content', description: 'Numbered sources without preloading.', schema: empty, children: ['Source'] },
|
|
89
133
|
Source: {
|
|
90
134
|
group: 'content',
|
|
@@ -118,7 +162,22 @@ export const UI_CATALOG = {
|
|
|
118
162
|
binding: 'value'
|
|
119
163
|
},
|
|
120
164
|
Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
|
|
121
|
-
|
|
165
|
+
Select: {
|
|
166
|
+
group: 'inputs',
|
|
167
|
+
description: 'Choose one local value from a dropdown, for more or longer options than Segmented holds; the text beside its Options is its label.',
|
|
168
|
+
schema: z.strictObject({ value: scalar }),
|
|
169
|
+
children: ['Option'],
|
|
170
|
+
binding: 'value'
|
|
171
|
+
},
|
|
172
|
+
Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: ['Segmented', 'Select'] },
|
|
173
|
+
TextField: {
|
|
174
|
+
group: 'inputs',
|
|
175
|
+
description: 'A line of local text up to maxLength characters (256 without one, at most 1024); children are the label.',
|
|
176
|
+
schema: z
|
|
177
|
+
.strictObject({ value: text, maxLength: z.number().int().min(1).max(1024).optional(), placeholder: text.max(128).optional() })
|
|
178
|
+
.refine((props) => props.value.length <= (props.maxLength ?? 256), 'Value must not be longer than maxLength.'),
|
|
179
|
+
binding: 'value'
|
|
180
|
+
},
|
|
122
181
|
Button: {
|
|
123
182
|
group: 'inputs',
|
|
124
183
|
description: 'Run one local state action when pressed; children are the label.',
|
package/dist/compiler.d.ts
CHANGED
|
@@ -12,6 +12,7 @@ export declare const UI_REPLY_LIMITS: {
|
|
|
12
12
|
export declare const UI_HOST_LIMITS: {
|
|
13
13
|
readonly queries: 8;
|
|
14
14
|
readonly links: 64;
|
|
15
|
+
readonly places: 50;
|
|
15
16
|
readonly refreshMilliseconds: 10000;
|
|
16
17
|
};
|
|
17
18
|
export declare function uiMayReferenceHost(text: string): boolean;
|
package/dist/compiler.js
CHANGED
|
@@ -2,14 +2,16 @@ import { safeKey, UI_LIMITS, UiBudget, UiFailure } from './budget.js';
|
|
|
2
2
|
import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.js';
|
|
3
3
|
import { uiActionProp, uiActionTarget, uiLocalState } from './actions.js';
|
|
4
4
|
import { uiNodeFallback, uiPlainText } from './fallback.js';
|
|
5
|
-
import { evaluateUiExpression } from './expression.js';
|
|
5
|
+
import { evaluateUiExpression, sameUiValue } from './expression.js';
|
|
6
6
|
import { parseUiSyntax, uiDiagnostic } from './syntax.js';
|
|
7
7
|
import {} from 'zod';
|
|
8
8
|
// The info string a fence carries when the host names none of its own.
|
|
9
9
|
export const UI_FENCE_LANGUAGE = 'ui';
|
|
10
10
|
export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 };
|
|
11
|
-
// What a host reads for one block: its queries, its link targets and how often a visible block reads again.
|
|
12
|
-
export const UI_HOST_LIMITS = { queries: 8, links: 64, refreshMilliseconds: 10_000 };
|
|
11
|
+
// What a host reads for one block: its queries, its link targets, the places it looks up and how often a visible block reads again.
|
|
12
|
+
export const UI_HOST_LIMITS = { queries: 8, links: 64, places: 50, refreshMilliseconds: 10_000 };
|
|
13
|
+
/* A Place that names an address, whose tag may hold quoted text with a `>` before it. */
|
|
14
|
+
const ADDRESSED_PLACE = /<Place\b(?:[^>"']|"[^"]*"|'[^']*')*?\baddress\s*=/;
|
|
13
15
|
/* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
|
|
14
16
|
export function uiMayReferenceHost(text) {
|
|
15
17
|
if (text.includes('@Query')) {
|
|
@@ -18,7 +20,19 @@ export function uiMayReferenceHost(text) {
|
|
|
18
20
|
const links = Object.entries(UI_CATALOG)
|
|
19
21
|
.filter(([, entry]) => entry.group === 'links')
|
|
20
22
|
.map(([name]) => name);
|
|
21
|
-
return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
|
|
23
|
+
return new RegExp(`<(?:${links.join('|')})\\b`).test(text) || ADDRESSED_PLACE.test(text);
|
|
24
|
+
}
|
|
25
|
+
/* The children of a Map as its fallback numbers them: each Place that drew, also inside a Show or an Each, in order, and anything else as it is. */
|
|
26
|
+
function numberedPlaces(children) {
|
|
27
|
+
let number = 0;
|
|
28
|
+
const visit = (nodes) => nodes
|
|
29
|
+
.map((child) => child.type === 'Place' && !child.error
|
|
30
|
+
? `${++number}. ${child.fallback}`
|
|
31
|
+
: child.type === 'Show' || child.type === 'Each'
|
|
32
|
+
? visit(child.children)
|
|
33
|
+
: child.fallback)
|
|
34
|
+
.join('');
|
|
35
|
+
return visit(children);
|
|
22
36
|
}
|
|
23
37
|
/* A spent budget ends the whole block; every other failure only marks the node or declaration it came from. */
|
|
24
38
|
function rethrowBudget(error) {
|
|
@@ -79,6 +93,10 @@ function isConstant(expression) {
|
|
|
79
93
|
}
|
|
80
94
|
}
|
|
81
95
|
const CHART_SERIES = 6;
|
|
96
|
+
/* How many of its children's kind a container holds, the ones an Each or a Show will draw not counted. */
|
|
97
|
+
function countChildren(children, kinds) {
|
|
98
|
+
return children.reduce((count, child) => count + (kinds.includes(child.type) ? 1 : 0) + (child.type === 'Show' || child.type === 'Each' ? countChildren(child.children, kinds) : 0), 0);
|
|
99
|
+
}
|
|
82
100
|
/* The keys besides `label` that hold a finite number in some row of a chart's data. */
|
|
83
101
|
function chartSeries(data) {
|
|
84
102
|
const keys = new Set();
|
|
@@ -158,8 +176,11 @@ export function compileUiBlock(source, options) {
|
|
|
158
176
|
throw new UiFailure('unknown_component', `Unknown component ${syntax.type}.`);
|
|
159
177
|
}
|
|
160
178
|
const entry = UI_CATALOG[syntax.type];
|
|
161
|
-
if ('parent' in entry
|
|
162
|
-
|
|
179
|
+
if ('parent' in entry) {
|
|
180
|
+
const parents = [entry.parent].flat();
|
|
181
|
+
if (parent === undefined || !parents.includes(parent)) {
|
|
182
|
+
throw new UiFailure('invalid_parent', `${syntax.type} belongs inside ${parents.join(' or ')}.`);
|
|
183
|
+
}
|
|
163
184
|
}
|
|
164
185
|
if (parent && isUiComponent(parent)) {
|
|
165
186
|
const container = UI_CATALOG[parent];
|
|
@@ -224,6 +245,15 @@ export function compileUiBlock(source, options) {
|
|
|
224
245
|
if (!unresolved && syntax.complete && syntax.type === 'Chart' && chartSeries(evaluated.data) > CHART_SERIES) {
|
|
225
246
|
diagnose(new UiFailure('too_many_series', `Chart draws up to ${CHART_SERIES} numeric series; the rest are only in its values.`), syntax.start, syntax.end, syntax.id);
|
|
226
247
|
}
|
|
248
|
+
if (syntax.complete && 'count' in entry) {
|
|
249
|
+
const counted = countChildren(syntax.children, entry.children);
|
|
250
|
+
const limit = entry.count;
|
|
251
|
+
if (counted < limit.min || (limit.max !== undefined && counted > limit.max)) {
|
|
252
|
+
throw new UiFailure('invalid_props', limit.max === undefined
|
|
253
|
+
? `${syntax.type} needs at least ${limit.min} of ${entry.children.join(' or ')}.`
|
|
254
|
+
: `${syntax.type} takes ${limit.min} to ${limit.max} of ${entry.children.join(' or ')}.`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
227
257
|
// A Choice sends its label, so one without a label can never send; whether it closed is only known once final.
|
|
228
258
|
if (syntax.type === 'Choice' && syntax.complete && !syntax.children.some(isLabelSource)) {
|
|
229
259
|
throw new UiFailure('invalid_props', 'A choice needs a visible label.');
|
|
@@ -243,7 +273,17 @@ export function compileUiBlock(source, options) {
|
|
|
243
273
|
}
|
|
244
274
|
const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
|
|
245
275
|
node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
|
|
246
|
-
|
|
276
|
+
// A reader of the fallback has no control to look at, so the option a dropdown or a segmented control starts on says it is chosen.
|
|
277
|
+
const chosen = syntax.type === 'Select' || syntax.type === 'Segmented' ? fallbackProps.value : undefined;
|
|
278
|
+
const childText = syntax.type === 'Map'
|
|
279
|
+
? numberedPlaces(node.children)
|
|
280
|
+
: node.children
|
|
281
|
+
.map((child) => syntax.type === 'Select' && child.type === '$text' && child.fallback.trim() !== ''
|
|
282
|
+
? `${child.fallback.trim()}\n`
|
|
283
|
+
: chosen !== undefined && child.type === 'Option' && !child.error && sameUiValue(child.props.value, chosen)
|
|
284
|
+
? `${child.fallback.trimEnd()} (chosen)\n`
|
|
285
|
+
: child.fallback)
|
|
286
|
+
.join('');
|
|
247
287
|
node.fallback = uiNodeFallback(syntax.type, fallbackProps, childText);
|
|
248
288
|
// A part drawn as its text keeps the words written inside it, also where its kind reads only props.
|
|
249
289
|
if (node.error !== undefined && childText.trim() !== '' && !node.fallback.includes(childText.trim())) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const EVENT_TIME = /^(?:(\d{2}):(\d{2})|(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?)$/;
|
|
2
|
+
function validClock(hour, minute, second = 0) {
|
|
3
|
+
return hour <= 23 && minute <= 59 && second <= 59;
|
|
4
|
+
}
|
|
5
|
+
/* The minutes east of UTC that `Z` or `+02:00` stand for, or null for an offset no zone has: from -12:00 to +14:00. */
|
|
6
|
+
function zoneOffset(zone) {
|
|
7
|
+
if (zone === 'Z') {
|
|
8
|
+
return 0;
|
|
9
|
+
}
|
|
10
|
+
const [hours, minutes] = [Number(zone.slice(1, 3)), Number(zone.slice(4, 6))];
|
|
11
|
+
const offset = (zone[0] === '-' ? -1 : 1) * (hours * 60 + minutes);
|
|
12
|
+
return minutes > 59 || offset < -12 * 60 || offset > 14 * 60 ? null : offset;
|
|
13
|
+
}
|
|
14
|
+
/* The moment an Event's `at` names, or null when it is no `HH:MM`, `YYYY-MM-DD` or ISO date-time with an optional offset. */
|
|
15
|
+
export function parseUiEventTime(at) {
|
|
16
|
+
const match = EVENT_TIME.exec(at);
|
|
17
|
+
if (match === null) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const [, clockHour, clockMinute, year, month, day, hour, minute, second, zone] = match.map((part) => part);
|
|
21
|
+
if (clockHour !== undefined) {
|
|
22
|
+
// The day is not shown for a time, so any day will do.
|
|
23
|
+
return validClock(Number(clockHour), Number(clockMinute))
|
|
24
|
+
? { kind: 'time', date: new Date(Date.UTC(2000, 0, 1, Number(clockHour), Number(clockMinute))), written: true }
|
|
25
|
+
: null;
|
|
26
|
+
}
|
|
27
|
+
const [y, m, d] = [Number(year), Number(month), Number(day)];
|
|
28
|
+
const calendar = new Date(Date.UTC(y, m - 1, d));
|
|
29
|
+
if (calendar.getUTCFullYear() !== y || calendar.getUTCMonth() !== m - 1 || calendar.getUTCDate() !== d) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (hour === undefined) {
|
|
33
|
+
return { kind: 'date', date: calendar, written: true };
|
|
34
|
+
}
|
|
35
|
+
const [h, min, sec] = [Number(hour), Number(minute), Number(second ?? 0)];
|
|
36
|
+
if (!validClock(h, min, sec)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (zone === undefined) {
|
|
40
|
+
return { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec)), written: true };
|
|
41
|
+
}
|
|
42
|
+
const offset = zoneOffset(zone);
|
|
43
|
+
return offset === null ? null : { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec) - offset * 60000), written: false };
|
|
44
|
+
}
|
package/dist/fallback.js
CHANGED
|
@@ -18,6 +18,8 @@ export function uiNodeFallback(type, props, children) {
|
|
|
18
18
|
return `${children.trimEnd()}${given(props.badge) ? ` (${uiPlainText(props.badge)})` : ''}\n`;
|
|
19
19
|
case 'Step':
|
|
20
20
|
return `${children.trimEnd()}${given(props.state) ? ` (${uiPlainText(props.state)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
|
|
21
|
+
case 'Event':
|
|
22
|
+
return `${uiPlainText(props.at)} ${children.trimEnd()}${given(props.tone) ? ` (${uiPlainText(props.tone)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
|
|
21
23
|
case 'Progress':
|
|
22
24
|
return given(props.value) ? `${children.trimEnd()}: ${uiPlainText(props.value)} of ${uiPlainText(props.max ?? 100)}\n` : `${children.trimEnd()}\n`;
|
|
23
25
|
case 'Stat': {
|
|
@@ -38,6 +40,18 @@ export function uiNodeFallback(type, props, children) {
|
|
|
38
40
|
}
|
|
39
41
|
case 'Chart':
|
|
40
42
|
return `${uiPlainText(props.data ?? [])}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
|
|
43
|
+
case 'Compare':
|
|
44
|
+
return `[Compare: ${uiPlainText(props.beforeLabel || 'Before')} ${uiPlainText(props.before)}, ${uiPlainText(props.afterLabel || 'After')} ${uiPlainText(props.after)}]${children.trim() ? ` ${children.trim()}` : ''}\n`;
|
|
45
|
+
case 'Place': {
|
|
46
|
+
const point = given(props.lat) && given(props.lon) ? ` (${uiPlainText(props.lat)}, ${uiPlainText(props.lon)})` : '';
|
|
47
|
+
const address = given(props.address) ? `, ${uiPlainText(props.address)}` : '';
|
|
48
|
+
const detail = given(props.detail) ? `: ${uiPlainText(props.detail)}` : '';
|
|
49
|
+
// One line per place, so the numbering of its map counts places and not the lines of a name.
|
|
50
|
+
return `${children.trim()}${point}${address}${detail}`.replace(/\s+/g, ' ').trim() + '\n';
|
|
51
|
+
}
|
|
52
|
+
case 'Map':
|
|
53
|
+
// The places are numbered where the compiler joins them, since only it knows which of the children drew.
|
|
54
|
+
return children;
|
|
41
55
|
case 'Image':
|
|
42
56
|
return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
|
|
43
57
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText, type UiComponentName, type UiProps, type UiTone } from './catalog.ts';
|
|
1
|
+
export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText, type UiComponentName, type UiGroupName, type UiProps, type UiTone } from './catalog.ts';
|
|
2
|
+
export { parseUiEventTime, type UiEventTime } from './event-time.ts';
|
|
2
3
|
export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
|
|
3
4
|
export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
|
|
4
5
|
export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
|
|
@@ -6,6 +7,7 @@ export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compile
|
|
|
6
7
|
export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, uiChoiceLabel, type UiChoiceSelection, type UiBinding, type UiEvaluation, type UiViewNode } from './runtime.ts';
|
|
7
8
|
export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
|
|
8
9
|
export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
|
|
9
|
-
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
10
|
+
export { uiCompactCatalog, uiSessionNote, uiGroupReference, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
10
11
|
export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
|
|
12
|
+
export { uiPlaceQueries, type UiPlaceQuery } from './places.ts';
|
|
11
13
|
export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { UI_CATALOG, UI_CATALOG_VERSION, UI_GROUPS, UiToneSchema, isUiComponent, uiCatalogText } from './catalog.js';
|
|
2
|
+
export { parseUiEventTime } from './event-time.js';
|
|
2
3
|
export { UI_LIMITS, UiBudget, UiFailure, safeKey } from './budget.js';
|
|
3
4
|
export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue } from './expression.js';
|
|
4
5
|
export { parseUiSyntax, uiDiagnostic } from './syntax.js';
|
|
@@ -6,6 +7,7 @@ export { UI_FENCE_LANGUAGE, UI_HOST_LIMITS, UI_REPLY_LIMITS, UiCompiler, compile
|
|
|
6
7
|
export { evaluateUiBlock, UiState, uiInputValues, resolveUiChoice, uiChoiceLabel } from './runtime.js';
|
|
7
8
|
export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.js';
|
|
8
9
|
export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.js';
|
|
9
|
-
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText } from './text.js';
|
|
10
|
+
export { uiCompactCatalog, uiSessionNote, uiGroupReference, uiReferenceText, uiFallbackText } from './text.js';
|
|
10
11
|
export { UI_STREAM_INTERVAL_MS, UiStream } from './stream.js';
|
|
12
|
+
export { uiPlaceQueries } from './places.js';
|
|
11
13
|
export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets } from './links.js';
|
package/dist/inputs.js
CHANGED
|
@@ -9,9 +9,12 @@ export function uiCompiledNodes(nodes, into = new Map()) {
|
|
|
9
9
|
}
|
|
10
10
|
/* Whether a control's value is one its own visible options offer. */
|
|
11
11
|
function offered(node) {
|
|
12
|
-
if (node.type === 'Segmented') {
|
|
12
|
+
if (node.type === 'Segmented' || node.type === 'Select') {
|
|
13
13
|
return node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
|
|
14
14
|
}
|
|
15
|
+
if (node.type === 'Map') {
|
|
16
|
+
return node.children.some((place) => place.type === 'Place' && !place.error && place.props.value === node.props.value);
|
|
17
|
+
}
|
|
15
18
|
if (node.type === 'Checklist') {
|
|
16
19
|
const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
|
|
17
20
|
return node.props.value.every((value) => options.includes(value));
|
package/dist/places.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { UiLimits } from './budget.ts';
|
|
2
|
+
import type { UiBlock } from './compiler.ts';
|
|
3
|
+
export interface UiPlaceQuery {
|
|
4
|
+
address: string;
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function uiPlaceQueries(block: UiBlock, input?: Readonly<Record<string, unknown>>, queries?: Readonly<Record<string, unknown>>, limits?: Partial<UiLimits>): Record<string, UiPlaceQuery>;
|
package/dist/places.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { uiValidatedState } from './query.js';
|
|
2
|
+
import { evaluateUiBlock } from './runtime.js';
|
|
3
|
+
function nameOf(node) {
|
|
4
|
+
return node.children
|
|
5
|
+
.map((child) => (child.type === '$text' ? String(child.props.text ?? '') : nameOf(child)))
|
|
6
|
+
.join('')
|
|
7
|
+
.replace(/\s+/g, ' ')
|
|
8
|
+
.trim();
|
|
9
|
+
}
|
|
10
|
+
/*
|
|
11
|
+
* The complete, valid Places that give an address, by node id, as the block evaluates with `input` and the
|
|
12
|
+
* values of its `queries`. A place that also gives coordinates is looked up too: what the host finds for
|
|
13
|
+
* the address wins, and the coordinates stand when it finds nothing.
|
|
14
|
+
*/
|
|
15
|
+
export function uiPlaceQueries(block, input = {}, queries = {}, limits = {}) {
|
|
16
|
+
const state = uiValidatedState(block, input, queries, limits);
|
|
17
|
+
const places = Object.create(null);
|
|
18
|
+
const visit = (nodes) => {
|
|
19
|
+
for (const node of nodes) {
|
|
20
|
+
if (node.error || !node.complete) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const { address } = node.props;
|
|
24
|
+
if (node.type === 'Place' && typeof address === 'string' && address.trim() !== '') {
|
|
25
|
+
places[node.id] = { address: address.trim(), name: nameOf(node) };
|
|
26
|
+
}
|
|
27
|
+
visit(node.children);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
visit(evaluateUiBlock(block, state, limits).nodes);
|
|
31
|
+
return places;
|
|
32
|
+
}
|
package/dist/runtime.d.ts
CHANGED
package/dist/runtime.js
CHANGED
|
@@ -207,6 +207,23 @@ export function evaluateUiBlock(block, state = new UiState(block), limits = {})
|
|
|
207
207
|
};
|
|
208
208
|
}
|
|
209
209
|
view.children = children(node.children, variables, depth + 1, suffix);
|
|
210
|
+
if ('count' in entry) {
|
|
211
|
+
const limit = entry.count;
|
|
212
|
+
const kinds = entry.children;
|
|
213
|
+
const counted = view.children.filter((child) => kinds.includes(child.type)).length;
|
|
214
|
+
// What an Each or a Show drew is only known now, so the count the compiler checked on the source is checked again.
|
|
215
|
+
if (node.complete && counted < limit.min) {
|
|
216
|
+
throw new UiFailure('invalid_props', `${node.type} needs at least ${limit.min} of ${kinds.join(' or ')}.`);
|
|
217
|
+
}
|
|
218
|
+
if (limit.max !== undefined && counted > limit.max) {
|
|
219
|
+
let kept = 0;
|
|
220
|
+
view.children = view.children.filter((child) => !kinds.includes(child.type) || ++kept <= limit.max);
|
|
221
|
+
view.omitted = counted - limit.max;
|
|
222
|
+
if (result.diagnostics.length < budget.limits.diagnostics) {
|
|
223
|
+
result.diagnostics.push(uiDiagnostic(new UiFailure('too_many_children', `${node.type} shows its first ${limit.max} of ${kinds.join(' or ')}.`), node.start, node.end, node.id));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
210
227
|
return [view];
|
|
211
228
|
}
|
|
212
229
|
catch (error) {
|
package/dist/text.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { type UiGroupName } from './catalog.ts';
|
|
1
2
|
import { type UiBlock } from './compiler.ts';
|
|
2
3
|
export declare function uiCompactCatalog(): string;
|
|
3
4
|
export interface UiTextOptions {
|
|
4
5
|
fenceLanguage?: string;
|
|
6
|
+
catalog?: 'props' | 'names';
|
|
7
|
+
detailHint?: string;
|
|
5
8
|
}
|
|
6
|
-
export declare function uiSessionNote({ fenceLanguage }?: UiTextOptions): string;
|
|
9
|
+
export declare function uiSessionNote({ fenceLanguage, catalog, detailHint }?: UiTextOptions): string;
|
|
10
|
+
export declare function uiGroupReference(group: UiGroupName): string;
|
|
7
11
|
export declare function uiReferenceText({ fenceLanguage }?: UiTextOptions): string;
|
|
8
12
|
export declare function uiFallbackText(text: string, blocks: readonly UiBlock[]): string;
|
package/dist/text.js
CHANGED
|
@@ -22,21 +22,30 @@ function uiExample(language) {
|
|
|
22
22
|
'```'
|
|
23
23
|
].join('\n');
|
|
24
24
|
}
|
|
25
|
-
|
|
26
|
-
return
|
|
25
|
+
function uiGroupComponents(group) {
|
|
26
|
+
return Object.entries(UI_CATALOG).filter(([, entry]) => entry.group === group);
|
|
27
|
+
}
|
|
28
|
+
/* One line per group with the names of its components, for a host that hands the props out per group. */
|
|
29
|
+
function uiGroupNames(detailHint) {
|
|
30
|
+
const lines = Object.keys(UI_GROUPS).map((group) => `${group}: ${uiGroupComponents(group)
|
|
31
|
+
.map(([name]) => name)
|
|
32
|
+
.join(', ')}`);
|
|
33
|
+
return `Components by group${detailHint ? ` (${detailHint})` : ''}:\n${lines.join('\n')}`;
|
|
34
|
+
}
|
|
35
|
+
export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE, catalog = 'props', detailHint } = {}) {
|
|
36
|
+
const components = catalog === 'names' ? uiGroupNames(detailHint) : `Catalog (? means optional props): ${uiCompactCatalog()}`;
|
|
37
|
+
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. Choices are shortcuts after finished work that the person may ignore. When your turn would end waiting on the person (a missing fact, a decision, a go-ahead for work you would otherwise do), ask with your own question tool if you have one, otherwise in prose, never with Choices. 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. ${components}\nExample:\n${uiExample(fenceLanguage)}\n`;
|
|
38
|
+
}
|
|
39
|
+
/* One group as the reference writes it: its description and rules, then every component with its props. */
|
|
40
|
+
export function uiGroupReference(group) {
|
|
41
|
+
const components = uiGroupComponents(group).map(([name, entry]) => {
|
|
42
|
+
const props = uiPropList(entry).join(', ');
|
|
43
|
+
return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
|
|
44
|
+
});
|
|
45
|
+
return [UI_GROUPS[group].description, UI_GROUPS[group].rules, ...components].join('\n');
|
|
27
46
|
}
|
|
28
47
|
export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE } = {}) {
|
|
29
|
-
const groups = Object.
|
|
30
|
-
.map(([group, definition]) => {
|
|
31
|
-
const components = Object.entries(UI_CATALOG)
|
|
32
|
-
.filter(([, entry]) => entry.group === group)
|
|
33
|
-
.map(([name, entry]) => {
|
|
34
|
-
const props = uiPropList(entry).join(', ');
|
|
35
|
-
return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
|
|
36
|
-
});
|
|
37
|
-
return [definition.description, definition.rules, ...components].join('\n');
|
|
38
|
-
})
|
|
39
|
-
.join('\n\n');
|
|
48
|
+
const groups = Object.keys(UI_GROUPS).map(uiGroupReference).join('\n\n');
|
|
40
49
|
return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
|
|
41
50
|
}
|
|
42
51
|
export function uiFallbackText(text, blocks) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adecore/intelligent-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.0",
|
|
4
4
|
"description": "A streaming UI catalog, compiler and bounded expression interpreter without a framework or DOM.",
|
|
5
5
|
"license": "FSL-1.1-MIT",
|
|
6
6
|
"author": "Bas Milius",
|
|
@@ -32,6 +32,11 @@
|
|
|
32
32
|
"types": "./dist/links.d.ts",
|
|
33
33
|
"default": "./dist/links.js"
|
|
34
34
|
},
|
|
35
|
+
"./places": {
|
|
36
|
+
"source": "./src/places.ts",
|
|
37
|
+
"types": "./dist/places.d.ts",
|
|
38
|
+
"default": "./dist/places.js"
|
|
39
|
+
},
|
|
35
40
|
"./query": {
|
|
36
41
|
"source": "./src/query.ts",
|
|
37
42
|
"types": "./dist/query.d.ts",
|
|
@@ -42,6 +47,11 @@
|
|
|
42
47
|
"types": "./dist/catalog.d.ts",
|
|
43
48
|
"default": "./dist/catalog.js"
|
|
44
49
|
},
|
|
50
|
+
"./event-time": {
|
|
51
|
+
"source": "./src/event-time.ts",
|
|
52
|
+
"types": "./dist/event-time.d.ts",
|
|
53
|
+
"default": "./dist/event-time.js"
|
|
54
|
+
},
|
|
45
55
|
"./budget": {
|
|
46
56
|
"source": "./src/budget.ts",
|
|
47
57
|
"types": "./dist/budget.d.ts",
|
package/src/catalog.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { parseUiEventTime } from './event-time.ts';
|
|
2
3
|
import { uiPropList } from './prop-list.ts';
|
|
3
4
|
|
|
4
5
|
export const UI_CATALOG_VERSION = 1;
|
|
@@ -19,7 +20,11 @@ export const UI_GROUPS = {
|
|
|
19
20
|
structure: { description: 'Organize distinct topics.', rules: 'Use Tabs for alternatives and Sections for independent topics.' },
|
|
20
21
|
content: {
|
|
21
22
|
description: 'Include code, attachments and sources.',
|
|
22
|
-
rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path.'
|
|
23
|
+
rules: 'Image accepts a chat attachment or generated="latest", never a URL or a local path. Gallery and Compare take chat attachments too. The host says how to attach a file of your own.'
|
|
24
|
+
},
|
|
25
|
+
places: {
|
|
26
|
+
description: 'Show places on a map the host draws.',
|
|
27
|
+
rules: 'Give lat and lon in decimal degrees, or an address the host looks up. Never a tile or map URL. The host chooses the map.'
|
|
23
28
|
},
|
|
24
29
|
links: { description: 'Refer to a host-owned resource.', rules: 'Targets are checked by the host. No arbitrary navigation or file reads.' },
|
|
25
30
|
inputs: {
|
|
@@ -48,6 +53,17 @@ export const UI_CATALOG = {
|
|
|
48
53
|
schema: z.strictObject({ state: z.enum(['done', 'running', 'pending', 'failed', 'skipped']), detail: text.optional() }),
|
|
49
54
|
parent: 'Steps'
|
|
50
55
|
},
|
|
56
|
+
Timeline: { group: 'status', description: 'Events in time order.', schema: empty, children: ['Event'], count: { min: 1 } },
|
|
57
|
+
Event: {
|
|
58
|
+
group: 'status',
|
|
59
|
+
description: 'One event at a time or date; children are the title.',
|
|
60
|
+
schema: z.strictObject({
|
|
61
|
+
at: text.refine((value) => parseUiEventTime(value) !== null, 'At must be HH:MM, YYYY-MM-DD or an ISO date-time.'),
|
|
62
|
+
tone,
|
|
63
|
+
detail: text.optional()
|
|
64
|
+
}),
|
|
65
|
+
parent: 'Timeline'
|
|
66
|
+
},
|
|
51
67
|
Stats: { group: 'data', description: 'A small set of key numbers.', schema: empty, children: ['Stat'] },
|
|
52
68
|
Stat: {
|
|
53
69
|
group: 'data',
|
|
@@ -90,6 +106,38 @@ export const UI_CATALOG = {
|
|
|
90
106
|
.strictObject({ attachment: name.optional(), generated: z.literal('latest').optional(), alt: text.optional() })
|
|
91
107
|
.refine((props) => Boolean(props.attachment) !== Boolean(props.generated), 'Choose attachment or generated, exclusively.')
|
|
92
108
|
},
|
|
109
|
+
Gallery: { group: 'content', description: 'A grid of images with captions; at most 24.', schema: empty, children: ['Image'], count: { min: 1, max: 24 } },
|
|
110
|
+
Compare: {
|
|
111
|
+
group: 'content',
|
|
112
|
+
description: 'Two chat attachments in one frame with a handle to wipe between them; children are the caption.',
|
|
113
|
+
schema: z.strictObject({ before: name, after: name, beforeLabel: text.optional(), afterLabel: text.optional() })
|
|
114
|
+
},
|
|
115
|
+
Map: {
|
|
116
|
+
group: 'places',
|
|
117
|
+
description: 'One to 50 places on a map the host draws, with a numbered list under it; bind value to let a person pick a place.',
|
|
118
|
+
schema: z.strictObject({ value: scalar.optional() }),
|
|
119
|
+
children: ['Place'],
|
|
120
|
+
binding: 'value',
|
|
121
|
+
count: { min: 1, max: 50 }
|
|
122
|
+
},
|
|
123
|
+
Place: {
|
|
124
|
+
group: 'places',
|
|
125
|
+
description:
|
|
126
|
+
'One place by lat and lon, or by an address the host looks up; with both, the point the host finds for the address wins and lat and lon stand when it finds none. Children are the name.',
|
|
127
|
+
schema: z
|
|
128
|
+
.strictObject({
|
|
129
|
+
lat: number.min(-90).max(90).optional(),
|
|
130
|
+
lon: number.min(-180).max(180).optional(),
|
|
131
|
+
address: text.max(512).optional(),
|
|
132
|
+
detail: text.optional(),
|
|
133
|
+
value: scalar.optional()
|
|
134
|
+
})
|
|
135
|
+
.refine(
|
|
136
|
+
(props) => (props.lat === undefined) === (props.lon === undefined) && (props.lat !== undefined || props.address !== undefined),
|
|
137
|
+
'Give lat with lon, or an address.'
|
|
138
|
+
),
|
|
139
|
+
parent: 'Map'
|
|
140
|
+
},
|
|
93
141
|
Sources: { group: 'content', description: 'Numbered sources without preloading.', schema: empty, children: ['Source'] },
|
|
94
142
|
Source: {
|
|
95
143
|
group: 'content',
|
|
@@ -123,7 +171,22 @@ export const UI_CATALOG = {
|
|
|
123
171
|
binding: 'value'
|
|
124
172
|
},
|
|
125
173
|
Segmented: { group: 'inputs', description: 'Choose one local value.', schema: z.strictObject({ value: scalar }), children: ['Option'], binding: 'value' },
|
|
126
|
-
|
|
174
|
+
Select: {
|
|
175
|
+
group: 'inputs',
|
|
176
|
+
description: 'Choose one local value from a dropdown, for more or longer options than Segmented holds; the text beside its Options is its label.',
|
|
177
|
+
schema: z.strictObject({ value: scalar }),
|
|
178
|
+
children: ['Option'],
|
|
179
|
+
binding: 'value'
|
|
180
|
+
},
|
|
181
|
+
Option: { group: 'inputs', description: 'One local alternative.', schema: z.strictObject({ value: scalar }), parent: ['Segmented', 'Select'] },
|
|
182
|
+
TextField: {
|
|
183
|
+
group: 'inputs',
|
|
184
|
+
description: 'A line of local text up to maxLength characters (256 without one, at most 1024); children are the label.',
|
|
185
|
+
schema: z
|
|
186
|
+
.strictObject({ value: text, maxLength: z.number().int().min(1).max(1024).optional(), placeholder: text.max(128).optional() })
|
|
187
|
+
.refine((props) => props.value.length <= (props.maxLength ?? 256), 'Value must not be longer than maxLength.'),
|
|
188
|
+
binding: 'value'
|
|
189
|
+
},
|
|
127
190
|
Button: {
|
|
128
191
|
group: 'inputs',
|
|
129
192
|
description: 'Run one local state action when pressed; children are the label.',
|
|
@@ -145,6 +208,7 @@ export const UI_CATALOG = {
|
|
|
145
208
|
}
|
|
146
209
|
} as const;
|
|
147
210
|
|
|
211
|
+
export type UiGroupName = keyof typeof UI_GROUPS;
|
|
148
212
|
export type UiComponentName = keyof typeof UI_CATALOG;
|
|
149
213
|
export type UiProps<Name extends UiComponentName> = z.infer<(typeof UI_CATALOG)[Name]['schema']>;
|
|
150
214
|
|
package/src/compiler.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { safeKey, UI_LIMITS, UiBudget, UiFailure, type UiLimits } from './budget
|
|
|
2
2
|
import { isUiComponent, UI_CATALOG, UI_CATALOG_VERSION } from './catalog.ts';
|
|
3
3
|
import { uiActionProp, uiActionTarget, uiLocalState } from './actions.ts';
|
|
4
4
|
import { uiNodeFallback, uiPlainText } from './fallback.ts';
|
|
5
|
-
import { evaluateUiExpression, type UiExpression, type UiValue } from './expression.ts';
|
|
5
|
+
import { evaluateUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
|
|
6
6
|
import { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntaxNode } from './syntax.ts';
|
|
7
7
|
import { type z } from 'zod';
|
|
8
8
|
|
|
@@ -11,8 +11,11 @@ export const UI_FENCE_LANGUAGE = 'ui';
|
|
|
11
11
|
|
|
12
12
|
export const UI_REPLY_LIMITS = { blocks: 16, characters: 262144, nodes: 2048, milliseconds: 60 } as const;
|
|
13
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;
|
|
14
|
+
// What a host reads for one block: its queries, its link targets, the places it looks up and how often a visible block reads again.
|
|
15
|
+
export const UI_HOST_LIMITS = { queries: 8, links: 64, places: 50, refreshMilliseconds: 10_000 } as const;
|
|
16
|
+
|
|
17
|
+
/* A Place that names an address, whose tag may hold quoted text with a `>` before it. */
|
|
18
|
+
const ADDRESSED_PLACE = /<Place\b(?:[^>"']|"[^"]*"|'[^']*')*?\baddress\s*=/;
|
|
16
19
|
|
|
17
20
|
/* A cheap test for whether `text` may declare a query or name a link target, before anything is read for it. */
|
|
18
21
|
export function uiMayReferenceHost(text: string): boolean {
|
|
@@ -22,7 +25,23 @@ export function uiMayReferenceHost(text: string): boolean {
|
|
|
22
25
|
const links = Object.entries(UI_CATALOG)
|
|
23
26
|
.filter(([, entry]) => entry.group === 'links')
|
|
24
27
|
.map(([name]) => name);
|
|
25
|
-
return new RegExp(`<(?:${links.join('|')})\\b`).test(text);
|
|
28
|
+
return new RegExp(`<(?:${links.join('|')})\\b`).test(text) || ADDRESSED_PLACE.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* The children of a Map as its fallback numbers them: each Place that drew, also inside a Show or an Each, in order, and anything else as it is. */
|
|
32
|
+
function numberedPlaces(children: readonly UiNode[]): string {
|
|
33
|
+
let number = 0;
|
|
34
|
+
const visit = (nodes: readonly UiNode[]): string =>
|
|
35
|
+
nodes
|
|
36
|
+
.map((child) =>
|
|
37
|
+
child.type === 'Place' && !child.error
|
|
38
|
+
? `${++number}. ${child.fallback}`
|
|
39
|
+
: child.type === 'Show' || child.type === 'Each'
|
|
40
|
+
? visit(child.children)
|
|
41
|
+
: child.fallback
|
|
42
|
+
)
|
|
43
|
+
.join('');
|
|
44
|
+
return visit(children);
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
export interface UiNode {
|
|
@@ -140,6 +159,15 @@ function isConstant(expression: UiExpression): boolean {
|
|
|
140
159
|
|
|
141
160
|
const CHART_SERIES = 6;
|
|
142
161
|
|
|
162
|
+
/* How many of its children's kind a container holds, the ones an Each or a Show will draw not counted. */
|
|
163
|
+
function countChildren(children: readonly UiSyntaxNode[], kinds: readonly string[]): number {
|
|
164
|
+
return children.reduce(
|
|
165
|
+
(count, child) =>
|
|
166
|
+
count + (kinds.includes(child.type) ? 1 : 0) + (child.type === 'Show' || child.type === 'Each' ? countChildren(child.children, kinds) : 0),
|
|
167
|
+
0
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
143
171
|
/* The keys besides `label` that hold a finite number in some row of a chart's data. */
|
|
144
172
|
function chartSeries(data: unknown): number {
|
|
145
173
|
const keys = new Set<string>();
|
|
@@ -219,8 +247,11 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
219
247
|
throw new UiFailure('unknown_component', `Unknown component ${syntax.type}.`);
|
|
220
248
|
}
|
|
221
249
|
const entry = UI_CATALOG[syntax.type];
|
|
222
|
-
if ('parent' in entry
|
|
223
|
-
|
|
250
|
+
if ('parent' in entry) {
|
|
251
|
+
const parents: readonly string[] = [entry.parent].flat();
|
|
252
|
+
if (parent === undefined || !parents.includes(parent)) {
|
|
253
|
+
throw new UiFailure('invalid_parent', `${syntax.type} belongs inside ${parents.join(' or ')}.`);
|
|
254
|
+
}
|
|
224
255
|
}
|
|
225
256
|
if (parent && isUiComponent(parent)) {
|
|
226
257
|
const container = UI_CATALOG[parent];
|
|
@@ -290,6 +321,18 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
290
321
|
syntax.id
|
|
291
322
|
);
|
|
292
323
|
}
|
|
324
|
+
if (syntax.complete && 'count' in entry) {
|
|
325
|
+
const counted = countChildren(syntax.children, entry.children);
|
|
326
|
+
const limit = entry.count as { min: number; max?: number };
|
|
327
|
+
if (counted < limit.min || (limit.max !== undefined && counted > limit.max)) {
|
|
328
|
+
throw new UiFailure(
|
|
329
|
+
'invalid_props',
|
|
330
|
+
limit.max === undefined
|
|
331
|
+
? `${syntax.type} needs at least ${limit.min} of ${entry.children.join(' or ')}.`
|
|
332
|
+
: `${syntax.type} takes ${limit.min} to ${limit.max} of ${entry.children.join(' or ')}.`
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
293
336
|
// A Choice sends its label, so one without a label can never send; whether it closed is only known once final.
|
|
294
337
|
if (syntax.type === 'Choice' && syntax.complete && !syntax.children.some(isLabelSource)) {
|
|
295
338
|
throw new UiFailure('invalid_props', 'A choice needs a visible label.');
|
|
@@ -308,7 +351,20 @@ export function compileUiBlock(source: string, options: UiCompileOptions): UiBlo
|
|
|
308
351
|
}
|
|
309
352
|
const effectiveParent = syntax.type === 'Show' || syntax.type === 'Each' ? parent : syntax.type;
|
|
310
353
|
node.children = syntax.children.map((child) => compile(child, depth + 1, effectiveParent));
|
|
311
|
-
|
|
354
|
+
// A reader of the fallback has no control to look at, so the option a dropdown or a segmented control starts on says it is chosen.
|
|
355
|
+
const chosen = syntax.type === 'Select' || syntax.type === 'Segmented' ? fallbackProps.value : undefined;
|
|
356
|
+
const childText =
|
|
357
|
+
syntax.type === 'Map'
|
|
358
|
+
? numberedPlaces(node.children)
|
|
359
|
+
: node.children
|
|
360
|
+
.map((child) =>
|
|
361
|
+
syntax.type === 'Select' && child.type === '$text' && child.fallback.trim() !== ''
|
|
362
|
+
? `${child.fallback.trim()}\n`
|
|
363
|
+
: chosen !== undefined && child.type === 'Option' && !child.error && sameUiValue(child.props.value, chosen)
|
|
364
|
+
? `${child.fallback.trimEnd()} (chosen)\n`
|
|
365
|
+
: child.fallback
|
|
366
|
+
)
|
|
367
|
+
.join('');
|
|
312
368
|
node.fallback = uiNodeFallback(syntax.type, fallbackProps, childText);
|
|
313
369
|
// A part drawn as its text keeps the words written inside it, also where its kind reads only props.
|
|
314
370
|
if (node.error !== undefined && childText.trim() !== '' && !node.fallback.includes(childText.trim())) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const EVENT_TIME = /^(?:(\d{2}):(\d{2})|(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})?)?)$/;
|
|
2
|
+
|
|
3
|
+
export interface UiEventTime {
|
|
4
|
+
kind: 'time' | 'date' | 'datetime';
|
|
5
|
+
date: Date;
|
|
6
|
+
// True when the text gave no offset: `date` then holds the parts as written in UTC, to be written in UTC, so no zone or clock change moves them. False is a real instant, to be written in the reader's zone.
|
|
7
|
+
written: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function validClock(hour: number, minute: number, second = 0): boolean {
|
|
11
|
+
return hour <= 23 && minute <= 59 && second <= 59;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* The minutes east of UTC that `Z` or `+02:00` stand for, or null for an offset no zone has: from -12:00 to +14:00. */
|
|
15
|
+
function zoneOffset(zone: string): number | null {
|
|
16
|
+
if (zone === 'Z') {
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
|
+
const [hours, minutes] = [Number(zone.slice(1, 3)), Number(zone.slice(4, 6))];
|
|
20
|
+
const offset = (zone[0] === '-' ? -1 : 1) * (hours * 60 + minutes);
|
|
21
|
+
return minutes > 59 || offset < -12 * 60 || offset > 14 * 60 ? null : offset;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* The moment an Event's `at` names, or null when it is no `HH:MM`, `YYYY-MM-DD` or ISO date-time with an optional offset. */
|
|
25
|
+
export function parseUiEventTime(at: string): UiEventTime | null {
|
|
26
|
+
const match = EVENT_TIME.exec(at);
|
|
27
|
+
if (match === null) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const [, clockHour, clockMinute, year, month, day, hour, minute, second, zone] = match.map((part) => part as string | undefined);
|
|
31
|
+
if (clockHour !== undefined) {
|
|
32
|
+
// The day is not shown for a time, so any day will do.
|
|
33
|
+
return validClock(Number(clockHour), Number(clockMinute))
|
|
34
|
+
? { kind: 'time', date: new Date(Date.UTC(2000, 0, 1, Number(clockHour), Number(clockMinute))), written: true }
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
const [y, m, d] = [Number(year), Number(month), Number(day)];
|
|
38
|
+
const calendar = new Date(Date.UTC(y, m - 1, d));
|
|
39
|
+
if (calendar.getUTCFullYear() !== y || calendar.getUTCMonth() !== m - 1 || calendar.getUTCDate() !== d) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (hour === undefined) {
|
|
43
|
+
return { kind: 'date', date: calendar, written: true };
|
|
44
|
+
}
|
|
45
|
+
const [h, min, sec] = [Number(hour), Number(minute), Number(second ?? 0)];
|
|
46
|
+
if (!validClock(h, min, sec)) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (zone === undefined) {
|
|
50
|
+
return { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec)), written: true };
|
|
51
|
+
}
|
|
52
|
+
const offset = zoneOffset(zone);
|
|
53
|
+
return offset === null ? null : { kind: 'datetime', date: new Date(Date.UTC(y, m - 1, d, h, min, sec) - offset * 60000), written: false };
|
|
54
|
+
}
|
package/src/fallback.ts
CHANGED
|
@@ -20,6 +20,8 @@ export function uiNodeFallback(type: string, props: Readonly<Record<string, unkn
|
|
|
20
20
|
return `${children.trimEnd()}${given(props.badge) ? ` (${uiPlainText(props.badge)})` : ''}\n`;
|
|
21
21
|
case 'Step':
|
|
22
22
|
return `${children.trimEnd()}${given(props.state) ? ` (${uiPlainText(props.state)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
|
|
23
|
+
case 'Event':
|
|
24
|
+
return `${uiPlainText(props.at)} ${children.trimEnd()}${given(props.tone) ? ` (${uiPlainText(props.tone)})` : ''}${given(props.detail) ? `: ${uiPlainText(props.detail)}` : ''}\n`;
|
|
23
25
|
case 'Progress':
|
|
24
26
|
return given(props.value) ? `${children.trimEnd()}: ${uiPlainText(props.value)} of ${uiPlainText(props.max ?? 100)}\n` : `${children.trimEnd()}\n`;
|
|
25
27
|
case 'Stat': {
|
|
@@ -40,6 +42,18 @@ export function uiNodeFallback(type: string, props: Readonly<Record<string, unkn
|
|
|
40
42
|
}
|
|
41
43
|
case 'Chart':
|
|
42
44
|
return `${uiPlainText(props.data ?? [])}${given(props.unit) ? ` (${uiPlainText(props.unit)})` : ''}\n`;
|
|
45
|
+
case 'Compare':
|
|
46
|
+
return `[Compare: ${uiPlainText(props.beforeLabel || 'Before')} ${uiPlainText(props.before)}, ${uiPlainText(props.afterLabel || 'After')} ${uiPlainText(props.after)}]${children.trim() ? ` ${children.trim()}` : ''}\n`;
|
|
47
|
+
case 'Place': {
|
|
48
|
+
const point = given(props.lat) && given(props.lon) ? ` (${uiPlainText(props.lat)}, ${uiPlainText(props.lon)})` : '';
|
|
49
|
+
const address = given(props.address) ? `, ${uiPlainText(props.address)}` : '';
|
|
50
|
+
const detail = given(props.detail) ? `: ${uiPlainText(props.detail)}` : '';
|
|
51
|
+
// One line per place, so the numbering of its map counts places and not the lines of a name.
|
|
52
|
+
return `${children.trim()}${point}${address}${detail}`.replace(/\s+/g, ' ').trim() + '\n';
|
|
53
|
+
}
|
|
54
|
+
case 'Map':
|
|
55
|
+
// The places are numbered where the compiler joins them, since only it knows which of the children drew.
|
|
56
|
+
return children;
|
|
43
57
|
case 'Image':
|
|
44
58
|
return `[Image: ${uiPlainText(props.alt ?? props.attachment ?? props.generated ?? 'unavailable')}]${children}\n`;
|
|
45
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,9 +6,11 @@ export {
|
|
|
6
6
|
isUiComponent,
|
|
7
7
|
uiCatalogText,
|
|
8
8
|
type UiComponentName,
|
|
9
|
+
type UiGroupName,
|
|
9
10
|
type UiProps,
|
|
10
11
|
type UiTone
|
|
11
12
|
} from './catalog.ts';
|
|
13
|
+
export { parseUiEventTime, type UiEventTime } from './event-time.ts';
|
|
12
14
|
export { UI_LIMITS, UiBudget, UiFailure, safeKey, type UiLimits } from './budget.ts';
|
|
13
15
|
export { copyUiValue, evaluateUiExpression, parseUiExpression, sameUiValue, type UiExpression, type UiValue } from './expression.ts';
|
|
14
16
|
export { parseUiSyntax, uiDiagnostic, type UiDiagnostic, type UiSyntax, type UiSyntaxNode } from './syntax.ts';
|
|
@@ -41,7 +43,8 @@ export {
|
|
|
41
43
|
} from './runtime.ts';
|
|
42
44
|
export { uiValidatedState, uiQueryArguments, uiQueryFallback } from './query.ts';
|
|
43
45
|
export { UiNodeSchema, UiDiagnosticSchema, UiBlockSchema, UiBlocksSchema } from './protocol.ts';
|
|
44
|
-
export { uiCompactCatalog, uiSessionNote, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
46
|
+
export { uiCompactCatalog, uiSessionNote, uiGroupReference, uiReferenceText, uiFallbackText, type UiTextOptions } from './text.ts';
|
|
45
47
|
export { UI_STREAM_INTERVAL_MS, UiStream, type UiStreamClock, type UiStreamOptions, type UiStreamPreview } from './stream.ts';
|
|
46
48
|
|
|
49
|
+
export { uiPlaceQueries, type UiPlaceQuery } from './places.ts';
|
|
47
50
|
export { UiLinkTargetSchema, UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget, type UiLinkResolution } from './links.ts';
|
package/src/inputs.ts
CHANGED
|
@@ -13,9 +13,12 @@ export function uiCompiledNodes(nodes: readonly UiNode[], into = new Map<string,
|
|
|
13
13
|
|
|
14
14
|
/* Whether a control's value is one its own visible options offer. */
|
|
15
15
|
function offered(node: UiViewNode): boolean {
|
|
16
|
-
if (node.type === 'Segmented') {
|
|
16
|
+
if (node.type === 'Segmented' || node.type === 'Select') {
|
|
17
17
|
return node.children.some((option) => option.type === 'Option' && !option.error && option.props.value === node.props.value);
|
|
18
18
|
}
|
|
19
|
+
if (node.type === 'Map') {
|
|
20
|
+
return node.children.some((place) => place.type === 'Place' && !place.error && place.props.value === node.props.value);
|
|
21
|
+
}
|
|
19
22
|
if (node.type === 'Checklist') {
|
|
20
23
|
const options = node.children.filter((item) => item.type === 'Item' && !item.error).map((item) => item.props.value);
|
|
21
24
|
return (node.props.value as unknown[]).every((value) => options.includes(value));
|
package/src/places.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { UiLimits } from './budget.ts';
|
|
2
|
+
import type { UiBlock } from './compiler.ts';
|
|
3
|
+
import { uiValidatedState } from './query.ts';
|
|
4
|
+
import { evaluateUiBlock, type UiViewNode } from './runtime.ts';
|
|
5
|
+
|
|
6
|
+
/* A place the agent named by an address, with the name it gave it, for a host to look up. */
|
|
7
|
+
export interface UiPlaceQuery {
|
|
8
|
+
address: string;
|
|
9
|
+
name: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function nameOf(node: UiViewNode): string {
|
|
13
|
+
return node.children
|
|
14
|
+
.map((child) => (child.type === '$text' ? String(child.props.text ?? '') : nameOf(child)))
|
|
15
|
+
.join('')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/*
|
|
21
|
+
* The complete, valid Places that give an address, by node id, as the block evaluates with `input` and the
|
|
22
|
+
* values of its `queries`. A place that also gives coordinates is looked up too: what the host finds for
|
|
23
|
+
* the address wins, and the coordinates stand when it finds nothing.
|
|
24
|
+
*/
|
|
25
|
+
export function uiPlaceQueries(
|
|
26
|
+
block: UiBlock,
|
|
27
|
+
input: Readonly<Record<string, unknown>> = {},
|
|
28
|
+
queries: Readonly<Record<string, unknown>> = {},
|
|
29
|
+
limits: Partial<UiLimits> = {}
|
|
30
|
+
): Record<string, UiPlaceQuery> {
|
|
31
|
+
const state = uiValidatedState(block, input, queries, limits);
|
|
32
|
+
const places: Record<string, UiPlaceQuery> = Object.create(null);
|
|
33
|
+
const visit = (nodes: readonly UiViewNode[]) => {
|
|
34
|
+
for (const node of nodes) {
|
|
35
|
+
if (node.error || !node.complete) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const { address } = node.props as { address?: string };
|
|
39
|
+
if (node.type === 'Place' && typeof address === 'string' && address.trim() !== '') {
|
|
40
|
+
places[node.id] = { address: address.trim(), name: nameOf(node) };
|
|
41
|
+
}
|
|
42
|
+
visit(node.children);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
visit(evaluateUiBlock(block, state, limits).nodes);
|
|
46
|
+
return places;
|
|
47
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface UiViewNode<Props = Record<string, unknown>> {
|
|
|
21
21
|
complete: boolean;
|
|
22
22
|
fallback: string;
|
|
23
23
|
error?: string;
|
|
24
|
+
// How many children a container left out because it holds at most so many of its kind; absent when it left none out.
|
|
25
|
+
omitted?: number;
|
|
24
26
|
// Runs the node's state action; present only on a component that has one.
|
|
25
27
|
onAction?(): void;
|
|
26
28
|
}
|
|
@@ -239,6 +241,30 @@ export function evaluateUiBlock(block: UiBlock, state = new UiState(block), limi
|
|
|
239
241
|
};
|
|
240
242
|
}
|
|
241
243
|
view.children = children(node.children, variables, depth + 1, suffix);
|
|
244
|
+
if ('count' in entry) {
|
|
245
|
+
const limit = entry.count as { min: number; max?: number };
|
|
246
|
+
const kinds = entry.children as readonly string[];
|
|
247
|
+
const counted = view.children.filter((child) => kinds.includes(child.type)).length;
|
|
248
|
+
// What an Each or a Show drew is only known now, so the count the compiler checked on the source is checked again.
|
|
249
|
+
if (node.complete && counted < limit.min) {
|
|
250
|
+
throw new UiFailure('invalid_props', `${node.type} needs at least ${limit.min} of ${kinds.join(' or ')}.`);
|
|
251
|
+
}
|
|
252
|
+
if (limit.max !== undefined && counted > limit.max) {
|
|
253
|
+
let kept = 0;
|
|
254
|
+
view.children = view.children.filter((child) => !kinds.includes(child.type) || ++kept <= limit.max!);
|
|
255
|
+
view.omitted = counted - limit.max;
|
|
256
|
+
if (result.diagnostics.length < budget.limits.diagnostics) {
|
|
257
|
+
result.diagnostics.push(
|
|
258
|
+
uiDiagnostic(
|
|
259
|
+
new UiFailure('too_many_children', `${node.type} shows its first ${limit.max} of ${kinds.join(' or ')}.`),
|
|
260
|
+
node.start,
|
|
261
|
+
node.end,
|
|
262
|
+
node.id
|
|
263
|
+
)
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
242
268
|
return [view];
|
|
243
269
|
} catch (error) {
|
|
244
270
|
if (error instanceof UiFailure && error.code === 'budget_exceeded') {
|
package/src/text.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { UI_CATALOG, UI_GROUPS } from './catalog.ts';
|
|
1
|
+
import { UI_CATALOG, UI_GROUPS, type UiGroupName } from './catalog.ts';
|
|
2
2
|
import { uiPropList } from './prop-list.ts';
|
|
3
3
|
import { UI_FENCE_LANGUAGE, type UiBlock } from './compiler.ts';
|
|
4
4
|
|
|
@@ -14,6 +14,10 @@ export function uiCompactCatalog(): string {
|
|
|
14
14
|
export interface UiTextOptions {
|
|
15
15
|
// The info string the host's compiler looks for; `UI_FENCE_LANGUAGE` without one.
|
|
16
16
|
fenceLanguage?: string;
|
|
17
|
+
// What the session note lists: every component with its props, or only the names per group.
|
|
18
|
+
catalog?: 'props' | 'names';
|
|
19
|
+
// With `names`, how the agent gets the props of a group, in the host's own words.
|
|
20
|
+
detailHint?: string;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
function uiSyntax(language: string): string {
|
|
@@ -31,22 +35,37 @@ function uiExample(language: string): string {
|
|
|
31
35
|
].join('\n');
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
return
|
|
38
|
+
function uiGroupComponents(group: string) {
|
|
39
|
+
return Object.entries(UI_CATALOG).filter(([, entry]) => entry.group === group);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* One line per group with the names of its components, for a host that hands the props out per group. */
|
|
43
|
+
function uiGroupNames(detailHint: string | undefined): string {
|
|
44
|
+
const lines = Object.keys(UI_GROUPS).map(
|
|
45
|
+
(group) =>
|
|
46
|
+
`${group}: ${uiGroupComponents(group)
|
|
47
|
+
.map(([name]) => name)
|
|
48
|
+
.join(', ')}`
|
|
49
|
+
);
|
|
50
|
+
return `Components by group${detailHint ? ` (${detailHint})` : ''}:\n${lines.join('\n')}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function uiSessionNote({ fenceLanguage = UI_FENCE_LANGUAGE, catalog = 'props', detailHint }: UiTextOptions = {}): string {
|
|
54
|
+
const components = catalog === 'names' ? uiGroupNames(detailHint) : `Catalog (? means optional props): ${uiCompactCatalog()}`;
|
|
55
|
+
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. Choices are shortcuts after finished work that the person may ignore. When your turn would end waiting on the person (a missing fact, a decision, a go-ahead for work you would otherwise do), ask with your own question tool if you have one, otherwise in prose, never with Choices. 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. ${components}\nExample:\n${uiExample(fenceLanguage)}\n`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* One group as the reference writes it: its description and rules, then every component with its props. */
|
|
59
|
+
export function uiGroupReference(group: UiGroupName): string {
|
|
60
|
+
const components = uiGroupComponents(group).map(([name, entry]) => {
|
|
61
|
+
const props = uiPropList(entry).join(', ');
|
|
62
|
+
return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
|
|
63
|
+
});
|
|
64
|
+
return [UI_GROUPS[group].description, UI_GROUPS[group].rules, ...components].join('\n');
|
|
36
65
|
}
|
|
37
66
|
|
|
38
67
|
export function uiReferenceText({ fenceLanguage = UI_FENCE_LANGUAGE }: UiTextOptions = {}): string {
|
|
39
|
-
const groups = Object.
|
|
40
|
-
.map(([group, definition]) => {
|
|
41
|
-
const components = Object.entries(UI_CATALOG)
|
|
42
|
-
.filter(([, entry]) => entry.group === group)
|
|
43
|
-
.map(([name, entry]) => {
|
|
44
|
-
const props = uiPropList(entry).join(', ');
|
|
45
|
-
return `${name}: ${entry.description}${props ? ` Props: ${props}.` : ''}`;
|
|
46
|
-
});
|
|
47
|
-
return [definition.description, definition.rules, ...components].join('\n');
|
|
48
|
-
})
|
|
49
|
-
.join('\n\n');
|
|
68
|
+
const groups = (Object.keys(UI_GROUPS) as UiGroupName[]).map(uiGroupReference).join('\n\n');
|
|
50
69
|
return `${uiSyntax(fenceLanguage)}\nExample:\n${uiExample(fenceLanguage)}\n\n${groups}`;
|
|
51
70
|
}
|
|
52
71
|
|