@agentable-ui/lang 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +72 -2
- package/dist/index.d.ts +230 -0
- package/dist/index.js +951 -0
- package/package.json +43 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agentable contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @agentable-ui/lang
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Parse, stream, and generate prompts for model-written UI programs. Each line names a component and its arguments. The package is framework-free and makes no model or network calls. Use [genui](../genui) to render the result.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @agentable-ui/lang @agentable-ui/core
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Read a program
|
|
10
|
+
|
|
11
|
+
```txt
|
|
12
|
+
root = Stack([stats, note])
|
|
13
|
+
stats = StatGroup([rev, orders])
|
|
14
|
+
rev = Stat("Revenue", "$48.2k", "+6.1%", "up")
|
|
15
|
+
orders = Stat("Orders", 140, "+12", "up")
|
|
16
|
+
note = Callout("On track", "Ahead of target by 4%.", "success")
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Parse or stream a response
|
|
20
|
+
|
|
21
|
+
The built-in catalog comes from `@agentable-ui/ui`; install it separately if you use this example. `createLibrary` also accepts your own catalog, without React.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { createLibrary } from "@agentable-ui/lang";
|
|
25
|
+
import { uiCatalog } from "@agentable-ui/ui";
|
|
26
|
+
|
|
27
|
+
const library = createLibrary(uiCatalog);
|
|
28
|
+
const system = library.prompt({ tools: [{ name: "refund_order", description: "Refund a paid order." }] });
|
|
29
|
+
|
|
30
|
+
// Your application sends system with the user's message to a model.
|
|
31
|
+
// Parse a finished response:
|
|
32
|
+
const result = library.parse('root = Text("Hello")');
|
|
33
|
+
console.log(result.root, result.errors);
|
|
34
|
+
|
|
35
|
+
// Or feed successive chunks to the streaming parser:
|
|
36
|
+
const parser = library.stream();
|
|
37
|
+
parser.push('root = Text("Hel');
|
|
38
|
+
parser.push('lo")\n');
|
|
39
|
+
const finished = parser.push("", true); // flush the final partial line
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Syntax at a glance
|
|
43
|
+
|
|
44
|
+
- One statement per line: `name = Component(args)`. The entry is `root`. Forward references are fine; the last definition of a name wins.
|
|
45
|
+
- Arguments are positional in the component's declared prop order, or named: `Callout("On track", text: "Ahead by 4%.")`. `null` skips an optional positional argument.
|
|
46
|
+
- Children are a list of statement names, wherever they sit: `Stack([a, b])`, `Card("Title", [a, b])`, or `children: [a, b]`. A name may be used once.
|
|
47
|
+
- Strings in double quotes, lists in `[]`, objects in `{key: value}`, numbers, `true`, `false`, `null`. `#` starts a comment.
|
|
48
|
+
- State: `$data.x` reads app data (read-only); `$ui.x` is state the interface owns. A path given as an argument binds that prop: `rows: $data.paid`.
|
|
49
|
+
- Read paths can filter and count lists: `$data.items[done=true]`, `$data.items[status!="archived"]`, `$data.items[done=true].length`. A missing boolean field counts as `false`. Filters are read-only; actions can't write through them.
|
|
50
|
+
- `when: $ui.selected` shows a node while the path is truthy, `when: !$ui.done` while falsy.
|
|
51
|
+
- Events are named arguments holding actions: `press: @Set($ui.selected, $event)` or a list `[@Set(...), @Tool(...)]`. `$event` is the event's value, `$event.id` a field of it.
|
|
52
|
+
- Actions: `@Set(path, value)`, `@Merge(path, object)`, `@Push(path, value)`, `@Remove(path)`, `@Increment(path, by?)`, `@Toggle(path)`, `@Tool(name, args?, into?)`, `@Say(text)`. Written paths must be `$ui.*`.
|
|
53
|
+
|
|
54
|
+
A bad line is dropped with a structured error and the rest still renders. Unknown components, unknown arguments, excess arguments and bad action arguments are reported with codes (`unknown-component`, `unknown-arg`, `excess-args`, `bad-arg`, `missing-required`, `parse-failed`, `cycle`, `reused-node`, `no-root`) a host can feed back to the model.
|
|
55
|
+
|
|
56
|
+
## API reference
|
|
57
|
+
|
|
58
|
+
- `createLibrary(catalog, { root? })` — a catalog is `{ Name: { description, props, childrenProp?, events? } }`; `props` is JSON Schema or any Standard Schema (zod 4, valibot 1). The `uiCatalog` from `@agentable-ui/ui` works as is.
|
|
59
|
+
- `library.prompt({ preamble?, rules?, examples?, tools?, data?, interactive? })` — the system prompt: rules, one signature line per component, tools, an example.
|
|
60
|
+
- `library.parse(text)` — `{ root, nodes, unresolved, errors, chars, tokens }`.
|
|
61
|
+
- `library.stream()` — `set(text, done?)` / `push(chunk, done?)`; the last unterminated line is held back until `done`.
|
|
62
|
+
- `serialize(tree, library)` — a node tree as a program, root line first.
|
|
63
|
+
- `parseProgram`, `evaluate`, `signatureLine`, `typeName` for lower-level use.
|
|
64
|
+
|
|
65
|
+
The node tree it produces is the same `{ component, props, bind, on, when, children }` shape `@agentable-ui/genui` validates and renders, so a program and a JSON tree are interchangeable everywhere, including the `present` tool.
|
|
66
|
+
|
|
67
|
+
## Rendering and error handling
|
|
68
|
+
|
|
69
|
+
Parsing returns a tree and structured errors; it does not execute application tools. Pass the accumulated program to `Renderer` from `@agentable-ui/genui` to display it. In Lang, `$event` is the event payload; the JSON tree representation uses `{{value}}`.
|
|
70
|
+
|
|
71
|
+
Handle `errors` and `unresolved` separately. Forward references can remain unresolved while streaming. Pass `done: true` on the final push so the parser processes the final line even without a newline. Token figures are estimates, not a guaranteed reduction compared with JSON.
|
|
72
|
+
|
|
73
|
+
MIT licensed.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { JSONSchema, SchemaInput } from "@agentable-ui/core";
|
|
2
|
+
//#region src/ast.d.ts
|
|
3
|
+
/** The syntax tree of an Agentable Lang program: one statement per line, `name = Expression`. */
|
|
4
|
+
type Expr = {
|
|
5
|
+
kind: "string";
|
|
6
|
+
value: string;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "number";
|
|
9
|
+
value: number;
|
|
10
|
+
} | {
|
|
11
|
+
kind: "boolean";
|
|
12
|
+
value: boolean;
|
|
13
|
+
} | {
|
|
14
|
+
kind: "null";
|
|
15
|
+
} | {
|
|
16
|
+
kind: "array";
|
|
17
|
+
items: Expr[];
|
|
18
|
+
} | {
|
|
19
|
+
kind: "object";
|
|
20
|
+
entries: Array<[string, Expr]>;
|
|
21
|
+
} |
|
|
22
|
+
/** Another statement's name: a node used as a child. */
|
|
23
|
+
{
|
|
24
|
+
kind: "ref";
|
|
25
|
+
name: string;
|
|
26
|
+
} |
|
|
27
|
+
/** `$ui.selected.id`, `$data.rows`, `$event`; `!$ui.x` only in `when:`. */
|
|
28
|
+
{
|
|
29
|
+
kind: "path";
|
|
30
|
+
path: string;
|
|
31
|
+
negated: boolean;
|
|
32
|
+
} |
|
|
33
|
+
/** `Component(args)` */
|
|
34
|
+
{
|
|
35
|
+
kind: "call";
|
|
36
|
+
name: string;
|
|
37
|
+
args: Arg[];
|
|
38
|
+
} |
|
|
39
|
+
/** `@Action(args)` */
|
|
40
|
+
{
|
|
41
|
+
kind: "action";
|
|
42
|
+
name: string;
|
|
43
|
+
args: Arg[];
|
|
44
|
+
};
|
|
45
|
+
interface Arg {
|
|
46
|
+
/** Named argument (`label: "x"`); positional when absent. */
|
|
47
|
+
name?: string;
|
|
48
|
+
expr: Expr;
|
|
49
|
+
}
|
|
50
|
+
interface Statement {
|
|
51
|
+
name: string;
|
|
52
|
+
expr: Expr;
|
|
53
|
+
/** 1-based line number in the program. */
|
|
54
|
+
line: number;
|
|
55
|
+
}
|
|
56
|
+
type ErrorCode = "parse-failed" | "unknown-component" | "unknown-action" | "unknown-arg" | "excess-args" | "missing-required" | "bad-arg" | "unresolved-ref" | "reused-node" | "cycle" | "no-root" | "not-a-component";
|
|
57
|
+
interface LangError {
|
|
58
|
+
code: ErrorCode;
|
|
59
|
+
message: string;
|
|
60
|
+
/** 1-based line; 0 when the error is about the whole program. */
|
|
61
|
+
line: number;
|
|
62
|
+
/** "error" drops the statement or argument; "warning" keeps rendering. */
|
|
63
|
+
severity: "error" | "warning";
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/parser.d.ts
|
|
67
|
+
interface Program {
|
|
68
|
+
statements: Statement[];
|
|
69
|
+
errors: LangError[];
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse a whole program: one statement per line, `#` or `//` comments,
|
|
73
|
+
* blank lines ignored. A bad line is reported and skipped; the rest still
|
|
74
|
+
* parses. Later statements with the same name replace earlier ones.
|
|
75
|
+
*/
|
|
76
|
+
export declare function parseProgram(text: string): Program;
|
|
77
|
+
/**
|
|
78
|
+
* Split streamed text into the part that is safe to parse and the trailing
|
|
79
|
+
* partial line. A program is complete only when `done` is true; until then
|
|
80
|
+
* the last unterminated line is held back.
|
|
81
|
+
*/
|
|
82
|
+
export declare function stableText(text: string, done: boolean): string;
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/prompt.d.ts
|
|
85
|
+
interface PromptOptions {
|
|
86
|
+
/** Opening paragraph; replaces the default. */
|
|
87
|
+
preamble?: string;
|
|
88
|
+
/** Extra rules appended to the language rules. */
|
|
89
|
+
rules?: string[];
|
|
90
|
+
/** Example programs; replace the default one. Each is `{ ask, program }`. */
|
|
91
|
+
examples?: Array<{
|
|
92
|
+
ask: string;
|
|
93
|
+
program: string;
|
|
94
|
+
}>;
|
|
95
|
+
/** Tools that @Tool may call: name and a one-line description. */
|
|
96
|
+
tools?: Array<{
|
|
97
|
+
name: string;
|
|
98
|
+
description?: string;
|
|
99
|
+
}>;
|
|
100
|
+
/** A compact description of $data.* the program may bind to (e.g. from genui's shapeOf). */
|
|
101
|
+
data?: unknown;
|
|
102
|
+
/** Set false to leave out the state and action rules for display-only use. */
|
|
103
|
+
interactive?: boolean;
|
|
104
|
+
}
|
|
105
|
+
/** Build the system prompt that teaches a model the language and this library. */
|
|
106
|
+
export declare function buildPrompt(lib: Library, opts?: PromptOptions): string;
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/library.d.ts
|
|
109
|
+
/** What the language needs to know about a component. The same shape as a genui catalog entry; extra keys are ignored. */
|
|
110
|
+
interface LibraryEntry {
|
|
111
|
+
description: string;
|
|
112
|
+
props: SchemaInput;
|
|
113
|
+
childrenProp?: string;
|
|
114
|
+
/** Event name -> callback prop. Only the keys matter here. */
|
|
115
|
+
events?: Record<string, string>;
|
|
116
|
+
}
|
|
117
|
+
type LibraryInput = Record<string, LibraryEntry>;
|
|
118
|
+
/** The resolved, ordered contract of one component. */
|
|
119
|
+
interface Signature {
|
|
120
|
+
name: string;
|
|
121
|
+
description: string;
|
|
122
|
+
/** Prop names in positional order. */
|
|
123
|
+
props: string[];
|
|
124
|
+
required: Set<string>;
|
|
125
|
+
/** Rendered `name?: type` per prop. */
|
|
126
|
+
types: Record<string, string>;
|
|
127
|
+
childrenProp?: string;
|
|
128
|
+
events: string[];
|
|
129
|
+
schema: JSONSchema;
|
|
130
|
+
}
|
|
131
|
+
interface ParseResult extends EvalResult {
|
|
132
|
+
/** True once the whole program is in; partial input renders what has arrived. */
|
|
133
|
+
complete: boolean;
|
|
134
|
+
/** Characters and estimated tokens of the program text parsed. */
|
|
135
|
+
chars: number;
|
|
136
|
+
tokens: number;
|
|
137
|
+
}
|
|
138
|
+
interface Library {
|
|
139
|
+
signatures: Record<string, Signature>;
|
|
140
|
+
root: string | undefined;
|
|
141
|
+
/** Parse and evaluate a complete program. */
|
|
142
|
+
parse(text: string): ParseResult;
|
|
143
|
+
/** A parser that accepts the text received so far; `set(text, done)` re-evaluates. */
|
|
144
|
+
stream(): StreamingParser;
|
|
145
|
+
/** The system prompt that teaches a model this library. */
|
|
146
|
+
prompt(options?: PromptOptions): string;
|
|
147
|
+
/** One line per component, as the prompt shows them. */
|
|
148
|
+
signatureLines(): string[];
|
|
149
|
+
}
|
|
150
|
+
interface StreamingParser {
|
|
151
|
+
/** Re-parse with the full text so far. The last unterminated line is held back until `done`. */
|
|
152
|
+
set(text: string, done?: boolean): ParseResult;
|
|
153
|
+
/** Append a chunk. */
|
|
154
|
+
push(chunk: string, done?: boolean): ParseResult;
|
|
155
|
+
result(): ParseResult;
|
|
156
|
+
}
|
|
157
|
+
export declare function createLibrary(components: LibraryInput, options?: {
|
|
158
|
+
root?: string;
|
|
159
|
+
}): Library;
|
|
160
|
+
/** A compact type for a prompt: string, number, "a"|"b", T[], {k: T}. */
|
|
161
|
+
export declare function typeName(s: JSONSchema, depth: number): string;
|
|
162
|
+
export declare function signatureLine(sig: Signature): string;
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/evaluate.d.ts
|
|
165
|
+
/** The node tree the renderer draws: structurally the same as @agentable-ui/genui's UISpec. */
|
|
166
|
+
interface UINode {
|
|
167
|
+
component: string;
|
|
168
|
+
id?: string;
|
|
169
|
+
props?: Record<string, unknown>;
|
|
170
|
+
bind?: Record<string, string>;
|
|
171
|
+
on?: Record<string, UIAction[]>;
|
|
172
|
+
when?: string;
|
|
173
|
+
children?: UINode[];
|
|
174
|
+
}
|
|
175
|
+
type UIAction = {
|
|
176
|
+
type: "set";
|
|
177
|
+
path: string;
|
|
178
|
+
value: unknown;
|
|
179
|
+
} | {
|
|
180
|
+
type: "merge";
|
|
181
|
+
path: string;
|
|
182
|
+
value: Record<string, unknown>;
|
|
183
|
+
} | {
|
|
184
|
+
type: "push";
|
|
185
|
+
path: string;
|
|
186
|
+
value: unknown;
|
|
187
|
+
} | {
|
|
188
|
+
type: "remove";
|
|
189
|
+
path: string;
|
|
190
|
+
} | {
|
|
191
|
+
type: "increment";
|
|
192
|
+
path: string;
|
|
193
|
+
by?: number;
|
|
194
|
+
} | {
|
|
195
|
+
type: "toggle";
|
|
196
|
+
path: string;
|
|
197
|
+
} | {
|
|
198
|
+
type: "tool";
|
|
199
|
+
name: string;
|
|
200
|
+
args?: Record<string, unknown>;
|
|
201
|
+
into?: string;
|
|
202
|
+
} | {
|
|
203
|
+
type: "message";
|
|
204
|
+
text: string;
|
|
205
|
+
};
|
|
206
|
+
interface EvalResult {
|
|
207
|
+
root: UINode | null;
|
|
208
|
+
/** Node count in `root`. */
|
|
209
|
+
nodes: number;
|
|
210
|
+
/** Names used as children that no statement defines yet. Expected while streaming. */
|
|
211
|
+
unresolved: string[];
|
|
212
|
+
errors: LangError[];
|
|
213
|
+
}
|
|
214
|
+
export declare const ACTION_NAMES: string[];
|
|
215
|
+
/**
|
|
216
|
+
* Turn parsed statements into a node tree using the library's signatures.
|
|
217
|
+
* Positional arguments map to props in signature order, then to children.
|
|
218
|
+
* Named arguments may be props, `children`, `when`, or an event name.
|
|
219
|
+
*/
|
|
220
|
+
export declare function evaluate(statements: Statement[], signatures: Record<string, Signature>): EvalResult;
|
|
221
|
+
//#endregion
|
|
222
|
+
//#region src/serialize.d.ts
|
|
223
|
+
/**
|
|
224
|
+
* Write a node tree as a program. Positional arguments are used while the
|
|
225
|
+
* props line up with the signature; named arguments after the first gap.
|
|
226
|
+
* Useful for showing a JSON spec as the language, and for measuring both.
|
|
227
|
+
*/
|
|
228
|
+
export declare function serialize(root: UINode, lib: Library): string;
|
|
229
|
+
//#endregion
|
|
230
|
+
export type { Arg, ErrorCode, EvalResult, Expr, LangError, Library, LibraryEntry, LibraryInput, ParseResult, Program, PromptOptions, Signature, Statement, StreamingParser, UIAction, UINode };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
import { estimateTokens, toJsonSchema } from "@agentable-ui/core";
|
|
2
|
+
//#region src/lexer.ts
|
|
3
|
+
/**
|
|
4
|
+
* A recursive-descent parser for one statement line. Kept deliberately
|
|
5
|
+
* small: strings, numbers, booleans, null, arrays, objects, refs, $paths,
|
|
6
|
+
* Component(args) and @Action(args).
|
|
7
|
+
*/
|
|
8
|
+
var LineParser = class {
|
|
9
|
+
src;
|
|
10
|
+
i = 0;
|
|
11
|
+
constructor(src) {
|
|
12
|
+
this.src = src;
|
|
13
|
+
}
|
|
14
|
+
/** Parse `name = expr`; throws a plain Error with a message on bad syntax. */
|
|
15
|
+
statement() {
|
|
16
|
+
this.ws();
|
|
17
|
+
const name = this.ident();
|
|
18
|
+
if (!name) throw new Error("A statement starts with a name, e.g. `root = Stack([...])`.");
|
|
19
|
+
this.ws();
|
|
20
|
+
if (!this.eat("=")) throw new Error(`Expected "=" after "${name}".`);
|
|
21
|
+
this.ws();
|
|
22
|
+
const expr = this.expr();
|
|
23
|
+
this.ws();
|
|
24
|
+
if (this.i < this.src.length) throw new Error(`Unexpected "${this.src[this.i]}" after the expression.`);
|
|
25
|
+
return {
|
|
26
|
+
name,
|
|
27
|
+
expr
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
ws() {
|
|
31
|
+
while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++;
|
|
32
|
+
}
|
|
33
|
+
eat(s) {
|
|
34
|
+
if (this.src.startsWith(s, this.i)) {
|
|
35
|
+
this.i += s.length;
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
ident() {
|
|
41
|
+
const m = /^[A-Za-z_][A-Za-z0-9_-]*/.exec(this.src.slice(this.i));
|
|
42
|
+
if (!m) return null;
|
|
43
|
+
this.i += m[0].length;
|
|
44
|
+
return m[0];
|
|
45
|
+
}
|
|
46
|
+
expr() {
|
|
47
|
+
const c = this.src[this.i];
|
|
48
|
+
if (c === void 0) throw new Error("Expected a value.");
|
|
49
|
+
if (c === "\"" || c === "'") return {
|
|
50
|
+
kind: "string",
|
|
51
|
+
value: this.string()
|
|
52
|
+
};
|
|
53
|
+
if (c === "[") return this.array();
|
|
54
|
+
if (c === "{") return this.object();
|
|
55
|
+
if (c === "$") return this.path(false);
|
|
56
|
+
if (c === "!") {
|
|
57
|
+
this.i++;
|
|
58
|
+
this.ws();
|
|
59
|
+
if (this.src[this.i] !== "$") throw new Error("\"!\" may only negate a $path.");
|
|
60
|
+
return this.path(true);
|
|
61
|
+
}
|
|
62
|
+
if (c === "@") {
|
|
63
|
+
this.i++;
|
|
64
|
+
const name = this.ident();
|
|
65
|
+
if (!name) throw new Error("Expected an action name after \"@\".");
|
|
66
|
+
this.ws();
|
|
67
|
+
if (!this.eat("(")) throw new Error(`Expected "(" after @${name}.`);
|
|
68
|
+
return {
|
|
69
|
+
kind: "action",
|
|
70
|
+
name,
|
|
71
|
+
args: this.args()
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (c === "-" || /[0-9]/.test(c)) return this.number();
|
|
75
|
+
const name = this.ident();
|
|
76
|
+
if (!name) throw new Error(`Unexpected "${c}".`);
|
|
77
|
+
if (name === "true" || name === "false") return {
|
|
78
|
+
kind: "boolean",
|
|
79
|
+
value: name === "true"
|
|
80
|
+
};
|
|
81
|
+
if (name === "null") return { kind: "null" };
|
|
82
|
+
this.ws();
|
|
83
|
+
if (this.eat("(")) return {
|
|
84
|
+
kind: "call",
|
|
85
|
+
name,
|
|
86
|
+
args: this.args()
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
kind: "ref",
|
|
90
|
+
name
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** After "(": comma-separated args, positional or `name: expr`, up to ")". */
|
|
94
|
+
args() {
|
|
95
|
+
const out = [];
|
|
96
|
+
this.ws();
|
|
97
|
+
if (this.eat(")")) return out;
|
|
98
|
+
for (;;) {
|
|
99
|
+
this.ws();
|
|
100
|
+
let name;
|
|
101
|
+
const save = this.i;
|
|
102
|
+
const id = this.ident();
|
|
103
|
+
this.ws();
|
|
104
|
+
if (id && this.eat(":")) {
|
|
105
|
+
name = id;
|
|
106
|
+
this.ws();
|
|
107
|
+
} else this.i = save;
|
|
108
|
+
const expr = this.expr();
|
|
109
|
+
out.push(name ? {
|
|
110
|
+
name,
|
|
111
|
+
expr
|
|
112
|
+
} : { expr });
|
|
113
|
+
this.ws();
|
|
114
|
+
if (this.eat(")")) return out;
|
|
115
|
+
if (!this.eat(",")) throw new Error("Expected \",\" or \")\" in the argument list.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
array() {
|
|
119
|
+
this.i++;
|
|
120
|
+
const items = [];
|
|
121
|
+
this.ws();
|
|
122
|
+
if (this.eat("]")) return {
|
|
123
|
+
kind: "array",
|
|
124
|
+
items
|
|
125
|
+
};
|
|
126
|
+
for (;;) {
|
|
127
|
+
this.ws();
|
|
128
|
+
items.push(this.expr());
|
|
129
|
+
this.ws();
|
|
130
|
+
if (this.eat("]")) return {
|
|
131
|
+
kind: "array",
|
|
132
|
+
items
|
|
133
|
+
};
|
|
134
|
+
if (!this.eat(",")) throw new Error("Expected \",\" or \"]\" in the list.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
object() {
|
|
138
|
+
this.i++;
|
|
139
|
+
const entries = [];
|
|
140
|
+
this.ws();
|
|
141
|
+
if (this.eat("}")) return {
|
|
142
|
+
kind: "object",
|
|
143
|
+
entries
|
|
144
|
+
};
|
|
145
|
+
for (;;) {
|
|
146
|
+
this.ws();
|
|
147
|
+
const c = this.src[this.i];
|
|
148
|
+
const key = c === "\"" || c === "'" ? this.string() : this.ident();
|
|
149
|
+
if (!key) throw new Error("Expected a key in the object.");
|
|
150
|
+
this.ws();
|
|
151
|
+
if (!this.eat(":")) throw new Error(`Expected ":" after key "${key}".`);
|
|
152
|
+
this.ws();
|
|
153
|
+
entries.push([key, this.expr()]);
|
|
154
|
+
this.ws();
|
|
155
|
+
if (this.eat("}")) return {
|
|
156
|
+
kind: "object",
|
|
157
|
+
entries
|
|
158
|
+
};
|
|
159
|
+
if (!this.eat(",")) throw new Error("Expected \",\" or \"}\" in the object.");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
string() {
|
|
163
|
+
const quote = this.src[this.i];
|
|
164
|
+
this.i++;
|
|
165
|
+
let out = "";
|
|
166
|
+
for (;;) {
|
|
167
|
+
const c = this.src[this.i];
|
|
168
|
+
if (c === void 0) throw new Error("Unterminated string.");
|
|
169
|
+
this.i++;
|
|
170
|
+
if (c === quote) return out;
|
|
171
|
+
if (c === "\\") {
|
|
172
|
+
const n = this.src[this.i];
|
|
173
|
+
this.i++;
|
|
174
|
+
if (n === "n") out += "\n";
|
|
175
|
+
else if (n === "t") out += " ";
|
|
176
|
+
else if (n === "u") {
|
|
177
|
+
const hex = this.src.slice(this.i, this.i + 4);
|
|
178
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) throw new Error("Bad \\u escape.");
|
|
179
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
180
|
+
this.i += 4;
|
|
181
|
+
} else if (n !== void 0) out += n;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
out += c;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
number() {
|
|
188
|
+
const m = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(this.src.slice(this.i));
|
|
189
|
+
if (!m) throw new Error("Bad number.");
|
|
190
|
+
this.i += m[0].length;
|
|
191
|
+
return {
|
|
192
|
+
kind: "number",
|
|
193
|
+
value: Number(m[0])
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
path(negated) {
|
|
197
|
+
this.i++;
|
|
198
|
+
const head = this.ident();
|
|
199
|
+
if (!head) throw new Error("Expected a name after \"$\", e.g. $ui.selected.");
|
|
200
|
+
let path = head;
|
|
201
|
+
for (;;) {
|
|
202
|
+
if (this.src[this.i] === ".") {
|
|
203
|
+
this.i++;
|
|
204
|
+
const seg = this.ident();
|
|
205
|
+
if (!seg) throw new Error(`Expected a segment after "." in $${path}.`);
|
|
206
|
+
path += `.${seg}`;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (this.src[this.i] === "[") {
|
|
210
|
+
const rest = this.src.slice(this.i);
|
|
211
|
+
const m = /^\[(\d+)\]/.exec(rest);
|
|
212
|
+
if (m) {
|
|
213
|
+
this.i += m[0].length;
|
|
214
|
+
path += `[${m[1]}]`;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const f = /^\[\s*([A-Za-z_][A-Za-z0-9_]*)\s*(!?=)\s*("[^"\]]*"|[A-Za-z0-9_.-]+)\s*\]/.exec(rest);
|
|
218
|
+
if (!f) throw new Error(`Use [0] or [field=value] in $${path}[...].`);
|
|
219
|
+
this.i += f[0].length;
|
|
220
|
+
path += `[${f[1]}${f[2]}${f[3]}]`;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
kind: "path",
|
|
225
|
+
path,
|
|
226
|
+
negated
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/parser.ts
|
|
233
|
+
/**
|
|
234
|
+
* Parse a whole program: one statement per line, `#` or `//` comments,
|
|
235
|
+
* blank lines ignored. A bad line is reported and skipped; the rest still
|
|
236
|
+
* parses. Later statements with the same name replace earlier ones.
|
|
237
|
+
*/
|
|
238
|
+
function parseProgram(text) {
|
|
239
|
+
const statements = [];
|
|
240
|
+
const errors = [];
|
|
241
|
+
const byName = /* @__PURE__ */ new Map();
|
|
242
|
+
text.split(/\r?\n/).forEach((raw, idx) => {
|
|
243
|
+
const line = idx + 1;
|
|
244
|
+
const src = stripComment(raw);
|
|
245
|
+
if (!src.trim()) return;
|
|
246
|
+
try {
|
|
247
|
+
const { name, expr } = new LineParser(src).statement();
|
|
248
|
+
const st = {
|
|
249
|
+
name,
|
|
250
|
+
expr,
|
|
251
|
+
line
|
|
252
|
+
};
|
|
253
|
+
const prev = byName.get(name);
|
|
254
|
+
if (prev !== void 0) statements[prev] = st;
|
|
255
|
+
else {
|
|
256
|
+
byName.set(name, statements.length);
|
|
257
|
+
statements.push(st);
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {
|
|
260
|
+
errors.push({
|
|
261
|
+
code: "parse-failed",
|
|
262
|
+
message: `Line ${line}: ${e instanceof Error ? e.message : String(e)}`,
|
|
263
|
+
line,
|
|
264
|
+
severity: "error"
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
return {
|
|
269
|
+
statements,
|
|
270
|
+
errors
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/** Drop a trailing `# comment` or `// comment` outside of strings. */
|
|
274
|
+
function stripComment(line) {
|
|
275
|
+
let quote = null;
|
|
276
|
+
for (let i = 0; i < line.length; i++) {
|
|
277
|
+
const c = line[i];
|
|
278
|
+
if (quote) {
|
|
279
|
+
if (c === "\\") i++;
|
|
280
|
+
else if (c === quote) quote = null;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (c === "\"" || c === "'") quote = c;
|
|
284
|
+
else if (c === "#" || c === "/" && line[i + 1] === "/") return line.slice(0, i);
|
|
285
|
+
}
|
|
286
|
+
return line;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Split streamed text into the part that is safe to parse and the trailing
|
|
290
|
+
* partial line. A program is complete only when `done` is true; until then
|
|
291
|
+
* the last unterminated line is held back.
|
|
292
|
+
*/
|
|
293
|
+
function stableText(text, done) {
|
|
294
|
+
if (done) return text;
|
|
295
|
+
const nl = text.lastIndexOf("\n");
|
|
296
|
+
return nl < 0 ? "" : text.slice(0, nl + 1);
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
299
|
+
//#region src/evaluate.ts
|
|
300
|
+
const isRefList = (x) => x.kind === "ref" || x.kind === "array" && x.items.every((i) => i.kind === "ref");
|
|
301
|
+
/** Built-in actions: name -> positional parameters. `path` params must be $ui.* paths. */
|
|
302
|
+
const ACTIONS = {
|
|
303
|
+
Set: {
|
|
304
|
+
type: "set",
|
|
305
|
+
params: [{
|
|
306
|
+
name: "path",
|
|
307
|
+
kind: "path",
|
|
308
|
+
required: true
|
|
309
|
+
}, {
|
|
310
|
+
name: "value",
|
|
311
|
+
kind: "value",
|
|
312
|
+
required: true
|
|
313
|
+
}]
|
|
314
|
+
},
|
|
315
|
+
Merge: {
|
|
316
|
+
type: "merge",
|
|
317
|
+
params: [{
|
|
318
|
+
name: "path",
|
|
319
|
+
kind: "path",
|
|
320
|
+
required: true
|
|
321
|
+
}, {
|
|
322
|
+
name: "value",
|
|
323
|
+
kind: "object",
|
|
324
|
+
required: true
|
|
325
|
+
}]
|
|
326
|
+
},
|
|
327
|
+
Push: {
|
|
328
|
+
type: "push",
|
|
329
|
+
params: [{
|
|
330
|
+
name: "path",
|
|
331
|
+
kind: "path",
|
|
332
|
+
required: true
|
|
333
|
+
}, {
|
|
334
|
+
name: "value",
|
|
335
|
+
kind: "value",
|
|
336
|
+
required: true
|
|
337
|
+
}]
|
|
338
|
+
},
|
|
339
|
+
Remove: {
|
|
340
|
+
type: "remove",
|
|
341
|
+
params: [{
|
|
342
|
+
name: "path",
|
|
343
|
+
kind: "path",
|
|
344
|
+
required: true
|
|
345
|
+
}]
|
|
346
|
+
},
|
|
347
|
+
Increment: {
|
|
348
|
+
type: "increment",
|
|
349
|
+
params: [{
|
|
350
|
+
name: "path",
|
|
351
|
+
kind: "path",
|
|
352
|
+
required: true
|
|
353
|
+
}, {
|
|
354
|
+
name: "by",
|
|
355
|
+
kind: "number"
|
|
356
|
+
}]
|
|
357
|
+
},
|
|
358
|
+
Toggle: {
|
|
359
|
+
type: "toggle",
|
|
360
|
+
params: [{
|
|
361
|
+
name: "path",
|
|
362
|
+
kind: "path",
|
|
363
|
+
required: true
|
|
364
|
+
}]
|
|
365
|
+
},
|
|
366
|
+
Tool: {
|
|
367
|
+
type: "tool",
|
|
368
|
+
params: [
|
|
369
|
+
{
|
|
370
|
+
name: "name",
|
|
371
|
+
kind: "string",
|
|
372
|
+
required: true
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: "args",
|
|
376
|
+
kind: "object"
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
name: "into",
|
|
380
|
+
kind: "path"
|
|
381
|
+
}
|
|
382
|
+
]
|
|
383
|
+
},
|
|
384
|
+
Say: {
|
|
385
|
+
type: "message",
|
|
386
|
+
params: [{
|
|
387
|
+
name: "text",
|
|
388
|
+
kind: "string",
|
|
389
|
+
required: true
|
|
390
|
+
}]
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
const ACTION_NAMES = Object.keys(ACTIONS);
|
|
394
|
+
/**
|
|
395
|
+
* Turn parsed statements into a node tree using the library's signatures.
|
|
396
|
+
* Positional arguments map to props in signature order, then to children.
|
|
397
|
+
* Named arguments may be props, `children`, `when`, or an event name.
|
|
398
|
+
*/
|
|
399
|
+
function evaluate(statements, signatures) {
|
|
400
|
+
const errors = [];
|
|
401
|
+
const unresolved = /* @__PURE__ */ new Set();
|
|
402
|
+
const byName = new Map(statements.map((s) => [s.name, s]));
|
|
403
|
+
const used = /* @__PURE__ */ new Set();
|
|
404
|
+
let nodes = 0;
|
|
405
|
+
const rootStatement = byName.get("root") ?? statements[0];
|
|
406
|
+
if (!rootStatement) return {
|
|
407
|
+
root: null,
|
|
408
|
+
nodes: 0,
|
|
409
|
+
unresolved: [],
|
|
410
|
+
errors
|
|
411
|
+
};
|
|
412
|
+
if (rootStatement.name !== "root") errors.push({
|
|
413
|
+
code: "no-root",
|
|
414
|
+
message: `No "root" statement; using "${rootStatement.name}".`,
|
|
415
|
+
line: rootStatement.line,
|
|
416
|
+
severity: "warning"
|
|
417
|
+
});
|
|
418
|
+
function node(st, stack) {
|
|
419
|
+
if (stack.includes(st.name)) {
|
|
420
|
+
errors.push({
|
|
421
|
+
code: "cycle",
|
|
422
|
+
message: `"${st.name}" contains itself.`,
|
|
423
|
+
line: st.line,
|
|
424
|
+
severity: "error"
|
|
425
|
+
});
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
if (used.has(st.name)) {
|
|
429
|
+
errors.push({
|
|
430
|
+
code: "reused-node",
|
|
431
|
+
message: `"${st.name}" is used in more than one place; define a second statement.`,
|
|
432
|
+
line: st.line,
|
|
433
|
+
severity: "error"
|
|
434
|
+
});
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const e = st.expr;
|
|
438
|
+
if (e.kind !== "call") {
|
|
439
|
+
errors.push({
|
|
440
|
+
code: "not-a-component",
|
|
441
|
+
message: `"${st.name}" must be a component call like Card(...).`,
|
|
442
|
+
line: st.line,
|
|
443
|
+
severity: "error"
|
|
444
|
+
});
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const sig = signatures[e.name];
|
|
448
|
+
if (!sig) {
|
|
449
|
+
errors.push({
|
|
450
|
+
code: "unknown-component",
|
|
451
|
+
message: `Unknown component "${e.name}". Components: ${Object.keys(signatures).join(", ")}.`,
|
|
452
|
+
line: st.line,
|
|
453
|
+
severity: "error"
|
|
454
|
+
});
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
used.add(st.name);
|
|
458
|
+
nodes++;
|
|
459
|
+
const out = {
|
|
460
|
+
component: e.name,
|
|
461
|
+
id: st.name
|
|
462
|
+
};
|
|
463
|
+
const props = {};
|
|
464
|
+
const bind = {};
|
|
465
|
+
const on = {};
|
|
466
|
+
let childExprs;
|
|
467
|
+
const slots = [...sig.props, ...sig.childrenProp ? ["children"] : []];
|
|
468
|
+
let pos = 0;
|
|
469
|
+
for (const arg of e.args) {
|
|
470
|
+
let key = arg.name;
|
|
471
|
+
if (!key) {
|
|
472
|
+
if (sig.childrenProp && isRefList(arg.expr)) key = "children";
|
|
473
|
+
else if (pos >= sig.props.length) {
|
|
474
|
+
errors.push({
|
|
475
|
+
code: "excess-args",
|
|
476
|
+
message: `${e.name} takes ${slots.length} arguments; extra ones dropped.`,
|
|
477
|
+
line: st.line,
|
|
478
|
+
severity: "warning"
|
|
479
|
+
});
|
|
480
|
+
break;
|
|
481
|
+
} else key = slots[pos++];
|
|
482
|
+
}
|
|
483
|
+
if (key === "children") {
|
|
484
|
+
if (!sig.childrenProp) errors.push({
|
|
485
|
+
code: "unknown-arg",
|
|
486
|
+
message: `${e.name} does not take children.`,
|
|
487
|
+
line: st.line,
|
|
488
|
+
severity: "warning"
|
|
489
|
+
});
|
|
490
|
+
else childExprs = arg.expr;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (key === "when") {
|
|
494
|
+
if (arg.expr.kind !== "path" || !/^(ui|data)\./.test(arg.expr.path)) errors.push({
|
|
495
|
+
code: "bad-arg",
|
|
496
|
+
message: `when: must be a $ui.* or $data.* path.`,
|
|
497
|
+
line: st.line,
|
|
498
|
+
severity: "warning"
|
|
499
|
+
});
|
|
500
|
+
else out.when = `${arg.expr.negated ? "!" : ""}${arg.expr.path}`;
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (sig.events.includes(key)) {
|
|
504
|
+
const list = actionList(arg.expr, st, e.name, key);
|
|
505
|
+
if (list) on[key] = list;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if (!sig.props.includes(key)) {
|
|
509
|
+
errors.push({
|
|
510
|
+
code: "unknown-arg",
|
|
511
|
+
message: `${e.name} has no argument "${key}". Arguments: ${slots.join(", ")}${sig.events.length ? `; events: ${sig.events.join(", ")}` : ""}.`,
|
|
512
|
+
line: st.line,
|
|
513
|
+
severity: "warning"
|
|
514
|
+
});
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (arg.expr.kind === "null") continue;
|
|
518
|
+
if (arg.expr.kind === "path") {
|
|
519
|
+
if (arg.expr.negated || !/^(ui|data)\./.test(arg.expr.path)) errors.push({
|
|
520
|
+
code: "bad-arg",
|
|
521
|
+
message: `${key}: bind to a $ui.* or $data.* path.`,
|
|
522
|
+
line: st.line,
|
|
523
|
+
severity: "warning"
|
|
524
|
+
});
|
|
525
|
+
else bind[key] = arg.expr.path;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const v = literal(arg.expr, st, `${e.name}(${key})`);
|
|
529
|
+
if (v !== void 0) props[key] = v;
|
|
530
|
+
}
|
|
531
|
+
if (Object.keys(props).length) out.props = props;
|
|
532
|
+
if (Object.keys(bind).length) out.bind = bind;
|
|
533
|
+
if (Object.keys(on).length) out.on = on;
|
|
534
|
+
if (childExprs) {
|
|
535
|
+
const items = childExprs.kind === "array" ? childExprs.items : [childExprs];
|
|
536
|
+
const kids = [];
|
|
537
|
+
for (const item of items) {
|
|
538
|
+
if (item.kind !== "ref") {
|
|
539
|
+
errors.push({
|
|
540
|
+
code: "bad-arg",
|
|
541
|
+
message: `${e.name} children must be names of other statements.`,
|
|
542
|
+
line: st.line,
|
|
543
|
+
severity: "warning"
|
|
544
|
+
});
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const target = byName.get(item.name);
|
|
548
|
+
if (!target) {
|
|
549
|
+
unresolved.add(item.name);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
const kid = node(target, [...stack, st.name]);
|
|
553
|
+
if (kid) kids.push(kid);
|
|
554
|
+
}
|
|
555
|
+
out.children = kids;
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
/** Literal JSON for a prop: no refs, paths or actions inside. */
|
|
560
|
+
function literal(x, st, where) {
|
|
561
|
+
switch (x.kind) {
|
|
562
|
+
case "string":
|
|
563
|
+
case "number":
|
|
564
|
+
case "boolean": return x.value;
|
|
565
|
+
case "null": return null;
|
|
566
|
+
case "array": return x.items.map((i) => literal(i, st, where)).filter((v) => v !== void 0);
|
|
567
|
+
case "object": return Object.fromEntries(x.entries.map(([k, v]) => [k, literal(v, st, where)]).filter(([, v]) => v !== void 0));
|
|
568
|
+
default:
|
|
569
|
+
errors.push({
|
|
570
|
+
code: "bad-arg",
|
|
571
|
+
message: `${where}: ${x.kind === "ref" ? "a node reference" : x.kind === "path" ? "a $path" : "a call"} is not allowed inside a value. Bind a whole prop with $ui.x or $data.x instead.`,
|
|
572
|
+
line: st.line,
|
|
573
|
+
severity: "warning"
|
|
574
|
+
});
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/** Action argument: paths become {{templates}} resolved when the event fires. */
|
|
579
|
+
function template(x, st, where) {
|
|
580
|
+
switch (x.kind) {
|
|
581
|
+
case "path":
|
|
582
|
+
if (x.negated) {
|
|
583
|
+
errors.push({
|
|
584
|
+
code: "bad-arg",
|
|
585
|
+
message: `${where}: "!" is only allowed in when:.`,
|
|
586
|
+
line: st.line,
|
|
587
|
+
severity: "warning"
|
|
588
|
+
});
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
return `{{${x.path === "event" ? "value" : x.path.startsWith("event.") ? `value.${x.path.slice(6)}` : x.path}}}`;
|
|
592
|
+
case "array": return x.items.map((i) => template(i, st, where)).filter((v) => v !== void 0);
|
|
593
|
+
case "object": return Object.fromEntries(x.entries.map(([k, v]) => [k, template(v, st, where)]).filter(([, v]) => v !== void 0));
|
|
594
|
+
default: return literal(x, st, where);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function actionList(x, st, comp, event) {
|
|
598
|
+
const items = x.kind === "array" ? x.items : [x];
|
|
599
|
+
const out = [];
|
|
600
|
+
for (const item of items) {
|
|
601
|
+
if (item.kind !== "action") {
|
|
602
|
+
errors.push({
|
|
603
|
+
code: "bad-arg",
|
|
604
|
+
message: `${comp} ${event}: expected an @Action, e.g. @Set($ui.x, $event).`,
|
|
605
|
+
line: st.line,
|
|
606
|
+
severity: "warning"
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const a = action(item.name, item.args, st, `${comp} ${event}`);
|
|
611
|
+
if (a) out.push(a);
|
|
612
|
+
}
|
|
613
|
+
return out.length ? out : null;
|
|
614
|
+
}
|
|
615
|
+
function action(name, args, st, where) {
|
|
616
|
+
const def = ACTIONS[name];
|
|
617
|
+
if (!def) {
|
|
618
|
+
errors.push({
|
|
619
|
+
code: "unknown-action",
|
|
620
|
+
message: `${where}: unknown action @${name}. Actions: ${ACTION_NAMES.map((n) => `@${n}`).join(", ")}.`,
|
|
621
|
+
line: st.line,
|
|
622
|
+
severity: "warning"
|
|
623
|
+
});
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
const out = { type: def.type };
|
|
627
|
+
let pos = 0;
|
|
628
|
+
for (const arg of args) {
|
|
629
|
+
const p = arg.name ? def.params.find((q) => q.name === arg.name) : def.params[pos++];
|
|
630
|
+
if (!p) {
|
|
631
|
+
errors.push({
|
|
632
|
+
code: "excess-args",
|
|
633
|
+
message: `${where}: @${name} takes ${def.params.map((q) => q.name).join(", ")}.`,
|
|
634
|
+
line: st.line,
|
|
635
|
+
severity: "warning"
|
|
636
|
+
});
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const x = arg.expr;
|
|
640
|
+
if (p.kind === "path") {
|
|
641
|
+
if (x.kind !== "path" || x.negated || !x.path.startsWith("ui.")) {
|
|
642
|
+
errors.push({
|
|
643
|
+
code: "bad-arg",
|
|
644
|
+
message: `${where}: @${name} ${p.name} must be a $ui.* path.`,
|
|
645
|
+
line: st.line,
|
|
646
|
+
severity: "warning"
|
|
647
|
+
});
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
out[p.name] = x.path;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
if (x.kind === "null") {
|
|
654
|
+
if (p.kind === "value") out[p.name] = null;
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const v = template(x, st, `${where} @${name}`);
|
|
658
|
+
if (v === void 0) continue;
|
|
659
|
+
if (p.kind === "string" && typeof v !== "string") {
|
|
660
|
+
errors.push({
|
|
661
|
+
code: "bad-arg",
|
|
662
|
+
message: `${where}: @${name} ${p.name} must be a string.`,
|
|
663
|
+
line: st.line,
|
|
664
|
+
severity: "warning"
|
|
665
|
+
});
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
if (p.kind === "number" && typeof v !== "number") {
|
|
669
|
+
errors.push({
|
|
670
|
+
code: "bad-arg",
|
|
671
|
+
message: `${where}: @${name} ${p.name} must be a number.`,
|
|
672
|
+
line: st.line,
|
|
673
|
+
severity: "warning"
|
|
674
|
+
});
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
if (p.kind === "object" && (typeof v !== "object" || v === null || Array.isArray(v))) {
|
|
678
|
+
errors.push({
|
|
679
|
+
code: "bad-arg",
|
|
680
|
+
message: `${where}: @${name} ${p.name} must be an object.`,
|
|
681
|
+
line: st.line,
|
|
682
|
+
severity: "warning"
|
|
683
|
+
});
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
out[p.name] = v;
|
|
687
|
+
}
|
|
688
|
+
for (const p of def.params) if (p.required && out[p.name] === void 0) {
|
|
689
|
+
errors.push({
|
|
690
|
+
code: "missing-required",
|
|
691
|
+
message: `${where}: @${name} needs ${p.name}.`,
|
|
692
|
+
line: st.line,
|
|
693
|
+
severity: "warning"
|
|
694
|
+
});
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
root: node(rootStatement, []),
|
|
701
|
+
nodes,
|
|
702
|
+
unresolved: [...unresolved],
|
|
703
|
+
errors
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
//#endregion
|
|
707
|
+
//#region src/prompt.ts
|
|
708
|
+
const DEFAULT_EXAMPLE = {
|
|
709
|
+
ask: "Show August revenue, orders and refunds, and a note that we are on track.",
|
|
710
|
+
program: `root = Stack([stats, note])
|
|
711
|
+
stats = StatGroup([rev, orders, refunds])
|
|
712
|
+
rev = Stat("Revenue", "$48.2k", "+6.1%", "up")
|
|
713
|
+
orders = Stat("Orders", "140", "+12", "up")
|
|
714
|
+
refunds = Stat("Refunds", "9", "-2", "down")
|
|
715
|
+
note = Callout("On track", "Revenue is ahead of target by 4%.", "success")`
|
|
716
|
+
};
|
|
717
|
+
/** Build the system prompt that teaches a model the language and this library. */
|
|
718
|
+
function buildPrompt(lib, opts = {}) {
|
|
719
|
+
const interactive = opts.interactive ?? true;
|
|
720
|
+
const out = [];
|
|
721
|
+
out.push(opts.preamble ?? "You answer by writing a user interface in Agentable Lang, a small line-based language. Each line defines one node: `name = Component(args)`. The host renders the lines as they stream, so write the root first and keep programs short.");
|
|
722
|
+
out.push("");
|
|
723
|
+
out.push("Rules:");
|
|
724
|
+
const rules = [
|
|
725
|
+
"One statement per line, `name = Component(...)`. Names are [a-zA-Z0-9_-]. The entry is the statement named `root`.",
|
|
726
|
+
"Arguments are positional in the order shown below, or named (`kind: \"success\"`). Use `null` to skip an optional positional argument.",
|
|
727
|
+
"Children are a list of statement names, `Stack([a, b])` or `Card(\"Title\", [a, b])`; a bare list anywhere in the arguments is the children. A name may be used once. Forward references are fine.",
|
|
728
|
+
"Strings use double quotes. Lists use [], objects use {key: value}. Numbers, true, false and null are literal.",
|
|
729
|
+
"Only the components listed below exist. Do not invent components, arguments or events.",
|
|
730
|
+
"Lines starting with # are comments. Do not wrap the program in code fences or add prose before or after it."
|
|
731
|
+
];
|
|
732
|
+
if (interactive) rules.push("State: `$data.x` reads app data (read-only); `$ui.x` is interface state you own. Pass a path as an argument to bind that prop, e.g. `rows: $data.paid`.", "Read paths can filter lists and count them: `$data.items[done=true]`, `$data.items[status!=\"archived\"]`, `$data.items[done=true].length`. Filters are read-only; actions cannot write through them.", "`when: $ui.selected` shows a node only while that path is truthy; `when: !$ui.done` while it is falsy.", "Events take actions: `press: @Set($ui.selected, $event)` or a list `[@Set(...), @Tool(...)]`. `$event` is the value the event fired with; `$event.id` a field of it.", "Actions: @Set(path, value) @Merge(path, object) @Push(path, value) @Remove(path) @Increment(path, by?) @Toggle(path) @Tool(name, args?, into?) @Say(text). Paths written by actions must be $ui.*.");
|
|
733
|
+
rules.push(...opts.rules ?? []);
|
|
734
|
+
for (const r of rules) out.push(`- ${r}`);
|
|
735
|
+
out.push("");
|
|
736
|
+
out.push("Components:");
|
|
737
|
+
for (const line of lib.signatureLines()) out.push(`- ${line}`);
|
|
738
|
+
if (interactive && opts.tools?.length) {
|
|
739
|
+
out.push("");
|
|
740
|
+
out.push("Tools @Tool may call (the host confirms consequential ones with the user):");
|
|
741
|
+
for (const t of opts.tools) out.push(`- ${t.name}${t.description ? `: ${t.description}` : ""}`);
|
|
742
|
+
}
|
|
743
|
+
if (interactive && opts.data !== void 0) {
|
|
744
|
+
out.push("");
|
|
745
|
+
out.push(`Shape of $data: ${JSON.stringify(opts.data)}`);
|
|
746
|
+
}
|
|
747
|
+
const examples = opts.examples ?? [DEFAULT_EXAMPLE];
|
|
748
|
+
if (examples.length) {
|
|
749
|
+
out.push("");
|
|
750
|
+
out.push(examples.length > 1 ? "Examples:" : "Example:");
|
|
751
|
+
for (const ex of examples) {
|
|
752
|
+
out.push(`Ask: ${ex.ask}`);
|
|
753
|
+
out.push("Program:");
|
|
754
|
+
out.push(ex.program.trim());
|
|
755
|
+
out.push("");
|
|
756
|
+
}
|
|
757
|
+
out.pop();
|
|
758
|
+
}
|
|
759
|
+
return out.join("\n");
|
|
760
|
+
}
|
|
761
|
+
//#endregion
|
|
762
|
+
//#region src/library.ts
|
|
763
|
+
function createLibrary(components, options = {}) {
|
|
764
|
+
const signatures = {};
|
|
765
|
+
for (const [name, entry] of Object.entries(components)) signatures[name] = signature(name, entry);
|
|
766
|
+
if (options.root && !signatures[options.root]) throw new Error(`root "${options.root}" is not in the library.`);
|
|
767
|
+
const parseText = (text, complete) => {
|
|
768
|
+
const program = parseProgram(text);
|
|
769
|
+
const evaluated = evaluate(program.statements, signatures);
|
|
770
|
+
let errors = [...program.errors, ...evaluated.errors];
|
|
771
|
+
if (!complete) errors = errors.filter((e) => e.code !== "no-root");
|
|
772
|
+
return {
|
|
773
|
+
...evaluated,
|
|
774
|
+
errors,
|
|
775
|
+
complete,
|
|
776
|
+
chars: text.length,
|
|
777
|
+
tokens: estimateTokens(text.length)
|
|
778
|
+
};
|
|
779
|
+
};
|
|
780
|
+
const lib = {
|
|
781
|
+
signatures,
|
|
782
|
+
root: options.root,
|
|
783
|
+
parse: (text) => parseText(text, true),
|
|
784
|
+
stream() {
|
|
785
|
+
let buffer = "";
|
|
786
|
+
let last = parseText("", false);
|
|
787
|
+
const set = (text, done = false) => {
|
|
788
|
+
buffer = text;
|
|
789
|
+
last = parseText(stableText(text, done), done);
|
|
790
|
+
return last;
|
|
791
|
+
};
|
|
792
|
+
return {
|
|
793
|
+
set,
|
|
794
|
+
push: (chunk, done) => set(buffer + chunk, done),
|
|
795
|
+
result: () => last
|
|
796
|
+
};
|
|
797
|
+
},
|
|
798
|
+
prompt: (opts) => buildPrompt(lib, opts),
|
|
799
|
+
signatureLines: () => Object.values(signatures).map(signatureLine)
|
|
800
|
+
};
|
|
801
|
+
return lib;
|
|
802
|
+
}
|
|
803
|
+
function signature(name, entry) {
|
|
804
|
+
let schema;
|
|
805
|
+
try {
|
|
806
|
+
schema = toJsonSchema(entry.props);
|
|
807
|
+
} catch {
|
|
808
|
+
schema = {
|
|
809
|
+
type: "object",
|
|
810
|
+
properties: {}
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
const properties = schema.properties ?? {};
|
|
814
|
+
const required = new Set((schema.required ?? []).filter((k) => k in properties));
|
|
815
|
+
const props = Object.keys(properties);
|
|
816
|
+
const types = {};
|
|
817
|
+
for (const p of props) types[p] = typeName(properties[p], 0);
|
|
818
|
+
return {
|
|
819
|
+
name,
|
|
820
|
+
description: entry.description,
|
|
821
|
+
props,
|
|
822
|
+
required,
|
|
823
|
+
types,
|
|
824
|
+
childrenProp: entry.childrenProp,
|
|
825
|
+
events: Object.keys(entry.events ?? {}),
|
|
826
|
+
schema
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
/** A compact type for a prompt: string, number, "a"|"b", T[], {k: T}. */
|
|
830
|
+
function typeName(s, depth) {
|
|
831
|
+
if (Array.isArray(s.enum)) return s.enum.map((v) => JSON.stringify(v)).join("|");
|
|
832
|
+
if (Array.isArray(s.anyOf)) return s.anyOf.map((x) => typeName(x, depth)).join("|");
|
|
833
|
+
if (Array.isArray(s.oneOf)) return s.oneOf.map((x) => typeName(x, depth)).join("|");
|
|
834
|
+
const t = Array.isArray(s.type) ? s.type : s.type ? [String(s.type)] : [];
|
|
835
|
+
if (t.length > 1) return t.map((x) => typeName({
|
|
836
|
+
...s,
|
|
837
|
+
type: x
|
|
838
|
+
}, depth)).join("|");
|
|
839
|
+
const type = t[0] ?? "any";
|
|
840
|
+
if (type === "array") return `${typeName(s.items ?? {}, depth + 1)}[]`;
|
|
841
|
+
if (type === "object") {
|
|
842
|
+
const props = s.properties ?? {};
|
|
843
|
+
const keys = Object.keys(props);
|
|
844
|
+
if (!keys.length || depth >= 2) return "object";
|
|
845
|
+
const req = new Set(s.required ?? []);
|
|
846
|
+
return `{${keys.map((k) => `${k}${req.has(k) ? "" : "?"}: ${typeName(props[k], depth + 1)}`).join(", ")}}`;
|
|
847
|
+
}
|
|
848
|
+
if (type === "integer") return "number";
|
|
849
|
+
return type;
|
|
850
|
+
}
|
|
851
|
+
function signatureLine(sig) {
|
|
852
|
+
const args = sig.props.map((p) => `${p}${sig.required.has(p) ? "" : "?"}: ${sig.types[p]}`);
|
|
853
|
+
if (sig.childrenProp) args.push("children?: [nodes]");
|
|
854
|
+
const events = sig.events.length ? ` · events: ${sig.events.join(", ")}` : "";
|
|
855
|
+
return `${sig.name}(${args.join(", ")})${events} — ${sig.description}`;
|
|
856
|
+
}
|
|
857
|
+
//#endregion
|
|
858
|
+
//#region src/serialize.ts
|
|
859
|
+
/**
|
|
860
|
+
* Write a node tree as a program. Positional arguments are used while the
|
|
861
|
+
* props line up with the signature; named arguments after the first gap.
|
|
862
|
+
* Useful for showing a JSON spec as the language, and for measuring both.
|
|
863
|
+
*/
|
|
864
|
+
function serialize(root, lib) {
|
|
865
|
+
const lines = [];
|
|
866
|
+
const taken = /* @__PURE__ */ new Set();
|
|
867
|
+
const nameFor = (n, i) => {
|
|
868
|
+
let base = n.id ?? `${n.component.toLowerCase()}${i}`;
|
|
869
|
+
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(base)) base = `n${i}`;
|
|
870
|
+
let name = base;
|
|
871
|
+
let k = 2;
|
|
872
|
+
while (taken.has(name)) name = `${base}${k++}`;
|
|
873
|
+
taken.add(name);
|
|
874
|
+
return name;
|
|
875
|
+
};
|
|
876
|
+
let counter = 0;
|
|
877
|
+
const walk = (n, forced) => {
|
|
878
|
+
const name = forced ?? nameFor(n, counter++);
|
|
879
|
+
const at = lines.length;
|
|
880
|
+
lines.push("");
|
|
881
|
+
const kids = (n.children ?? []).map((c) => walk(c));
|
|
882
|
+
lines[at] = `${name} = ${call(n, kids, lib)}`;
|
|
883
|
+
return name;
|
|
884
|
+
};
|
|
885
|
+
taken.add("root");
|
|
886
|
+
walk(root, "root");
|
|
887
|
+
return lines.join("\n");
|
|
888
|
+
}
|
|
889
|
+
function call(n, kids, lib) {
|
|
890
|
+
const sig = lib.signatures[n.component];
|
|
891
|
+
const props = n.props ?? {};
|
|
892
|
+
const bind = n.bind ?? {};
|
|
893
|
+
const args = [];
|
|
894
|
+
let positional = true;
|
|
895
|
+
const order = sig?.props ?? Object.keys(props);
|
|
896
|
+
for (const p of order) {
|
|
897
|
+
if (!(p in props || p in bind)) {
|
|
898
|
+
positional = false;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const v = p in bind ? `$${bind[p]}` : value(props[p]);
|
|
902
|
+
args.push(positional ? v : `${p}: ${v}`);
|
|
903
|
+
}
|
|
904
|
+
for (const p of Object.keys(props)) if (!order.includes(p)) args.push(`${p}: ${value(props[p])}`);
|
|
905
|
+
if (kids.length) args.push(positional && sig?.childrenProp ? `[${kids.join(", ")}]` : `children: [${kids.join(", ")}]`);
|
|
906
|
+
if (n.when) args.push(`when: ${n.when.startsWith("!") ? `!$${n.when.slice(1)}` : `$${n.when}`}`);
|
|
907
|
+
for (const [ev, actions] of Object.entries(n.on ?? {})) {
|
|
908
|
+
const list = actions.map(action);
|
|
909
|
+
args.push(`${ev}: ${list.length === 1 ? list[0] : `[${list.join(", ")}]`}`);
|
|
910
|
+
}
|
|
911
|
+
return `${n.component}(${args.join(", ")})`;
|
|
912
|
+
}
|
|
913
|
+
function action(a) {
|
|
914
|
+
switch (a.type) {
|
|
915
|
+
case "set": return `@Set($${a.path}, ${template(a.value)})`;
|
|
916
|
+
case "merge": return `@Merge($${a.path}, ${template(a.value)})`;
|
|
917
|
+
case "push": return `@Push($${a.path}, ${template(a.value)})`;
|
|
918
|
+
case "remove": return `@Remove($${a.path})`;
|
|
919
|
+
case "increment": return a.by === void 0 ? `@Increment($${a.path})` : `@Increment($${a.path}, ${a.by})`;
|
|
920
|
+
case "toggle": return `@Toggle($${a.path})`;
|
|
921
|
+
case "tool": {
|
|
922
|
+
const parts = [JSON.stringify(a.name)];
|
|
923
|
+
if (a.args) parts.push(template(a.args));
|
|
924
|
+
if (a.into) parts.push(`$${a.into}`);
|
|
925
|
+
return `@Tool(${parts.join(", ")})`;
|
|
926
|
+
}
|
|
927
|
+
case "message": return `@Say(${JSON.stringify(a.text)})`;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
/** {{value}} -> $event, {{ui.x}} -> $ui.x; anything else is a literal. */
|
|
931
|
+
function template(v) {
|
|
932
|
+
if (typeof v === "string") {
|
|
933
|
+
const m = /^\{\{\s*([^}]+?)\s*\}\}$/.exec(v);
|
|
934
|
+
if (m) {
|
|
935
|
+
const p = m[1];
|
|
936
|
+
return p === "value" ? "$event" : p.startsWith("value.") ? `$event.${p.slice(6)}` : `$${p}`;
|
|
937
|
+
}
|
|
938
|
+
return JSON.stringify(v);
|
|
939
|
+
}
|
|
940
|
+
if (Array.isArray(v)) return `[${v.map(template).join(", ")}]`;
|
|
941
|
+
if (v && typeof v === "object") return `{${Object.entries(v).map(([k, x]) => `${key(k)}: ${template(x)}`).join(", ")}}`;
|
|
942
|
+
return JSON.stringify(v) ?? "null";
|
|
943
|
+
}
|
|
944
|
+
function value(v) {
|
|
945
|
+
if (Array.isArray(v)) return `[${v.map(value).join(", ")}]`;
|
|
946
|
+
if (v && typeof v === "object") return `{${Object.entries(v).map(([k, x]) => `${key(k)}: ${value(x)}`).join(", ")}}`;
|
|
947
|
+
return JSON.stringify(v) ?? "null";
|
|
948
|
+
}
|
|
949
|
+
const key = (k) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(k) ? k : JSON.stringify(k);
|
|
950
|
+
//#endregion
|
|
951
|
+
export { ACTION_NAMES, buildPrompt, createLibrary, evaluate, parseProgram, serialize, signatureLine, stableText, typeName };
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentable-ui/lang",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Agentable Lang: a compact, streaming-first language for model-generated UI. Parser, streaming parser, prompt generation and serializer, framework-free.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/harsh071/agentable-ui",
|
|
23
|
+
"directory": "packages/lang"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"generative-ui",
|
|
27
|
+
"genui",
|
|
28
|
+
"webmcp",
|
|
29
|
+
"agent",
|
|
30
|
+
"mcp",
|
|
31
|
+
"components",
|
|
32
|
+
"react",
|
|
33
|
+
"streaming"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@agentable-ui/core": "^0.2.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsdown",
|
|
43
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
44
|
+
"test": "vitest run"
|
|
45
|
+
}
|
|
6
46
|
}
|