@assistant-ui/react-generative-ui 0.0.7 → 0.0.9
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/JSONGenerativeUI.client.d.ts +0 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.server.d.ts +0 -1
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +0 -1
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/actionRegistry.d.ts +2 -11
- package/dist/actionRegistry.d.ts.map +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/buildPresentParameters.d.ts +5 -5
- package/dist/buildPresentParameters.d.ts.map +1 -1
- package/dist/buildPresentParameters.js +10 -5
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/constants.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +0 -1
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +15 -10
- package/dist/generativeUIToJSX.d.ts.map +1 -1
- package/dist/generativeUIToJSX.js +47 -17
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -2
- package/dist/index.server.d.ts +4 -3
- package/dist/index.server.js +2 -2
- package/dist/index.shared.d.ts +3 -3
- package/dist/index.shared.js +2 -2
- package/dist/ir.d.ts +6 -6
- package/dist/ir.d.ts.map +1 -1
- package/dist/ir.js +27 -1
- package/dist/ir.js.map +1 -1
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +3 -3
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +0 -1
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/slack/constants.d.ts +111 -0
- package/dist/slack/constants.d.ts.map +1 -0
- package/dist/slack/constants.js +162 -0
- package/dist/slack/constants.js.map +1 -0
- package/dist/slack/decodeBlockAction.d.ts +11 -0
- package/dist/slack/decodeBlockAction.d.ts.map +1 -0
- package/dist/slack/decodeBlockAction.js +40 -0
- package/dist/slack/decodeBlockAction.js.map +1 -0
- package/dist/slack/fromSlackBlocks.d.ts +30 -0
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -0
- package/dist/slack/fromSlackBlocks.js +447 -0
- package/dist/slack/fromSlackBlocks.js.map +1 -0
- package/dist/slack/toSlackBlocks.d.ts +7 -0
- package/dist/slack/toSlackBlocks.d.ts.map +1 -0
- package/dist/slack/toSlackBlocks.js +649 -0
- package/dist/slack/toSlackBlocks.js.map +1 -0
- package/dist/slack/types.d.ts +178 -0
- package/dist/slack/types.d.ts.map +1 -0
- package/dist/slack/types.js +0 -0
- package/dist/slack.d.ts +5 -0
- package/dist/slack.js +4 -0
- package/dist/teams/boundSpec.d.ts +20 -0
- package/dist/teams/boundSpec.d.ts.map +1 -0
- package/dist/teams/boundSpec.js +67 -0
- package/dist/teams/boundSpec.js.map +1 -0
- package/dist/teams/constants.d.ts +57 -0
- package/dist/teams/constants.d.ts.map +1 -0
- package/dist/teams/constants.js +92 -0
- package/dist/teams/constants.js.map +1 -0
- package/dist/teams/decodeSubmitData.d.ts +25 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -0
- package/dist/teams/decodeSubmitData.js +44 -0
- package/dist/teams/decodeSubmitData.js.map +1 -0
- package/dist/teams/toAdaptiveCard.d.ts +39 -0
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -0
- package/dist/teams/toAdaptiveCard.js +496 -0
- package/dist/teams/toAdaptiveCard.js.map +1 -0
- package/dist/teams/toTeamsAttachments.d.ts +13 -0
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -0
- package/dist/teams/toTeamsAttachments.js +95 -0
- package/dist/teams/toTeamsAttachments.js.map +1 -0
- package/dist/teams/types.d.ts +196 -0
- package/dist/teams/types.d.ts.map +1 -0
- package/dist/teams/types.js +0 -0
- package/dist/teams.d.ts +5 -0
- package/dist/teams.js +4 -0
- package/dist/types.d.ts +7 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +6 -12
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +13 -4
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +23 -0
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -0
- package/dist/vocabulary/collectFormValues.js +32 -0
- package/dist/vocabulary/collectFormValues.js.map +1 -0
- package/dist/vocabulary/data.d.ts +16 -17
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +244 -13
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +11 -0
- package/dist/vocabulary/dispatch.d.ts.map +1 -0
- package/dist/vocabulary/dispatch.js +21 -0
- package/dist/vocabulary/dispatch.js.map +1 -0
- package/dist/vocabulary/fact.d.ts +1 -6
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/form.d.ts +14 -0
- package/dist/vocabulary/form.d.ts.map +1 -0
- package/dist/vocabulary/form.js +23 -0
- package/dist/vocabulary/form.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +44 -0
- package/dist/vocabulary/icon.d.ts.map +1 -0
- package/dist/vocabulary/icon.js +272 -0
- package/dist/vocabulary/icon.js.map +1 -0
- package/dist/vocabulary/index.d.ts +0 -1
- package/dist/vocabulary/index.d.ts.map +1 -1
- package/dist/vocabulary/index.js +7 -1
- package/dist/vocabulary/index.js.map +1 -1
- package/dist/vocabulary/interactive.d.ts +45 -33
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +78 -29
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts +27 -23
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +78 -14
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/list.d.ts +17 -0
- package/dist/vocabulary/list.d.ts.map +1 -0
- package/dist/vocabulary/list.js +48 -0
- package/dist/vocabulary/list.js.map +1 -0
- package/dist/vocabulary/media.d.ts +3 -9
- package/dist/vocabulary/media.d.ts.map +1 -1
- package/dist/vocabulary/media.js +17 -7
- package/dist/vocabulary/media.js.map +1 -1
- package/dist/vocabulary/text.d.ts +3 -17
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/text.js.map +1 -1
- package/package.json +15 -7
- package/src/actionRegistry.ts +2 -10
- package/src/buildPresentParameters.ts +15 -9
- package/src/generativeUIToJSX.test.ts +186 -0
- package/src/generativeUIToJSX.ts +112 -21
- package/src/index.shared.ts +3 -0
- package/src/ir.ts +31 -5
- package/src/renderGenerativeUI.test.tsx +16 -2
- package/src/slack/constants.ts +212 -0
- package/src/slack/decodeBlockAction.test.ts +135 -0
- package/src/slack/decodeBlockAction.ts +66 -0
- package/src/slack/fromSlackBlocks.test.ts +1145 -0
- package/src/slack/fromSlackBlocks.ts +709 -0
- package/src/slack/toSlackBlocks.test.ts +1936 -0
- package/src/slack/toSlackBlocks.ts +1319 -0
- package/src/slack/types.ts +230 -0
- package/src/slack.ts +37 -0
- package/src/teams/boundSpec.ts +102 -0
- package/src/teams/constants.ts +127 -0
- package/src/teams/decodeSubmitData.test.ts +148 -0
- package/src/teams/decodeSubmitData.ts +57 -0
- package/src/teams/toAdaptiveCard.test.ts +1410 -0
- package/src/teams/toAdaptiveCard.ts +762 -0
- package/src/teams/toTeamsAttachments.test.ts +29 -0
- package/src/teams/toTeamsAttachments.ts +137 -0
- package/src/teams/types.ts +237 -0
- package/src/teams.ts +34 -0
- package/src/vocabulary/alert.test.tsx +72 -2
- package/src/vocabulary/alert.tsx +24 -5
- package/src/vocabulary/collectFormValues.test.ts +127 -0
- package/src/vocabulary/collectFormValues.ts +58 -0
- package/src/vocabulary/data.test.tsx +503 -3
- package/src/vocabulary/data.tsx +426 -18
- package/src/vocabulary/dispatch.ts +22 -0
- package/src/vocabulary/form.test.tsx +112 -0
- package/src/vocabulary/form.tsx +34 -0
- package/src/vocabulary/icon.test.tsx +64 -0
- package/src/vocabulary/icon.tsx +174 -0
- package/src/vocabulary/index.test.ts +7 -0
- package/src/vocabulary/index.ts +6 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +243 -1
- package/src/vocabulary/interactive.test.tsx +143 -3
- package/src/vocabulary/interactive.tsx +134 -30
- package/src/vocabulary/layout.test.tsx +231 -3
- package/src/vocabulary/layout.tsx +168 -15
- package/src/vocabulary/list.test.tsx +195 -0
- package/src/vocabulary/list.tsx +48 -0
- package/src/vocabulary/media.test.tsx +36 -4
- package/src/vocabulary/media.tsx +27 -8
|
@@ -6,10 +6,11 @@ import type { GenerativeUILibrary } from "./types";
|
|
|
6
6
|
/**
|
|
7
7
|
* Builds the JSON schema for the `present` tool from a {@link GenerativeUILibrary}.
|
|
8
8
|
*
|
|
9
|
-
* The model produces a node `{ $type, ...props }` where `$type` selects
|
|
10
|
-
* component
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The model produces a node `{ $type, $key?, ...props }` where `$type` selects
|
|
10
|
+
* a component, the optional `$key` pins a stable identity for list items that
|
|
11
|
+
* may reorder, and the rest are its props. The schema is a flat object: `$type`
|
|
12
|
+
* is an enum of the component names, every component's props are merged into
|
|
13
|
+
* one optional bag, and `children` recurses via `$defs` so the tree can nest.
|
|
13
14
|
*
|
|
14
15
|
* It is intentionally flat rather than a per-`$type` discriminated union. Tool /
|
|
15
16
|
* function-call schemas (OpenAI and others) require the top-level parameters to
|
|
@@ -24,10 +25,10 @@ export function buildPresentParameters(
|
|
|
24
25
|
): JSONSchema7 {
|
|
25
26
|
const names = Object.keys(library);
|
|
26
27
|
|
|
27
|
-
// Merge every component's props into one optional bag.
|
|
28
|
-
// framework-reserved, so drop any author-declared
|
|
29
|
-
// first component's schema wins — props are an
|
|
30
|
-
// strict per-component contract.
|
|
28
|
+
// Merge every component's props into one optional bag. `$`-prefixed keys and
|
|
29
|
+
// `children` are framework-reserved (see ir.ts), so drop any author-declared
|
|
30
|
+
// copies. On a name clash the first component's schema wins — props are an
|
|
31
|
+
// advisory hint here, not a strict per-component contract.
|
|
31
32
|
const props: Record<string, JSONSchema7Definition> = {};
|
|
32
33
|
const propOwners = new Map<string, string[]>();
|
|
33
34
|
for (const name of names) {
|
|
@@ -39,7 +40,7 @@ export function buildPresentParameters(
|
|
|
39
40
|
);
|
|
40
41
|
}
|
|
41
42
|
for (const [key, schema] of Object.entries(propsSchema.properties ?? {})) {
|
|
42
|
-
if (key
|
|
43
|
+
if (key.startsWith("$") || key === "children") continue;
|
|
43
44
|
if (!(key in props)) {
|
|
44
45
|
props[key] = schema;
|
|
45
46
|
}
|
|
@@ -72,6 +73,11 @@ export function buildPresentParameters(
|
|
|
72
73
|
type: "object",
|
|
73
74
|
properties: {
|
|
74
75
|
[TYPE_KEY]: { type: "string", enum: names, description: typeDescription },
|
|
76
|
+
$key: {
|
|
77
|
+
description:
|
|
78
|
+
"Stable identity for this UI node. Use it for list items that may reorder.",
|
|
79
|
+
anyOf: [{ type: "string" }, { type: "number" }],
|
|
80
|
+
},
|
|
75
81
|
...props,
|
|
76
82
|
children: { $ref: "#/$defs/children" },
|
|
77
83
|
},
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { generativeUIToJSX } from "./generativeUIToJSX";
|
|
3
3
|
|
|
4
|
+
describe("generativeUIToJSX pretty depth bound", () => {
|
|
5
|
+
it("tolerates children nested in arrays beyond the depth bound", () => {
|
|
6
|
+
let node: unknown = { $type: "Text", value: "leaf" };
|
|
7
|
+
for (let i = 0; i < 100; i++) node = [node];
|
|
8
|
+
expect(() =>
|
|
9
|
+
generativeUIToJSX({ $type: "Card", children: node }, { pretty: true }),
|
|
10
|
+
).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
4
14
|
describe("generativeUIToJSX", () => {
|
|
5
15
|
it("renders a leaf element as a self-closing tag", () => {
|
|
6
16
|
expect(generativeUIToJSX({ $type: "Weather", id: "5d99d2e9" })).toBe(
|
|
@@ -31,6 +41,15 @@ describe("generativeUIToJSX", () => {
|
|
|
31
41
|
);
|
|
32
42
|
});
|
|
33
43
|
|
|
44
|
+
it("emits a model-provided $key as the JSX key attribute", () => {
|
|
45
|
+
expect(
|
|
46
|
+
generativeUIToJSX({ $type: "Text", $key: "1:Text", children: "hello" }),
|
|
47
|
+
).toBe('<Text key="1:Text">hello</Text>');
|
|
48
|
+
expect(generativeUIToJSX({ $type: "Item", $key: 2 })).toBe(
|
|
49
|
+
"<Item key={2} />",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
34
53
|
it("renders string children between tags", () => {
|
|
35
54
|
expect(
|
|
36
55
|
generativeUIToJSX({ $type: "Text", tone: "muted", children: "hello" }),
|
|
@@ -62,3 +81,170 @@ describe("generativeUIToJSX", () => {
|
|
|
62
81
|
expect(generativeUIToJSX({})).toBe(""); // no $type yet (still streaming)
|
|
63
82
|
});
|
|
64
83
|
});
|
|
84
|
+
|
|
85
|
+
describe("generativeUIToJSX with escape: true", () => {
|
|
86
|
+
it("escapes a string child containing HTML-like tags", () => {
|
|
87
|
+
expect(
|
|
88
|
+
generativeUIToJSX(
|
|
89
|
+
{ $type: "Text", children: "<script>alert(1)</script>" },
|
|
90
|
+
{ escape: true },
|
|
91
|
+
),
|
|
92
|
+
).toBe('<Text>{"<script>alert(1)</script>"}</Text>');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("escapes a string child containing braces", () => {
|
|
96
|
+
expect(
|
|
97
|
+
generativeUIToJSX(
|
|
98
|
+
{ $type: "Text", children: "a {b} c" },
|
|
99
|
+
{ escape: true },
|
|
100
|
+
),
|
|
101
|
+
).toBe('<Text>{"a {b} c"}</Text>');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("escapes a string child containing an ampersand", () => {
|
|
105
|
+
expect(
|
|
106
|
+
generativeUIToJSX({ $type: "Text", children: "Q&A" }, { escape: true }),
|
|
107
|
+
).toBe('<Text>{"Q&A"}</Text>');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("leaves a plain text child verbatim", () => {
|
|
111
|
+
expect(
|
|
112
|
+
generativeUIToJSX(
|
|
113
|
+
{ $type: "Text", children: "hello world" },
|
|
114
|
+
{ escape: true },
|
|
115
|
+
),
|
|
116
|
+
).toBe("<Text>hello world</Text>");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("leaves numbers, arrays, and nested objects unaffected", () => {
|
|
120
|
+
expect(
|
|
121
|
+
generativeUIToJSX(
|
|
122
|
+
{
|
|
123
|
+
$type: "Card",
|
|
124
|
+
children: [
|
|
125
|
+
{ $type: "Text", children: 42 },
|
|
126
|
+
{ $type: "Text", data: { a: 1 }, children: "plain" },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
{ escape: true },
|
|
130
|
+
),
|
|
131
|
+
).toBe('<Card><Text>42</Text><Text data={{"a":1}}>plain</Text></Card>');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("generativeUIToJSX with pretty: true", () => {
|
|
136
|
+
const nestedCard = {
|
|
137
|
+
$type: "Card",
|
|
138
|
+
title: "Hi",
|
|
139
|
+
children: [
|
|
140
|
+
{ $type: "Text", children: "a" },
|
|
141
|
+
{
|
|
142
|
+
$type: "Box",
|
|
143
|
+
children: { $type: "Text", tone: "muted", children: "b" },
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
it("keeps default output single-line and byte-identical without pretty", () => {
|
|
149
|
+
expect(generativeUIToJSX(nestedCard)).toBe(
|
|
150
|
+
'<Card title="Hi"><Text>a</Text><Box><Text tone="muted">b</Text></Box></Card>',
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("block-nests elements with two-space grandchild indentation", () => {
|
|
155
|
+
expect(generativeUIToJSX(nestedCard, { pretty: true })).toBe(
|
|
156
|
+
[
|
|
157
|
+
'<Card title="Hi">',
|
|
158
|
+
" <Text>a</Text>",
|
|
159
|
+
" <Box>",
|
|
160
|
+
' <Text tone="muted">b</Text>',
|
|
161
|
+
" </Box>",
|
|
162
|
+
"</Card>",
|
|
163
|
+
].join("\n"),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("keeps elements with only string or number children on one line", () => {
|
|
168
|
+
expect(
|
|
169
|
+
generativeUIToJSX(
|
|
170
|
+
{ $type: "Text", tone: "muted", children: "hello" },
|
|
171
|
+
{ pretty: true },
|
|
172
|
+
),
|
|
173
|
+
).toBe('<Text tone="muted">hello</Text>');
|
|
174
|
+
expect(
|
|
175
|
+
generativeUIToJSX({ $type: "Text", children: 42 }, { pretty: true }),
|
|
176
|
+
).toBe("<Text>42</Text>");
|
|
177
|
+
expect(generativeUIToJSX({ $type: "Divider" }, { pretty: true })).toBe(
|
|
178
|
+
"<Divider />",
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("renders a root-level array with each item on its own line", () => {
|
|
183
|
+
expect(
|
|
184
|
+
generativeUIToJSX(
|
|
185
|
+
[
|
|
186
|
+
{ $type: "Text", children: "a" },
|
|
187
|
+
{ $type: "Text", children: "b" },
|
|
188
|
+
],
|
|
189
|
+
{ pretty: true },
|
|
190
|
+
),
|
|
191
|
+
).toBe("<Text>a</Text>\n<Text>b</Text>");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("puts mixed text and element children each on their own line", () => {
|
|
195
|
+
expect(
|
|
196
|
+
generativeUIToJSX(
|
|
197
|
+
{
|
|
198
|
+
$type: "Card",
|
|
199
|
+
children: ["hello", { $type: "Text", children: "world" }, 7],
|
|
200
|
+
},
|
|
201
|
+
{ pretty: true },
|
|
202
|
+
),
|
|
203
|
+
).toBe(
|
|
204
|
+
["<Card>", " hello", " <Text>world</Text>", " 7", "</Card>"].join(
|
|
205
|
+
"\n",
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("skips non-renderable children without blank lines", () => {
|
|
211
|
+
expect(
|
|
212
|
+
generativeUIToJSX(
|
|
213
|
+
{
|
|
214
|
+
$type: "Card",
|
|
215
|
+
children: [
|
|
216
|
+
null,
|
|
217
|
+
{ $type: "Text", children: "a" },
|
|
218
|
+
false,
|
|
219
|
+
{ $type: "Text", children: "b" },
|
|
220
|
+
true,
|
|
221
|
+
],
|
|
222
|
+
},
|
|
223
|
+
{ pretty: true },
|
|
224
|
+
),
|
|
225
|
+
).toBe(
|
|
226
|
+
["<Card>", " <Text>a</Text>", " <Text>b</Text>", "</Card>"].join("\n"),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("composes escape with pretty", () => {
|
|
231
|
+
expect(
|
|
232
|
+
generativeUIToJSX(
|
|
233
|
+
{
|
|
234
|
+
$type: "Card",
|
|
235
|
+
children: {
|
|
236
|
+
$type: "Text",
|
|
237
|
+
children: "<script>alert(1)</script>",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{ escape: true, pretty: true },
|
|
241
|
+
),
|
|
242
|
+
).toBe(
|
|
243
|
+
[
|
|
244
|
+
"<Card>",
|
|
245
|
+
' <Text>{"<script>alert(1)</script>"}</Text>',
|
|
246
|
+
"</Card>",
|
|
247
|
+
].join("\n"),
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
package/src/generativeUIToJSX.ts
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { TYPE_KEY } from "./constants";
|
|
2
2
|
|
|
3
|
+
/** Options for {@link generativeUIToJSX}. */
|
|
4
|
+
export interface GenerativeUIToJSXOptions {
|
|
5
|
+
/** Escape string children so the output is safe to paste back into a JSX file. Defaults to `false`. */
|
|
6
|
+
escape?: boolean;
|
|
7
|
+
/** Pretty-print nested element trees with two-space indentation. Defaults to `false`. */
|
|
8
|
+
pretty?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
/**
|
|
4
|
-
* Serializes a generative-UI node to a JSX-like string for display
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `children` render between tags: `<Card title="Hi"><Text>hello</Text></Card>`.
|
|
12
|
+
* Serializes a generative-UI node to a JSX-like string for display: the "view source" of a model-produced tree. The wire form `{ $type: "Weather", id: "x" }` becomes `<Weather id="x" />`, and nested `children` render between tags: `<Card title="Hi"><Text>hello</Text></Card>`. A model-provided `$key` becomes the JSX `key` attribute.
|
|
13
|
+
*
|
|
14
|
+
* By default this is a faithful textual rendering, not a parser: text children are emitted verbatim (not HTML/JSX-escaped), so the result is meant to be shown, not re-parsed. Returns `""` for nodes that aren't renderable (no `$type` yet, `null`, booleans).
|
|
8
15
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `null`, booleans).
|
|
16
|
+
* Pass `{ escape: true }` to make the output safe to paste back into JSX: any string child containing `<`, `>`, `&`, `{`, or `}` is emitted as a JSON-stringified expression (`{JSON.stringify(child)}`) instead of verbatim, while a string child with none of those characters still renders verbatim.
|
|
17
|
+
*
|
|
18
|
+
* Pass `{ pretty: true }` to pretty-print nested element trees with two-space indentation, while pure text children stay on one line.
|
|
13
19
|
*/
|
|
14
|
-
export function generativeUIToJSX(
|
|
15
|
-
|
|
20
|
+
export function generativeUIToJSX(
|
|
21
|
+
node: unknown,
|
|
22
|
+
options?: GenerativeUIToJSXOptions,
|
|
23
|
+
): string {
|
|
24
|
+
return toJSX(node, 0, options?.escape ?? false, options?.pretty ?? false, 0);
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
/**
|
|
@@ -22,30 +31,112 @@ export function generativeUIToJSX(node: unknown): string {
|
|
|
22
31
|
*/
|
|
23
32
|
const MAX_DEPTH = 64;
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
/** Characters that would break JSX if emitted verbatim inside a text child. */
|
|
35
|
+
const UNSAFE_CHILD_CHARS = /[<>&{}]/;
|
|
36
|
+
|
|
37
|
+
function toJSX(
|
|
38
|
+
node: unknown,
|
|
39
|
+
depth: number,
|
|
40
|
+
escape: boolean,
|
|
41
|
+
pretty: boolean,
|
|
42
|
+
indent: number,
|
|
43
|
+
): string {
|
|
26
44
|
if (depth > MAX_DEPTH) return "";
|
|
27
45
|
if (node == null || typeof node === "boolean") return "";
|
|
28
|
-
if (typeof node === "string") return node;
|
|
46
|
+
if (typeof node === "string") return formatChildText(node, escape);
|
|
29
47
|
if (typeof node === "number") return String(node);
|
|
30
|
-
if (Array.isArray(node))
|
|
31
|
-
|
|
48
|
+
if (Array.isArray(node)) {
|
|
49
|
+
if (pretty) {
|
|
50
|
+
return collectBlockLines(node, depth, escape, indent).join("\n");
|
|
51
|
+
}
|
|
52
|
+
return node
|
|
53
|
+
.map((child) => toJSX(child, depth + 1, escape, false, 0))
|
|
54
|
+
.join("");
|
|
55
|
+
}
|
|
32
56
|
if (typeof node !== "object") return "";
|
|
33
57
|
|
|
34
58
|
const {
|
|
35
59
|
[TYPE_KEY]: type,
|
|
60
|
+
$key,
|
|
36
61
|
children,
|
|
37
62
|
...props
|
|
38
63
|
} = node as Record<string, unknown>;
|
|
39
64
|
if (typeof type !== "string") return "";
|
|
40
65
|
|
|
41
|
-
const attrs =
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
|
|
66
|
+
const attrs =
|
|
67
|
+
formatAttr("key", $key) +
|
|
68
|
+
Object.entries(props)
|
|
69
|
+
.map(([key, value]) => formatAttr(key, value))
|
|
70
|
+
.join("");
|
|
71
|
+
|
|
72
|
+
if (!pretty) {
|
|
73
|
+
const inner =
|
|
74
|
+
children === undefined
|
|
75
|
+
? ""
|
|
76
|
+
: toJSX(children, depth + 1, escape, false, 0);
|
|
77
|
+
|
|
78
|
+
return inner === ""
|
|
79
|
+
? `<${type}${attrs} />`
|
|
80
|
+
: `<${type}${attrs}>${inner}</${type}>`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const pad = " ".repeat(indent);
|
|
84
|
+
|
|
85
|
+
if (children === undefined || !hasElementChild(children)) {
|
|
86
|
+
const inner =
|
|
87
|
+
children === undefined
|
|
88
|
+
? ""
|
|
89
|
+
: toJSX(children, depth + 1, escape, false, 0);
|
|
90
|
+
return inner === ""
|
|
91
|
+
? `${pad}<${type}${attrs} />`
|
|
92
|
+
: `${pad}<${type}${attrs}>${inner}</${type}>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines = collectBlockLines(children, depth + 1, escape, indent + 1);
|
|
96
|
+
if (lines.length === 0) {
|
|
97
|
+
return `${pad}<${type}${attrs} />`;
|
|
98
|
+
}
|
|
99
|
+
return `${pad}<${type}${attrs}>\n${lines.join("\n")}\n${pad}</${type}>`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasElementChild(node: unknown, depth = 0): boolean {
|
|
103
|
+
if (depth > MAX_DEPTH) return false;
|
|
104
|
+
if (Array.isArray(node))
|
|
105
|
+
return node.some((child) => hasElementChild(child, depth + 1));
|
|
106
|
+
return (
|
|
107
|
+
node != null &&
|
|
108
|
+
typeof node === "object" &&
|
|
109
|
+
typeof (node as Record<string, unknown>)[TYPE_KEY] === "string"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function collectBlockLines(
|
|
114
|
+
node: unknown,
|
|
115
|
+
depth: number,
|
|
116
|
+
escape: boolean,
|
|
117
|
+
indent: number,
|
|
118
|
+
): string[] {
|
|
119
|
+
if (depth > MAX_DEPTH) return [];
|
|
120
|
+
if (node == null || typeof node === "boolean") return [];
|
|
121
|
+
if (Array.isArray(node)) {
|
|
122
|
+
return node.flatMap((child) =>
|
|
123
|
+
collectBlockLines(child, depth + 1, escape, indent),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (typeof node === "string") {
|
|
127
|
+
return [`${" ".repeat(indent)}${formatChildText(node, escape)}`];
|
|
128
|
+
}
|
|
129
|
+
if (typeof node === "number") {
|
|
130
|
+
return [`${" ".repeat(indent)}${String(node)}`];
|
|
131
|
+
}
|
|
132
|
+
const rendered = toJSX(node, depth, escape, true, indent);
|
|
133
|
+
return rendered === "" ? [] : [rendered];
|
|
134
|
+
}
|
|
45
135
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
136
|
+
function formatChildText(value: string, escape: boolean): string {
|
|
137
|
+
return escape && UNSAFE_CHILD_CHARS.test(value)
|
|
138
|
+
? `{${JSON.stringify(value)}}`
|
|
139
|
+
: value;
|
|
49
140
|
}
|
|
50
141
|
|
|
51
142
|
/** Formats one prop as a JSX attribute (`id="x"`, `count={3}`, `open`, …). */
|
package/src/index.shared.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { renderGenerativeUI } from "./renderGenerativeUI";
|
|
2
2
|
export { generativeUIToJSX } from "./generativeUIToJSX";
|
|
3
|
+
export type { GenerativeUIToJSXOptions } from "./generativeUIToJSX";
|
|
3
4
|
export { buildPresentParameters } from "./buildPresentParameters";
|
|
4
5
|
export { defineGenerativeComponents } from "./defineGenerativeComponents";
|
|
5
6
|
export { TYPE_KEY } from "./constants";
|
|
@@ -13,6 +14,7 @@ export {
|
|
|
13
14
|
JUSTIFIES,
|
|
14
15
|
BUTTON_STYLES,
|
|
15
16
|
ALERT_TONES,
|
|
17
|
+
ICON_NAMES,
|
|
16
18
|
} from "./ir";
|
|
17
19
|
export { defaultGenerativeUILibrary } from "./vocabulary";
|
|
18
20
|
export { createActionRegistry, emptyActionRegistry } from "./actionRegistry";
|
|
@@ -55,4 +57,5 @@ export type {
|
|
|
55
57
|
Justify,
|
|
56
58
|
ButtonStyle,
|
|
57
59
|
AlertTone,
|
|
60
|
+
IconName,
|
|
58
61
|
} from "./ir";
|
package/src/ir.ts
CHANGED
|
@@ -52,14 +52,40 @@ export const BUTTON_STYLES = [
|
|
|
52
52
|
export type ButtonStyle = (typeof BUTTON_STYLES)[number];
|
|
53
53
|
|
|
54
54
|
export const ALERT_TONES = ["info", "success", "warning", "danger"] as const;
|
|
55
|
-
/**
|
|
55
|
+
/** A severity level, from informational to destructive. */
|
|
56
56
|
export type AlertTone = (typeof ALERT_TONES)[number];
|
|
57
57
|
|
|
58
|
+
export const ICON_NAMES = [
|
|
59
|
+
"sun",
|
|
60
|
+
"moon",
|
|
61
|
+
"cloud",
|
|
62
|
+
"rain",
|
|
63
|
+
"snow",
|
|
64
|
+
"wind",
|
|
65
|
+
"play",
|
|
66
|
+
"pause",
|
|
67
|
+
"check",
|
|
68
|
+
"x",
|
|
69
|
+
"star",
|
|
70
|
+
"heart",
|
|
71
|
+
"arrow-right",
|
|
72
|
+
"arrow-up-right",
|
|
73
|
+
"chevron-right",
|
|
74
|
+
"calendar",
|
|
75
|
+
"clock",
|
|
76
|
+
"map-pin",
|
|
77
|
+
"plane",
|
|
78
|
+
"truck",
|
|
79
|
+
"credit-card",
|
|
80
|
+
"user",
|
|
81
|
+
"search",
|
|
82
|
+
"bell",
|
|
83
|
+
] as const;
|
|
84
|
+
/** A name from the built-in icon set; the closed enum lets the model see exactly which icons exist. */
|
|
85
|
+
export type IconName = (typeof ICON_NAMES)[number];
|
|
86
|
+
|
|
58
87
|
/**
|
|
59
|
-
* Behavior payload carried by an interactive node. `type` is resolved by the
|
|
60
|
-
* host's action registry, not the renderer; keeping behavior as data keeps the
|
|
61
|
-
* tree serializable so the same node renders on web and converts to a native
|
|
62
|
-
* action id on Slack.
|
|
88
|
+
* Behavior payload carried by an interactive node. `type` is resolved by the host's action registry, not the renderer; keeping behavior as data keeps the tree serializable, so the same node renders on web while converters may bind the type to a native action id on other platforms.
|
|
63
89
|
*/
|
|
64
90
|
export interface Action {
|
|
65
91
|
readonly type: string;
|
|
@@ -212,6 +212,11 @@ describe("buildPresentParameters", () => {
|
|
|
212
212
|
]);
|
|
213
213
|
// each component's description rides along on the $type enum.
|
|
214
214
|
expect(schema.properties.$type.description).toContain("Card");
|
|
215
|
+
expect(schema.properties.$key).toEqual({
|
|
216
|
+
description:
|
|
217
|
+
"Stable identity for this UI node. Use it for list items that may reorder.",
|
|
218
|
+
anyOf: [{ type: "string" }, { type: "number" }],
|
|
219
|
+
});
|
|
215
220
|
expect(schema.properties.children.$ref).toBe("#/$defs/children");
|
|
216
221
|
|
|
217
222
|
// every component's props are merged into the one flat property bag.
|
|
@@ -228,12 +233,14 @@ describe("buildPresentParameters", () => {
|
|
|
228
233
|
expect(schema.$defs.node.oneOf).toBeUndefined();
|
|
229
234
|
});
|
|
230
235
|
|
|
231
|
-
it("drops author-declared
|
|
236
|
+
it("drops author-declared `$`-prefixed and `children` props, keeping framework fields", () => {
|
|
232
237
|
const schema = buildPresentParameters({
|
|
233
238
|
Reserved: {
|
|
234
239
|
description: "Declares reserved keys that must not leak through.",
|
|
235
240
|
properties: z.object({
|
|
236
241
|
$type: z.number(),
|
|
242
|
+
$key: z.boolean(),
|
|
243
|
+
$action: z.string(),
|
|
237
244
|
children: z.number(),
|
|
238
245
|
label: z.string(),
|
|
239
246
|
}),
|
|
@@ -242,8 +249,15 @@ describe("buildPresentParameters", () => {
|
|
|
242
249
|
}) as any;
|
|
243
250
|
|
|
244
251
|
// The discriminator is the framework enum, not the author's `$type`; the
|
|
245
|
-
// author's `children`
|
|
252
|
+
// author's `$`-prefixed props and `children` are dropped in favor of the
|
|
253
|
+
// framework fields.
|
|
246
254
|
expect(schema.properties.$type.enum).toEqual(["Reserved"]);
|
|
255
|
+
expect(schema.properties.$key).toEqual({
|
|
256
|
+
description:
|
|
257
|
+
"Stable identity for this UI node. Use it for list items that may reorder.",
|
|
258
|
+
anyOf: [{ type: "string" }, { type: "number" }],
|
|
259
|
+
});
|
|
260
|
+
expect(schema.properties.$action).toBeUndefined();
|
|
247
261
|
expect(schema.properties.children.$ref).toBe("#/$defs/children");
|
|
248
262
|
expect(schema.properties.label).toBeDefined();
|
|
249
263
|
expect(schema.required).toEqual(["$type"]);
|