@company-semantics/contracts 52.0.0 → 53.0.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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +122 -69
  4. package/src/generated/openapi-routes.ts +3 -1
  5. package/src/identity/README.md +2 -2
  6. package/src/identity/__tests__/people-org-chart.test.ts +52 -17
  7. package/src/identity/__tests__/position-ref.test.ts +44 -0
  8. package/src/identity/index.ts +6 -2
  9. package/src/identity/people-org-chart.ts +26 -15
  10. package/src/identity/position-ref.ts +24 -0
  11. package/src/index.ts +21 -2
  12. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  13. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  14. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  15. package/src/notifications/renderers/README.md +8 -4
  16. package/src/notifications/renderers/ascii/README.md +75 -0
  17. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  18. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  19. package/src/notifications/renderers/ascii/chat.ts +179 -0
  20. package/src/notifications/renderers/ascii/cta.ts +57 -0
  21. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  22. package/src/notifications/renderers/ascii/index.ts +40 -0
  23. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  24. package/src/notifications/renderers/ascii/rule.ts +53 -0
  25. package/src/notifications/renderers/ascii/runs.ts +84 -0
  26. package/src/notifications/renderers/ascii/signature.ts +41 -0
  27. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  28. package/src/notifications/renderers/brand.ts +12 -0
  29. package/src/notifications/renderers/email/chat.ts +62 -146
  30. package/src/notifications/renderers/email/constants.ts +17 -2
  31. package/src/notifications/renderers/email/cta.ts +8 -13
  32. package/src/notifications/renderers/email/render.ts +29 -5
  33. package/src/notifications/renderers/layout.ts +31 -0
  34. package/src/notifications/renderers/slack/README.md +135 -79
  35. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  36. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  37. package/src/notifications/renderers/slack/blocks.ts +149 -0
  38. package/src/notifications/renderers/slack/chat.ts +167 -0
  39. package/src/notifications/renderers/slack/cta.ts +69 -0
  40. package/src/notifications/renderers/slack/index.ts +136 -229
  41. package/src/notifications/renderers/slack/message.ts +23 -0
  42. package/src/org/README.md +10 -2
  43. package/src/org/__tests__/org-units.test.ts +1 -1
  44. package/src/org/__tests__/set-seat-manager.test.ts +177 -0
  45. package/src/org/index.ts +20 -2
  46. package/src/org/reconciliation.ts +162 -0
  47. package/src/org/schemas.ts +43 -17
  48. package/src/identity/org-chart-actor.ts +0 -24
@@ -21,7 +21,9 @@
21
21
  import { describe, expect, it } from "vitest";
22
22
 
23
23
  import type {
24
+ ChatUnitItem,
24
25
  NotificationContent,
26
+ NotificationElement,
25
27
  NotificationElementType,
26
28
  } from "../../../content";
27
29
  import type { RenderContext } from "../../../context";
@@ -76,6 +78,28 @@ const ALL_TYPES: NotificationElementType[] = [
76
78
  "heroImage",
77
79
  ];
78
80
 
81
+ /** One of every element, so the exhaustiveness check has something to render. */
82
+ const SPECIMENS: Record<NotificationElementType, NotificationElement> = {
83
+ greeting: { type: "greeting", recipientName: "Ada" },
84
+ body: { type: "body", text: "Acme uses Company Semantics." },
85
+ keyValueTable: {
86
+ type: "keyValueTable",
87
+ rows: [{ label: "Org", value: "Acme" }],
88
+ },
89
+ callToAction: { type: "callToAction", label: "JOIN", href: "https://e.test" },
90
+ list: { type: "list", items: ["Read"] },
91
+ divider: { type: "divider" },
92
+ warning: { type: "warning" },
93
+ metadata: { type: "metadata", rows: [{ label: "IP", value: "203.0.113.7" }] },
94
+ notice: { type: "notice", lines: ["Not expecting this?"] },
95
+ chatUnit: {
96
+ type: "chatUnit",
97
+ items: [{ type: "message", role: "user", text: "Hello?" }],
98
+ },
99
+ signature: { type: "signature" },
100
+ heroImage: { type: "heroImage", src: "https://e.test/h.png", alt: "Acme" },
101
+ };
102
+
79
103
  /** One content carrying one element — the shape assertions read better alone. */
80
104
  function only(
81
105
  element: NotificationContent["sections"][number]["elements"][number],
@@ -87,6 +111,32 @@ function only(
87
111
  };
88
112
  }
89
113
 
114
+ /**
115
+ * Every character of monospace text in a message, blocks joined by newline.
116
+ *
117
+ * Most assertions here are about WHAT THE READER SEES, and after coalescing that
118
+ * is spread across a `rich_text` → `rich_text_preformatted` → runs nesting that
119
+ * is noise in an expectation. Reading the block SHAPE is still done directly,
120
+ * where the shape is the point.
121
+ */
122
+ function monospaceText(message: { blocks: unknown[] }): string {
123
+ return message.blocks
124
+ .map((raw) => {
125
+ const block = raw as { type: string; elements?: unknown[] };
126
+ if (block.type !== "rich_text") return "";
127
+ return (block.elements ?? [])
128
+ .map((rawElement) => {
129
+ const element = rawElement as { type: string; elements?: unknown[] };
130
+ if (element.type !== "rich_text_preformatted") return "";
131
+ return (element.elements ?? [])
132
+ .map((run) => (run as { text?: string }).text ?? "")
133
+ .join("");
134
+ })
135
+ .join("");
136
+ })
137
+ .join("\n");
138
+ }
139
+
90
140
  describe("slackRenderer", () => {
91
141
  it("declares a stable channel id", () => {
92
142
  expect(slackRenderer.id).toBe("slack");
@@ -98,9 +148,13 @@ describe("slackRenderer", () => {
98
148
  }
99
149
  });
100
150
 
101
- it("declines only chatUnitBlock Kit cannot depict turn-taking", () => {
151
+ it("depicts every elementnothing is declined any more", () => {
152
+ // `chatUnit` was the one decline, and it was costing real payloads: both
153
+ // `auth.otp` and `org.invite` nest theirs inside one, so the code and the
154
+ // accept button never reached Slack. A monospace drawing depicts the
155
+ // conversation, so there is nothing left to decline.
102
156
  for (const type of ALL_TYPES) {
103
- expect(slackRenderer.supports(type), type).toBe(type !== "chatUnit");
157
+ expect(slackRenderer.supports(type), type).toBe(true);
104
158
  }
105
159
  });
106
160
 
@@ -108,32 +162,35 @@ describe("slackRenderer", () => {
108
162
  expect(() => slackRenderer.render(CONTENT, CONTEXT)).not.toThrow();
109
163
  });
110
164
 
111
- it("omits the declined element rather than flattening it", () => {
165
+ it("delivers the chatUnit's content instead of dropping it", () => {
166
+ // The regression this pins is the one that motivated the rebuild: this
167
+ // assertion used to read `.not.toContain`, and a Slack notification whose
168
+ // conversation is silently missing is a notification with a hole in it.
112
169
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
113
170
 
114
- expect(JSON.stringify(rendered)).not.toContain("Hello?");
171
+ expect(JSON.stringify(rendered)).toContain("Hello?");
115
172
  });
116
173
 
117
174
  it("returns its natural type — a Block Kit record, not a string", () => {
118
175
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
119
176
 
120
- // `text` is the fallback Slack shows where blocks cannot render. It is
121
- // metadata.title the same field email spends as its subject, and this
122
- // channel ALSO leads with as a header.
177
+ // `text` is the fallback Slack shows where blocks cannot render a push
178
+ // notification, a screen reader. It is `metadata.title`, the same field
179
+ // email spends as its subject.
123
180
  expect(rendered.text).toBe("Join Acme");
181
+
182
+ // Contiguous monospace coalesces into ONE block; the run breaks only where a
183
+ // native primitive genuinely interrupts it. One block per element would stack
184
+ // a bordered grey box per element, which is not what a message looks like.
124
185
  expect(rendered.blocks.map((block) => block.type)).toEqual([
125
- "header",
126
- "image",
127
- "section",
128
- "section",
129
- "section",
130
- "rich_text",
186
+ "rich_text", // title + its rule
187
+ "image", // heroImage — breaks the run
188
+ "rich_text", // greeting + body + keyValueTable
189
+ "rich_text", // list — a rich_text_list is NOT folded into a code box
131
190
  "divider",
132
- "section",
133
- "context",
134
- "context",
135
- "actions",
136
- "context",
191
+ "rich_text", // warning + chatUnit + metadata + notice
192
+ "actions", // the linked CTA — a native button
193
+ "rich_text", // signature
137
194
  ]);
138
195
  });
139
196
 
@@ -141,51 +198,60 @@ describe("slackRenderer", () => {
141
198
  // The header — metadata.title's third spend
142
199
  // ===========================================================================
143
200
 
144
- it("leads with a header block carrying the title", () => {
145
- expect(slackRenderer.render(CONTENT, CONTEXT).blocks[0]).toEqual({
146
- type: "header",
147
- text: { type: "plain_text", text: "Join Acme" },
148
- });
201
+ it("leads with the title in the monospace body, not a header block", () => {
202
+ // A `header` block is `plain_text` and Slack owns its typography entirely:
203
+ // `HeaderBlock` is `{ type, text }`, with no font, size, weight or colour
204
+ // anywhere in the schema. Putting the title in the body is what makes it
205
+ // monospace in a real workspace rather than only under a gallery stylesheet.
206
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
207
+
208
+ expect(rendered.blocks.some((block) => block.type === "header")).toBe(
209
+ false,
210
+ );
211
+ expect(monospaceText(rendered).startsWith("Join Acme\n─")).toBe(true);
149
212
  });
150
213
 
151
- it("spends the title on BOTH the header and the fallback", () => {
152
- // Neither use replaces the other: `text` is what a push notification and a
153
- // screen reader get, the header is what the message looks like.
214
+ it("spends the title on BOTH the body and the fallback", () => {
215
+ // Dropping the header block cost the in-channel headline treatment and
216
+ // nothing else: the fallback was never carried by that block.
154
217
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
155
218
 
156
219
  expect(rendered.text).toBe("Join Acme");
157
- expect(JSON.stringify(rendered.blocks[0])).toContain("Join Acme");
220
+ expect(monospaceText(rendered)).toContain("Join Acme");
158
221
  });
159
222
 
160
- it("truncates a header at Slack's documented 150 characters", () => {
223
+ it("keeps the whole title the 150-char cap left with the header block", () => {
224
+ const long = "A".repeat(200);
161
225
  const rendered = slackRenderer.render(
162
- only({ type: "body", text: "x" }, "A".repeat(200)),
226
+ only({ type: "body", text: "x" }, long),
163
227
  CONTEXT,
164
228
  );
165
- const header = rendered.blocks[0];
166
229
 
167
- expect(header.type).toBe("header");
168
- expect(header).toMatchObject({
169
- text: { text: `${"A".repeat(149)}…` },
170
- });
171
- // The FALLBACK is not truncated — only the block has the limit.
172
230
  expect(rendered.text).toHaveLength(200);
231
+ // Wrapped across monospace lines rather than cut, and no ellipsis anywhere.
232
+ expect(monospaceText(rendered)).not.toContain("…");
233
+ expect(monospaceText(rendered).replace(/\n/g, "")).toContain(long);
173
234
  });
174
235
 
175
- it("emits no header when the title is empty — not an empty block", () => {
236
+ it("emits no title lines when the title is empty — not an empty rule", () => {
176
237
  const rendered = slackRenderer.render(
177
238
  only({ type: "body", text: "x" }, " "),
178
239
  CONTEXT,
179
240
  );
180
241
 
181
- expect(rendered.blocks.map((block) => block.type)).toEqual(["section"]);
242
+ expect(rendered.blocks.map((block) => block.type)).toEqual(["rich_text"]);
243
+ expect(monospaceText(rendered)).toBe("x");
182
244
  });
183
245
 
184
246
  // ===========================================================================
185
247
  // Shape — each element reaches its real Block Kit primitive
186
248
  // ===========================================================================
187
249
 
188
- it("renders a keyValueTable as section fields, Slack's two-column device", () => {
250
+ it("says a keyValueTable as `Label: value` lines in the monospace flow", () => {
251
+ // A `table` block was tried here and removed. It draws its own bordered
252
+ // grid, so every key/value pair cut the message into another container and a
253
+ // two-fact notification arrived as five stacked boxes. The email states
254
+ // these as plain lines and reads as one thing.
189
255
  const rendered = slackRenderer.render(
190
256
  only({
191
257
  type: "keyValueTable",
@@ -197,25 +263,28 @@ describe("slackRenderer", () => {
197
263
  CONTEXT,
198
264
  );
199
265
 
200
- expect(rendered.blocks[1]).toEqual({
201
- type: "section",
202
- fields: [
203
- { type: "mrkdwn", text: "*Status*\nValid" },
204
- { type: "mrkdwn", text: "*Expires in*\n10 minutes" },
205
- ],
206
- });
266
+ expect(rendered.blocks.some((block) => block.type === "table")).toBe(false);
267
+ expect(monospaceText(rendered)).toContain(
268
+ "Status: Valid\nExpires in: 10 minutes",
269
+ );
207
270
  });
208
271
 
209
- it("chunks a keyValueTable at ten fields Slack rejects an eleventh", () => {
272
+ it("keeps a long keyValueTable whole the ten-field cap is gone", () => {
273
+ // `section.fields` capped at ten, so a 23-row table used to become three
274
+ // stacked sections and stopped reading as one table. Lines have no cap.
210
275
  const rows = Array.from({ length: 23 }, (_, index) => ({
211
276
  label: `L${index}`,
212
277
  value: `V${index}`,
213
278
  }));
214
- const blocks = slackRenderer
215
- .render(only({ type: "keyValueTable", rows }), CONTEXT)
216
- .blocks.filter((block) => block.type === "section");
279
+ const rendered = slackRenderer.render(
280
+ only({ type: "keyValueTable", rows }),
281
+ CONTEXT,
282
+ );
217
283
 
218
- expect(blocks.map((block) => block.fields?.length)).toEqual([10, 10, 3]);
284
+ const text = monospaceText(rendered);
285
+ for (const row of rows) {
286
+ expect(text).toContain(`${row.label}: ${row.value}`);
287
+ }
219
288
  });
220
289
 
221
290
  it("renders a list as a real rich_text_list, not bullet characters", () => {
@@ -279,54 +348,48 @@ describe("slackRenderer", () => {
279
348
  });
280
349
  });
281
350
 
282
- it("renders a call to action without an href as a code block, not a button", () => {
351
+ it("draws a hrefless call to action as the `>> LABEL <<` box, not a button", () => {
283
352
  // `href` absent means the label IS the payload (an OTP code). A button with
284
- // no destination would be a lie about what the notification is asking for;
285
- // `rich_text_preformatted` is Slack's own device for a selectable code.
353
+ // no destination would be a lie about what the notification is asking for.
354
+ // The box is drawn by `../../ascii`, so it is the same box the email draws.
286
355
  const otp: NotificationContent = {
287
356
  metadata: { kind: "auth.otp", title: "Your code" },
288
357
  sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
289
358
  };
359
+ const rendered = slackRenderer.render(otp, CONTEXT);
290
360
 
291
- expect(slackRenderer.render(otp, CONTEXT).blocks[1]).toEqual({
292
- type: "rich_text",
293
- elements: [
294
- {
295
- type: "rich_text_preformatted",
296
- elements: [{ type: "text", text: "123456" }],
297
- },
298
- ],
299
- });
361
+ expect(rendered.blocks.some((block) => block.type === "actions")).toBe(
362
+ false,
363
+ );
364
+ expect(monospaceText(rendered)).toContain(">> 123456 <<");
300
365
  });
301
366
 
302
367
  // ===========================================================================
303
368
  // Escaping — the rule follows the SURFACE, not the string
304
369
  // ===========================================================================
305
370
 
306
- it("escapes Slack's reserved characters on an mrkdwn surface", () => {
307
- const rendered = slackRenderer.render(
308
- only({ type: "body", text: "a & b <c> <!channel>" }),
309
- CONTEXT,
310
- );
371
+ it("emits no mrkdwn surface at all so nothing needs escaping", () => {
372
+ // The structural version of the old escaping rule. `mrkdwn` parses
373
+ // `&`/`<`/`>` and had to be escaped; `plain_text` and `rich_text` are
374
+ // literal and had to NOT be. Both mistakes were possible and neither was a
375
+ // type error, because both surfaces are `string`. Moving the body to
376
+ // `rich_text_preformatted` retired the last mrkdwn emitter, so the bug class
377
+ // is now impossible rather than merely avoided.
378
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
311
379
 
312
- expect(rendered.blocks[1]).toEqual({
313
- type: "section",
314
- text: { type: "mrkdwn", text: "a &amp; b &lt;c&gt; &lt;!channel&gt;" },
315
- });
380
+ expect(JSON.stringify(rendered)).not.toContain('"mrkdwn"');
316
381
  });
317
382
 
318
- it("does NOT escape a plain_text surface a header is literal", () => {
319
- // Escaping here is a real defect, not belt-and-braces: `plain_text` does not
320
- // parse the entities, so the reader would see `Acme &amp; Co`.
383
+ it("passes user text through literally`&`, `<` and `>` are not entities", () => {
384
+ // Escaping here is a real defect, not belt-and-braces: a literal surface
385
+ // does not parse the entities, so the reader would see `Acme &amp; Co`.
321
386
  const rendered = slackRenderer.render(
322
- only({ type: "body", text: "x" }, "Acme & Co <the org>"),
387
+ only({ type: "body", text: "Acme & Co <the org>" }),
323
388
  CONTEXT,
324
389
  );
325
390
 
326
- expect(rendered.blocks[0]).toEqual({
327
- type: "header",
328
- text: { type: "plain_text", text: "Acme & Co <the org>" },
329
- });
391
+ expect(monospaceText(rendered)).toContain("Acme & Co <the org>");
392
+ expect(JSON.stringify(rendered)).not.toContain("&amp;");
330
393
  });
331
394
 
332
395
  it("does NOT escape a plain_text surface — a button label is literal", () => {
@@ -350,14 +413,7 @@ describe("slackRenderer", () => {
350
413
  CONTEXT,
351
414
  );
352
415
 
353
- expect(rendered.blocks[1]).toMatchObject({
354
- elements: [
355
- {
356
- type: "rich_text_preformatted",
357
- elements: [{ type: "text", text: "a&b<c>" }],
358
- },
359
- ],
360
- });
416
+ expect(monospaceText(rendered)).toContain(">> a&b<c> <<");
361
417
  });
362
418
 
363
419
  it("does NOT escape a rich_text surface — list items are literal", () => {
@@ -384,8 +440,9 @@ describe("slackRenderer", () => {
384
440
  const pinned: RenderContext = {
385
441
  brand: { name: "Acme", copyrightYear: 1999 },
386
442
  };
387
- expect(JSON.stringify(slackRenderer.render(CONTENT, pinned))).toContain(
388
- 1999 Acme",
443
+
444
+ expect(monospaceText(slackRenderer.render(CONTENT, pinned))).toContain(
445
+ "ⓒ 1999 • Acme",
389
446
  );
390
447
  });
391
448
 
@@ -395,10 +452,93 @@ describe("slackRenderer", () => {
395
452
  CONTEXT,
396
453
  );
397
454
 
398
- expect(rendered.blocks[1]).toEqual({
399
- type: "context",
400
- elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
401
- });
455
+ // The same sign-off email draws, from the same function.
456
+ expect(monospaceText(rendered)).toContain("ⓒ 2026 • Grace");
457
+ expect(monospaceText(rendered)).toContain("https://companysemantics.ai");
458
+ });
459
+
460
+ // ===========================================================================
461
+ // chatUnit segmentation — where the drawing is cut for a native button
462
+ // ===========================================================================
463
+
464
+ function chat(items: ChatUnitItem[]) {
465
+ return slackRenderer.render(only({ type: "chatUnit", items }, ""), CONTEXT)
466
+ .blocks;
467
+ }
468
+
469
+ const LINKED: ChatUnitItem = {
470
+ type: "callToAction",
471
+ label: "GO",
472
+ href: "https://example.test/g",
473
+ };
474
+ const SAID: ChatUnitItem = { type: "message", role: "user", text: "hi" };
475
+
476
+ it("cuts the drawing where a linked CTA breaks out, keeping both halves", () => {
477
+ expect(chat([SAID, LINKED, SAID]).map((block) => block.type)).toEqual([
478
+ "rich_text",
479
+ "actions",
480
+ "rich_text",
481
+ ]);
482
+ });
483
+
484
+ it("emits NO empty code box when the unit opens on a button", () => {
485
+ // Slack draws an empty `rich_text_preformatted` as a visible empty
486
+ // rectangle, so a zero-line block beside the button is a drawing bug rather
487
+ // than a tidiness question.
488
+ expect(chat([LINKED, SAID]).map((block) => block.type)).toEqual([
489
+ "actions",
490
+ "rich_text",
491
+ ]);
492
+ });
493
+
494
+ it("emits NO empty code box when the unit closes on a button", () => {
495
+ expect(chat([SAID, LINKED]).map((block) => block.type)).toEqual([
496
+ "rich_text",
497
+ "actions",
498
+ ]);
499
+ });
500
+
501
+ it("emits only the button for a unit that is nothing but one", () => {
502
+ expect(chat([LINKED]).map((block) => block.type)).toEqual(["actions"]);
503
+ });
504
+
505
+ it("coalesces adjacent linked CTAs into ONE actions block", () => {
506
+ // Two buttons in one `actions` sit side by side, the way a reader expects a
507
+ // choice to be offered; two `actions` blocks stack them and read as two
508
+ // unrelated prompts.
509
+ const blocks = chat([SAID, LINKED, LINKED]);
510
+
511
+ expect(blocks.map((block) => block.type)).toEqual(["rich_text", "actions"]);
512
+ const actions = blocks[1];
513
+ if (actions.type !== "actions") throw new Error("expected actions");
514
+ expect(actions.elements).toHaveLength(2);
515
+ });
516
+
517
+ it("keeps a hrefless CTA INSIDE the drawing — the box is the payload", () => {
518
+ const blocks = chat([SAID, { type: "callToAction", label: "123456" }]);
519
+
520
+ expect(blocks.map((block) => block.type)).toEqual(["rich_text"]);
521
+ });
522
+
523
+ // ===========================================================================
524
+ // Exhaustiveness — the guard that replaced `supports` returning false
525
+ // ===========================================================================
526
+
527
+ it("gives every element type a representation, never a silent nothing", () => {
528
+ // `supports` is now total-true, so it can no longer be what makes a new
529
+ // element safe: an unmapped thirteenth would fall through and post a message
530
+ // with a hole in it — the `chatUnit` bug under a new name, and harder to see
531
+ // because there would be no `supports` returning false to point at.
532
+ //
533
+ // `renderElement` has no `default` arm and ends in `assertNever`, so adding a
534
+ // variant fails COMPILATION. This is the runtime half: every type that exists
535
+ // today actually draws something.
536
+ for (const type of ALL_TYPES) {
537
+ const element = SPECIMENS[type];
538
+ const blocks = slackRenderer.render(only(element, ""), CONTEXT).blocks;
539
+
540
+ expect(blocks.length, `${type} produced no blocks`).toBeGreaterThan(0);
541
+ }
402
542
  });
403
543
 
404
544
  it("is pure — same inputs, same output", () => {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Slack's surfaces, and the bridge from `../ascii` onto them.
3
+ *
4
+ * **This channel no longer has an mrkdwn surface, and that is a safety property
5
+ * rather than a coincidence.** Escaping used to be a rule someone had to apply
6
+ * correctly per call site: `mrkdwn` parses `&`/`<`/`>` and must be escaped,
7
+ * while `plain_text` and `rich_text` are literal and must NOT be — escaping one
8
+ * of those is a defect that shows the reader `Acme &amp; Co`. Both mistakes were
9
+ * possible and neither was a type error, because both surfaces are `string`.
10
+ *
11
+ * Moving the body to `rich_text_preformatted` retired the last mrkdwn emitter.
12
+ * Every user-controlled string now reaches a LITERAL surface — a `plain_text`
13
+ * button label, or `rich_text` for everything else — so there is nothing left to
14
+ * escape and no call site that could get it wrong. `escapeMrkdwn`, `mrkdwn`,
15
+ * `section` and `context` were deleted rather than kept "in case": a helper that
16
+ * exists is a helper someone will reach for, and reaching for that one is now
17
+ * always the bug.
18
+ */
19
+
20
+ import type {
21
+ KnownBlock,
22
+ PlainTextElement,
23
+ RichTextBlock,
24
+ RichTextLink,
25
+ RichTextText,
26
+ } from "@slack/types";
27
+
28
+ import type { MonospaceLine } from "../ascii";
29
+
30
+ /** Elements per `actions` block. Adjacent CTAs coalesce up to this, then split. */
31
+ export const ACTIONS_MAX_ELEMENTS = 25;
32
+
33
+ /** A literal text object. Never escaped — see `escapeMrkdwn`. */
34
+ export function plainText(text: string): PlainTextElement {
35
+ return { type: "plain_text", text };
36
+ }
37
+
38
+ /** Split into chunks of at most `size`. Slack caps several collections. */
39
+ export function chunk<T>(items: T[], size: number): T[][] {
40
+ const chunks: T[][] = [];
41
+ for (let index = 0; index < items.length; index += size) {
42
+ chunks.push(items.slice(index, index + size));
43
+ }
44
+ return chunks;
45
+ }
46
+
47
+ /**
48
+ * A monospace drawing as a Slack code block.
49
+ *
50
+ * This is where `../ascii`'s runs earn their existence. `rich_text_preformatted`
51
+ * takes `(RichTextText | RichTextLink)[]`, so a `link` run survives as a LINK
52
+ * rather than being dissolved into characters. Slack decides what it does with
53
+ * one inside a code block — we have not verified that it renders as an anchor —
54
+ * but the destination is legible either way, and passing the structure through
55
+ * is strictly better than throwing it away here.
56
+ *
57
+ * Lines are rejoined with `\n` into one run-stream because a preformatted block
58
+ * has no line elements of its own; the newlines ARE the layout.
59
+ *
60
+ * Returns no block for an empty drawing. An empty `rich_text` is not neutral —
61
+ * Slack renders it as a stray empty code box, which is how a break-out in
62
+ * `./chat.ts` would otherwise leave a visible hole in the message.
63
+ */
64
+ export function preformatted(lines: MonospaceLine[]): RichTextBlock[] {
65
+ const elements: (RichTextText | RichTextLink)[] = [];
66
+
67
+ lines.forEach((line, index) => {
68
+ if (index > 0) elements.push({ type: "text", text: "\n" });
69
+ for (const run of line) {
70
+ elements.push(
71
+ run.type === "link"
72
+ ? { type: "link", text: run.text, url: run.href }
73
+ : { type: "text", text: run.text },
74
+ );
75
+ }
76
+ });
77
+
78
+ if (elements.length === 0) return [];
79
+ return [
80
+ {
81
+ type: "rich_text",
82
+ elements: [{ type: "rich_text_preformatted", elements }],
83
+ },
84
+ ];
85
+ }
86
+
87
+ /**
88
+ * Merge neighbouring monospace blocks into one.
89
+ *
90
+ * Slack draws every `rich_text_preformatted` as its own bordered box. Rendering
91
+ * each element independently therefore turns a five-element notification into
92
+ * five stacked grey rectangles — which is not what the email looks like and not
93
+ * what anyone means by a message. Email's `text/plain` body is ONE continuous
94
+ * surface with blank lines between its elements, so Slack's is too.
95
+ *
96
+ * The run is broken only by a block that genuinely interrupts it: a table, a
97
+ * button, a divider, an image, a list. Those are the native primitives the
98
+ * monospace rule already exempts, and a break there is a real change of
99
+ * material rather than an artefact of how many elements happened to compose.
100
+ *
101
+ * Blocks are joined with a blank line, matching `textShell`'s `normal` spacing.
102
+ */
103
+ export function coalescePreformatted(blocks: KnownBlock[]): KnownBlock[] {
104
+ const merged: KnownBlock[] = [];
105
+
106
+ for (const block of blocks) {
107
+ const previous = merged.at(-1);
108
+ const runs = onlyPreformatted(block);
109
+ const previousRuns = previous ? onlyPreformatted(previous) : undefined;
110
+
111
+ if (runs && previousRuns) {
112
+ merged[merged.length - 1] = {
113
+ type: "rich_text",
114
+ elements: [
115
+ {
116
+ type: "rich_text_preformatted",
117
+ elements: [
118
+ ...previousRuns,
119
+ { type: "text", text: "\n\n" },
120
+ ...runs,
121
+ ],
122
+ },
123
+ ],
124
+ };
125
+ continue;
126
+ }
127
+ merged.push(block);
128
+ }
129
+
130
+ return merged;
131
+ }
132
+
133
+ /**
134
+ * The runs of a block that is nothing but one preformatted element, else
135
+ * undefined.
136
+ *
137
+ * A `rich_text` holding a LIST is deliberately not a match: a list is Slack
138
+ * drawing its own markers and indentation, and folding it into a code box would
139
+ * throw that away to gain a merge nobody asked for.
140
+ */
141
+ function onlyPreformatted(
142
+ block: KnownBlock,
143
+ ): (RichTextText | RichTextLink)[] | undefined {
144
+ if (block.type !== "rich_text" || block.elements.length !== 1)
145
+ return undefined;
146
+ const [element] = block.elements;
147
+ if (element.type !== "rich_text_preformatted") return undefined;
148
+ return element.elements;
149
+ }