@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
|
@@ -0,0 +1,1319 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeSpec,
|
|
3
|
+
type NormalizedUIElement,
|
|
4
|
+
type NormalizedUINode,
|
|
5
|
+
} from "../ir";
|
|
6
|
+
import {
|
|
7
|
+
ACTION_ID_CAP,
|
|
8
|
+
ACTIONS_ELEMENT_CAP,
|
|
9
|
+
ALERT_TEXT_CAP,
|
|
10
|
+
BUTTON_VALUE_CAP,
|
|
11
|
+
CARD_ACTIONS_CAP,
|
|
12
|
+
CARD_BODY_CAP,
|
|
13
|
+
CARD_SUBTEXT_CAP,
|
|
14
|
+
CARD_TITLE_CAP,
|
|
15
|
+
CAROUSEL_CARD_CAP,
|
|
16
|
+
CAROUSEL_CARD_MIN,
|
|
17
|
+
CHILDREN_CAP,
|
|
18
|
+
CONTEXT_ELEMENT_CAP,
|
|
19
|
+
CONTEXT_TEXT_CAP,
|
|
20
|
+
DATA_TABLE_CHAR_BUDGET,
|
|
21
|
+
DATA_TABLE_COLUMN_CAP,
|
|
22
|
+
DATA_TABLE_ROW_CAP,
|
|
23
|
+
FACT_FIELD_CAP,
|
|
24
|
+
FACT_FIELD_TEXT_CAP,
|
|
25
|
+
HEADER_TEXT_CAP,
|
|
26
|
+
INPUT_LABEL_CAP,
|
|
27
|
+
INTERACTIVE_TEXT_CAP,
|
|
28
|
+
MARKDOWN_TEXT_BUDGET,
|
|
29
|
+
MAX_TRAVERSAL_DEPTH,
|
|
30
|
+
MESSAGE_BLOCK_CAP,
|
|
31
|
+
MODAL_BLOCK_CAP,
|
|
32
|
+
NODE_BUDGET,
|
|
33
|
+
PLACEHOLDER_TEXT_CAP,
|
|
34
|
+
RADIO_OPTION_CAP,
|
|
35
|
+
SECTION_TEXT_CAP,
|
|
36
|
+
SELECT_OPTION_CAP,
|
|
37
|
+
buildAlertBlock,
|
|
38
|
+
buildAlertFallback,
|
|
39
|
+
buildBlockBudgetNote,
|
|
40
|
+
buildCardBlock,
|
|
41
|
+
buildCarouselBlock,
|
|
42
|
+
buildDataTableBlock,
|
|
43
|
+
} from "./constants";
|
|
44
|
+
import type {
|
|
45
|
+
SlackActionElement,
|
|
46
|
+
SlackBlock,
|
|
47
|
+
SlackButtonElement,
|
|
48
|
+
SlackCardBlock,
|
|
49
|
+
SlackConversionWarning,
|
|
50
|
+
SlackDataTableCell,
|
|
51
|
+
SlackImageBlock,
|
|
52
|
+
SlackOption,
|
|
53
|
+
SlackPlainText,
|
|
54
|
+
SlackTextObject,
|
|
55
|
+
SlackBlocksResult,
|
|
56
|
+
ToSlackBlocksOptions,
|
|
57
|
+
} from "./types";
|
|
58
|
+
|
|
59
|
+
type ConversionContext = {
|
|
60
|
+
readonly blockCap: number;
|
|
61
|
+
readonly surface: "message" | "modal";
|
|
62
|
+
readonly warnings: SlackConversionWarning[];
|
|
63
|
+
markdownCharacters: number;
|
|
64
|
+
markdownExhausted: boolean;
|
|
65
|
+
dataTableCharacters: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const INTERACTIVE_TYPES = new Set([
|
|
69
|
+
"Button",
|
|
70
|
+
"Select",
|
|
71
|
+
"DatePicker",
|
|
72
|
+
"Checkbox",
|
|
73
|
+
"RadioGroup",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
77
|
+
|
|
78
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
79
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
80
|
+
|
|
81
|
+
const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
|
|
82
|
+
isRecord(node);
|
|
83
|
+
|
|
84
|
+
const asString = (value: unknown): string =>
|
|
85
|
+
typeof value === "string" ? value : "";
|
|
86
|
+
|
|
87
|
+
const warn = (
|
|
88
|
+
context: ConversionContext,
|
|
89
|
+
code: SlackConversionWarning["code"],
|
|
90
|
+
component: string,
|
|
91
|
+
detail: string,
|
|
92
|
+
) => {
|
|
93
|
+
context.warnings.push({ code, component, detail });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const clampText = (
|
|
97
|
+
value: string,
|
|
98
|
+
cap: number,
|
|
99
|
+
component: string,
|
|
100
|
+
field: string,
|
|
101
|
+
context: ConversionContext,
|
|
102
|
+
): string => {
|
|
103
|
+
if (value.length <= cap) return value;
|
|
104
|
+
warn(
|
|
105
|
+
context,
|
|
106
|
+
"clamped",
|
|
107
|
+
component,
|
|
108
|
+
`${field} was clamped to ${cap} characters.`,
|
|
109
|
+
);
|
|
110
|
+
return value.slice(0, cap);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const asActionId = (
|
|
114
|
+
action: unknown,
|
|
115
|
+
component: string,
|
|
116
|
+
context: ConversionContext,
|
|
117
|
+
): string =>
|
|
118
|
+
clampText(
|
|
119
|
+
isRecord(action) && typeof action["type"] === "string"
|
|
120
|
+
? action["type"]
|
|
121
|
+
: "action",
|
|
122
|
+
ACTION_ID_CAP,
|
|
123
|
+
component,
|
|
124
|
+
"action_id",
|
|
125
|
+
context,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const plainText = (text: string): SlackPlainText => ({
|
|
129
|
+
type: "plain_text",
|
|
130
|
+
text,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const actionValue = (action: unknown): string | undefined => {
|
|
134
|
+
if (!isRecord(action)) return undefined;
|
|
135
|
+
const { type: _type, ...payload } = action;
|
|
136
|
+
if (Object.keys(payload).length === 0) return undefined;
|
|
137
|
+
try {
|
|
138
|
+
const serialized = JSON.stringify(payload);
|
|
139
|
+
return serialized === "{}" ? undefined : serialized;
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const buttonElement = (
|
|
146
|
+
label: string,
|
|
147
|
+
buttonStyle: unknown,
|
|
148
|
+
action: unknown,
|
|
149
|
+
component: string,
|
|
150
|
+
context: ConversionContext,
|
|
151
|
+
): SlackButtonElement => {
|
|
152
|
+
const serializedValue = actionValue(action);
|
|
153
|
+
let value = serializedValue;
|
|
154
|
+
if (value !== undefined && value.length > BUTTON_VALUE_CAP) {
|
|
155
|
+
warn(
|
|
156
|
+
context,
|
|
157
|
+
"dropped",
|
|
158
|
+
component,
|
|
159
|
+
"value was dropped because the action payload was too large to round-trip.",
|
|
160
|
+
);
|
|
161
|
+
value = undefined;
|
|
162
|
+
}
|
|
163
|
+
const style =
|
|
164
|
+
buttonStyle === "primary" || buttonStyle === "danger"
|
|
165
|
+
? buttonStyle
|
|
166
|
+
: undefined;
|
|
167
|
+
return {
|
|
168
|
+
type: "button",
|
|
169
|
+
text: plainText(
|
|
170
|
+
clampText(label, INTERACTIVE_TEXT_CAP, component, "label", context),
|
|
171
|
+
),
|
|
172
|
+
action_id: asActionId(action, component, context),
|
|
173
|
+
...(value !== undefined ? { value } : {}),
|
|
174
|
+
...(style !== undefined ? { style } : {}),
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const optionFrom = (
|
|
179
|
+
value: unknown,
|
|
180
|
+
component: string,
|
|
181
|
+
context: ConversionContext,
|
|
182
|
+
): SlackOption | undefined => {
|
|
183
|
+
if (
|
|
184
|
+
!isRecord(value) ||
|
|
185
|
+
typeof value["label"] !== "string" ||
|
|
186
|
+
typeof value["value"] !== "string"
|
|
187
|
+
) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
text: plainText(
|
|
192
|
+
clampText(
|
|
193
|
+
value["label"],
|
|
194
|
+
INTERACTIVE_TEXT_CAP,
|
|
195
|
+
component,
|
|
196
|
+
"option label",
|
|
197
|
+
context,
|
|
198
|
+
),
|
|
199
|
+
),
|
|
200
|
+
value: value["value"],
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const toActionElement = (
|
|
205
|
+
element: NormalizedUIElement,
|
|
206
|
+
context: ConversionContext,
|
|
207
|
+
): SlackActionElement | undefined => {
|
|
208
|
+
const { props, action } = element;
|
|
209
|
+
switch (element.type) {
|
|
210
|
+
case "Button":
|
|
211
|
+
return buttonElement(
|
|
212
|
+
asString(props["label"]),
|
|
213
|
+
props["buttonStyle"],
|
|
214
|
+
action,
|
|
215
|
+
"Button",
|
|
216
|
+
context,
|
|
217
|
+
);
|
|
218
|
+
case "Select": {
|
|
219
|
+
const rawOptions = Array.isArray(props["options"])
|
|
220
|
+
? props["options"]
|
|
221
|
+
: [];
|
|
222
|
+
if (rawOptions.length > SELECT_OPTION_CAP) {
|
|
223
|
+
warn(
|
|
224
|
+
context,
|
|
225
|
+
"clamped",
|
|
226
|
+
"Select",
|
|
227
|
+
`options were clamped to ${SELECT_OPTION_CAP} entries.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const options = rawOptions
|
|
231
|
+
.slice(0, SELECT_OPTION_CAP)
|
|
232
|
+
.map((option) => optionFrom(option, "Select", context))
|
|
233
|
+
.filter((option): option is SlackOption => option !== undefined);
|
|
234
|
+
const placeholder = clampText(
|
|
235
|
+
asString(props["placeholder"]),
|
|
236
|
+
PLACEHOLDER_TEXT_CAP,
|
|
237
|
+
"Select",
|
|
238
|
+
"placeholder",
|
|
239
|
+
context,
|
|
240
|
+
);
|
|
241
|
+
return {
|
|
242
|
+
type: "static_select",
|
|
243
|
+
action_id: asActionId(action, "Select", context),
|
|
244
|
+
options,
|
|
245
|
+
...(placeholder ? { placeholder: plainText(placeholder) } : {}),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
case "DatePicker": {
|
|
249
|
+
const rawDate = asString(props["value"]);
|
|
250
|
+
const initialDate = DATE_PATTERN.test(rawDate) ? rawDate : undefined;
|
|
251
|
+
if (rawDate && initialDate === undefined) {
|
|
252
|
+
warn(
|
|
253
|
+
context,
|
|
254
|
+
"dropped",
|
|
255
|
+
"DatePicker",
|
|
256
|
+
"value did not match the YYYY-MM-DD format and was dropped.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
type: "datepicker",
|
|
261
|
+
action_id: asActionId(action, "DatePicker", context),
|
|
262
|
+
...(initialDate !== undefined ? { initial_date: initialDate } : {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
case "Checkbox": {
|
|
266
|
+
const label = clampText(
|
|
267
|
+
asString(props["label"]),
|
|
268
|
+
INTERACTIVE_TEXT_CAP,
|
|
269
|
+
"Checkbox",
|
|
270
|
+
"label",
|
|
271
|
+
context,
|
|
272
|
+
);
|
|
273
|
+
const name = asString(props["name"]);
|
|
274
|
+
const option: SlackOption = {
|
|
275
|
+
text: plainText(label),
|
|
276
|
+
value: name || label,
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
type: "checkboxes",
|
|
280
|
+
action_id: asActionId(action, "Checkbox", context),
|
|
281
|
+
options: [option],
|
|
282
|
+
...(props["defaultChecked"] === true
|
|
283
|
+
? { initial_options: [option] }
|
|
284
|
+
: {}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
case "RadioGroup": {
|
|
288
|
+
const rawOptions = Array.isArray(props["options"])
|
|
289
|
+
? props["options"]
|
|
290
|
+
: [];
|
|
291
|
+
if (rawOptions.length > RADIO_OPTION_CAP) {
|
|
292
|
+
warn(
|
|
293
|
+
context,
|
|
294
|
+
"clamped",
|
|
295
|
+
"RadioGroup",
|
|
296
|
+
`options were clamped to ${RADIO_OPTION_CAP} entries.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
const options = rawOptions
|
|
300
|
+
.slice(0, RADIO_OPTION_CAP)
|
|
301
|
+
.map((option) => optionFrom(option, "RadioGroup", context))
|
|
302
|
+
.filter((option): option is SlackOption => option !== undefined);
|
|
303
|
+
const selectedValue =
|
|
304
|
+
typeof props["value"] === "string"
|
|
305
|
+
? props["value"]
|
|
306
|
+
: asString(props["defaultValue"]);
|
|
307
|
+
const initialOption = options.find(
|
|
308
|
+
(option) => option.value === selectedValue,
|
|
309
|
+
);
|
|
310
|
+
return {
|
|
311
|
+
type: "radio_buttons",
|
|
312
|
+
action_id: asActionId(action, "RadioGroup", context),
|
|
313
|
+
options,
|
|
314
|
+
...(initialOption !== undefined
|
|
315
|
+
? { initial_option: initialOption }
|
|
316
|
+
: {}),
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
default:
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const normalizedList = (
|
|
325
|
+
node: NormalizedUINode | undefined,
|
|
326
|
+
): NormalizedUINode[] => {
|
|
327
|
+
if (node === undefined || node === null) return [];
|
|
328
|
+
if (!Array.isArray(node)) return [node];
|
|
329
|
+
return node.flatMap((child) => normalizedList(child));
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const convertFacts = (
|
|
333
|
+
facts: readonly NormalizedUIElement[],
|
|
334
|
+
context: ConversionContext,
|
|
335
|
+
): SlackBlock[] => {
|
|
336
|
+
const fields = facts.map((fact) => {
|
|
337
|
+
const label = asString(fact.props["label"]);
|
|
338
|
+
const value = asString(fact.props["value"]);
|
|
339
|
+
return {
|
|
340
|
+
type: "mrkdwn" as const,
|
|
341
|
+
text: clampText(
|
|
342
|
+
`*${label}*\n${value}`,
|
|
343
|
+
FACT_FIELD_TEXT_CAP,
|
|
344
|
+
"Fact",
|
|
345
|
+
"field",
|
|
346
|
+
context,
|
|
347
|
+
),
|
|
348
|
+
};
|
|
349
|
+
});
|
|
350
|
+
const blocks: SlackBlock[] = [];
|
|
351
|
+
for (let index = 0; index < fields.length; index += FACT_FIELD_CAP) {
|
|
352
|
+
blocks.push({
|
|
353
|
+
type: "section",
|
|
354
|
+
fields: fields.slice(index, index + FACT_FIELD_CAP),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return blocks;
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
const convertActions = (
|
|
361
|
+
elements: readonly NormalizedUIElement[],
|
|
362
|
+
context: ConversionContext,
|
|
363
|
+
): SlackBlock[] => {
|
|
364
|
+
const converted = elements
|
|
365
|
+
.map((element) => toActionElement(element, context))
|
|
366
|
+
.filter((element): element is SlackActionElement => element !== undefined);
|
|
367
|
+
const blocks: SlackBlock[] = [];
|
|
368
|
+
for (let index = 0; index < converted.length; index += ACTIONS_ELEMENT_CAP) {
|
|
369
|
+
blocks.push({
|
|
370
|
+
type: "actions",
|
|
371
|
+
elements: converted.slice(index, index + ACTIONS_ELEMENT_CAP),
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return blocks;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const cardActionButtons = (
|
|
378
|
+
props: Readonly<Record<string, unknown>>,
|
|
379
|
+
context: ConversionContext,
|
|
380
|
+
): SlackButtonElement[] => {
|
|
381
|
+
const buttons: SlackButtonElement[] = [];
|
|
382
|
+
for (const key of ["confirm", "cancel"] as const) {
|
|
383
|
+
const footer = props[key];
|
|
384
|
+
if (!isRecord(footer)) continue;
|
|
385
|
+
buttons.push(
|
|
386
|
+
buttonElement(
|
|
387
|
+
asString(footer["label"]),
|
|
388
|
+
key === "confirm" ? "primary" : undefined,
|
|
389
|
+
footer["$action"],
|
|
390
|
+
"Card",
|
|
391
|
+
context,
|
|
392
|
+
),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
return buttons;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const cardTitleText = (
|
|
399
|
+
props: Readonly<Record<string, unknown>>,
|
|
400
|
+
context: ConversionContext,
|
|
401
|
+
): string | undefined => {
|
|
402
|
+
const rawTitle = asString(props["title"]);
|
|
403
|
+
return rawTitle
|
|
404
|
+
? clampText(rawTitle, CARD_TITLE_CAP, "Card", "title", context)
|
|
405
|
+
: undefined;
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const isCardNoOp = (node: NormalizedUINode): boolean =>
|
|
409
|
+
isElement(node) && (node.type === "Spacer" || node.type === "Icon");
|
|
410
|
+
|
|
411
|
+
const collectText = (
|
|
412
|
+
node: NormalizedUINode | undefined,
|
|
413
|
+
depth: number,
|
|
414
|
+
): string[] => {
|
|
415
|
+
if (node === undefined || node === null || depth > MAX_TRAVERSAL_DEPTH) {
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
if (typeof node === "string" || typeof node === "number") {
|
|
419
|
+
return [String(node)];
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(node)) {
|
|
422
|
+
return node.flatMap((child) => collectText(child, depth + 1));
|
|
423
|
+
}
|
|
424
|
+
const element = node as NormalizedUIElement;
|
|
425
|
+
const own = ["text", "value", "label", "title", "description"]
|
|
426
|
+
.map((key) => element.props[key])
|
|
427
|
+
.filter((value): value is string => typeof value === "string");
|
|
428
|
+
return [...own, ...collectText(element.children, depth + 1)];
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
type CardFields = {
|
|
432
|
+
readonly heroImage: SlackImageBlock | undefined;
|
|
433
|
+
readonly bodyRaw: string | undefined;
|
|
434
|
+
readonly subtextRaw: string | undefined;
|
|
435
|
+
readonly leftover: readonly NormalizedUINode[];
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Scans a card's children for the pieces the native card block shape can
|
|
440
|
+
* carry (a hero image, body text, and subtext), each taken from the first
|
|
441
|
+
* matching child. Anything left over means the card cannot map cleanly onto
|
|
442
|
+
* the native shape.
|
|
443
|
+
*/
|
|
444
|
+
const extractCardFields = (element: NormalizedUIElement): CardFields => {
|
|
445
|
+
const children = normalizedList(element.children);
|
|
446
|
+
let heroImage: SlackImageBlock | undefined;
|
|
447
|
+
let bodyRaw: string | undefined;
|
|
448
|
+
let subtextRaw: string | undefined;
|
|
449
|
+
const leftover: NormalizedUINode[] = [];
|
|
450
|
+
for (const child of children) {
|
|
451
|
+
if (heroImage === undefined && isElement(child) && child.type === "Image") {
|
|
452
|
+
heroImage = {
|
|
453
|
+
type: "image",
|
|
454
|
+
image_url: asString(child.props["src"]),
|
|
455
|
+
alt_text: asString(child.props["alt"]),
|
|
456
|
+
};
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (
|
|
460
|
+
bodyRaw === undefined &&
|
|
461
|
+
isElement(child) &&
|
|
462
|
+
(child.type === "Text" || child.type === "Markdown")
|
|
463
|
+
) {
|
|
464
|
+
bodyRaw = asString(child.props["value"]);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (
|
|
468
|
+
subtextRaw === undefined &&
|
|
469
|
+
isElement(child) &&
|
|
470
|
+
child.type === "Caption"
|
|
471
|
+
) {
|
|
472
|
+
subtextRaw = asString(child.props["value"]);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
if (isCardNoOp(child)) continue;
|
|
476
|
+
leftover.push(child);
|
|
477
|
+
}
|
|
478
|
+
return { heroImage, bodyRaw, subtextRaw, leftover };
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/** Assembles a native card block, clamping its body and subtext text. */
|
|
482
|
+
const assembleCleanCard = (
|
|
483
|
+
titleText: string | undefined,
|
|
484
|
+
fields: CardFields,
|
|
485
|
+
buttons: readonly SlackButtonElement[],
|
|
486
|
+
context: ConversionContext,
|
|
487
|
+
): SlackCardBlock => {
|
|
488
|
+
const body: SlackTextObject | undefined =
|
|
489
|
+
fields.bodyRaw !== undefined
|
|
490
|
+
? {
|
|
491
|
+
type: "mrkdwn",
|
|
492
|
+
text: clampText(
|
|
493
|
+
fields.bodyRaw,
|
|
494
|
+
CARD_BODY_CAP,
|
|
495
|
+
"Card",
|
|
496
|
+
"body",
|
|
497
|
+
context,
|
|
498
|
+
),
|
|
499
|
+
}
|
|
500
|
+
: undefined;
|
|
501
|
+
const subtext: SlackTextObject | undefined =
|
|
502
|
+
fields.subtextRaw !== undefined
|
|
503
|
+
? {
|
|
504
|
+
type: "mrkdwn",
|
|
505
|
+
text: clampText(
|
|
506
|
+
fields.subtextRaw,
|
|
507
|
+
CARD_SUBTEXT_CAP,
|
|
508
|
+
"Card",
|
|
509
|
+
"subtext",
|
|
510
|
+
context,
|
|
511
|
+
),
|
|
512
|
+
}
|
|
513
|
+
: undefined;
|
|
514
|
+
if (buttons.length > CARD_ACTIONS_CAP) {
|
|
515
|
+
warn(
|
|
516
|
+
context,
|
|
517
|
+
"clamped",
|
|
518
|
+
"Card",
|
|
519
|
+
`actions were clamped to ${CARD_ACTIONS_CAP} entries.`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return buildCardBlock({
|
|
523
|
+
...(fields.heroImage !== undefined ? { heroImage: fields.heroImage } : {}),
|
|
524
|
+
...(titleText !== undefined
|
|
525
|
+
? { title: { type: "mrkdwn", text: titleText } }
|
|
526
|
+
: {}),
|
|
527
|
+
...(body !== undefined ? { body } : {}),
|
|
528
|
+
...(subtext !== undefined ? { subtext } : {}),
|
|
529
|
+
...(buttons.length > 0
|
|
530
|
+
? { actions: buttons.slice(0, CARD_ACTIONS_CAP) }
|
|
531
|
+
: {}),
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A card requires at least one of `hero_image`, `title`, `body`, or `actions`
|
|
537
|
+
* (see {@link SlackCardBlock}); a card with none of them is not a valid
|
|
538
|
+
* native block and must be dropped instead of emitted.
|
|
539
|
+
*/
|
|
540
|
+
const isEmptyCard = (card: SlackCardBlock): boolean =>
|
|
541
|
+
card.hero_image === undefined &&
|
|
542
|
+
card.title === undefined &&
|
|
543
|
+
card.body === undefined &&
|
|
544
|
+
(card.actions === undefined || card.actions.length === 0);
|
|
545
|
+
|
|
546
|
+
const convertCard = (
|
|
547
|
+
element: NormalizedUIElement,
|
|
548
|
+
context: ConversionContext,
|
|
549
|
+
depth: number,
|
|
550
|
+
): SlackBlock[] => {
|
|
551
|
+
const titleText = cardTitleText(element.props, context);
|
|
552
|
+
const fields = extractCardFields(element);
|
|
553
|
+
const buttons = cardActionButtons(element.props, context);
|
|
554
|
+
|
|
555
|
+
if (fields.leftover.length > 0) {
|
|
556
|
+
warn(
|
|
557
|
+
context,
|
|
558
|
+
"fallback",
|
|
559
|
+
"Card",
|
|
560
|
+
"A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
|
|
561
|
+
);
|
|
562
|
+
return [
|
|
563
|
+
...(titleText
|
|
564
|
+
? [{ type: "header" as const, text: plainText(titleText) }]
|
|
565
|
+
: []),
|
|
566
|
+
...convertSequence(element.children, context, depth + 1),
|
|
567
|
+
...(buttons.length > 0
|
|
568
|
+
? [{ type: "actions" as const, elements: buttons }]
|
|
569
|
+
: []),
|
|
570
|
+
];
|
|
571
|
+
}
|
|
572
|
+
const card = assembleCleanCard(titleText, fields, buttons, context);
|
|
573
|
+
if (isEmptyCard(card)) {
|
|
574
|
+
warn(
|
|
575
|
+
context,
|
|
576
|
+
"dropped",
|
|
577
|
+
"Card",
|
|
578
|
+
"A card with none of hero_image, title, body, or actions was dropped.",
|
|
579
|
+
);
|
|
580
|
+
return [];
|
|
581
|
+
}
|
|
582
|
+
return [card];
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Degrades a card that cannot map cleanly into a title-and-body card block.
|
|
587
|
+
* A carousel cannot fall back to a block sequence like a standalone card
|
|
588
|
+
* can, so its content is clamped down to fit the card shape instead.
|
|
589
|
+
*/
|
|
590
|
+
const degradeCard = (
|
|
591
|
+
element: NormalizedUIElement,
|
|
592
|
+
context: ConversionContext,
|
|
593
|
+
depth: number,
|
|
594
|
+
): SlackCardBlock => {
|
|
595
|
+
const rawTitle = asString(element.props["title"]);
|
|
596
|
+
const textChunks = collectText(element.children, depth + 1);
|
|
597
|
+
const titleSource = rawTitle || textChunks[0] || "";
|
|
598
|
+
const bodyChunks = rawTitle ? textChunks : textChunks.slice(1);
|
|
599
|
+
const titleText = titleSource.slice(0, CARD_TITLE_CAP);
|
|
600
|
+
const bodyText = bodyChunks.join("\n").slice(0, CARD_BODY_CAP);
|
|
601
|
+
warn(
|
|
602
|
+
context,
|
|
603
|
+
"clamped",
|
|
604
|
+
"Card",
|
|
605
|
+
"A card inside a carousel was degraded to title and body.",
|
|
606
|
+
);
|
|
607
|
+
return buildCardBlock({
|
|
608
|
+
...(titleText ? { title: { type: "mrkdwn", text: titleText } } : {}),
|
|
609
|
+
...(bodyText ? { body: { type: "mrkdwn", text: bodyText } } : {}),
|
|
610
|
+
});
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const convertCarouselCard = (
|
|
614
|
+
element: NormalizedUIElement,
|
|
615
|
+
context: ConversionContext,
|
|
616
|
+
depth: number,
|
|
617
|
+
): SlackCardBlock | undefined => {
|
|
618
|
+
const fields = extractCardFields(element);
|
|
619
|
+
const card =
|
|
620
|
+
fields.leftover.length === 0
|
|
621
|
+
? assembleCleanCard(
|
|
622
|
+
cardTitleText(element.props, context),
|
|
623
|
+
fields,
|
|
624
|
+
cardActionButtons(element.props, context),
|
|
625
|
+
context,
|
|
626
|
+
)
|
|
627
|
+
: degradeCard(element, context, depth);
|
|
628
|
+
if (isEmptyCard(card)) {
|
|
629
|
+
warn(
|
|
630
|
+
context,
|
|
631
|
+
"dropped",
|
|
632
|
+
"Card",
|
|
633
|
+
"A card with none of hero_image, title, body, or actions was dropped.",
|
|
634
|
+
);
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
return card;
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const isBadgeOrCaption = (
|
|
641
|
+
node: NormalizedUINode,
|
|
642
|
+
): node is NormalizedUIElement =>
|
|
643
|
+
isElement(node) && (node.type === "Badge" || node.type === "Caption");
|
|
644
|
+
|
|
645
|
+
const contextRow = (
|
|
646
|
+
element: NormalizedUIElement,
|
|
647
|
+
context: ConversionContext,
|
|
648
|
+
): SlackBlock[] | undefined => {
|
|
649
|
+
const allChildren = normalizedList(element.children);
|
|
650
|
+
const children = allChildren.filter(isBadgeOrCaption);
|
|
651
|
+
if (allChildren.length === 0 || children.length !== allChildren.length) {
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
if (children.length > CONTEXT_ELEMENT_CAP) {
|
|
655
|
+
warn(
|
|
656
|
+
context,
|
|
657
|
+
"clamped",
|
|
658
|
+
"Row",
|
|
659
|
+
`context elements were clamped to ${CONTEXT_ELEMENT_CAP} entries.`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
return [
|
|
663
|
+
{
|
|
664
|
+
type: "context",
|
|
665
|
+
elements: children.slice(0, CONTEXT_ELEMENT_CAP).map((child) => ({
|
|
666
|
+
type: "mrkdwn",
|
|
667
|
+
text: clampText(
|
|
668
|
+
asString(child.props["value"]),
|
|
669
|
+
CONTEXT_TEXT_CAP,
|
|
670
|
+
child.type,
|
|
671
|
+
"value",
|
|
672
|
+
context,
|
|
673
|
+
),
|
|
674
|
+
})),
|
|
675
|
+
},
|
|
676
|
+
];
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const convertListItem = (
|
|
680
|
+
element: NormalizedUIElement,
|
|
681
|
+
context: ConversionContext,
|
|
682
|
+
depth: number,
|
|
683
|
+
): SlackBlock => {
|
|
684
|
+
const text = clampText(
|
|
685
|
+
collectText(element.children, depth + 1).join("\n"),
|
|
686
|
+
SECTION_TEXT_CAP,
|
|
687
|
+
"ListViewItem",
|
|
688
|
+
"text",
|
|
689
|
+
context,
|
|
690
|
+
);
|
|
691
|
+
const accessory = isRecord(element.action)
|
|
692
|
+
? buttonElement("Open", undefined, element.action, "ListViewItem", context)
|
|
693
|
+
: undefined;
|
|
694
|
+
return {
|
|
695
|
+
type: "section",
|
|
696
|
+
text: { type: "mrkdwn", text },
|
|
697
|
+
...(accessory !== undefined ? { accessory } : {}),
|
|
698
|
+
};
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
const convertListView = (
|
|
702
|
+
element: NormalizedUIElement,
|
|
703
|
+
context: ConversionContext,
|
|
704
|
+
depth: number,
|
|
705
|
+
): SlackBlock[] => {
|
|
706
|
+
const items = normalizedList(element.children).filter(
|
|
707
|
+
(child): child is NormalizedUIElement =>
|
|
708
|
+
isElement(child) && child.type === "ListViewItem",
|
|
709
|
+
);
|
|
710
|
+
return items.flatMap((item, index) => [
|
|
711
|
+
...(index > 0 ? [{ type: "divider" as const }] : []),
|
|
712
|
+
convertListItem(item, context, depth + 1),
|
|
713
|
+
]);
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
const convertCarousel = (
|
|
717
|
+
element: NormalizedUIElement,
|
|
718
|
+
context: ConversionContext,
|
|
719
|
+
depth: number,
|
|
720
|
+
): SlackBlock[] => {
|
|
721
|
+
const cardChildren: NormalizedUIElement[] = [];
|
|
722
|
+
for (const child of normalizedList(element.children)) {
|
|
723
|
+
if (isElement(child) && child.type === "Card") {
|
|
724
|
+
cardChildren.push(child);
|
|
725
|
+
} else {
|
|
726
|
+
warn(context, "dropped", "Carousel", "A non-card child was dropped.");
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (cardChildren.length > CAROUSEL_CARD_CAP) {
|
|
730
|
+
warn(
|
|
731
|
+
context,
|
|
732
|
+
"clamped",
|
|
733
|
+
"Carousel",
|
|
734
|
+
`cards were clamped to ${CAROUSEL_CARD_CAP} entries.`,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
const cards = cardChildren
|
|
738
|
+
.slice(0, CAROUSEL_CARD_CAP)
|
|
739
|
+
.map((card) => convertCarouselCard(card, context, depth + 1))
|
|
740
|
+
.filter((card): card is SlackCardBlock => card !== undefined);
|
|
741
|
+
if (cards.length < CAROUSEL_CARD_MIN) {
|
|
742
|
+
warn(
|
|
743
|
+
context,
|
|
744
|
+
"dropped",
|
|
745
|
+
"Carousel",
|
|
746
|
+
"A carousel without renderable cards was dropped.",
|
|
747
|
+
);
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
return [buildCarouselBlock(cards)];
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const toDataTableCell = (value: unknown): SlackDataTableCell | undefined => {
|
|
754
|
+
if (typeof value === "number") {
|
|
755
|
+
return { type: "raw_number", text: String(value) };
|
|
756
|
+
}
|
|
757
|
+
if (typeof value === "string" || typeof value === "boolean") {
|
|
758
|
+
return { type: "raw_text", text: String(value) };
|
|
759
|
+
}
|
|
760
|
+
return undefined;
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
const rowCharacters = (row: readonly SlackDataTableCell[]): number =>
|
|
764
|
+
row.reduce((sum, cell) => sum + cell.text.length, 0);
|
|
765
|
+
|
|
766
|
+
const convertTable = (
|
|
767
|
+
element: NormalizedUIElement,
|
|
768
|
+
context: ConversionContext,
|
|
769
|
+
): SlackBlock[] => {
|
|
770
|
+
const rawColumns = Array.isArray(element.props["columns"])
|
|
771
|
+
? element.props["columns"]
|
|
772
|
+
: [];
|
|
773
|
+
const rawRows = Array.isArray(element.props["rows"])
|
|
774
|
+
? element.props["rows"]
|
|
775
|
+
: [];
|
|
776
|
+
if (rawColumns.length > DATA_TABLE_COLUMN_CAP) {
|
|
777
|
+
warn(
|
|
778
|
+
context,
|
|
779
|
+
"clamped",
|
|
780
|
+
"Table",
|
|
781
|
+
`columns were clamped to ${DATA_TABLE_COLUMN_CAP} entries.`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
if (rawRows.length > DATA_TABLE_ROW_CAP) {
|
|
785
|
+
warn(
|
|
786
|
+
context,
|
|
787
|
+
"clamped",
|
|
788
|
+
"Table",
|
|
789
|
+
`rows were clamped to ${DATA_TABLE_ROW_CAP} entries.`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
const columnHeaderRow: SlackDataTableCell[] = rawColumns
|
|
794
|
+
.slice(0, DATA_TABLE_COLUMN_CAP)
|
|
795
|
+
.filter(isRecord)
|
|
796
|
+
.map((column) => ({
|
|
797
|
+
type: "raw_text" as const,
|
|
798
|
+
text: asString(column["label"]),
|
|
799
|
+
}));
|
|
800
|
+
const dataRows: SlackDataTableCell[][] = rawRows
|
|
801
|
+
.slice(0, DATA_TABLE_ROW_CAP)
|
|
802
|
+
.map((row) =>
|
|
803
|
+
(Array.isArray(row) ? row : [])
|
|
804
|
+
.slice(0, DATA_TABLE_COLUMN_CAP)
|
|
805
|
+
.map(
|
|
806
|
+
(value) =>
|
|
807
|
+
toDataTableCell(value) ?? { type: "raw_text" as const, text: "" },
|
|
808
|
+
),
|
|
809
|
+
);
|
|
810
|
+
const width = Math.max(
|
|
811
|
+
columnHeaderRow.length,
|
|
812
|
+
0,
|
|
813
|
+
...dataRows.map((row) => row.length),
|
|
814
|
+
);
|
|
815
|
+
const padRow = (row: SlackDataTableCell[]): SlackDataTableCell[] =>
|
|
816
|
+
row.length >= width
|
|
817
|
+
? row
|
|
818
|
+
: [
|
|
819
|
+
...row,
|
|
820
|
+
...Array.from({ length: width - row.length }, () => ({
|
|
821
|
+
type: "raw_text" as const,
|
|
822
|
+
text: "",
|
|
823
|
+
})),
|
|
824
|
+
];
|
|
825
|
+
const headerRow = padRow(columnHeaderRow);
|
|
826
|
+
|
|
827
|
+
const headerCharacters = rowCharacters(headerRow);
|
|
828
|
+
if (context.dataTableCharacters + headerCharacters > DATA_TABLE_CHAR_BUDGET) {
|
|
829
|
+
warn(
|
|
830
|
+
context,
|
|
831
|
+
"dropped",
|
|
832
|
+
"Table",
|
|
833
|
+
`the table was dropped because its header row exceeds the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
|
|
834
|
+
);
|
|
835
|
+
return [];
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const kept: SlackDataTableCell[][] = [headerRow];
|
|
839
|
+
let tableCharacters = headerCharacters;
|
|
840
|
+
let budgetClamped = false;
|
|
841
|
+
for (const row of dataRows.map(padRow)) {
|
|
842
|
+
const chars = rowCharacters(row);
|
|
843
|
+
if (
|
|
844
|
+
context.dataTableCharacters + tableCharacters + chars >
|
|
845
|
+
DATA_TABLE_CHAR_BUDGET
|
|
846
|
+
) {
|
|
847
|
+
budgetClamped = true;
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
kept.push(row);
|
|
851
|
+
tableCharacters += chars;
|
|
852
|
+
}
|
|
853
|
+
context.dataTableCharacters += tableCharacters;
|
|
854
|
+
if (budgetClamped) {
|
|
855
|
+
warn(
|
|
856
|
+
context,
|
|
857
|
+
"clamped",
|
|
858
|
+
"Table",
|
|
859
|
+
`rows were clamped to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
return [buildDataTableBlock(kept)];
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const convertElement = (
|
|
867
|
+
element: NormalizedUIElement,
|
|
868
|
+
context: ConversionContext,
|
|
869
|
+
depth: number,
|
|
870
|
+
): SlackBlock[] => {
|
|
871
|
+
const { props } = element;
|
|
872
|
+
switch (element.type) {
|
|
873
|
+
case "Header":
|
|
874
|
+
return [
|
|
875
|
+
{
|
|
876
|
+
type: "header",
|
|
877
|
+
text: plainText(
|
|
878
|
+
clampText(
|
|
879
|
+
asString(props["text"]),
|
|
880
|
+
HEADER_TEXT_CAP,
|
|
881
|
+
"Header",
|
|
882
|
+
"text",
|
|
883
|
+
context,
|
|
884
|
+
),
|
|
885
|
+
),
|
|
886
|
+
},
|
|
887
|
+
];
|
|
888
|
+
case "Text":
|
|
889
|
+
return [
|
|
890
|
+
{
|
|
891
|
+
type: "section",
|
|
892
|
+
text: {
|
|
893
|
+
type: "mrkdwn",
|
|
894
|
+
text: clampText(
|
|
895
|
+
asString(props["value"]),
|
|
896
|
+
SECTION_TEXT_CAP,
|
|
897
|
+
"Text",
|
|
898
|
+
"value",
|
|
899
|
+
context,
|
|
900
|
+
),
|
|
901
|
+
},
|
|
902
|
+
},
|
|
903
|
+
];
|
|
904
|
+
case "Caption":
|
|
905
|
+
case "Badge":
|
|
906
|
+
return [
|
|
907
|
+
{
|
|
908
|
+
type: "context",
|
|
909
|
+
elements: [
|
|
910
|
+
{
|
|
911
|
+
type: "mrkdwn",
|
|
912
|
+
text: clampText(
|
|
913
|
+
asString(props["value"]),
|
|
914
|
+
CONTEXT_TEXT_CAP,
|
|
915
|
+
element.type,
|
|
916
|
+
"value",
|
|
917
|
+
context,
|
|
918
|
+
),
|
|
919
|
+
},
|
|
920
|
+
],
|
|
921
|
+
},
|
|
922
|
+
];
|
|
923
|
+
case "Fact":
|
|
924
|
+
return convertFacts([element], context);
|
|
925
|
+
case "Image":
|
|
926
|
+
return [
|
|
927
|
+
{
|
|
928
|
+
type: "image",
|
|
929
|
+
image_url: asString(props["src"]),
|
|
930
|
+
alt_text: asString(props["alt"]),
|
|
931
|
+
},
|
|
932
|
+
];
|
|
933
|
+
case "Divider":
|
|
934
|
+
return [{ type: "divider" }];
|
|
935
|
+
case "Button":
|
|
936
|
+
case "Select":
|
|
937
|
+
case "DatePicker":
|
|
938
|
+
case "Checkbox":
|
|
939
|
+
case "RadioGroup":
|
|
940
|
+
return convertActions([element], context);
|
|
941
|
+
case "Input": {
|
|
942
|
+
const label = clampText(
|
|
943
|
+
asString(props["label"]),
|
|
944
|
+
INPUT_LABEL_CAP,
|
|
945
|
+
"Input",
|
|
946
|
+
"label",
|
|
947
|
+
context,
|
|
948
|
+
);
|
|
949
|
+
const placeholder = clampText(
|
|
950
|
+
asString(props["placeholder"]),
|
|
951
|
+
PLACEHOLDER_TEXT_CAP,
|
|
952
|
+
"Input",
|
|
953
|
+
"placeholder",
|
|
954
|
+
context,
|
|
955
|
+
);
|
|
956
|
+
return [
|
|
957
|
+
{
|
|
958
|
+
type: "input",
|
|
959
|
+
label: plainText(label),
|
|
960
|
+
element: {
|
|
961
|
+
type: "plain_text_input",
|
|
962
|
+
action_id: asActionId(element.action, "Input", context),
|
|
963
|
+
...(props["multiline"] === true ? { multiline: true } : {}),
|
|
964
|
+
...(placeholder ? { placeholder: plainText(placeholder) } : {}),
|
|
965
|
+
},
|
|
966
|
+
},
|
|
967
|
+
];
|
|
968
|
+
}
|
|
969
|
+
case "Alert": {
|
|
970
|
+
const tone =
|
|
971
|
+
props["tone"] === "success" ||
|
|
972
|
+
props["tone"] === "warning" ||
|
|
973
|
+
props["tone"] === "danger"
|
|
974
|
+
? props["tone"]
|
|
975
|
+
: "info";
|
|
976
|
+
const titleText = asString(props["title"]);
|
|
977
|
+
const description = asString(props["description"]);
|
|
978
|
+
if (context.surface === "modal") {
|
|
979
|
+
const combined = [titleText, description].filter(Boolean).join("\n");
|
|
980
|
+
return [
|
|
981
|
+
buildAlertBlock(
|
|
982
|
+
clampText(combined, ALERT_TEXT_CAP, "Alert", "text", context),
|
|
983
|
+
tone,
|
|
984
|
+
),
|
|
985
|
+
];
|
|
986
|
+
}
|
|
987
|
+
warn(
|
|
988
|
+
context,
|
|
989
|
+
"fallback",
|
|
990
|
+
"Alert",
|
|
991
|
+
"Alert was rendered with message-surface fallback blocks.",
|
|
992
|
+
);
|
|
993
|
+
return [
|
|
994
|
+
...buildAlertFallback(
|
|
995
|
+
titleText,
|
|
996
|
+
clampText(
|
|
997
|
+
description,
|
|
998
|
+
SECTION_TEXT_CAP,
|
|
999
|
+
"Alert",
|
|
1000
|
+
"description",
|
|
1001
|
+
context,
|
|
1002
|
+
),
|
|
1003
|
+
tone,
|
|
1004
|
+
),
|
|
1005
|
+
];
|
|
1006
|
+
}
|
|
1007
|
+
case "Card":
|
|
1008
|
+
return convertCard(element, context, depth);
|
|
1009
|
+
case "Carousel":
|
|
1010
|
+
return convertCarousel(element, context, depth);
|
|
1011
|
+
case "Col":
|
|
1012
|
+
case "Box":
|
|
1013
|
+
return convertSequence(element.children, context, depth + 1);
|
|
1014
|
+
case "Row":
|
|
1015
|
+
return (
|
|
1016
|
+
contextRow(element, context) ??
|
|
1017
|
+
convertSequence(element.children, context, depth + 1)
|
|
1018
|
+
);
|
|
1019
|
+
case "Spacer":
|
|
1020
|
+
case "Icon":
|
|
1021
|
+
return [];
|
|
1022
|
+
case "Table":
|
|
1023
|
+
return convertTable(element, context);
|
|
1024
|
+
case "Markdown": {
|
|
1025
|
+
const value = asString(props["value"]);
|
|
1026
|
+
if (
|
|
1027
|
+
!context.markdownExhausted &&
|
|
1028
|
+
context.markdownCharacters + value.length <= MARKDOWN_TEXT_BUDGET
|
|
1029
|
+
) {
|
|
1030
|
+
context.markdownCharacters += value.length;
|
|
1031
|
+
return [{ type: "markdown", text: value }];
|
|
1032
|
+
}
|
|
1033
|
+
context.markdownExhausted = true;
|
|
1034
|
+
warn(
|
|
1035
|
+
context,
|
|
1036
|
+
"fallback",
|
|
1037
|
+
"Markdown",
|
|
1038
|
+
`The ${MARKDOWN_TEXT_BUDGET}-character markdown budget was exceeded.`,
|
|
1039
|
+
);
|
|
1040
|
+
return [
|
|
1041
|
+
{
|
|
1042
|
+
type: "section",
|
|
1043
|
+
text: {
|
|
1044
|
+
type: "mrkdwn",
|
|
1045
|
+
text: clampText(
|
|
1046
|
+
value,
|
|
1047
|
+
SECTION_TEXT_CAP,
|
|
1048
|
+
"Markdown",
|
|
1049
|
+
"fallback text",
|
|
1050
|
+
context,
|
|
1051
|
+
),
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
];
|
|
1055
|
+
}
|
|
1056
|
+
case "Chart":
|
|
1057
|
+
warn(
|
|
1058
|
+
context,
|
|
1059
|
+
"dropped",
|
|
1060
|
+
"Chart",
|
|
1061
|
+
"Chart was replaced by a Slack omission note.",
|
|
1062
|
+
);
|
|
1063
|
+
return [
|
|
1064
|
+
{
|
|
1065
|
+
type: "context",
|
|
1066
|
+
elements: [{ type: "mrkdwn", text: "Chart omitted on Slack." }],
|
|
1067
|
+
},
|
|
1068
|
+
];
|
|
1069
|
+
case "ListView":
|
|
1070
|
+
return convertListView(element, context, depth);
|
|
1071
|
+
case "ListViewItem":
|
|
1072
|
+
return [convertListItem(element, context, depth)];
|
|
1073
|
+
case "Form":
|
|
1074
|
+
return [
|
|
1075
|
+
...convertSequence(element.children, context, depth + 1),
|
|
1076
|
+
{
|
|
1077
|
+
type: "actions",
|
|
1078
|
+
elements: [
|
|
1079
|
+
buttonElement("Submit", "primary", element.action, "Form", context),
|
|
1080
|
+
],
|
|
1081
|
+
},
|
|
1082
|
+
];
|
|
1083
|
+
default:
|
|
1084
|
+
warn(
|
|
1085
|
+
context,
|
|
1086
|
+
"dropped",
|
|
1087
|
+
element.type,
|
|
1088
|
+
"Unknown component type was dropped.",
|
|
1089
|
+
);
|
|
1090
|
+
return [];
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
function convertSequence(
|
|
1095
|
+
node: NormalizedUINode | undefined,
|
|
1096
|
+
context: ConversionContext,
|
|
1097
|
+
depth: number,
|
|
1098
|
+
): SlackBlock[] {
|
|
1099
|
+
if (node === undefined || node === null || depth > MAX_TRAVERSAL_DEPTH) {
|
|
1100
|
+
return [];
|
|
1101
|
+
}
|
|
1102
|
+
const nodes = Array.isArray(node) ? node : [node];
|
|
1103
|
+
const blocks: SlackBlock[] = [];
|
|
1104
|
+
for (let index = 0; index < nodes.length;) {
|
|
1105
|
+
const current = nodes[index];
|
|
1106
|
+
if (current === undefined || current === null) {
|
|
1107
|
+
index += 1;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
if (isElement(current) && current.type === "Fact") {
|
|
1111
|
+
const facts: NormalizedUIElement[] = [];
|
|
1112
|
+
while (index < nodes.length) {
|
|
1113
|
+
const candidate = nodes[index];
|
|
1114
|
+
if (!candidate || !isElement(candidate) || candidate.type !== "Fact") {
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
facts.push(candidate);
|
|
1118
|
+
index += 1;
|
|
1119
|
+
}
|
|
1120
|
+
blocks.push(...convertFacts(facts, context));
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
1123
|
+
if (isElement(current) && INTERACTIVE_TYPES.has(current.type)) {
|
|
1124
|
+
const controls: NormalizedUIElement[] = [];
|
|
1125
|
+
while (index < nodes.length) {
|
|
1126
|
+
const candidate = nodes[index];
|
|
1127
|
+
if (
|
|
1128
|
+
!candidate ||
|
|
1129
|
+
!isElement(candidate) ||
|
|
1130
|
+
!INTERACTIVE_TYPES.has(candidate.type)
|
|
1131
|
+
) {
|
|
1132
|
+
break;
|
|
1133
|
+
}
|
|
1134
|
+
controls.push(candidate);
|
|
1135
|
+
index += 1;
|
|
1136
|
+
}
|
|
1137
|
+
blocks.push(...convertActions(controls, context));
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
if (typeof current === "string" || typeof current === "number") {
|
|
1141
|
+
blocks.push({
|
|
1142
|
+
type: "section",
|
|
1143
|
+
text: {
|
|
1144
|
+
type: "mrkdwn",
|
|
1145
|
+
text: clampText(
|
|
1146
|
+
String(current),
|
|
1147
|
+
SECTION_TEXT_CAP,
|
|
1148
|
+
"Text",
|
|
1149
|
+
"value",
|
|
1150
|
+
context,
|
|
1151
|
+
),
|
|
1152
|
+
},
|
|
1153
|
+
});
|
|
1154
|
+
} else if (Array.isArray(current)) {
|
|
1155
|
+
blocks.push(...convertSequence(current, context, depth + 1));
|
|
1156
|
+
} else {
|
|
1157
|
+
blocks.push(
|
|
1158
|
+
...convertElement(current as NormalizedUIElement, context, depth + 1),
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
index += 1;
|
|
1162
|
+
}
|
|
1163
|
+
return blocks;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
interface BoundState {
|
|
1167
|
+
remaining: number;
|
|
1168
|
+
exhausted: boolean;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
type ClampReason = "children" | "budget" | "cycle" | "depth";
|
|
1172
|
+
|
|
1173
|
+
function boundNode(
|
|
1174
|
+
value: unknown,
|
|
1175
|
+
depth: number,
|
|
1176
|
+
onClamp: (reason: ClampReason) => void,
|
|
1177
|
+
state: BoundState,
|
|
1178
|
+
ancestors: WeakSet<object>,
|
|
1179
|
+
): unknown {
|
|
1180
|
+
if (state.remaining <= 0) {
|
|
1181
|
+
if (!state.exhausted) {
|
|
1182
|
+
state.exhausted = true;
|
|
1183
|
+
onClamp("budget");
|
|
1184
|
+
}
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
state.remaining -= 1;
|
|
1188
|
+
if (depth > MAX_TRAVERSAL_DEPTH) {
|
|
1189
|
+
onClamp("depth");
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
if (Array.isArray(value)) {
|
|
1193
|
+
if (ancestors.has(value)) {
|
|
1194
|
+
onClamp("cycle");
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
ancestors.add(value);
|
|
1198
|
+
const bounded = Array.prototype.slice.call(
|
|
1199
|
+
value,
|
|
1200
|
+
0,
|
|
1201
|
+
CHILDREN_CAP,
|
|
1202
|
+
) as unknown[];
|
|
1203
|
+
if (value.length > CHILDREN_CAP) onClamp("children");
|
|
1204
|
+
const result = bounded.map((item) =>
|
|
1205
|
+
boundNode(item, depth + 1, onClamp, state, ancestors),
|
|
1206
|
+
);
|
|
1207
|
+
ancestors.delete(value);
|
|
1208
|
+
return result;
|
|
1209
|
+
}
|
|
1210
|
+
if (
|
|
1211
|
+
value !== null &&
|
|
1212
|
+
typeof value === "object" &&
|
|
1213
|
+
"children" in (value as Record<string, unknown>)
|
|
1214
|
+
) {
|
|
1215
|
+
if (ancestors.has(value)) {
|
|
1216
|
+
onClamp("cycle");
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
ancestors.add(value);
|
|
1220
|
+
const record = value as Record<string, unknown>;
|
|
1221
|
+
const result = {
|
|
1222
|
+
...record,
|
|
1223
|
+
children: boundNode(
|
|
1224
|
+
record["children"],
|
|
1225
|
+
depth + 1,
|
|
1226
|
+
onClamp,
|
|
1227
|
+
state,
|
|
1228
|
+
ancestors,
|
|
1229
|
+
),
|
|
1230
|
+
};
|
|
1231
|
+
ancestors.delete(value);
|
|
1232
|
+
return result;
|
|
1233
|
+
}
|
|
1234
|
+
return value;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Produces a bounded plain copy of a raw generative-ui spec before it
|
|
1239
|
+
* reaches `normalizeSpec`, whose own traversal of the root array or any
|
|
1240
|
+
* `children` array walks the full reported length of a hostile proxied
|
|
1241
|
+
* array before any per-field cap downstream ever applies. Every array
|
|
1242
|
+
* (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
|
|
1243
|
+
* entries via `Array.prototype.slice`, which bounds even a proxy with a
|
|
1244
|
+
* fabricated `length`; recursion itself is capped at
|
|
1245
|
+
* {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
|
|
1246
|
+
* truncated, receiving the reason for that truncation: `"children"`,
|
|
1247
|
+
* `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
|
|
1248
|
+
* {@link NODE_BUDGET} nodes, so shared references cannot multiply work
|
|
1249
|
+
* exponentially, and a node that is its own ancestor is cut to `null`.
|
|
1250
|
+
*/
|
|
1251
|
+
function boundSpec(
|
|
1252
|
+
spec: unknown,
|
|
1253
|
+
onClamp: (reason: ClampReason) => void,
|
|
1254
|
+
): unknown {
|
|
1255
|
+
return boundNode(
|
|
1256
|
+
spec,
|
|
1257
|
+
0,
|
|
1258
|
+
onClamp,
|
|
1259
|
+
{ remaining: NODE_BUDGET, exhausted: false },
|
|
1260
|
+
new WeakSet(),
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** Converts a generative-UI tree into Slack Block Kit JSON and downgrade warnings. */
|
|
1265
|
+
export function toSlackBlocks(
|
|
1266
|
+
node: unknown,
|
|
1267
|
+
options?: ToSlackBlocksOptions,
|
|
1268
|
+
): SlackBlocksResult {
|
|
1269
|
+
const surface = options?.surface === "modal" ? "modal" : "message";
|
|
1270
|
+
const context: ConversionContext = {
|
|
1271
|
+
blockCap: surface === "modal" ? MODAL_BLOCK_CAP : MESSAGE_BLOCK_CAP,
|
|
1272
|
+
surface,
|
|
1273
|
+
warnings: [],
|
|
1274
|
+
markdownCharacters: 0,
|
|
1275
|
+
markdownExhausted: false,
|
|
1276
|
+
dataTableCharacters: 0,
|
|
1277
|
+
};
|
|
1278
|
+
try {
|
|
1279
|
+
const bounded = boundSpec(node, (reason) => {
|
|
1280
|
+
const detail =
|
|
1281
|
+
reason === "budget"
|
|
1282
|
+
? `the tree was truncated after ${NODE_BUDGET} nodes.`
|
|
1283
|
+
: reason === "cycle"
|
|
1284
|
+
? "a self-referencing node was dropped."
|
|
1285
|
+
: reason === "depth"
|
|
1286
|
+
? `nodes deeper than ${MAX_TRAVERSAL_DEPTH} levels were dropped.`
|
|
1287
|
+
: `children were clamped to ${CHILDREN_CAP} entries.`;
|
|
1288
|
+
warn(context, "clamped", "Root", detail);
|
|
1289
|
+
});
|
|
1290
|
+
const { root } = normalizeSpec(bounded as never);
|
|
1291
|
+
const converted = convertSequence(root, context, 0);
|
|
1292
|
+
if (converted.length <= context.blockCap) {
|
|
1293
|
+
return { blocks: converted, warnings: context.warnings };
|
|
1294
|
+
}
|
|
1295
|
+
const kept = converted.slice(0, context.blockCap - 1);
|
|
1296
|
+
const omitted = converted.length - kept.length;
|
|
1297
|
+
warn(
|
|
1298
|
+
context,
|
|
1299
|
+
"clamped",
|
|
1300
|
+
"Root",
|
|
1301
|
+
`${omitted} ${omitted === 1 ? "block was" : "blocks were"} omitted.`,
|
|
1302
|
+
);
|
|
1303
|
+
return {
|
|
1304
|
+
blocks: [...kept, buildBlockBudgetNote(omitted)],
|
|
1305
|
+
warnings: context.warnings,
|
|
1306
|
+
};
|
|
1307
|
+
} catch {
|
|
1308
|
+
return {
|
|
1309
|
+
blocks: [],
|
|
1310
|
+
warnings: [
|
|
1311
|
+
{
|
|
1312
|
+
code: "dropped",
|
|
1313
|
+
component: "Root",
|
|
1314
|
+
detail: "Input could not be converted.",
|
|
1315
|
+
},
|
|
1316
|
+
],
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
}
|