@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.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +122 -69
- package/src/generated/openapi-routes.ts +3 -1
- package/src/identity/README.md +2 -2
- package/src/identity/__tests__/people-org-chart.test.ts +52 -17
- package/src/identity/__tests__/position-ref.test.ts +44 -0
- package/src/identity/index.ts +6 -2
- package/src/identity/people-org-chart.ts +26 -15
- package/src/identity/position-ref.ts +24 -0
- package/src/index.ts +21 -2
- package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
- package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
- package/src/notifications/renderers/README.md +8 -4
- package/src/notifications/renderers/ascii/README.md +75 -0
- package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
- package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
- package/src/notifications/renderers/ascii/chat.ts +179 -0
- package/src/notifications/renderers/ascii/cta.ts +57 -0
- package/src/notifications/renderers/ascii/geometry.ts +112 -0
- package/src/notifications/renderers/ascii/index.ts +40 -0
- package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
- package/src/notifications/renderers/ascii/rule.ts +53 -0
- package/src/notifications/renderers/ascii/runs.ts +84 -0
- package/src/notifications/renderers/ascii/signature.ts +41 -0
- package/src/notifications/renderers/ascii/wrap.ts +96 -0
- package/src/notifications/renderers/brand.ts +12 -0
- package/src/notifications/renderers/email/chat.ts +62 -146
- package/src/notifications/renderers/email/constants.ts +17 -2
- package/src/notifications/renderers/email/cta.ts +8 -13
- package/src/notifications/renderers/email/render.ts +29 -5
- package/src/notifications/renderers/layout.ts +31 -0
- package/src/notifications/renderers/slack/README.md +135 -79
- package/src/notifications/renderers/slack/__tests__/README.md +3 -2
- package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
- package/src/notifications/renderers/slack/blocks.ts +149 -0
- package/src/notifications/renderers/slack/chat.ts +167 -0
- package/src/notifications/renderers/slack/cta.ts +69 -0
- package/src/notifications/renderers/slack/index.ts +136 -229
- package/src/notifications/renderers/slack/message.ts +23 -0
- package/src/org/README.md +10 -2
- package/src/org/__tests__/org-units.test.ts +1 -1
- package/src/org/__tests__/set-seat-manager.test.ts +177 -0
- package/src/org/index.ts +20 -2
- package/src/org/reconciliation.ts +162 -0
- package/src/org/schemas.ts +43 -17
- 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("
|
|
151
|
+
it("depicts every element — nothing 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(
|
|
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("
|
|
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)).
|
|
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
|
|
121
|
-
// metadata.title
|
|
122
|
-
//
|
|
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
|
-
"
|
|
126
|
-
"image",
|
|
127
|
-
"
|
|
128
|
-
"
|
|
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
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
152
|
-
//
|
|
153
|
-
//
|
|
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(
|
|
220
|
+
expect(monospaceText(rendered)).toContain("Join Acme");
|
|
158
221
|
});
|
|
159
222
|
|
|
160
|
-
it("
|
|
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" },
|
|
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
|
|
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(["
|
|
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("
|
|
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
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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("
|
|
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
|
|
215
|
-
|
|
216
|
-
|
|
279
|
+
const rendered = slackRenderer.render(
|
|
280
|
+
only({ type: "keyValueTable", rows }),
|
|
281
|
+
CONTEXT,
|
|
282
|
+
);
|
|
217
283
|
|
|
218
|
-
|
|
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("
|
|
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
|
-
//
|
|
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(
|
|
292
|
-
|
|
293
|
-
|
|
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("
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
313
|
-
type: "section",
|
|
314
|
-
text: { type: "mrkdwn", text: "a & b <c> <!channel>" },
|
|
315
|
-
});
|
|
380
|
+
expect(JSON.stringify(rendered)).not.toContain('"mrkdwn"');
|
|
316
381
|
});
|
|
317
382
|
|
|
318
|
-
it("
|
|
319
|
-
// Escaping here is a real defect, not belt-and-braces:
|
|
320
|
-
// parse the entities, so the reader would see `Acme & 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 & Co`.
|
|
321
386
|
const rendered = slackRenderer.render(
|
|
322
|
-
only({ type: "body", text: "
|
|
387
|
+
only({ type: "body", text: "Acme & Co <the org>" }),
|
|
323
388
|
CONTEXT,
|
|
324
389
|
);
|
|
325
390
|
|
|
326
|
-
expect(rendered
|
|
327
|
-
|
|
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("&");
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 & 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
|
+
}
|