@assistant-ui/react-generative-ui 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/convert/boundSpec.d.ts +47 -0
- package/dist/convert/boundSpec.d.ts.map +1 -0
- package/dist/convert/boundSpec.js +99 -0
- package/dist/convert/boundSpec.js.map +1 -0
- package/dist/convert/copyBounded.d.ts +28 -0
- package/dist/convert/copyBounded.d.ts.map +1 -0
- package/dist/convert/copyBounded.js +38 -0
- package/dist/convert/copyBounded.js.map +1 -0
- package/dist/convert/isElement.d.ts +6 -0
- package/dist/convert/isElement.d.ts.map +1 -0
- package/dist/convert/isElement.js +6 -0
- package/dist/convert/isElement.js.map +1 -0
- package/dist/convert/takeRun.d.ts +9 -0
- package/dist/convert/takeRun.d.ts.map +1 -0
- package/dist/convert/takeRun.js +20 -0
- package/dist/convert/takeRun.js.map +1 -0
- package/dist/slack/constants.d.ts +1 -26
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +1 -26
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/fromSlackBlocks.js +6 -10
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +28 -95
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/teams/constants.d.ts +1 -28
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +1 -32
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +13 -24
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js +3 -4
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/vocabulary/list.js +4 -2
- package/dist/vocabulary/list.js.map +1 -1
- package/package.json +6 -6
- package/src/convert/boundSpec.test.ts +147 -0
- package/src/{teams → convert}/boundSpec.ts +48 -14
- package/src/convert/copyBounded.test.ts +131 -0
- package/src/convert/copyBounded.ts +38 -0
- package/src/convert/isElement.ts +6 -0
- package/src/convert/takeRun.test.ts +64 -0
- package/src/convert/takeRun.ts +19 -0
- package/src/slack/constants.ts +0 -29
- package/src/slack/fromSlackBlocks.test.ts +105 -7
- package/src/slack/fromSlackBlocks.ts +9 -9
- package/src/slack/toSlackBlocks.test.ts +82 -2
- package/src/slack/toSlackBlocks.ts +68 -164
- package/src/teams/constants.ts +0 -42
- package/src/teams/toAdaptiveCard.test.ts +75 -3
- package/src/teams/toAdaptiveCard.ts +30 -37
- package/src/teams/toTeamsAttachments.ts +2 -8
- package/src/vocabulary/list.test.tsx +35 -9
- package/src/vocabulary/list.tsx +4 -2
- package/dist/teams/boundSpec.d.ts +0 -20
- package/dist/teams/boundSpec.d.ts.map +0 -1
- package/dist/teams/boundSpec.js +0 -67
- package/dist/teams/boundSpec.js.map +0 -1
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_TRAVERSAL_DEPTH,
|
|
3
|
+
boundSpec,
|
|
4
|
+
clampReasonDetail,
|
|
5
|
+
} from "../convert/boundSpec";
|
|
6
|
+
import { copyBounded } from "../convert/copyBounded";
|
|
7
|
+
import { isElement } from "../convert/isElement";
|
|
8
|
+
import { takeRun } from "../convert/takeRun";
|
|
1
9
|
import {
|
|
2
10
|
normalizeSpec,
|
|
3
11
|
type NormalizedUIElement,
|
|
@@ -14,7 +22,6 @@ import {
|
|
|
14
22
|
CARD_TITLE_CAP,
|
|
15
23
|
CAROUSEL_CARD_CAP,
|
|
16
24
|
CAROUSEL_CARD_MIN,
|
|
17
|
-
CHILDREN_CAP,
|
|
18
25
|
CONTEXT_ELEMENT_CAP,
|
|
19
26
|
CONTEXT_TEXT_CAP,
|
|
20
27
|
DATA_TABLE_CHAR_BUDGET,
|
|
@@ -26,11 +33,8 @@ import {
|
|
|
26
33
|
INPUT_LABEL_CAP,
|
|
27
34
|
INTERACTIVE_TEXT_CAP,
|
|
28
35
|
MARKDOWN_TEXT_BUDGET,
|
|
29
|
-
MAX_ELEMENT_DEPTH,
|
|
30
|
-
MAX_TRAVERSAL_DEPTH,
|
|
31
36
|
MESSAGE_BLOCK_CAP,
|
|
32
37
|
MODAL_BLOCK_CAP,
|
|
33
|
-
NODE_BUDGET,
|
|
34
38
|
PLACEHOLDER_TEXT_CAP,
|
|
35
39
|
RADIO_OPTION_CAP,
|
|
36
40
|
SECTION_TEXT_CAP,
|
|
@@ -87,9 +91,6 @@ const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
87
91
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
88
92
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
89
93
|
|
|
90
|
-
const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
|
|
91
|
-
isRecord(node);
|
|
92
|
-
|
|
93
94
|
const asString = (value: unknown): string =>
|
|
94
95
|
typeof value === "string" ? value : "";
|
|
95
96
|
|
|
@@ -244,7 +245,11 @@ const toActionElement = (
|
|
|
244
245
|
const rawOptions = Array.isArray(props["options"])
|
|
245
246
|
? props["options"]
|
|
246
247
|
: [];
|
|
247
|
-
|
|
248
|
+
const { items: takenOptions, truncated } = copyBounded(
|
|
249
|
+
rawOptions,
|
|
250
|
+
SELECT_OPTION_CAP,
|
|
251
|
+
);
|
|
252
|
+
if (truncated) {
|
|
248
253
|
warn(
|
|
249
254
|
context,
|
|
250
255
|
"clamped",
|
|
@@ -252,7 +257,6 @@ const toActionElement = (
|
|
|
252
257
|
`options were clamped to ${SELECT_OPTION_CAP} entries.`,
|
|
253
258
|
);
|
|
254
259
|
}
|
|
255
|
-
const takenOptions = rawOptions.slice(0, SELECT_OPTION_CAP);
|
|
256
260
|
const options = takenOptions
|
|
257
261
|
.map((option) => optionFrom(option, "Select", context))
|
|
258
262
|
.filter((option): option is SlackOption => option !== undefined);
|
|
@@ -319,7 +323,11 @@ const toActionElement = (
|
|
|
319
323
|
const rawOptions = Array.isArray(props["options"])
|
|
320
324
|
? props["options"]
|
|
321
325
|
: [];
|
|
322
|
-
|
|
326
|
+
const { items: takenOptions, truncated } = copyBounded(
|
|
327
|
+
rawOptions,
|
|
328
|
+
RADIO_OPTION_CAP,
|
|
329
|
+
);
|
|
330
|
+
if (truncated) {
|
|
323
331
|
warn(
|
|
324
332
|
context,
|
|
325
333
|
"clamped",
|
|
@@ -327,7 +335,6 @@ const toActionElement = (
|
|
|
327
335
|
`options were clamped to ${RADIO_OPTION_CAP} entries.`,
|
|
328
336
|
);
|
|
329
337
|
}
|
|
330
|
-
const takenOptions = rawOptions.slice(0, RADIO_OPTION_CAP);
|
|
331
338
|
const options = takenOptions
|
|
332
339
|
.map((option) => optionFrom(option, "RadioGroup", context))
|
|
333
340
|
.filter((option): option is SlackOption => option !== undefined);
|
|
@@ -548,7 +555,11 @@ const assembleCleanCard = (
|
|
|
548
555
|
),
|
|
549
556
|
}
|
|
550
557
|
: undefined;
|
|
551
|
-
|
|
558
|
+
const { items: boundedButtons, truncated } = copyBounded(
|
|
559
|
+
buttons,
|
|
560
|
+
CARD_ACTIONS_CAP,
|
|
561
|
+
);
|
|
562
|
+
if (truncated) {
|
|
552
563
|
warn(
|
|
553
564
|
context,
|
|
554
565
|
"clamped",
|
|
@@ -563,9 +574,7 @@ const assembleCleanCard = (
|
|
|
563
574
|
: {}),
|
|
564
575
|
...(body !== undefined ? { body } : {}),
|
|
565
576
|
...(subtext !== undefined ? { subtext } : {}),
|
|
566
|
-
...(
|
|
567
|
-
? { actions: buttons.slice(0, CARD_ACTIONS_CAP) }
|
|
568
|
-
: {}),
|
|
577
|
+
...(boundedButtons.length > 0 ? { actions: boundedButtons } : {}),
|
|
569
578
|
});
|
|
570
579
|
};
|
|
571
580
|
|
|
@@ -758,7 +767,11 @@ const contextRow = (
|
|
|
758
767
|
if (allChildren.length === 0 || children.length !== allChildren.length) {
|
|
759
768
|
return undefined;
|
|
760
769
|
}
|
|
761
|
-
|
|
770
|
+
const { items: boundedChildren, truncated } = copyBounded(
|
|
771
|
+
children,
|
|
772
|
+
CONTEXT_ELEMENT_CAP,
|
|
773
|
+
);
|
|
774
|
+
if (truncated) {
|
|
762
775
|
warn(
|
|
763
776
|
context,
|
|
764
777
|
"clamped",
|
|
@@ -769,7 +782,7 @@ const contextRow = (
|
|
|
769
782
|
return [
|
|
770
783
|
{
|
|
771
784
|
type: "context",
|
|
772
|
-
elements:
|
|
785
|
+
elements: boundedChildren.map((child) => ({
|
|
773
786
|
type: "mrkdwn",
|
|
774
787
|
text: clampText(
|
|
775
788
|
asString(child.props["value"]),
|
|
@@ -874,7 +887,11 @@ const convertCarousel = (
|
|
|
874
887
|
`${droppedCards} non-card ${droppedCards === 1 ? "child was" : "children were"} dropped.`,
|
|
875
888
|
);
|
|
876
889
|
}
|
|
877
|
-
|
|
890
|
+
const { items: boundedCards, truncated } = copyBounded(
|
|
891
|
+
cardChildren,
|
|
892
|
+
CAROUSEL_CARD_CAP,
|
|
893
|
+
);
|
|
894
|
+
if (truncated) {
|
|
878
895
|
warn(
|
|
879
896
|
context,
|
|
880
897
|
"clamped",
|
|
@@ -882,8 +899,7 @@ const convertCarousel = (
|
|
|
882
899
|
`cards were clamped to ${CAROUSEL_CARD_CAP} entries.`,
|
|
883
900
|
);
|
|
884
901
|
}
|
|
885
|
-
const cards =
|
|
886
|
-
.slice(0, CAROUSEL_CARD_CAP)
|
|
902
|
+
const cards = boundedCards
|
|
887
903
|
.map((card) => convertCarouselCard(card, context, depth + 1))
|
|
888
904
|
.filter((card): card is SlackCardBlock => card !== undefined);
|
|
889
905
|
if (cards.length < CAROUSEL_CARD_MIN) {
|
|
@@ -921,7 +937,15 @@ const convertTable = (
|
|
|
921
937
|
const rawRows = Array.isArray(element.props["rows"])
|
|
922
938
|
? element.props["rows"]
|
|
923
939
|
: [];
|
|
924
|
-
|
|
940
|
+
const { items: takenColumns, truncated: columnsTruncated } = copyBounded(
|
|
941
|
+
rawColumns,
|
|
942
|
+
DATA_TABLE_COLUMN_CAP,
|
|
943
|
+
);
|
|
944
|
+
const { items: takenRows, truncated: rowsTruncated } = copyBounded(
|
|
945
|
+
rawRows,
|
|
946
|
+
DATA_TABLE_ROW_CAP,
|
|
947
|
+
);
|
|
948
|
+
if (columnsTruncated) {
|
|
925
949
|
warn(
|
|
926
950
|
context,
|
|
927
951
|
"clamped",
|
|
@@ -929,7 +953,7 @@ const convertTable = (
|
|
|
929
953
|
`columns were clamped to ${DATA_TABLE_COLUMN_CAP} entries.`,
|
|
930
954
|
);
|
|
931
955
|
}
|
|
932
|
-
if (
|
|
956
|
+
if (rowsTruncated) {
|
|
933
957
|
warn(
|
|
934
958
|
context,
|
|
935
959
|
"clamped",
|
|
@@ -938,7 +962,6 @@ const convertTable = (
|
|
|
938
962
|
);
|
|
939
963
|
}
|
|
940
964
|
|
|
941
|
-
const takenColumns = rawColumns.slice(0, DATA_TABLE_COLUMN_CAP);
|
|
942
965
|
const unlabeled = takenColumns.filter(
|
|
943
966
|
(column) => !isRecord(column) || typeof column["label"] !== "string",
|
|
944
967
|
).length;
|
|
@@ -954,16 +977,15 @@ const convertTable = (
|
|
|
954
977
|
type: "raw_text" as const,
|
|
955
978
|
text: isRecord(column) ? asString(column["label"]) : "",
|
|
956
979
|
}));
|
|
957
|
-
const dataRows: SlackDataTableCell[][] =
|
|
958
|
-
.
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
);
|
|
980
|
+
const dataRows: SlackDataTableCell[][] = takenRows.map((row) =>
|
|
981
|
+
(Array.isArray(row)
|
|
982
|
+
? copyBounded(row, DATA_TABLE_COLUMN_CAP).items
|
|
983
|
+
: []
|
|
984
|
+
).map(
|
|
985
|
+
(value) =>
|
|
986
|
+
toDataTableCell(value) ?? { type: "raw_text" as const, text: "" },
|
|
987
|
+
),
|
|
988
|
+
);
|
|
967
989
|
const width = Math.max(
|
|
968
990
|
columnHeaderRow.length,
|
|
969
991
|
0,
|
|
@@ -1265,32 +1287,20 @@ function convertSequence(
|
|
|
1265
1287
|
continue;
|
|
1266
1288
|
}
|
|
1267
1289
|
if (isElement(current) && current.type === "Fact") {
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
facts.push(candidate);
|
|
1275
|
-
index += 1;
|
|
1276
|
-
}
|
|
1290
|
+
const { run: facts, next } = takeRun(
|
|
1291
|
+
nodes,
|
|
1292
|
+
index,
|
|
1293
|
+
(candidate) => candidate.type === "Fact",
|
|
1294
|
+
);
|
|
1295
|
+
index = next;
|
|
1277
1296
|
blocks.push(...convertFacts(facts, context));
|
|
1278
1297
|
continue;
|
|
1279
1298
|
}
|
|
1280
1299
|
if (isElement(current) && INTERACTIVE_TYPES.has(current.type)) {
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
!candidate ||
|
|
1286
|
-
!isElement(candidate) ||
|
|
1287
|
-
!INTERACTIVE_TYPES.has(candidate.type)
|
|
1288
|
-
) {
|
|
1289
|
-
break;
|
|
1290
|
-
}
|
|
1291
|
-
controls.push(candidate);
|
|
1292
|
-
index += 1;
|
|
1293
|
-
}
|
|
1300
|
+
const { run: controls, next } = takeRun(nodes, index, (candidate) =>
|
|
1301
|
+
INTERACTIVE_TYPES.has(candidate.type),
|
|
1302
|
+
);
|
|
1303
|
+
index = next;
|
|
1294
1304
|
blocks.push(...convertActions(controls, context));
|
|
1295
1305
|
continue;
|
|
1296
1306
|
}
|
|
@@ -1320,104 +1330,6 @@ function convertSequence(
|
|
|
1320
1330
|
return blocks;
|
|
1321
1331
|
}
|
|
1322
1332
|
|
|
1323
|
-
interface BoundState {
|
|
1324
|
-
remaining: number;
|
|
1325
|
-
exhausted: boolean;
|
|
1326
|
-
}
|
|
1327
|
-
|
|
1328
|
-
type ClampReason = "children" | "budget" | "cycle" | "depth";
|
|
1329
|
-
|
|
1330
|
-
function boundNode(
|
|
1331
|
-
value: unknown,
|
|
1332
|
-
depth: number,
|
|
1333
|
-
onClamp: (reason: ClampReason) => void,
|
|
1334
|
-
state: BoundState,
|
|
1335
|
-
ancestors: WeakSet<object>,
|
|
1336
|
-
): unknown {
|
|
1337
|
-
if (state.remaining <= 0) {
|
|
1338
|
-
if (!state.exhausted) {
|
|
1339
|
-
state.exhausted = true;
|
|
1340
|
-
onClamp("budget");
|
|
1341
|
-
}
|
|
1342
|
-
return null;
|
|
1343
|
-
}
|
|
1344
|
-
state.remaining -= 1;
|
|
1345
|
-
if (depth > MAX_TRAVERSAL_DEPTH) {
|
|
1346
|
-
onClamp("depth");
|
|
1347
|
-
return null;
|
|
1348
|
-
}
|
|
1349
|
-
if (Array.isArray(value)) {
|
|
1350
|
-
if (ancestors.has(value)) {
|
|
1351
|
-
onClamp("cycle");
|
|
1352
|
-
return null;
|
|
1353
|
-
}
|
|
1354
|
-
ancestors.add(value);
|
|
1355
|
-
const bounded = Array.prototype.slice.call(
|
|
1356
|
-
value,
|
|
1357
|
-
0,
|
|
1358
|
-
CHILDREN_CAP,
|
|
1359
|
-
) as unknown[];
|
|
1360
|
-
if (value.length > CHILDREN_CAP) onClamp("children");
|
|
1361
|
-
const result = bounded.map((item) =>
|
|
1362
|
-
boundNode(item, depth + 1, onClamp, state, ancestors),
|
|
1363
|
-
);
|
|
1364
|
-
ancestors.delete(value);
|
|
1365
|
-
return result;
|
|
1366
|
-
}
|
|
1367
|
-
if (
|
|
1368
|
-
value !== null &&
|
|
1369
|
-
typeof value === "object" &&
|
|
1370
|
-
"children" in (value as Record<string, unknown>)
|
|
1371
|
-
) {
|
|
1372
|
-
if (ancestors.has(value)) {
|
|
1373
|
-
onClamp("cycle");
|
|
1374
|
-
return null;
|
|
1375
|
-
}
|
|
1376
|
-
ancestors.add(value);
|
|
1377
|
-
const record = value as Record<string, unknown>;
|
|
1378
|
-
const result = {
|
|
1379
|
-
...record,
|
|
1380
|
-
children: boundNode(
|
|
1381
|
-
record["children"],
|
|
1382
|
-
depth + 1,
|
|
1383
|
-
onClamp,
|
|
1384
|
-
state,
|
|
1385
|
-
ancestors,
|
|
1386
|
-
),
|
|
1387
|
-
};
|
|
1388
|
-
ancestors.delete(value);
|
|
1389
|
-
return result;
|
|
1390
|
-
}
|
|
1391
|
-
return value;
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
/**
|
|
1395
|
-
* Produces a bounded plain copy of a raw generative-ui spec before it
|
|
1396
|
-
* reaches `normalizeSpec`, whose own traversal of the root array or any
|
|
1397
|
-
* `children` array walks the full reported length of a hostile proxied
|
|
1398
|
-
* array before any per-field cap downstream ever applies. Every array
|
|
1399
|
-
* (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
|
|
1400
|
-
* entries via `Array.prototype.slice`, which bounds even a proxy with a
|
|
1401
|
-
* fabricated `length`; recursion itself is capped at
|
|
1402
|
-
* {@link MAX_TRAVERSAL_DEPTH}. `onClamp` fires once per level that was
|
|
1403
|
-
* truncated, receiving the reason for that truncation: `"children"`,
|
|
1404
|
-
* `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
|
|
1405
|
-
* {@link NODE_BUDGET} nodes, so shared references cannot multiply work
|
|
1406
|
-
* exponentially, and a node that is its own ancestor is cut to `null`.
|
|
1407
|
-
*/
|
|
1408
|
-
function boundSpec(
|
|
1409
|
-
spec: unknown,
|
|
1410
|
-
onClamp: (reason: ClampReason) => void,
|
|
1411
|
-
): unknown {
|
|
1412
|
-
return boundNode(
|
|
1413
|
-
spec,
|
|
1414
|
-
0,
|
|
1415
|
-
onClamp,
|
|
1416
|
-
{ remaining: NODE_BUDGET, exhausted: false },
|
|
1417
|
-
new WeakSet(),
|
|
1418
|
-
);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
1333
|
/** Converts a generative-UI tree into Slack Block Kit JSON and downgrade warnings. */
|
|
1422
1334
|
export function toSlackBlocks(
|
|
1423
1335
|
node: unknown,
|
|
@@ -1433,17 +1345,9 @@ export function toSlackBlocks(
|
|
|
1433
1345
|
dataTableCharacters: 0,
|
|
1434
1346
|
};
|
|
1435
1347
|
try {
|
|
1436
|
-
const bounded = boundSpec(node, (reason) =>
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
? `the tree was truncated after ${NODE_BUDGET} nodes.`
|
|
1440
|
-
: reason === "cycle"
|
|
1441
|
-
? "a self-referencing node was dropped."
|
|
1442
|
-
: reason === "depth"
|
|
1443
|
-
? `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`
|
|
1444
|
-
: `children were clamped to ${CHILDREN_CAP} entries.`;
|
|
1445
|
-
warn(context, "clamped", "Root", detail);
|
|
1446
|
-
});
|
|
1348
|
+
const bounded = boundSpec(node, (reason) =>
|
|
1349
|
+
warn(context, "clamped", "Root", clampReasonDetail(reason)),
|
|
1350
|
+
);
|
|
1447
1351
|
const { root } = normalizeSpec(bounded as never);
|
|
1448
1352
|
const converted = convertSequence(root, context, 0);
|
|
1449
1353
|
if (converted.length <= context.blockCap) {
|
package/src/teams/constants.ts
CHANGED
|
@@ -17,48 +17,6 @@ export const ADAPTIVE_CARD_SCHEMA =
|
|
|
17
17
|
export const ATTACHMENT_CONTENT_TYPE =
|
|
18
18
|
"application/vnd.microsoft.card.adaptive";
|
|
19
19
|
|
|
20
|
-
/** The maximum traversal depth, counted in visited values, not tree levels. */
|
|
21
|
-
export const MAX_TRAVERSAL_DEPTH = 64;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The element-nesting ceiling a caller observes. `boundNode` visits an element
|
|
25
|
-
* and its `children` array separately, so one level of nesting spends two of
|
|
26
|
-
* {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never
|
|
27
|
-
* the looser of the two. A root array costs one more level than a root object.
|
|
28
|
-
*/
|
|
29
|
-
export const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* The maximum number of entries kept at any single level (root, or a
|
|
33
|
-
* `children` array at any depth) of the raw spec, before it ever reaches
|
|
34
|
-
* `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass
|
|
35
|
-
* can never be made to walk further than this regardless of what the array
|
|
36
|
-
* claims about itself.
|
|
37
|
-
*/
|
|
38
|
-
export const CHILDREN_CAP = 200;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The total number of nodes {@link boundSpec} will visit across one walk,
|
|
42
|
-
* regardless of how many times a shared reference recurs. Bounds the
|
|
43
|
-
* combinatorial work a DAG of shared or self-referential nodes would
|
|
44
|
-
* otherwise force even though each individual array stays within
|
|
45
|
-
* {@link CHILDREN_CAP}.
|
|
46
|
-
*/
|
|
47
|
-
export const NODE_BUDGET = 5000;
|
|
48
|
-
|
|
49
|
-
export type ClampReason = "children" | "budget" | "cycle" | "depth";
|
|
50
|
-
|
|
51
|
-
export function clampReasonDetail(reason: ClampReason): string {
|
|
52
|
-
if (reason === "budget") {
|
|
53
|
-
return `the tree was truncated after ${NODE_BUDGET} nodes.`;
|
|
54
|
-
}
|
|
55
|
-
if (reason === "cycle") return "a self-referencing node was dropped.";
|
|
56
|
-
if (reason === "depth") {
|
|
57
|
-
return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;
|
|
58
|
-
}
|
|
59
|
-
return `children were clamped to ${CHILDREN_CAP} entries.`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
20
|
/** The maximum number of attachments produced from a root-level Carousel. */
|
|
63
21
|
export const CAROUSEL_ATTACHMENT_CAP = 10;
|
|
64
22
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CHILDREN_CAP, NODE_BUDGET } from "../convert/boundSpec";
|
|
2
3
|
import { toAdaptiveCard } from "./toAdaptiveCard";
|
|
3
4
|
import { toTeamsAttachments } from "./toTeamsAttachments";
|
|
4
5
|
import {
|
|
5
6
|
CAROUSEL_ATTACHMENT_CAP,
|
|
6
|
-
|
|
7
|
-
NODE_BUDGET,
|
|
7
|
+
CHOICE_OPTION_CAP,
|
|
8
8
|
PAYLOAD_SOFT_CAP,
|
|
9
9
|
PRIMARY_ACTION_CAP,
|
|
10
10
|
TABLE_COLUMN_CAP,
|
|
@@ -25,6 +25,32 @@ import type {
|
|
|
25
25
|
TeamsTextBlock,
|
|
26
26
|
} from "./types";
|
|
27
27
|
|
|
28
|
+
const hostileSpecies = <T>(
|
|
29
|
+
source: T[],
|
|
30
|
+
injected: unknown[],
|
|
31
|
+
onDispatch: () => void,
|
|
32
|
+
): T[] => {
|
|
33
|
+
function HostileCtor() {
|
|
34
|
+
return {
|
|
35
|
+
map: () => {
|
|
36
|
+
onDispatch();
|
|
37
|
+
return injected;
|
|
38
|
+
},
|
|
39
|
+
[Symbol.iterator]: function* () {
|
|
40
|
+
onDispatch();
|
|
41
|
+
yield* injected;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const constructor = { [Symbol.species]: HostileCtor };
|
|
46
|
+
return new Proxy(source, {
|
|
47
|
+
get: (target, prop, receiver) =>
|
|
48
|
+
prop === "constructor"
|
|
49
|
+
? constructor
|
|
50
|
+
: Reflect.get(target, prop, receiver),
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
28
54
|
describe("toAdaptiveCard", () => {
|
|
29
55
|
describe("Header", () => {
|
|
30
56
|
it("renders a heading TextBlock", () => {
|
|
@@ -455,6 +481,29 @@ describe("toAdaptiveCard", () => {
|
|
|
455
481
|
expect((card.body[0] as TeamsInputChoiceSet).id).toBe("select");
|
|
456
482
|
});
|
|
457
483
|
|
|
484
|
+
it("does not dispatch a prop array through Symbol.species", () => {
|
|
485
|
+
let dispatches = 0;
|
|
486
|
+
const injected = Array.from(
|
|
487
|
+
{ length: CHOICE_OPTION_CAP + 1 },
|
|
488
|
+
(_, i) => ({ label: `injected-${i}`, value: `injected-${i}` }),
|
|
489
|
+
);
|
|
490
|
+
const { card, warnings } = toAdaptiveCard({
|
|
491
|
+
$type: "Select",
|
|
492
|
+
options: hostileSpecies(
|
|
493
|
+
[{ label: "Kept", value: "kept" }],
|
|
494
|
+
injected,
|
|
495
|
+
() => {
|
|
496
|
+
dispatches += 1;
|
|
497
|
+
},
|
|
498
|
+
),
|
|
499
|
+
});
|
|
500
|
+
expect((card.body[0] as TeamsInputChoiceSet).choices).toEqual([
|
|
501
|
+
{ title: "Kept", value: "kept" },
|
|
502
|
+
]);
|
|
503
|
+
expect(dispatches).toBe(0);
|
|
504
|
+
expect(warnings).toEqual([]);
|
|
505
|
+
});
|
|
506
|
+
|
|
458
507
|
it("appends a companion submit ActionSet with a fallback warning when $action is present", () => {
|
|
459
508
|
const { card, warnings } = toAdaptiveCard({
|
|
460
509
|
$type: "Select",
|
|
@@ -1154,6 +1203,29 @@ describe("toAdaptiveCard", () => {
|
|
|
1154
1203
|
detail: `rows were clamped to ${TABLE_ROW_CAP} entries.`,
|
|
1155
1204
|
});
|
|
1156
1205
|
});
|
|
1206
|
+
|
|
1207
|
+
it("does not dispatch a row array through Symbol.species", () => {
|
|
1208
|
+
let dispatches = 0;
|
|
1209
|
+
const row = hostileSpecies(
|
|
1210
|
+
["kept"],
|
|
1211
|
+
["injected", "also-injected"],
|
|
1212
|
+
() => {
|
|
1213
|
+
dispatches += 1;
|
|
1214
|
+
},
|
|
1215
|
+
);
|
|
1216
|
+
const { card } = toAdaptiveCard({
|
|
1217
|
+
$type: "Table",
|
|
1218
|
+
columns: [{ label: "A" }],
|
|
1219
|
+
rows: [row],
|
|
1220
|
+
});
|
|
1221
|
+
const table = card.body[0] as TeamsTable;
|
|
1222
|
+
expect(table.rows).toHaveLength(2);
|
|
1223
|
+
expect(table.rows[1]?.cells).toHaveLength(1);
|
|
1224
|
+
expect(table.rows[1]?.cells[0]?.items).toEqual([
|
|
1225
|
+
{ type: "TextBlock", text: "kept", wrap: true },
|
|
1226
|
+
]);
|
|
1227
|
+
expect(dispatches).toBe(0);
|
|
1228
|
+
});
|
|
1157
1229
|
});
|
|
1158
1230
|
|
|
1159
1231
|
describe("Alert", () => {
|
|
@@ -1162,7 +1234,7 @@ describe("toAdaptiveCard", () => {
|
|
|
1162
1234
|
["success", "good"],
|
|
1163
1235
|
["warning", "warning"],
|
|
1164
1236
|
["danger", "attention"],
|
|
1165
|
-
[undefined, "
|
|
1237
|
+
[undefined, "accent"],
|
|
1166
1238
|
] as const)("maps tone %s to style %s", (tone, style) => {
|
|
1167
1239
|
const { card } = toAdaptiveCard({
|
|
1168
1240
|
$type: "Alert",
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_TRAVERSAL_DEPTH,
|
|
3
|
+
boundSpec,
|
|
4
|
+
clampReasonDetail,
|
|
5
|
+
} from "../convert/boundSpec";
|
|
6
|
+
import { copyBounded } from "../convert/copyBounded";
|
|
7
|
+
import { isElement } from "../convert/isElement";
|
|
8
|
+
import { takeRun } from "../convert/takeRun";
|
|
1
9
|
import {
|
|
2
10
|
normalizeSpec,
|
|
3
11
|
type NormalizedUIElement,
|
|
4
12
|
type NormalizedUINode,
|
|
5
13
|
} from "../ir";
|
|
6
|
-
import { boundSpec } from "./boundSpec";
|
|
7
14
|
import {
|
|
8
15
|
CHOICE_OPTION_CAP,
|
|
9
|
-
MAX_TRAVERSAL_DEPTH,
|
|
10
16
|
PAYLOAD_SOFT_CAP,
|
|
11
17
|
PRIMARY_ACTION_CAP,
|
|
12
18
|
TABLE_COLUMN_CAP,
|
|
13
19
|
TABLE_ROW_CAP,
|
|
14
20
|
buildCard,
|
|
15
21
|
buildSubmitAction,
|
|
16
|
-
clampReasonDetail,
|
|
17
22
|
utf8ByteLength,
|
|
18
23
|
} from "./constants";
|
|
19
24
|
import type {
|
|
@@ -44,9 +49,6 @@ export interface ConversionContext {
|
|
|
44
49
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
45
50
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
51
|
|
|
47
|
-
const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
|
|
48
|
-
isRecord(node);
|
|
49
|
-
|
|
50
52
|
const asString = (value: unknown): string =>
|
|
51
53
|
typeof value === "string" ? value : "";
|
|
52
54
|
|
|
@@ -125,7 +127,7 @@ function reservedSafeId(
|
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
/**
|
|
128
|
-
* The shared bounded-iteration primitive:
|
|
130
|
+
* The shared bounded-iteration primitive: copies `value` to `cap` entries
|
|
129
131
|
* without ever reading past that many indices, so a hostile array (sparse or
|
|
130
132
|
* proxied with a fabricated `length`) cannot stall the event loop.
|
|
131
133
|
*/
|
|
@@ -134,7 +136,7 @@ const clampArray = (
|
|
|
134
136
|
cap: number,
|
|
135
137
|
): { readonly items: unknown[]; readonly truncated: boolean } => {
|
|
136
138
|
if (!Array.isArray(value)) return { items: [], truncated: false };
|
|
137
|
-
return
|
|
139
|
+
return copyBounded(value, cap);
|
|
138
140
|
};
|
|
139
141
|
|
|
140
142
|
const normalizedList = (
|
|
@@ -337,12 +339,13 @@ function convertTable(
|
|
|
337
339
|
: undefined;
|
|
338
340
|
const dataRows: TeamsTableRow[] = rawRows.map((row) => ({
|
|
339
341
|
type: "TableRow",
|
|
340
|
-
cells: (Array.isArray(row)
|
|
341
|
-
(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
342
|
+
cells: (Array.isArray(row)
|
|
343
|
+
? copyBounded(row, TABLE_COLUMN_CAP).items
|
|
344
|
+
: []
|
|
345
|
+
).map((cell) => ({
|
|
346
|
+
type: "TableCell" as const,
|
|
347
|
+
items: [textBlock(stringifyCell(cell))],
|
|
348
|
+
})),
|
|
346
349
|
}));
|
|
347
350
|
|
|
348
351
|
return {
|
|
@@ -618,7 +621,7 @@ export function convertElement(
|
|
|
618
621
|
case "Table":
|
|
619
622
|
return [convertTable(props, context)];
|
|
620
623
|
case "Alert": {
|
|
621
|
-
const tone = props["tone"];
|
|
624
|
+
const tone = props["tone"] ?? "info";
|
|
622
625
|
const style =
|
|
623
626
|
typeof tone === "string"
|
|
624
627
|
? (ALERT_STYLE_MAP[tone] ?? "default")
|
|
@@ -729,32 +732,22 @@ export function convertSequence(
|
|
|
729
732
|
continue;
|
|
730
733
|
}
|
|
731
734
|
if (isElement(current) && current.type === "Fact") {
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
facts.push(candidate);
|
|
739
|
-
index += 1;
|
|
740
|
-
}
|
|
735
|
+
const { run: facts, next } = takeRun(
|
|
736
|
+
nodes,
|
|
737
|
+
index,
|
|
738
|
+
(candidate) => candidate.type === "Fact",
|
|
739
|
+
);
|
|
740
|
+
index = next;
|
|
741
741
|
emit([convertFacts(facts)]);
|
|
742
742
|
continue;
|
|
743
743
|
}
|
|
744
744
|
if (isElement(current) && current.type === "Button") {
|
|
745
|
-
const
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
candidate.type !== "Button"
|
|
752
|
-
) {
|
|
753
|
-
break;
|
|
754
|
-
}
|
|
755
|
-
buttons.push(candidate);
|
|
756
|
-
index += 1;
|
|
757
|
-
}
|
|
745
|
+
const { run: buttons, next } = takeRun(
|
|
746
|
+
nodes,
|
|
747
|
+
index,
|
|
748
|
+
(candidate) => candidate.type === "Button",
|
|
749
|
+
);
|
|
750
|
+
index = next;
|
|
758
751
|
emit([convertButtons(buttons, context)]);
|
|
759
752
|
continue;
|
|
760
753
|
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
+
import { boundSpec, clampReasonDetail } from "../convert/boundSpec";
|
|
2
|
+
import { isElement } from "../convert/isElement";
|
|
1
3
|
import {
|
|
2
4
|
normalizeSpec,
|
|
3
5
|
type NormalizedUIElement,
|
|
4
6
|
type NormalizedUINode,
|
|
5
7
|
} from "../ir";
|
|
6
|
-
import { boundSpec } from "./boundSpec";
|
|
7
8
|
import {
|
|
8
9
|
CAROUSEL_ATTACHMENT_CAP,
|
|
9
10
|
PAYLOAD_SOFT_CAP,
|
|
10
11
|
buildAttachment,
|
|
11
|
-
clampReasonDetail,
|
|
12
12
|
utf8ByteLength,
|
|
13
13
|
} from "./constants";
|
|
14
14
|
import {
|
|
@@ -23,12 +23,6 @@ import type {
|
|
|
23
23
|
ToAdaptiveCardOptions,
|
|
24
24
|
} from "./types";
|
|
25
25
|
|
|
26
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
-
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
-
|
|
29
|
-
const isElement = (node: NormalizedUINode): node is NormalizedUIElement =>
|
|
30
|
-
isRecord(node);
|
|
31
|
-
|
|
32
26
|
const normalizedList = (
|
|
33
27
|
node: NormalizedUINode | undefined,
|
|
34
28
|
): NormalizedUINode[] => {
|