@assistant-ui/react-generative-ui 0.0.24 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (338) hide show
  1. package/README.md +2 -0
  2. package/dist/a2ui.d.ts +1 -4
  3. package/dist/a2ui.d.ts.map +1 -1
  4. package/dist/a2ui.js +1 -4
  5. package/dist/index.d.ts +1 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -10
  8. package/dist/index.server.d.ts +1 -2
  9. package/dist/index.server.d.ts.map +1 -1
  10. package/dist/index.server.js +1 -10
  11. package/dist/ir.d.ts +1 -111
  12. package/dist/ir.d.ts.map +1 -1
  13. package/dist/ir.js +1 -175
  14. package/dist/slack.d.ts +1 -4
  15. package/dist/slack.d.ts.map +1 -1
  16. package/dist/slack.js +1 -4
  17. package/dist/teams.d.ts +1 -4
  18. package/dist/teams.d.ts.map +1 -1
  19. package/dist/teams.js +1 -4
  20. package/package.json +4 -5
  21. package/src/a2ui.ts +1 -22
  22. package/src/index.server.ts +1 -2
  23. package/src/index.test.ts +75 -0
  24. package/src/index.ts +1 -2
  25. package/src/ir.ts +1 -279
  26. package/src/slack.ts +1 -39
  27. package/src/teams.ts +1 -35
  28. package/dist/JSONGenerativeUI.client.d.ts +0 -28
  29. package/dist/JSONGenerativeUI.client.d.ts.map +0 -1
  30. package/dist/JSONGenerativeUI.client.js +0 -101
  31. package/dist/JSONGenerativeUI.client.js.map +0 -1
  32. package/dist/JSONGenerativeUI.server.d.ts +0 -21
  33. package/dist/JSONGenerativeUI.server.d.ts.map +0 -1
  34. package/dist/JSONGenerativeUI.server.js +0 -32
  35. package/dist/JSONGenerativeUI.server.js.map +0 -1
  36. package/dist/JSONGenerativeUI.shared.d.ts +0 -63
  37. package/dist/JSONGenerativeUI.shared.d.ts.map +0 -1
  38. package/dist/JSONGenerativeUI.shared.js +0 -31
  39. package/dist/JSONGenerativeUI.shared.js.map +0 -1
  40. package/dist/RadioGroupScope.d.ts +0 -6
  41. package/dist/RadioGroupScope.d.ts.map +0 -1
  42. package/dist/RadioGroupScope.js +0 -25
  43. package/dist/RadioGroupScope.js.map +0 -1
  44. package/dist/a2ui/BindingContext.d.ts +0 -11
  45. package/dist/a2ui/BindingContext.d.ts.map +0 -1
  46. package/dist/a2ui/BindingContext.js +0 -12
  47. package/dist/a2ui/BindingContext.js.map +0 -1
  48. package/dist/a2ui/PresentRenderer.d.ts +0 -11
  49. package/dist/a2ui/PresentRenderer.d.ts.map +0 -1
  50. package/dist/a2ui/PresentRenderer.js +0 -39
  51. package/dist/a2ui/PresentRenderer.js.map +0 -1
  52. package/dist/a2ui/convert.d.ts +0 -15
  53. package/dist/a2ui/convert.d.ts.map +0 -1
  54. package/dist/a2ui/convert.js +0 -728
  55. package/dist/a2ui/convert.js.map +0 -1
  56. package/dist/a2ui/dataModel.d.ts +0 -9
  57. package/dist/a2ui/dataModel.d.ts.map +0 -1
  58. package/dist/a2ui/dataModel.js +0 -44
  59. package/dist/a2ui/dataModel.js.map +0 -1
  60. package/dist/a2ui/reducer.d.ts +0 -8
  61. package/dist/a2ui/reducer.d.ts.map +0 -1
  62. package/dist/a2ui/reducer.js +0 -242
  63. package/dist/a2ui/reducer.js.map +0 -1
  64. package/dist/a2ui/snapshot.d.ts +0 -3
  65. package/dist/a2ui/snapshot.d.ts.map +0 -1
  66. package/dist/a2ui/snapshot.js +0 -33
  67. package/dist/a2ui/snapshot.js.map +0 -1
  68. package/dist/a2ui/types.d.ts +0 -88
  69. package/dist/a2ui/types.d.ts.map +0 -1
  70. package/dist/a2ui/types.js +0 -6
  71. package/dist/a2ui/types.js.map +0 -1
  72. package/dist/a2ui/valueFunctions.d.ts +0 -16
  73. package/dist/a2ui/valueFunctions.d.ts.map +0 -1
  74. package/dist/a2ui/valueFunctions.js +0 -332
  75. package/dist/a2ui/valueFunctions.js.map +0 -1
  76. package/dist/actionRegistry.d.ts +0 -29
  77. package/dist/actionRegistry.d.ts.map +0 -1
  78. package/dist/actionRegistry.js +0 -41
  79. package/dist/actionRegistry.js.map +0 -1
  80. package/dist/answeredValues.d.ts +0 -16
  81. package/dist/answeredValues.d.ts.map +0 -1
  82. package/dist/answeredValues.js +0 -37
  83. package/dist/answeredValues.js.map +0 -1
  84. package/dist/buildPresentParameters.d.ts +0 -21
  85. package/dist/buildPresentParameters.d.ts.map +0 -1
  86. package/dist/buildPresentParameters.js +0 -95
  87. package/dist/buildPresentParameters.js.map +0 -1
  88. package/dist/constants.d.ts +0 -15
  89. package/dist/constants.d.ts.map +0 -1
  90. package/dist/constants.js +0 -19
  91. package/dist/constants.js.map +0 -1
  92. package/dist/convert/boundSpec.d.ts +0 -48
  93. package/dist/convert/boundSpec.d.ts.map +0 -1
  94. package/dist/convert/boundSpec.js +0 -108
  95. package/dist/convert/boundSpec.js.map +0 -1
  96. package/dist/convert/copyBounded.d.ts +0 -25
  97. package/dist/convert/copyBounded.d.ts.map +0 -1
  98. package/dist/convert/copyBounded.js +0 -38
  99. package/dist/convert/copyBounded.js.map +0 -1
  100. package/dist/convert/isElement.d.ts +0 -3
  101. package/dist/convert/isElement.d.ts.map +0 -1
  102. package/dist/convert/isElement.js +0 -6
  103. package/dist/convert/isElement.js.map +0 -1
  104. package/dist/convert/takeRun.d.ts +0 -6
  105. package/dist/convert/takeRun.d.ts.map +0 -1
  106. package/dist/convert/takeRun.js +0 -20
  107. package/dist/convert/takeRun.js.map +0 -1
  108. package/dist/defineGenerativeComponents.d.ts +0 -29
  109. package/dist/defineGenerativeComponents.d.ts.map +0 -1
  110. package/dist/defineGenerativeComponents.js +0 -34
  111. package/dist/defineGenerativeComponents.js.map +0 -1
  112. package/dist/fieldReferences.d.ts +0 -9
  113. package/dist/fieldReferences.d.ts.map +0 -1
  114. package/dist/fieldReferences.js +0 -53
  115. package/dist/fieldReferences.js.map +0 -1
  116. package/dist/generativeUIToJSX.d.ts +0 -18
  117. package/dist/generativeUIToJSX.d.ts.map +0 -1
  118. package/dist/generativeUIToJSX.js +0 -76
  119. package/dist/generativeUIToJSX.js.map +0 -1
  120. package/dist/index.shared.d.ts +0 -15
  121. package/dist/index.shared.d.ts.map +0 -1
  122. package/dist/index.shared.js +0 -9
  123. package/dist/ir.js.map +0 -1
  124. package/dist/renderGenerativeUI.d.ts +0 -14
  125. package/dist/renderGenerativeUI.d.ts.map +0 -1
  126. package/dist/renderGenerativeUI.js +0 -83
  127. package/dist/renderGenerativeUI.js.map +0 -1
  128. package/dist/scopeSchema.d.ts +0 -6
  129. package/dist/scopeSchema.d.ts.map +0 -1
  130. package/dist/scopeSchema.js +0 -56
  131. package/dist/scopeSchema.js.map +0 -1
  132. package/dist/slack/constants.d.ts +0 -93
  133. package/dist/slack/constants.d.ts.map +0 -1
  134. package/dist/slack/constants.js +0 -146
  135. package/dist/slack/constants.js.map +0 -1
  136. package/dist/slack/decodeBlockAction.d.ts +0 -10
  137. package/dist/slack/decodeBlockAction.d.ts.map +0 -1
  138. package/dist/slack/decodeBlockAction.js +0 -129
  139. package/dist/slack/decodeBlockAction.js.map +0 -1
  140. package/dist/slack/fromSlackBlocks.d.ts +0 -27
  141. package/dist/slack/fromSlackBlocks.d.ts.map +0 -1
  142. package/dist/slack/fromSlackBlocks.js +0 -483
  143. package/dist/slack/fromSlackBlocks.js.map +0 -1
  144. package/dist/slack/toSlackBlocks.d.ts +0 -4
  145. package/dist/slack/toSlackBlocks.d.ts.map +0 -1
  146. package/dist/slack/toSlackBlocks.js +0 -901
  147. package/dist/slack/toSlackBlocks.js.map +0 -1
  148. package/dist/slack/types.d.ts +0 -204
  149. package/dist/slack/types.d.ts.map +0 -1
  150. package/dist/slack/types.js +0 -0
  151. package/dist/teams/constants.d.ts +0 -34
  152. package/dist/teams/constants.d.ts.map +0 -1
  153. package/dist/teams/constants.js +0 -68
  154. package/dist/teams/constants.js.map +0 -1
  155. package/dist/teams/decodeSubmitData.d.ts +0 -26
  156. package/dist/teams/decodeSubmitData.d.ts.map +0 -1
  157. package/dist/teams/decodeSubmitData.js +0 -70
  158. package/dist/teams/decodeSubmitData.js.map +0 -1
  159. package/dist/teams/temporalId.d.ts +0 -5
  160. package/dist/teams/temporalId.d.ts.map +0 -1
  161. package/dist/teams/temporalId.js +0 -28
  162. package/dist/teams/temporalId.js.map +0 -1
  163. package/dist/teams/toAdaptiveCard.d.ts +0 -45
  164. package/dist/teams/toAdaptiveCard.d.ts.map +0 -1
  165. package/dist/teams/toAdaptiveCard.js +0 -642
  166. package/dist/teams/toAdaptiveCard.js.map +0 -1
  167. package/dist/teams/toTeamsAttachments.d.ts +0 -10
  168. package/dist/teams/toTeamsAttachments.d.ts.map +0 -1
  169. package/dist/teams/toTeamsAttachments.js +0 -99
  170. package/dist/teams/toTeamsAttachments.js.map +0 -1
  171. package/dist/teams/types.d.ts +0 -224
  172. package/dist/teams/types.d.ts.map +0 -1
  173. package/dist/teams/types.js +0 -0
  174. package/dist/temporal.d.ts +0 -33
  175. package/dist/temporal.d.ts.map +0 -1
  176. package/dist/temporal.js +0 -153
  177. package/dist/temporal.js.map +0 -1
  178. package/dist/types.d.ts +0 -99
  179. package/dist/types.d.ts.map +0 -1
  180. package/dist/types.js +0 -0
  181. package/dist/vocabulary/alert.d.ts +0 -32
  182. package/dist/vocabulary/alert.d.ts.map +0 -1
  183. package/dist/vocabulary/alert.js +0 -160
  184. package/dist/vocabulary/alert.js.map +0 -1
  185. package/dist/vocabulary/collectFormValues.d.ts +0 -23
  186. package/dist/vocabulary/collectFormValues.d.ts.map +0 -1
  187. package/dist/vocabulary/collectFormValues.js +0 -44
  188. package/dist/vocabulary/collectFormValues.js.map +0 -1
  189. package/dist/vocabulary/data.d.ts +0 -66
  190. package/dist/vocabulary/data.d.ts.map +0 -1
  191. package/dist/vocabulary/data.js +0 -503
  192. package/dist/vocabulary/data.js.map +0 -1
  193. package/dist/vocabulary/dispatch.d.ts +0 -9
  194. package/dist/vocabulary/dispatch.d.ts.map +0 -1
  195. package/dist/vocabulary/dispatch.js +0 -50
  196. package/dist/vocabulary/dispatch.js.map +0 -1
  197. package/dist/vocabulary/fact.d.ts +0 -19
  198. package/dist/vocabulary/fact.d.ts.map +0 -1
  199. package/dist/vocabulary/fact.js +0 -51
  200. package/dist/vocabulary/fact.js.map +0 -1
  201. package/dist/vocabulary/form.d.ts +0 -11
  202. package/dist/vocabulary/form.d.ts.map +0 -1
  203. package/dist/vocabulary/form.js +0 -23
  204. package/dist/vocabulary/form.js.map +0 -1
  205. package/dist/vocabulary/formatValue.d.ts +0 -7
  206. package/dist/vocabulary/formatValue.d.ts.map +0 -1
  207. package/dist/vocabulary/formatValue.js +0 -76
  208. package/dist/vocabulary/formatValue.js.map +0 -1
  209. package/dist/vocabulary/icon.d.ts +0 -41
  210. package/dist/vocabulary/icon.d.ts.map +0 -1
  211. package/dist/vocabulary/icon.js +0 -272
  212. package/dist/vocabulary/icon.js.map +0 -1
  213. package/dist/vocabulary/index.d.ts +0 -12
  214. package/dist/vocabulary/index.d.ts.map +0 -1
  215. package/dist/vocabulary/index.js +0 -36
  216. package/dist/vocabulary/index.js.map +0 -1
  217. package/dist/vocabulary/interactive.d.ts +0 -230
  218. package/dist/vocabulary/interactive.d.ts.map +0 -1
  219. package/dist/vocabulary/interactive.js +0 -584
  220. package/dist/vocabulary/interactive.js.map +0 -1
  221. package/dist/vocabulary/layout.d.ts +0 -79
  222. package/dist/vocabulary/layout.d.ts.map +0 -1
  223. package/dist/vocabulary/layout.js +0 -139
  224. package/dist/vocabulary/layout.js.map +0 -1
  225. package/dist/vocabulary/list.d.ts +0 -14
  226. package/dist/vocabulary/list.d.ts.map +0 -1
  227. package/dist/vocabulary/list.js +0 -50
  228. package/dist/vocabulary/list.js.map +0 -1
  229. package/dist/vocabulary/media.d.ts +0 -25
  230. package/dist/vocabulary/media.d.ts.map +0 -1
  231. package/dist/vocabulary/media.js +0 -42
  232. package/dist/vocabulary/media.js.map +0 -1
  233. package/dist/vocabulary/text.d.ts +0 -57
  234. package/dist/vocabulary/text.d.ts.map +0 -1
  235. package/dist/vocabulary/text.js +0 -49
  236. package/dist/vocabulary/text.js.map +0 -1
  237. package/dist/vocabulary/toTextContent.d.ts +0 -2
  238. package/dist/vocabulary/toTextContent.d.ts.map +0 -1
  239. package/dist/vocabulary/toTextContent.js +0 -6
  240. package/dist/vocabulary/toTextContent.js.map +0 -1
  241. package/src/JSONGenerativeUI.client.tsx +0 -168
  242. package/src/JSONGenerativeUI.server.tsx +0 -39
  243. package/src/JSONGenerativeUI.shared.ts +0 -90
  244. package/src/JSONGenerativeUI.test.tsx +0 -1298
  245. package/src/RadioGroupScope.server.test.tsx +0 -24
  246. package/src/RadioGroupScope.tsx +0 -34
  247. package/src/a2ui/BindingContext.ts +0 -26
  248. package/src/a2ui/PresentRenderer.tsx +0 -83
  249. package/src/a2ui/convert.test.ts +0 -2923
  250. package/src/a2ui/convert.ts +0 -1207
  251. package/src/a2ui/dataModel.ts +0 -69
  252. package/src/a2ui/reducer.test.ts +0 -422
  253. package/src/a2ui/reducer.ts +0 -363
  254. package/src/a2ui/snapshot.test.ts +0 -52
  255. package/src/a2ui/snapshot.ts +0 -52
  256. package/src/a2ui/types.ts +0 -114
  257. package/src/a2ui/valueFunctions.test.ts +0 -271
  258. package/src/a2ui/valueFunctions.ts +0 -523
  259. package/src/actionRegistry.test.ts +0 -90
  260. package/src/actionRegistry.ts +0 -88
  261. package/src/answeredValues.test.ts +0 -33
  262. package/src/answeredValues.tsx +0 -70
  263. package/src/buildPresentParameters.test.ts +0 -246
  264. package/src/buildPresentParameters.ts +0 -130
  265. package/src/constants.ts +0 -17
  266. package/src/convert/boundSpec.test.ts +0 -192
  267. package/src/convert/boundSpec.ts +0 -134
  268. package/src/convert/copyBounded.test.ts +0 -131
  269. package/src/convert/copyBounded.ts +0 -38
  270. package/src/convert/isElement.ts +0 -6
  271. package/src/convert/takeRun.test.ts +0 -64
  272. package/src/convert/takeRun.ts +0 -19
  273. package/src/defineGenerativeComponents.ts +0 -38
  274. package/src/fieldReferences.test.ts +0 -66
  275. package/src/fieldReferences.ts +0 -83
  276. package/src/generativeUIToJSX.test.ts +0 -268
  277. package/src/generativeUIToJSX.ts +0 -152
  278. package/src/index.server.test.ts +0 -106
  279. package/src/index.shared.ts +0 -61
  280. package/src/ir.test.ts +0 -203
  281. package/src/renderGenerativeUI.test.tsx +0 -373
  282. package/src/renderGenerativeUI.tsx +0 -165
  283. package/src/scopeSchema.test.ts +0 -72
  284. package/src/scopeSchema.ts +0 -71
  285. package/src/slack/a2uiSlack.test.ts +0 -198
  286. package/src/slack/constants.ts +0 -194
  287. package/src/slack/datePicker.test.ts +0 -612
  288. package/src/slack/decodeBlockAction.test.ts +0 -233
  289. package/src/slack/decodeBlockAction.ts +0 -232
  290. package/src/slack/fromSlackBlocks.test.ts +0 -1465
  291. package/src/slack/fromSlackBlocks.ts +0 -789
  292. package/src/slack/toSlackBlocks.test.ts +0 -3065
  293. package/src/slack/toSlackBlocks.ts +0 -1818
  294. package/src/slack/types.ts +0 -264
  295. package/src/teams/constants.ts +0 -93
  296. package/src/teams/decodeSubmitData.test.ts +0 -397
  297. package/src/teams/decodeSubmitData.ts +0 -110
  298. package/src/teams/temporal.test.ts +0 -766
  299. package/src/teams/temporalId.ts +0 -56
  300. package/src/teams/toAdaptiveCard.test.ts +0 -1897
  301. package/src/teams/toAdaptiveCard.ts +0 -1057
  302. package/src/teams/toTeamsAttachments.test.ts +0 -74
  303. package/src/teams/toTeamsAttachments.ts +0 -133
  304. package/src/teams/types.ts +0 -266
  305. package/src/temporal.test.ts +0 -337
  306. package/src/temporal.ts +0 -263
  307. package/src/types.ts +0 -119
  308. package/src/vocabulary/alert.test.tsx +0 -427
  309. package/src/vocabulary/alert.tsx +0 -217
  310. package/src/vocabulary/collectFormValues.test.ts +0 -389
  311. package/src/vocabulary/collectFormValues.ts +0 -116
  312. package/src/vocabulary/data.test.tsx +0 -858
  313. package/src/vocabulary/data.tsx +0 -819
  314. package/src/vocabulary/datepicker.dom.test.tsx +0 -1457
  315. package/src/vocabulary/dispatch.ts +0 -83
  316. package/src/vocabulary/fact.test.tsx +0 -94
  317. package/src/vocabulary/fact.tsx +0 -54
  318. package/src/vocabulary/form.test.tsx +0 -113
  319. package/src/vocabulary/form.tsx +0 -39
  320. package/src/vocabulary/formatValue.test.ts +0 -72
  321. package/src/vocabulary/formatValue.ts +0 -127
  322. package/src/vocabulary/icon.test.tsx +0 -64
  323. package/src/vocabulary/icon.tsx +0 -174
  324. package/src/vocabulary/index.test.ts +0 -65
  325. package/src/vocabulary/index.ts +0 -33
  326. package/src/vocabulary/interactive.dispatch.test.tsx +0 -454
  327. package/src/vocabulary/interactive.dom.test.tsx +0 -1747
  328. package/src/vocabulary/interactive.test.tsx +0 -377
  329. package/src/vocabulary/interactive.tsx +0 -1109
  330. package/src/vocabulary/layout.test.tsx +0 -323
  331. package/src/vocabulary/layout.tsx +0 -247
  332. package/src/vocabulary/list.test.tsx +0 -222
  333. package/src/vocabulary/list.tsx +0 -50
  334. package/src/vocabulary/media.test.tsx +0 -85
  335. package/src/vocabulary/media.tsx +0 -56
  336. package/src/vocabulary/text.test.tsx +0 -102
  337. package/src/vocabulary/text.tsx +0 -60
  338. package/src/vocabulary/toTextContent.ts +0 -2
@@ -1,1818 +0,0 @@
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";
9
- import { classifyTemporal, splitTemporalMinutes } from "../temporal";
10
- import {
11
- normalizeSpec,
12
- type NormalizedUIElement,
13
- type NormalizedUINode,
14
- } from "../ir";
15
- import {
16
- factTrend,
17
- formatFactDelta,
18
- formatValue,
19
- } from "../vocabulary/formatValue";
20
- import {
21
- ACTION_ID_CAP,
22
- ACTIONS_ELEMENT_CAP,
23
- ALERT_TEXT_CAP,
24
- BUTTON_VALUE_CAP,
25
- CARD_ACTIONS_CAP,
26
- CARD_BODY_CAP,
27
- CARD_SUBTEXT_CAP,
28
- CARD_TITLE_CAP,
29
- CAROUSEL_CARD_CAP,
30
- CAROUSEL_CARD_MIN,
31
- CHECKBOX_OPTION_CAP,
32
- CONTEXT_ELEMENT_CAP,
33
- CONTEXT_TEXT_CAP,
34
- DATA_TABLE_CHAR_BUDGET,
35
- DATA_TABLE_COLUMN_CAP,
36
- DATA_TABLE_ROW_CAP,
37
- FACT_FIELD_CAP,
38
- FACT_FIELD_TEXT_CAP,
39
- HEADER_TEXT_CAP,
40
- INPUT_LABEL_CAP,
41
- INTERACTIVE_TEXT_CAP,
42
- MARKDOWN_TEXT_BUDGET,
43
- MESSAGE_BLOCK_CAP,
44
- MODAL_BLOCK_CAP,
45
- PLACEHOLDER_TEXT_CAP,
46
- RADIO_OPTION_CAP,
47
- SECTION_TEXT_CAP,
48
- SELECT_OPTION_CAP,
49
- buildAlertBlock,
50
- buildAlertFallback,
51
- buildBlockBudgetNote,
52
- buildCardBlock,
53
- buildCarouselBlock,
54
- buildDataTableBlock,
55
- } from "./constants";
56
- import type {
57
- FieldMapping,
58
- SlackActionElement,
59
- SlackBlock,
60
- SlackButtonElement,
61
- SlackCardBlock,
62
- SlackConversionWarning,
63
- SlackDataTableCell,
64
- SlackImageBlock,
65
- SlackOption,
66
- SlackPlainText,
67
- SlackTextObject,
68
- SlackBlocksResult,
69
- ToSlackBlocksOptions,
70
- } from "./types";
71
-
72
- type ConversionContext = {
73
- readonly blockCap: number;
74
- readonly surface: "message" | "modal";
75
- readonly warnings: SlackConversionWarning[];
76
- readonly omittedPasswordNames: Set<string>;
77
- fieldBlockIdSequence: number;
78
- dateTimePairSequence: number;
79
- markdownCharacters: number;
80
- markdownExhausted: boolean;
81
- dataTableCharacters: number;
82
- };
83
-
84
- const SLACK_BLOCK_ID_CAP = 255;
85
-
86
- const INTERACTIVE_TYPES = new Set([
87
- "Button",
88
- "Select",
89
- "DatePicker",
90
- "Checkbox",
91
- "CheckboxGroup",
92
- "RadioGroup",
93
- ]);
94
-
95
- /**
96
- * Types whose control disappears when a card is reshaped to text. `Input` and
97
- * `Form` are not in {@link INTERACTIVE_TYPES} because they emit their own
98
- * block rather than an actions element, but a reshape loses them the same way,
99
- * and a `Form` gets a Submit button whether or not it carries an action.
100
- */
101
- const CONTROL_TYPES = new Set([
102
- ...INTERACTIVE_TYPES,
103
- "Input",
104
- "Slider",
105
- "Form",
106
- ]);
107
-
108
- const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
109
-
110
- const isRecord = (value: unknown): value is Record<string, unknown> =>
111
- typeof value === "object" && value !== null && !Array.isArray(value);
112
-
113
- const asString = (value: unknown): string =>
114
- typeof value === "string" ? value : "";
115
-
116
- const asFiniteNumber = (value: unknown): number | undefined =>
117
- typeof value === "number" && Number.isFinite(value) ? value : undefined;
118
-
119
- const warn = (
120
- context: ConversionContext,
121
- code: SlackConversionWarning["code"],
122
- component: string,
123
- detail: string,
124
- ) => {
125
- context.warnings.push({ code, component, detail });
126
- };
127
-
128
- const clampText = (
129
- value: string,
130
- cap: number,
131
- component: string,
132
- field: string,
133
- context: ConversionContext,
134
- ): string => {
135
- if (value.length <= cap) return value;
136
- warn(
137
- context,
138
- "clamped",
139
- component,
140
- `${field} was clamped to ${cap} characters.`,
141
- );
142
- return value.slice(0, cap);
143
- };
144
-
145
- const asActionId = (
146
- action: unknown,
147
- component: string,
148
- context: ConversionContext,
149
- ): string =>
150
- clampText(
151
- isRecord(action) && typeof action["type"] === "string"
152
- ? action["type"]
153
- : "action",
154
- ACTION_ID_CAP,
155
- component,
156
- "action_id",
157
- context,
158
- );
159
-
160
- const plainText = (text: string): SlackPlainText => ({
161
- type: "plain_text",
162
- text,
163
- });
164
-
165
- const serializeFieldBlockId = (
166
- sequence: number,
167
- fields: readonly FieldMapping[],
168
- ): string =>
169
- `aui:${sequence}:${JSON.stringify(
170
- fields.map(
171
- ({ actionId, name, component, inputType, offset, part, pairId }) =>
172
- inputType !== undefined
173
- ? [
174
- actionId,
175
- name,
176
- component,
177
- inputType,
178
- offset ?? null,
179
- part ?? null,
180
- pairId ?? null,
181
- ]
182
- : component === "Checkbox"
183
- ? [actionId, name, "Checkbox"]
184
- : [actionId, name],
185
- ),
186
- )}`;
187
-
188
- const fieldBlockId = (
189
- fields: readonly FieldMapping[],
190
- context: ConversionContext,
191
- ): string | undefined => {
192
- if (fields.length === 0) return undefined;
193
- const sequence = context.fieldBlockIdSequence++;
194
- const blockId = serializeFieldBlockId(sequence, fields);
195
- if (blockId.length <= SLACK_BLOCK_ID_CAP) return blockId;
196
- for (const field of fields) {
197
- warn(
198
- context,
199
- "dropped",
200
- field.component,
201
- "name could not be mapped because its Slack block_id exceeded 255 characters.",
202
- );
203
- }
204
- return undefined;
205
- };
206
-
207
- const collectOmittedPasswordNames = (
208
- node: NormalizedUINode,
209
- names: Set<string>,
210
- depth = 0,
211
- ): void => {
212
- if (depth > MAX_TRAVERSAL_DEPTH) return;
213
- if (Array.isArray(node)) {
214
- for (const child of node) {
215
- collectOmittedPasswordNames(child, names, depth + 1);
216
- }
217
- } else if (isElement(node)) {
218
- if (
219
- node.type === "Input" &&
220
- node.props["inputType"] === "password" &&
221
- typeof node.props["name"] === "string"
222
- ) {
223
- names.add(node.props["name"]);
224
- }
225
- if (node.children !== undefined) {
226
- collectOmittedPasswordNames(node.children, names, depth + 1);
227
- }
228
- }
229
- };
230
-
231
- const withoutOmittedPasswordFallbacks = (
232
- value: unknown,
233
- names: ReadonlySet<string>,
234
- depth = 0,
235
- ): unknown => {
236
- if (depth > MAX_TRAVERSAL_DEPTH) {
237
- throw new RangeError("Action payload nests too deeply to sanitize.");
238
- }
239
- if (Array.isArray(value)) {
240
- return value.map((item) =>
241
- withoutOmittedPasswordFallbacks(item, names, depth + 1),
242
- );
243
- }
244
- if (!isRecord(value)) return value;
245
- if (
246
- typeof value["$field"] === "string" &&
247
- names.has(value["$field"]) &&
248
- Object.keys(value).every((key) => key === "$field" || key === "fallback")
249
- ) {
250
- return { $field: value["$field"] };
251
- }
252
- return Object.fromEntries(
253
- Object.entries(value).map(([key, item]) => [
254
- key,
255
- withoutOmittedPasswordFallbacks(item, names, depth + 1),
256
- ]),
257
- );
258
- };
259
-
260
- const actionValue = (
261
- action: unknown,
262
- context: ConversionContext,
263
- ): string | undefined => {
264
- if (!isRecord(action)) return undefined;
265
- const { type: _type, ...payload } = action;
266
- if (Object.keys(payload).length === 0) return undefined;
267
- const serialized = JSON.stringify(
268
- context.omittedPasswordNames.size === 0
269
- ? payload
270
- : withoutOmittedPasswordFallbacks(payload, context.omittedPasswordNames),
271
- );
272
- return serialized === "{}" ? undefined : serialized;
273
- };
274
-
275
- const buttonElement = (
276
- label: string,
277
- buttonStyle: unknown,
278
- action: unknown,
279
- component: string,
280
- context: ConversionContext,
281
- ): SlackButtonElement => {
282
- let value: string | undefined;
283
- try {
284
- value = actionValue(action, context);
285
- } catch {
286
- warn(
287
- context,
288
- "dropped",
289
- component,
290
- "value was dropped because the action payload could not be serialized.",
291
- );
292
- }
293
- if (value !== undefined && value.length > BUTTON_VALUE_CAP) {
294
- warn(
295
- context,
296
- "dropped",
297
- component,
298
- "value was dropped because the action payload was too large to round-trip.",
299
- );
300
- value = undefined;
301
- }
302
- const style =
303
- buttonStyle === "primary" || buttonStyle === "danger"
304
- ? buttonStyle
305
- : undefined;
306
- return {
307
- type: "button",
308
- text: plainText(
309
- clampText(label, INTERACTIVE_TEXT_CAP, component, "label", context),
310
- ),
311
- action_id: asActionId(action, component, context),
312
- ...(value !== undefined ? { value } : {}),
313
- ...(style !== undefined ? { style } : {}),
314
- };
315
- };
316
-
317
- const optionFrom = (
318
- value: unknown,
319
- component: string,
320
- context: ConversionContext,
321
- ): SlackOption | undefined => {
322
- if (
323
- !isRecord(value) ||
324
- typeof value["label"] !== "string" ||
325
- typeof value["value"] !== "string"
326
- ) {
327
- return undefined;
328
- }
329
- const description = asString(value["description"]);
330
- return {
331
- text: plainText(
332
- clampText(
333
- value["label"],
334
- INTERACTIVE_TEXT_CAP,
335
- component,
336
- "option label",
337
- context,
338
- ),
339
- ),
340
- value: value["value"],
341
- ...(description
342
- ? {
343
- description: plainText(
344
- clampText(
345
- description,
346
- INTERACTIVE_TEXT_CAP,
347
- component,
348
- "option description",
349
- context,
350
- ),
351
- ),
352
- }
353
- : {}),
354
- } as SlackOption;
355
- };
356
-
357
- const warnDroppedOptions = (
358
- kept: number,
359
- taken: number,
360
- component: string,
361
- context: ConversionContext,
362
- ) => {
363
- const dropped = taken - kept;
364
- if (dropped === 0) return;
365
- warn(
366
- context,
367
- "dropped",
368
- component,
369
- `${dropped} ${dropped === 1 ? "option was" : "options were"} dropped for want of a string label and value.`,
370
- );
371
- };
372
-
373
- const toActionElement = (
374
- element: NormalizedUIElement,
375
- context: ConversionContext,
376
- ): SlackActionElement | SlackActionElement[] | undefined => {
377
- const { props, action } = element;
378
- switch (element.type) {
379
- case "Button":
380
- return buttonElement(
381
- asString(props["label"]),
382
- props["buttonStyle"],
383
- action,
384
- "Button",
385
- context,
386
- );
387
- case "Select": {
388
- const rawOptions = Array.isArray(props["options"])
389
- ? props["options"]
390
- : [];
391
- const { items: takenOptions, truncated } = copyBounded(
392
- rawOptions,
393
- SELECT_OPTION_CAP,
394
- );
395
- if (truncated) {
396
- warn(
397
- context,
398
- "clamped",
399
- "Select",
400
- `options were clamped to ${SELECT_OPTION_CAP} entries.`,
401
- );
402
- }
403
- const options = takenOptions
404
- .map((option) => optionFrom(option, "Select", context))
405
- .filter((option): option is SlackOption => option !== undefined);
406
- warnDroppedOptions(
407
- options.length,
408
- takenOptions.length,
409
- "Select",
410
- context,
411
- );
412
- const placeholder = clampText(
413
- asString(props["placeholder"]),
414
- PLACEHOLDER_TEXT_CAP,
415
- "Select",
416
- "placeholder",
417
- context,
418
- );
419
- const defaultValue = props["defaultValue"];
420
- const initialOption =
421
- typeof defaultValue === "string"
422
- ? options.find((option) => option.value === defaultValue)
423
- : undefined;
424
- return {
425
- type: "static_select",
426
- action_id: asActionId(action, "Select", context),
427
- options,
428
- ...(initialOption !== undefined
429
- ? { initial_option: initialOption }
430
- : {}),
431
- ...(placeholder ? { placeholder: plainText(placeholder) } : {}),
432
- };
433
- }
434
- case "DatePicker": {
435
- const rawDate = asString(props["value"]);
436
- const inputType = props["inputType"];
437
- if (inputType === "time" || inputType === "datetime") {
438
- const actionId = asActionId(action, "DatePicker", context);
439
- const temporal = classifyTemporal(rawDate);
440
- const parts = splitTemporalMinutes(rawDate);
441
- if (
442
- (inputType === "time" && temporal.kind === "time") ||
443
- (inputType === "datetime" && temporal.kind === "floating")
444
- ) {
445
- if (parts?.droppedPrecision) {
446
- warn(
447
- context,
448
- "dropped",
449
- "DatePicker",
450
- "Seconds were dropped because Slack timepickers only support minute precision.",
451
- );
452
- }
453
- const timePicker: SlackActionElement = {
454
- type: "timepicker",
455
- action_id: actionId,
456
- initial_time: parts!.time,
457
- };
458
- return inputType === "time"
459
- ? timePicker
460
- : [
461
- {
462
- type: "datepicker",
463
- action_id: actionId,
464
- initial_date: parts!.date!,
465
- },
466
- timePicker,
467
- ];
468
- }
469
- if (inputType === "datetime" && temporal.kind === "instant") {
470
- if (
471
- temporal.epochMs % 1000 !== 0 ||
472
- /[1-9]/.test(temporal.subMillisecondDigits ?? "")
473
- ) {
474
- warn(
475
- context,
476
- "dropped",
477
- "DatePicker",
478
- "Fractional seconds were dropped because Slack datetimepickers only support second precision.",
479
- );
480
- }
481
- return {
482
- type: "datetimepicker",
483
- action_id: actionId,
484
- initial_date_time: Math.floor(temporal.epochMs / 1000),
485
- };
486
- }
487
- if (rawDate)
488
- warn(
489
- context,
490
- "dropped",
491
- "DatePicker",
492
- `value did not match the ${inputType} format and was dropped.`,
493
- );
494
- return {
495
- type: inputType === "time" ? "timepicker" : "datetimepicker",
496
- action_id: actionId,
497
- };
498
- }
499
- const initialDate = DATE_PATTERN.test(rawDate) ? rawDate : undefined;
500
- if (rawDate && initialDate === undefined) {
501
- warn(
502
- context,
503
- "dropped",
504
- "DatePicker",
505
- "value did not match the YYYY-MM-DD format and was dropped.",
506
- );
507
- }
508
- return {
509
- type: "datepicker",
510
- action_id: asActionId(action, "DatePicker", context),
511
- ...(initialDate !== undefined ? { initial_date: initialDate } : {}),
512
- };
513
- }
514
- case "Checkbox": {
515
- const label = clampText(
516
- asString(props["label"]),
517
- INTERACTIVE_TEXT_CAP,
518
- "Checkbox",
519
- "label",
520
- context,
521
- );
522
- const name = asString(props["name"]);
523
- const option: SlackOption = {
524
- text: plainText(label),
525
- value: name || label,
526
- };
527
- return {
528
- type: "checkboxes",
529
- action_id: asActionId(action, "Checkbox", context),
530
- options: [option],
531
- ...(props["defaultChecked"] === true
532
- ? { initial_options: [option] }
533
- : {}),
534
- };
535
- }
536
- case "CheckboxGroup": {
537
- const rawOptions = Array.isArray(props["options"])
538
- ? props["options"]
539
- : [];
540
- const { items: takenOptions, truncated } = copyBounded(
541
- rawOptions,
542
- CHECKBOX_OPTION_CAP,
543
- );
544
- if (truncated) {
545
- warn(
546
- context,
547
- "clamped",
548
- "CheckboxGroup",
549
- `options were clamped to ${CHECKBOX_OPTION_CAP} entries.`,
550
- );
551
- }
552
- const options = takenOptions
553
- .map((option) => optionFrom(option, "CheckboxGroup", context))
554
- .filter((option): option is SlackOption => option !== undefined);
555
- warnDroppedOptions(
556
- options.length,
557
- takenOptions.length,
558
- "CheckboxGroup",
559
- context,
560
- );
561
- const defaultValue = Array.isArray(props["defaultValue"])
562
- ? props["defaultValue"]
563
- : [];
564
- const initialOptions = options.filter((option) =>
565
- defaultValue.includes(option.value),
566
- );
567
- return {
568
- type: "checkboxes",
569
- action_id: asActionId(action, "CheckboxGroup", context),
570
- options,
571
- ...(initialOptions.length > 0
572
- ? { initial_options: initialOptions }
573
- : {}),
574
- };
575
- }
576
- case "RadioGroup": {
577
- const rawOptions = Array.isArray(props["options"])
578
- ? props["options"]
579
- : [];
580
- const { items: takenOptions, truncated } = copyBounded(
581
- rawOptions,
582
- RADIO_OPTION_CAP,
583
- );
584
- if (truncated) {
585
- warn(
586
- context,
587
- "clamped",
588
- "RadioGroup",
589
- `options were clamped to ${RADIO_OPTION_CAP} entries.`,
590
- );
591
- }
592
- const options = takenOptions
593
- .map((option) => optionFrom(option, "RadioGroup", context))
594
- .filter((option): option is SlackOption => option !== undefined);
595
- warnDroppedOptions(
596
- options.length,
597
- takenOptions.length,
598
- "RadioGroup",
599
- context,
600
- );
601
- const selectedValue =
602
- typeof props["value"] === "string"
603
- ? props["value"]
604
- : asString(props["defaultValue"]);
605
- const initialOption = options.find(
606
- (option) => option.value === selectedValue,
607
- );
608
- return {
609
- type: "radio_buttons",
610
- action_id: asActionId(action, "RadioGroup", context),
611
- options,
612
- ...(initialOption !== undefined
613
- ? { initial_option: initialOption }
614
- : {}),
615
- };
616
- }
617
- default:
618
- return undefined;
619
- }
620
- };
621
-
622
- const normalizedList = (
623
- node: NormalizedUINode | undefined,
624
- ): NormalizedUINode[] => {
625
- if (node === undefined || node === null) return [];
626
- if (!Array.isArray(node)) return [node];
627
- return node.flatMap((child) => normalizedList(child));
628
- };
629
-
630
- const convertFacts = (
631
- facts: readonly NormalizedUIElement[],
632
- context: ConversionContext,
633
- ): SlackBlock[] => {
634
- const fields = facts.map((fact) => {
635
- const label = asString(fact.props["label"]);
636
- const value = asString(fact.props["value"]);
637
- const delta = fact.props["delta"];
638
- const deltaText =
639
- typeof delta === "string"
640
- ? formatFactDelta(delta, factTrend(delta, fact.props["trend"]))
641
- : undefined;
642
- return {
643
- type: "mrkdwn" as const,
644
- text: clampText(
645
- `*${label}*\n${deltaText === undefined ? value : `${value} (${deltaText})`}`,
646
- FACT_FIELD_TEXT_CAP,
647
- "Fact",
648
- "field",
649
- context,
650
- ),
651
- };
652
- });
653
- const blocks: SlackBlock[] = [];
654
- for (let index = 0; index < fields.length; index += FACT_FIELD_CAP) {
655
- blocks.push({
656
- type: "section",
657
- fields: fields.slice(index, index + FACT_FIELD_CAP),
658
- });
659
- }
660
- return blocks;
661
- };
662
-
663
- const convertActions = (
664
- elements: readonly NormalizedUIElement[],
665
- context: ConversionContext,
666
- ): SlackBlock[] => {
667
- const converted = elements.flatMap((source) => {
668
- const result = toActionElement(source, context);
669
- if (result === undefined) return [];
670
- const controls = Array.isArray(result) ? result : [result];
671
- const inputType =
672
- source.type === "DatePicker" ? source.props["inputType"] : undefined;
673
- const temporal =
674
- inputType === "datetime"
675
- ? classifyTemporal(asString(source.props["value"]))
676
- : undefined;
677
- const pairId =
678
- controls.length > 1 ? context.dateTimePairSequence++ : undefined;
679
- return controls.map((element) => {
680
- const name = asString(source.props["name"]);
681
- const field: FieldMapping | undefined =
682
- inputType === "time" || inputType === "datetime"
683
- ? {
684
- actionId: element.action_id,
685
- name,
686
- component: source.type,
687
- inputType,
688
- ...(temporal?.kind === "instant"
689
- ? { offset: temporal.offset }
690
- : {}),
691
- ...(pairId !== undefined
692
- ? ({
693
- part: element.type === "datepicker" ? "date" : "time",
694
- pairId,
695
- } as const)
696
- : {}),
697
- }
698
- : name
699
- ? { actionId: element.action_id, name, component: source.type }
700
- : undefined;
701
- return { element, field };
702
- });
703
- });
704
- const blocks: SlackBlock[] = [];
705
- const groups: (typeof converted)[] = [];
706
- let group: typeof converted = [];
707
- let actionIds = new Set<string>();
708
- const fieldsFor = (entries: typeof converted): FieldMapping[] =>
709
- entries.flatMap(({ field }) => (field === undefined ? [] : [field]));
710
- for (const entry of converted) {
711
- const repeatedMappedActionId =
712
- actionIds.has(entry.element.action_id) &&
713
- (entry.field !== undefined ||
714
- group.some(
715
- (candidate) =>
716
- candidate.element.action_id === entry.element.action_id &&
717
- candidate.field !== undefined,
718
- ));
719
- if (group.length === ACTIONS_ELEMENT_CAP || repeatedMappedActionId) {
720
- groups.push(group);
721
- group = [];
722
- actionIds = new Set();
723
- }
724
- group.push(entry);
725
- actionIds.add(entry.element.action_id);
726
- }
727
- if (group.length > 0) groups.push(group);
728
- const mappedGroups: (typeof converted)[] = [];
729
- let nextSequence = context.fieldBlockIdSequence;
730
- for (const entries of groups) {
731
- let current: typeof converted = [];
732
- for (const entry of entries) {
733
- const candidate = [...current, entry];
734
- const fields = fieldsFor(candidate);
735
- const blockId =
736
- fields.length > 0
737
- ? serializeFieldBlockId(nextSequence, fields)
738
- : undefined;
739
- if (
740
- current.length > 0 &&
741
- fields.length > fieldsFor(current).length &&
742
- blockId !== undefined &&
743
- blockId.length > SLACK_BLOCK_ID_CAP
744
- ) {
745
- mappedGroups.push(current);
746
- if (fieldsFor(current).length > 0) nextSequence += 1;
747
- current = [entry];
748
- } else {
749
- current = candidate;
750
- }
751
- }
752
- if (current.length > 0) {
753
- mappedGroups.push(current);
754
- if (fieldsFor(current).length > 0) nextSequence += 1;
755
- }
756
- }
757
- for (const group of mappedGroups) {
758
- const fields = fieldsFor(group);
759
- const blockId = fieldBlockId(fields, context);
760
- blocks.push({
761
- type: "actions",
762
- ...(blockId !== undefined ? { block_id: blockId } : {}),
763
- elements: group.map(({ element }) => element),
764
- });
765
- }
766
- return blocks;
767
- };
768
-
769
- const cardActionButtons = (
770
- props: Readonly<Record<string, unknown>>,
771
- context: ConversionContext,
772
- ): SlackButtonElement[] => {
773
- const buttons: SlackButtonElement[] = [];
774
- for (const key of ["confirm", "cancel"] as const) {
775
- const footer = props[key];
776
- if (!isRecord(footer)) continue;
777
- buttons.push(
778
- buttonElement(
779
- asString(footer["label"]),
780
- key === "confirm" ? "primary" : undefined,
781
- footer["$action"],
782
- "Card",
783
- context,
784
- ),
785
- );
786
- }
787
- return buttons;
788
- };
789
-
790
- const cardTitleText = (
791
- props: Readonly<Record<string, unknown>>,
792
- context: ConversionContext,
793
- ): string | undefined => {
794
- const rawTitle = asString(props["title"]);
795
- return rawTitle
796
- ? clampText(rawTitle, CARD_TITLE_CAP, "Card", "title", context)
797
- : undefined;
798
- };
799
-
800
- const isCardNoOp = (node: NormalizedUINode): boolean =>
801
- isElement(node) && (node.type === "Spacer" || node.type === "Icon");
802
-
803
- const collectText = (
804
- node: NormalizedUINode | undefined,
805
- depth: number,
806
- ): string[] => {
807
- if (node === undefined || node === null || depth > MAX_TRAVERSAL_DEPTH) {
808
- return [];
809
- }
810
- if (typeof node === "string" || typeof node === "number") {
811
- return [String(node)];
812
- }
813
- if (Array.isArray(node)) {
814
- return node.flatMap((child) => collectText(child, depth + 1));
815
- }
816
- const element = node as NormalizedUIElement;
817
- const own = ["text", "value", "label", "title", "description"]
818
- .map((key) => element.props[key])
819
- .filter((value): value is string => typeof value === "string");
820
- return [...own, ...collectText(element.children, depth + 1)];
821
- };
822
-
823
- type CardFields = {
824
- readonly heroImage: SlackImageBlock | undefined;
825
- readonly bodyRaw: string | undefined;
826
- readonly subtextRaw: string | undefined;
827
- readonly leftover: readonly NormalizedUINode[];
828
- };
829
-
830
- /**
831
- * Scans a card's children for the pieces the native card block shape can
832
- * carry (a hero image, body text, and subtext), each taken from the first
833
- * matching child. Anything left over means the card cannot map cleanly onto
834
- * the native shape.
835
- */
836
- const extractCardFields = (element: NormalizedUIElement): CardFields => {
837
- const children = normalizedList(element.children);
838
- let heroImage: SlackImageBlock | undefined;
839
- let bodyRaw: string | undefined;
840
- let subtextRaw: string | undefined;
841
- const leftover: NormalizedUINode[] = [];
842
- for (const child of children) {
843
- if (heroImage === undefined && isElement(child) && child.type === "Image") {
844
- heroImage = {
845
- type: "image",
846
- image_url: asString(child.props["src"]),
847
- alt_text: asString(child.props["alt"]),
848
- };
849
- continue;
850
- }
851
- if (
852
- bodyRaw === undefined &&
853
- isElement(child) &&
854
- (child.type === "Text" || child.type === "Markdown")
855
- ) {
856
- bodyRaw = asString(child.props["value"]);
857
- continue;
858
- }
859
- if (
860
- subtextRaw === undefined &&
861
- isElement(child) &&
862
- child.type === "Caption"
863
- ) {
864
- subtextRaw = asString(child.props["value"]);
865
- continue;
866
- }
867
- if (isCardNoOp(child)) continue;
868
- leftover.push(child);
869
- }
870
- return { heroImage, bodyRaw, subtextRaw, leftover };
871
- };
872
-
873
- /** Assembles a native card block, clamping its body and subtext text. */
874
- const assembleCleanCard = (
875
- titleText: string | undefined,
876
- fields: CardFields,
877
- buttons: readonly SlackButtonElement[],
878
- context: ConversionContext,
879
- ): SlackCardBlock => {
880
- const body: SlackTextObject | undefined =
881
- fields.bodyRaw !== undefined
882
- ? {
883
- type: "mrkdwn",
884
- text: clampText(
885
- fields.bodyRaw,
886
- CARD_BODY_CAP,
887
- "Card",
888
- "body",
889
- context,
890
- ),
891
- }
892
- : undefined;
893
- const subtext: SlackTextObject | undefined =
894
- fields.subtextRaw !== undefined
895
- ? {
896
- type: "mrkdwn",
897
- text: clampText(
898
- fields.subtextRaw,
899
- CARD_SUBTEXT_CAP,
900
- "Card",
901
- "subtext",
902
- context,
903
- ),
904
- }
905
- : undefined;
906
- const { items: boundedButtons, truncated } = copyBounded(
907
- buttons,
908
- CARD_ACTIONS_CAP,
909
- );
910
- if (truncated) {
911
- warn(
912
- context,
913
- "clamped",
914
- "Card",
915
- `actions were clamped to ${CARD_ACTIONS_CAP} entries.`,
916
- );
917
- }
918
- return buildCardBlock({
919
- ...(fields.heroImage !== undefined ? { heroImage: fields.heroImage } : {}),
920
- ...(titleText !== undefined
921
- ? { title: { type: "mrkdwn", text: titleText } }
922
- : {}),
923
- ...(body !== undefined ? { body } : {}),
924
- ...(subtext !== undefined ? { subtext } : {}),
925
- ...(boundedButtons.length > 0 ? { actions: boundedButtons } : {}),
926
- });
927
- };
928
-
929
- /**
930
- * A card requires at least one of `hero_image`, `title`, `body`, or `actions`
931
- * (see {@link SlackCardBlock}); a card with none of them is not a valid
932
- * native block and must be dropped instead of emitted.
933
- */
934
- const isEmptyCard = (card: SlackCardBlock): boolean =>
935
- card.hero_image === undefined &&
936
- card.title === undefined &&
937
- card.body === undefined &&
938
- (card.actions === undefined || card.actions.length === 0);
939
-
940
- const convertCard = (
941
- element: NormalizedUIElement,
942
- context: ConversionContext,
943
- depth: number,
944
- ): SlackBlock[] => {
945
- const titleText = cardTitleText(element.props, context);
946
- const fields = extractCardFields(element);
947
- const buttons = cardActionButtons(element.props, context);
948
-
949
- if (fields.leftover.length > 0) {
950
- warn(
951
- context,
952
- "fallback",
953
- "Card",
954
- "A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
955
- );
956
- return [
957
- ...(titleText
958
- ? [{ type: "header" as const, text: plainText(titleText) }]
959
- : []),
960
- ...convertSequence(element.children, context, depth + 1),
961
- ...(buttons.length > 0
962
- ? [{ type: "actions" as const, elements: buttons }]
963
- : []),
964
- ];
965
- }
966
- const card = assembleCleanCard(titleText, fields, buttons, context);
967
- if (isEmptyCard(card)) {
968
- warn(
969
- context,
970
- "dropped",
971
- "Card",
972
- "A card with none of hero_image, title, body, or actions was dropped.",
973
- );
974
- return [];
975
- }
976
- return [card];
977
- };
978
-
979
- /** The content kinds a reshaped carousel card cannot carry, in report order. */
980
- const LOST_CONTENT_KINDS = ["images", "tables", "charts", "controls"] as const;
981
-
982
- const listPhrase = (items: readonly string[]): string =>
983
- items.length <= 1
984
- ? (items[0] ?? "")
985
- : `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
986
-
987
- /**
988
- * Collects the kinds of content a reshape drops, walking the same tree
989
- * {@link collectText} does so a node nested below the top level counts too.
990
- * These are exactly the pieces text cannot represent: an image has no text, a
991
- * table and a chart carry theirs in array props, and a control is behavior,
992
- * whether or not it carries an action of its own. An `$action` counts only on
993
- * a node that renders a control from it, since a `Box` or a `Row` carrying one
994
- * renders no control on the clean path either.
995
- */
996
- const scanLostContent = (
997
- node: NormalizedUINode | undefined,
998
- into: Set<string>,
999
- depth: number,
1000
- ): void => {
1001
- if (depth > MAX_TRAVERSAL_DEPTH) return;
1002
- if (Array.isArray(node)) {
1003
- for (const child of node) scanLostContent(child, into, depth + 1);
1004
- return;
1005
- }
1006
- if (node === undefined || !isElement(node)) return;
1007
- if (
1008
- CONTROL_TYPES.has(node.type) ||
1009
- (node.type === "ListViewItem" && isRecord(node.action)) ||
1010
- (node.type === "Card" &&
1011
- (isRecord(node.props["confirm"]) || isRecord(node.props["cancel"])))
1012
- ) {
1013
- into.add("controls");
1014
- }
1015
- if (node.type === "Image") into.add("images");
1016
- if (node.type === "Table") into.add("tables");
1017
- if (node.type === "Chart") into.add("charts");
1018
- scanLostContent(node.children, into, depth + 1);
1019
- };
1020
-
1021
- /**
1022
- * Degrades a card that cannot map cleanly into a title-and-body card block.
1023
- * A carousel cannot fall back to a block sequence like a standalone card can,
1024
- * so the card is reshaped to the two text fields the block has. Text reachable
1025
- * by {@link collectText} survives at any depth; an image, a table, a chart,
1026
- * and a control do not, and are reported separately from the reshape itself.
1027
- */
1028
- const degradeCard = (
1029
- element: NormalizedUIElement,
1030
- context: ConversionContext,
1031
- depth: number,
1032
- ): SlackCardBlock => {
1033
- const rawTitle = asString(element.props["title"]);
1034
- const textChunks = collectText(element.children, depth + 1);
1035
- const titleSource = rawTitle || textChunks[0] || "";
1036
- const bodyChunks = rawTitle ? textChunks : textChunks.slice(1);
1037
- const titleText = clampText(
1038
- titleSource,
1039
- CARD_TITLE_CAP,
1040
- "Card",
1041
- "title",
1042
- context,
1043
- );
1044
- const bodyText = clampText(
1045
- bodyChunks.join("\n"),
1046
- CARD_BODY_CAP,
1047
- "Card",
1048
- "body",
1049
- context,
1050
- );
1051
- warn(
1052
- context,
1053
- "fallback",
1054
- "Card",
1055
- "A card inside a carousel was reshaped to title and body.",
1056
- );
1057
- const lostKinds = new Set<string>();
1058
- if (isRecord(element.props["confirm"]) || isRecord(element.props["cancel"])) {
1059
- lostKinds.add("controls");
1060
- }
1061
- scanLostContent(element.children, lostKinds, depth + 1);
1062
- const lost = LOST_CONTENT_KINDS.filter((kind) => lostKinds.has(kind));
1063
- if (lost.length > 0) {
1064
- warn(
1065
- context,
1066
- "dropped",
1067
- "Card",
1068
- `A reshaped carousel card's ${listPhrase(lost)} were dropped.`,
1069
- );
1070
- }
1071
- return buildCardBlock({
1072
- ...(titleText ? { title: { type: "mrkdwn", text: titleText } } : {}),
1073
- ...(bodyText ? { body: { type: "mrkdwn", text: bodyText } } : {}),
1074
- });
1075
- };
1076
-
1077
- const convertCarouselCard = (
1078
- element: NormalizedUIElement,
1079
- context: ConversionContext,
1080
- depth: number,
1081
- ): SlackCardBlock | undefined => {
1082
- const fields = extractCardFields(element);
1083
- const card =
1084
- fields.leftover.length === 0
1085
- ? assembleCleanCard(
1086
- cardTitleText(element.props, context),
1087
- fields,
1088
- cardActionButtons(element.props, context),
1089
- context,
1090
- )
1091
- : degradeCard(element, context, depth);
1092
- if (isEmptyCard(card)) {
1093
- warn(
1094
- context,
1095
- "dropped",
1096
- "Card",
1097
- "A card with none of hero_image, title, body, or actions was dropped.",
1098
- );
1099
- return undefined;
1100
- }
1101
- return card;
1102
- };
1103
-
1104
- const isBadgeOrCaption = (
1105
- node: NormalizedUINode,
1106
- ): node is NormalizedUIElement =>
1107
- isElement(node) && (node.type === "Badge" || node.type === "Caption");
1108
-
1109
- const contextRow = (
1110
- element: NormalizedUIElement,
1111
- context: ConversionContext,
1112
- ): SlackBlock[] | undefined => {
1113
- const allChildren = normalizedList(element.children);
1114
- const children = allChildren.filter(isBadgeOrCaption);
1115
- if (allChildren.length === 0 || children.length !== allChildren.length) {
1116
- return undefined;
1117
- }
1118
- const { items: boundedChildren, truncated } = copyBounded(
1119
- children,
1120
- CONTEXT_ELEMENT_CAP,
1121
- );
1122
- if (truncated) {
1123
- warn(
1124
- context,
1125
- "clamped",
1126
- "Row",
1127
- `context elements were clamped to ${CONTEXT_ELEMENT_CAP} entries.`,
1128
- );
1129
- }
1130
- return [
1131
- {
1132
- type: "context",
1133
- elements: boundedChildren.map((child) => ({
1134
- type: "mrkdwn",
1135
- text: clampText(
1136
- asString(child.props["value"]),
1137
- CONTEXT_TEXT_CAP,
1138
- child.type,
1139
- "value",
1140
- context,
1141
- ),
1142
- })),
1143
- },
1144
- ];
1145
- };
1146
-
1147
- const convertListItem = (
1148
- element: NormalizedUIElement,
1149
- context: ConversionContext,
1150
- depth: number,
1151
- ): SlackBlock => {
1152
- const text = clampText(
1153
- collectText(element.children, depth + 1).join("\n"),
1154
- SECTION_TEXT_CAP,
1155
- "ListViewItem",
1156
- "text",
1157
- context,
1158
- );
1159
- const accessory = isRecord(element.action)
1160
- ? buttonElement("Open", undefined, element.action, "ListViewItem", context)
1161
- : undefined;
1162
- return {
1163
- type: "section",
1164
- text: { type: "mrkdwn", text },
1165
- ...(accessory !== undefined ? { accessory } : {}),
1166
- };
1167
- };
1168
-
1169
- /**
1170
- * Converts a child whose output is thrown away, and reports whether anything
1171
- * was lost with it. A scratch context keeps the throwaway out of the shared
1172
- * markdown and data-table budgets that surviving blocks still need; only its
1173
- * `dropped` warnings are forwarded, because those describe the tree the caller
1174
- * wrote, while a clamp or a fallback would describe content never delivered.
1175
- */
1176
- const discardedChild = (
1177
- child: NormalizedUINode,
1178
- context: ConversionContext,
1179
- depth: number,
1180
- ): boolean => {
1181
- const scratch: ConversionContext = { ...context, warnings: [] };
1182
- const produced = convertSequence(child, scratch, depth).length > 0;
1183
- const lost = scratch.warnings.filter((warning) => warning.code === "dropped");
1184
- context.warnings.push(...lost);
1185
- return produced || lost.length > 0;
1186
- };
1187
-
1188
- const convertListView = (
1189
- element: NormalizedUIElement,
1190
- context: ConversionContext,
1191
- depth: number,
1192
- ): SlackBlock[] => {
1193
- const items: NormalizedUIElement[] = [];
1194
- let discarded = 0;
1195
- for (const child of normalizedList(element.children)) {
1196
- if (isElement(child) && child.type === "ListViewItem") {
1197
- items.push(child);
1198
- continue;
1199
- }
1200
- if (discardedChild(child, context, depth + 1)) discarded += 1;
1201
- }
1202
- if (discarded > 0) {
1203
- warn(
1204
- context,
1205
- "dropped",
1206
- "ListView",
1207
- `${discarded} non-item ${discarded === 1 ? "child was" : "children were"} dropped.`,
1208
- );
1209
- }
1210
- return items.flatMap((item, index) => [
1211
- ...(index > 0 ? [{ type: "divider" as const }] : []),
1212
- convertListItem(item, context, depth + 1),
1213
- ]);
1214
- };
1215
-
1216
- const convertCarousel = (
1217
- element: NormalizedUIElement,
1218
- context: ConversionContext,
1219
- depth: number,
1220
- ): SlackBlock[] => {
1221
- const cardChildren: NormalizedUIElement[] = [];
1222
- let droppedCards = 0;
1223
- for (const child of normalizedList(element.children)) {
1224
- if (isElement(child) && child.type === "Card") {
1225
- cardChildren.push(child);
1226
- continue;
1227
- }
1228
- if (discardedChild(child, context, depth + 1)) droppedCards += 1;
1229
- }
1230
- if (droppedCards > 0) {
1231
- warn(
1232
- context,
1233
- "dropped",
1234
- "Carousel",
1235
- `${droppedCards} non-card ${droppedCards === 1 ? "child was" : "children were"} dropped.`,
1236
- );
1237
- }
1238
- const { items: boundedCards, truncated } = copyBounded(
1239
- cardChildren,
1240
- CAROUSEL_CARD_CAP,
1241
- );
1242
- if (truncated) {
1243
- warn(
1244
- context,
1245
- "clamped",
1246
- "Carousel",
1247
- `cards were clamped to ${CAROUSEL_CARD_CAP} entries.`,
1248
- );
1249
- }
1250
- const cards = boundedCards
1251
- .map((card) => convertCarouselCard(card, context, depth + 1))
1252
- .filter((card): card is SlackCardBlock => card !== undefined);
1253
- if (cards.length < CAROUSEL_CARD_MIN) {
1254
- warn(
1255
- context,
1256
- "dropped",
1257
- "Carousel",
1258
- "A carousel without renderable cards was dropped.",
1259
- );
1260
- return [];
1261
- }
1262
- return [buildCarouselBlock(cards)];
1263
- };
1264
-
1265
- const toDataTableCell = (
1266
- value: unknown,
1267
- format: unknown,
1268
- ): SlackDataTableCell | undefined => {
1269
- if (typeof value === "number" && format === undefined) {
1270
- return { type: "raw_number", text: String(value) };
1271
- }
1272
- if (typeof value === "string" || typeof value === "boolean") {
1273
- return { type: "raw_text", text: formatValue(value, format) };
1274
- }
1275
- if (typeof value === "number") {
1276
- return { type: "raw_text", text: formatValue(value, format) };
1277
- }
1278
- return undefined;
1279
- };
1280
-
1281
- const rowCharacters = (row: readonly SlackDataTableCell[]): number =>
1282
- row.reduce((sum, cell) => sum + cell.text.length, 0);
1283
-
1284
- const convertTable = (
1285
- element: NormalizedUIElement,
1286
- context: ConversionContext,
1287
- ): SlackBlock[] => {
1288
- const rawColumns = Array.isArray(element.props["columns"])
1289
- ? element.props["columns"]
1290
- : [];
1291
- const rawRows = Array.isArray(element.props["rows"])
1292
- ? element.props["rows"]
1293
- : [];
1294
- const { items: takenColumns, truncated: columnsTruncated } = copyBounded(
1295
- rawColumns,
1296
- DATA_TABLE_COLUMN_CAP,
1297
- );
1298
- const { items: takenRows, truncated: rowsTruncated } = copyBounded(
1299
- rawRows,
1300
- DATA_TABLE_ROW_CAP,
1301
- );
1302
- if (columnsTruncated) {
1303
- warn(
1304
- context,
1305
- "clamped",
1306
- "Table",
1307
- `columns were clamped to ${DATA_TABLE_COLUMN_CAP} entries.`,
1308
- );
1309
- }
1310
- if (rowsTruncated) {
1311
- warn(
1312
- context,
1313
- "clamped",
1314
- "Table",
1315
- `rows were clamped to ${DATA_TABLE_ROW_CAP} entries.`,
1316
- );
1317
- }
1318
-
1319
- const unlabeled = takenColumns.filter(
1320
- (column) => !isRecord(column) || typeof column["label"] !== "string",
1321
- ).length;
1322
- if (unlabeled > 0) {
1323
- warn(
1324
- context,
1325
- "dropped",
1326
- "Table",
1327
- `${unlabeled} column ${unlabeled === 1 ? "header was" : "headers were"} left blank for want of a string label.`,
1328
- );
1329
- }
1330
- const columnHeaderRow: SlackDataTableCell[] = takenColumns.map((column) => ({
1331
- type: "raw_text" as const,
1332
- text: isRecord(column) ? asString(column["label"]) : "",
1333
- }));
1334
- const dataRows: SlackDataTableCell[][] = takenRows.map((row) =>
1335
- (Array.isArray(row)
1336
- ? copyBounded(row, DATA_TABLE_COLUMN_CAP).items
1337
- : []
1338
- ).map(
1339
- (value, index) =>
1340
- toDataTableCell(
1341
- value,
1342
- isRecord(takenColumns[index])
1343
- ? takenColumns[index]["format"]
1344
- : undefined,
1345
- ) ?? { type: "raw_text" as const, text: "" },
1346
- ),
1347
- );
1348
- const width = Math.max(
1349
- columnHeaderRow.length,
1350
- 0,
1351
- ...dataRows.map((row) => row.length),
1352
- );
1353
- const padRow = (row: SlackDataTableCell[]): SlackDataTableCell[] =>
1354
- row.length >= width
1355
- ? row
1356
- : [
1357
- ...row,
1358
- ...Array.from({ length: width - row.length }, () => ({
1359
- type: "raw_text" as const,
1360
- text: "",
1361
- })),
1362
- ];
1363
- const headerRow = padRow(columnHeaderRow);
1364
-
1365
- const headerCharacters = rowCharacters(headerRow);
1366
- if (context.dataTableCharacters + headerCharacters > DATA_TABLE_CHAR_BUDGET) {
1367
- warn(
1368
- context,
1369
- "dropped",
1370
- "Table",
1371
- `the table was dropped because its header row exceeds the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
1372
- );
1373
- return [];
1374
- }
1375
-
1376
- const kept: SlackDataTableCell[][] = [headerRow];
1377
- let tableCharacters = headerCharacters;
1378
- let budgetClamped = false;
1379
- for (const row of dataRows.map(padRow)) {
1380
- const chars = rowCharacters(row);
1381
- if (
1382
- context.dataTableCharacters + tableCharacters + chars >
1383
- DATA_TABLE_CHAR_BUDGET
1384
- ) {
1385
- budgetClamped = true;
1386
- break;
1387
- }
1388
- kept.push(row);
1389
- tableCharacters += chars;
1390
- }
1391
- context.dataTableCharacters += tableCharacters;
1392
- if (budgetClamped) {
1393
- warn(
1394
- context,
1395
- "clamped",
1396
- "Table",
1397
- `rows were clamped to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
1398
- );
1399
- }
1400
-
1401
- return [buildDataTableBlock(kept)];
1402
- };
1403
-
1404
- const convertElement = (
1405
- element: NormalizedUIElement,
1406
- context: ConversionContext,
1407
- depth: number,
1408
- ): SlackBlock[] => {
1409
- const { props } = element;
1410
- switch (element.type) {
1411
- case "Header":
1412
- return [
1413
- {
1414
- type: "header",
1415
- text: plainText(
1416
- clampText(
1417
- asString(props["text"]),
1418
- HEADER_TEXT_CAP,
1419
- "Header",
1420
- "text",
1421
- context,
1422
- ),
1423
- ),
1424
- },
1425
- ];
1426
- case "Text":
1427
- return [
1428
- {
1429
- type: "section",
1430
- text: {
1431
- type: "mrkdwn",
1432
- text: clampText(
1433
- asString(props["value"]),
1434
- SECTION_TEXT_CAP,
1435
- "Text",
1436
- "value",
1437
- context,
1438
- ),
1439
- },
1440
- },
1441
- ];
1442
- case "Caption":
1443
- case "Badge":
1444
- return [
1445
- {
1446
- type: "context",
1447
- elements: [
1448
- {
1449
- type: "mrkdwn",
1450
- text: clampText(
1451
- asString(props["value"]),
1452
- CONTEXT_TEXT_CAP,
1453
- element.type,
1454
- "value",
1455
- context,
1456
- ),
1457
- },
1458
- ],
1459
- },
1460
- ];
1461
- case "Fact":
1462
- return convertFacts([element], context);
1463
- case "Image":
1464
- return [
1465
- {
1466
- type: "image",
1467
- image_url: asString(props["src"]),
1468
- alt_text: asString(props["alt"]),
1469
- },
1470
- ];
1471
- case "Divider":
1472
- return [{ type: "divider" }];
1473
- case "Button":
1474
- case "Select":
1475
- case "DatePicker":
1476
- case "Checkbox":
1477
- case "CheckboxGroup":
1478
- case "RadioGroup":
1479
- return convertActions([element], context);
1480
- case "Slider": {
1481
- const label = clampText(
1482
- asString(props["label"]),
1483
- INPUT_LABEL_CAP,
1484
- "Slider",
1485
- "label",
1486
- context,
1487
- );
1488
- const min = asFiniteNumber(props["min"]);
1489
- const max = asFiniteNumber(props["max"]);
1490
- const step = asFiniteNumber(props["step"]);
1491
- const defaultValue = asFiniteNumber(props["defaultValue"]);
1492
- const actionId = asActionId(element.action, "Slider", context);
1493
- const name = props["name"];
1494
- const blockId = fieldBlockId(
1495
- typeof name === "string" && name.length > 0
1496
- ? [{ actionId, name, component: "Slider" }]
1497
- : [],
1498
- context,
1499
- );
1500
- return [
1501
- {
1502
- type: "input",
1503
- ...(blockId !== undefined ? { block_id: blockId } : {}),
1504
- label: plainText(label),
1505
- element: {
1506
- type: "number_input",
1507
- action_id: actionId,
1508
- ...(min !== undefined ? { min_value: min } : {}),
1509
- ...(max !== undefined ? { max_value: max } : {}),
1510
- ...(defaultValue !== undefined
1511
- ? { initial_value: defaultValue }
1512
- : {}),
1513
- ...([step, min, max, defaultValue].some(
1514
- (value) => value !== undefined && !Number.isInteger(value),
1515
- )
1516
- ? { is_decimal_allowed: true }
1517
- : {}),
1518
- },
1519
- } as unknown as SlackBlock,
1520
- ];
1521
- }
1522
- case "Input": {
1523
- if (props["inputType"] === "password") {
1524
- warn(
1525
- context,
1526
- "dropped",
1527
- "Input",
1528
- "Password input was replaced by a Slack omission note.",
1529
- );
1530
- return [
1531
- {
1532
- type: "context",
1533
- elements: [
1534
- { type: "mrkdwn", text: "Password input omitted on Slack." },
1535
- ],
1536
- },
1537
- ];
1538
- }
1539
- const label = clampText(
1540
- asString(props["label"]),
1541
- INPUT_LABEL_CAP,
1542
- "Input",
1543
- "label",
1544
- context,
1545
- );
1546
- const placeholder = clampText(
1547
- asString(props["placeholder"]),
1548
- PLACEHOLDER_TEXT_CAP,
1549
- "Input",
1550
- "placeholder",
1551
- context,
1552
- );
1553
- const actionId = asActionId(element.action, "Input", context);
1554
- const name = props["name"];
1555
- const blockId = fieldBlockId(
1556
- typeof name === "string" && name.length > 0
1557
- ? [{ actionId, name, component: "Input" }]
1558
- : [],
1559
- context,
1560
- );
1561
- const defaultValue = props["defaultValue"];
1562
- return [
1563
- {
1564
- type: "input",
1565
- ...(blockId !== undefined ? { block_id: blockId } : {}),
1566
- label: plainText(label),
1567
- element: {
1568
- type: "plain_text_input",
1569
- action_id: actionId,
1570
- ...(props["multiline"] === true &&
1571
- (props["inputType"] === undefined || props["inputType"] === "text")
1572
- ? { multiline: true }
1573
- : {}),
1574
- ...(typeof defaultValue === "string" && defaultValue
1575
- ? { initial_value: defaultValue }
1576
- : {}),
1577
- ...(placeholder ? { placeholder: plainText(placeholder) } : {}),
1578
- },
1579
- },
1580
- ];
1581
- }
1582
- case "Alert": {
1583
- const tone =
1584
- props["tone"] === "success" ||
1585
- props["tone"] === "warning" ||
1586
- props["tone"] === "danger"
1587
- ? props["tone"]
1588
- : "info";
1589
- const titleText = asString(props["title"]);
1590
- const description = asString(props["description"]);
1591
- if (context.surface === "modal") {
1592
- const combined = [titleText, description].filter(Boolean).join("\n");
1593
- return [
1594
- buildAlertBlock(
1595
- clampText(combined, ALERT_TEXT_CAP, "Alert", "text", context),
1596
- tone,
1597
- ),
1598
- ];
1599
- }
1600
- warn(
1601
- context,
1602
- "fallback",
1603
- "Alert",
1604
- "Alert was rendered with message-surface fallback blocks.",
1605
- );
1606
- return [
1607
- ...buildAlertFallback(
1608
- titleText,
1609
- clampText(
1610
- description,
1611
- SECTION_TEXT_CAP,
1612
- "Alert",
1613
- "description",
1614
- context,
1615
- ),
1616
- tone,
1617
- ),
1618
- ];
1619
- }
1620
- case "Card":
1621
- return convertCard(element, context, depth);
1622
- case "Carousel":
1623
- return convertCarousel(element, context, depth);
1624
- case "Col":
1625
- case "Box":
1626
- return convertSequence(element.children, context, depth + 1);
1627
- case "Row":
1628
- return (
1629
- contextRow(element, context) ??
1630
- convertSequence(element.children, context, depth + 1)
1631
- );
1632
- case "Spacer":
1633
- case "Icon":
1634
- return [];
1635
- case "Table":
1636
- return convertTable(element, context);
1637
- case "Markdown": {
1638
- const value = asString(props["value"]);
1639
- if (
1640
- !context.markdownExhausted &&
1641
- context.markdownCharacters + value.length <= MARKDOWN_TEXT_BUDGET
1642
- ) {
1643
- context.markdownCharacters += value.length;
1644
- return [{ type: "markdown", text: value }];
1645
- }
1646
- context.markdownExhausted = true;
1647
- warn(
1648
- context,
1649
- "fallback",
1650
- "Markdown",
1651
- `The ${MARKDOWN_TEXT_BUDGET}-character markdown budget was exceeded.`,
1652
- );
1653
- return [
1654
- {
1655
- type: "section",
1656
- text: {
1657
- type: "mrkdwn",
1658
- text: clampText(
1659
- value,
1660
- SECTION_TEXT_CAP,
1661
- "Markdown",
1662
- "fallback text",
1663
- context,
1664
- ),
1665
- },
1666
- },
1667
- ];
1668
- }
1669
- case "Chart":
1670
- warn(
1671
- context,
1672
- "dropped",
1673
- "Chart",
1674
- "Chart was replaced by a Slack omission note.",
1675
- );
1676
- return [
1677
- {
1678
- type: "context",
1679
- elements: [{ type: "mrkdwn", text: "Chart omitted on Slack." }],
1680
- },
1681
- ];
1682
- case "ListView":
1683
- return convertListView(element, context, depth);
1684
- case "ListViewItem":
1685
- return [convertListItem(element, context, depth)];
1686
- case "Form":
1687
- return [
1688
- ...convertSequence(element.children, context, depth + 1),
1689
- {
1690
- type: "actions",
1691
- elements: [
1692
- buttonElement("Submit", "primary", element.action, "Form", context),
1693
- ],
1694
- },
1695
- ];
1696
- default:
1697
- warn(
1698
- context,
1699
- "dropped",
1700
- element.type,
1701
- "Unknown component type was dropped.",
1702
- );
1703
- return [];
1704
- }
1705
- };
1706
-
1707
- function convertSequence(
1708
- node: NormalizedUINode | undefined,
1709
- context: ConversionContext,
1710
- depth: number,
1711
- ): SlackBlock[] {
1712
- if (node === undefined || node === null || depth > MAX_TRAVERSAL_DEPTH) {
1713
- return [];
1714
- }
1715
- const nodes = Array.isArray(node) ? node : [node];
1716
- const blocks: SlackBlock[] = [];
1717
- for (let index = 0; index < nodes.length;) {
1718
- const current = nodes[index];
1719
- if (current === undefined || current === null) {
1720
- index += 1;
1721
- continue;
1722
- }
1723
- if (isElement(current) && current.type === "Fact") {
1724
- const { run: facts, next } = takeRun(
1725
- nodes,
1726
- index,
1727
- (candidate) => candidate.type === "Fact",
1728
- );
1729
- index = next;
1730
- blocks.push(...convertFacts(facts, context));
1731
- continue;
1732
- }
1733
- if (isElement(current) && INTERACTIVE_TYPES.has(current.type)) {
1734
- const { run: controls, next } = takeRun(nodes, index, (candidate) =>
1735
- INTERACTIVE_TYPES.has(candidate.type),
1736
- );
1737
- index = next;
1738
- blocks.push(...convertActions(controls, context));
1739
- continue;
1740
- }
1741
- if (typeof current === "string" || typeof current === "number") {
1742
- blocks.push({
1743
- type: "section",
1744
- text: {
1745
- type: "mrkdwn",
1746
- text: clampText(
1747
- String(current),
1748
- SECTION_TEXT_CAP,
1749
- "Text",
1750
- "value",
1751
- context,
1752
- ),
1753
- },
1754
- });
1755
- } else if (Array.isArray(current)) {
1756
- blocks.push(...convertSequence(current, context, depth + 1));
1757
- } else {
1758
- blocks.push(
1759
- ...convertElement(current as NormalizedUIElement, context, depth + 1),
1760
- );
1761
- }
1762
- index += 1;
1763
- }
1764
- return blocks;
1765
- }
1766
-
1767
- /** Converts a generative-UI tree into Slack Block Kit JSON and downgrade warnings. */
1768
- export function toSlackBlocks(
1769
- node: unknown,
1770
- options?: ToSlackBlocksOptions,
1771
- ): SlackBlocksResult {
1772
- const surface = options?.surface === "modal" ? "modal" : "message";
1773
- const context: ConversionContext = {
1774
- blockCap: surface === "modal" ? MODAL_BLOCK_CAP : MESSAGE_BLOCK_CAP,
1775
- surface,
1776
- warnings: [],
1777
- omittedPasswordNames: new Set(),
1778
- fieldBlockIdSequence: 0,
1779
- dateTimePairSequence: 0,
1780
- markdownCharacters: 0,
1781
- markdownExhausted: false,
1782
- dataTableCharacters: 0,
1783
- };
1784
- try {
1785
- const bounded = boundSpec(node, (reason) =>
1786
- warn(context, "clamped", "Root", clampReasonDetail(reason)),
1787
- );
1788
- const { root } = normalizeSpec(bounded as never);
1789
- collectOmittedPasswordNames(root, context.omittedPasswordNames);
1790
- const converted = convertSequence(root, context, 0);
1791
- if (converted.length <= context.blockCap) {
1792
- return { blocks: converted, warnings: context.warnings };
1793
- }
1794
- const kept = converted.slice(0, context.blockCap - 1);
1795
- const omitted = converted.length - kept.length;
1796
- warn(
1797
- context,
1798
- "clamped",
1799
- "Root",
1800
- `${omitted} ${omitted === 1 ? "block was" : "blocks were"} omitted.`,
1801
- );
1802
- return {
1803
- blocks: [...kept, buildBlockBudgetNote(omitted)],
1804
- warnings: context.warnings,
1805
- };
1806
- } catch {
1807
- return {
1808
- blocks: [],
1809
- warnings: [
1810
- {
1811
- code: "dropped",
1812
- component: "Root",
1813
- detail: "Input could not be converted.",
1814
- },
1815
- ],
1816
- };
1817
- }
1818
- }