@company-semantics/contracts 33.0.0 → 35.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 (78) hide show
  1. package/package.json +4 -4
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +2 -2
  4. package/src/index.ts +17 -11
  5. package/src/notifications/README.md +140 -0
  6. package/src/notifications/__tests__/README.md +54 -0
  7. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/kinds.test.ts +81 -0
  12. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  13. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  14. package/src/notifications/__tests__/registry.test.ts +184 -0
  15. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  16. package/src/notifications/__tests__/renderer.test.ts +181 -0
  17. package/src/notifications/content.ts +249 -0
  18. package/src/notifications/context.ts +70 -0
  19. package/src/notifications/definition.ts +82 -0
  20. package/src/notifications/index.ts +104 -0
  21. package/src/notifications/kinds/README.md +57 -0
  22. package/src/notifications/kinds/auth-otp.ts +99 -0
  23. package/src/notifications/kinds/chat-shared.ts +56 -0
  24. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  25. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  26. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  27. package/src/notifications/kinds/index.ts +19 -0
  28. package/src/notifications/kinds/org-invite.ts +62 -0
  29. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  30. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  31. package/src/notifications/kinds/security-alert.ts +78 -0
  32. package/src/notifications/kinds/share-granted.ts +74 -0
  33. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  34. package/src/notifications/kinds.ts +68 -0
  35. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  36. package/src/notifications/registry.ts +107 -0
  37. package/src/notifications/render.ts +106 -0
  38. package/src/notifications/renderer.ts +50 -0
  39. package/src/notifications/renderers/README.md +50 -0
  40. package/src/notifications/renderers/email/README.md +74 -0
  41. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  42. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  43. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  44. package/src/notifications/renderers/email/constants.ts +47 -0
  45. package/src/notifications/renderers/email/cta.ts +63 -0
  46. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  47. package/src/notifications/renderers/email/index.ts +73 -0
  48. package/src/notifications/renderers/email/render.ts +225 -0
  49. package/src/notifications/renderers/email/shells.ts +60 -0
  50. package/src/notifications/renderers/slack/README.md +73 -0
  51. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  52. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  53. package/src/notifications/renderers/slack/index.ts +261 -0
  54. package/src/notifications/renderers/sms/README.md +69 -0
  55. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  56. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  57. package/src/notifications/renderers/sms/index.ts +131 -0
  58. package/src/notifications/text.ts +52 -0
  59. package/src/email/README.md +0 -51
  60. package/src/email/__tests__/registry.test.ts +0 -161
  61. package/src/email/index.ts +0 -36
  62. package/src/email/registry.ts +0 -155
  63. package/src/email/render/auth-otp.ts +0 -71
  64. package/src/email/render/blocks.ts +0 -281
  65. package/src/email/render/chat-shared.ts +0 -35
  66. package/src/email/render/company-md-access-approved.ts +0 -42
  67. package/src/email/render/company-md-access-denied.ts +0 -43
  68. package/src/email/render/company-md-access-requested.ts +0 -46
  69. package/src/email/render/constants.ts +0 -18
  70. package/src/email/render/index.ts +0 -58
  71. package/src/email/render/org-invite.ts +0 -40
  72. package/src/email/render/ownership-transfer-completed.ts +0 -41
  73. package/src/email/render/ownership-transfer.ts +0 -42
  74. package/src/email/render/render-email.ts +0 -194
  75. package/src/email/render/security-alert.ts +0 -61
  76. package/src/email/render/share-granted.ts +0 -52
  77. package/src/email/render/unit-owner-granted.ts +0 -60
  78. /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The email channel (ADR-CONTRACTS-086).
3
+ *
4
+ * `emailRenderer` is the first `Renderer` — email demoted from "the shape of the
5
+ * whole layer" to one channel among several. Everything that used to BE the
6
+ * render layer (the shells, the styling, the escaping, the box art) is now this
7
+ * directory's private business; what it exposes is a renderer and the type it
8
+ * returns. `src/email/` is gone, and this is what replaced it.
9
+ *
10
+ * INVARIANTS:
11
+ * - Pure. `render` is a function of `(content, context)` — no clock, no
12
+ * environment, no I/O. The copyright year comes from
13
+ * `context.brand.copyrightYear`.
14
+ * - Output is byte-identical to what the old `src/email/render` emitted. That is
15
+ * the whole constraint of this migration, and `__tests__/render.test.ts`
16
+ * proves it against the frozen output-parity golden — which, now that the old
17
+ * layer is deleted, is the only surviving record of those bytes.
18
+ * - `supports` answers true for every element type: email is the rich channel,
19
+ * and there is nothing in the content model it cannot depict.
20
+ */
21
+
22
+ import type { NotificationContent, NotificationElement } from "../../content";
23
+ import type { RenderContext } from "../../context";
24
+ import type { Renderer } from "../../renderer";
25
+
26
+ import type { Spacing } from "./constants";
27
+ import { renderElement } from "./render";
28
+ import { type EmailLine, htmlShell, textShell } from "./shells";
29
+
30
+ /** One rendered email — the email channel's natural output type. */
31
+ export interface RenderedEmail {
32
+ /** Subject line — the notification's `metadata.title`. */
33
+ subject: string;
34
+ /** Plain-text body. */
35
+ text: string;
36
+ /** HTML body (every kind is dual-output). */
37
+ html: string;
38
+ }
39
+
40
+ /**
41
+ * The gap after an element's last line.
42
+ *
43
+ * `signature` owns the blank line above its own rule, so whatever precedes it
44
+ * carries no trailing gap — the one positional rule the old templates stated
45
+ * out loud (every `footer(..., "none")` call, and `security.alert`'s Time row).
46
+ */
47
+ function trailingSpacing(
48
+ elements: NotificationElement[],
49
+ index: number,
50
+ ): Spacing {
51
+ return elements[index + 1]?.type === "signature" ? "none" : "normal";
52
+ }
53
+
54
+ /** Email. Sections are one body — the seam email declines to split on. */
55
+ export const emailRenderer: Renderer<RenderedEmail> = {
56
+ id: "email",
57
+ supports: () => true,
58
+ render: (content: NotificationContent, context: RenderContext) => {
59
+ const elements = content.sections
60
+ .flatMap((section) => section.elements)
61
+ .filter((element) => emailRenderer.supports(element.type));
62
+
63
+ const lines: EmailLine[] = elements.flatMap((element, index) =>
64
+ renderElement(element, context, trailingSpacing(elements, index)),
65
+ );
66
+
67
+ return {
68
+ subject: content.metadata.title,
69
+ text: textShell(lines),
70
+ html: htmlShell(lines),
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,225 @@
1
+ /**
2
+ * What each `NotificationElement` looks like as email (ADR-CONTRACTS-086).
3
+ *
4
+ * This is the deleted `src/email/render/blocks` read the other way round. That
5
+ * module offered templates a component per intent (`greeting()`, `keyValue()`,
6
+ * `footer()`) and each template chose which to call; here the CONTENT names the
7
+ * intent and this module is the only thing that knows the markup. The strings
8
+ * are the same strings — the output-parity golden asserts them byte-for-byte.
9
+ *
10
+ * **Spacing is derived, because the content model does not carry it.** The old
11
+ * layer passed a `Spacing` per block, and `../../kinds` deliberately dropped it:
12
+ * a margin is a channel's answer. So this module has to re-derive one, and it
13
+ * does it with a single rule:
14
+ *
15
+ * > An element renders as one or more lines. Lines WITHIN an element hug
16
+ * > (`"tight"`); the element's LAST line carries the trailing gap, which is
17
+ * > `"normal"` — or `"none"` when a signature follows, because `signature`
18
+ * > owns the blank above its own rule.
19
+ *
20
+ * That rule reproduces every hand-authored spacing choice in the old templates,
21
+ * which is not a coincidence: "these lines are one thing, that gap separates two
22
+ * things" is what the authors were expressing, and the element boundaries are
23
+ * where they were expressing it. The one place it did NOT fall out was
24
+ * `security.alert`'s two-sentence paragraph, and the fix was to say so in the
25
+ * content (one `body` with a newline) rather than to special-case a kind here —
26
+ * see `../../kinds/security-alert`.
27
+ *
28
+ * INVARIANTS:
29
+ * - Pure. The copyright year and the brand come from `RenderContext`, never from
30
+ * a clock — retiring the `new Date().getFullYear()` inside the old
31
+ * `signature()`.
32
+ * - The `:` between a label and its value is PRESENTATION and lives in no
33
+ * element (see `../../kinds/README.md`). This module puts it back, on
34
+ * key/value rows and on the metadata heading.
35
+ * - Values arrive pre-formatted (dates, title-casing, truncation are `compose`'s
36
+ * work). Nothing here re-formats a value.
37
+ */
38
+
39
+ import type {
40
+ CallToAction,
41
+ KeyValueRow,
42
+ NotificationElement,
43
+ } from "../../content";
44
+ import type { RenderContext } from "../../context";
45
+
46
+ import {
47
+ COMPANY_URL,
48
+ FONT_SIZE,
49
+ MONO,
50
+ SPACING,
51
+ type Spacing,
52
+ } from "./constants";
53
+ import { renderChatUnit } from "./chat";
54
+ import { ctaButton } from "./cta";
55
+ import { escapeHtml } from "./escape-html";
56
+ import type { EmailLine } from "./shells";
57
+
58
+ /** One `<p>` — the primitive every prose-ish element is built from. */
59
+ function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
60
+ return {
61
+ html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
62
+ text,
63
+ spacing,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Apply the spacing rule to one element's lines: everything but the last hugs,
69
+ * the last carries the element's trailing gap.
70
+ */
71
+ function spaced(
72
+ lines: Array<{ html: string; text: string }>,
73
+ trailing: Spacing,
74
+ ): EmailLine[] {
75
+ return lines.map((line, i) =>
76
+ paragraph(
77
+ line.html,
78
+ line.text,
79
+ i === lines.length - 1 ? trailing : "tight",
80
+ ),
81
+ );
82
+ }
83
+
84
+ /** `Label: value` — the separator the content model does not carry. */
85
+ function keyValueLine(row: KeyValueRow): { html: string; text: string } {
86
+ const text = `${row.label}: ${row.value}`;
87
+ return { html: escapeHtml(text), text };
88
+ }
89
+
90
+ /** The standalone `>> LABEL <<` box. */
91
+ function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
92
+ const { html, text } = ctaButton(cta, SPACING[trailing]);
93
+ return { html, text, spacing: trailing };
94
+ }
95
+
96
+ /** The trailing sign-off — a blank line, the `/* EOM *\/` rule, then the signer. */
97
+ function signatureLine(signer: string, year: number): EmailLine {
98
+ const line = `ⓒ ${year} • ${signer}`;
99
+ return {
100
+ html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${COMPANY_URL}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(COMPANY_URL)}</a></p>`,
101
+ text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
102
+ spacing: "none",
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Render one element to its email lines.
108
+ *
109
+ * `trailing` is the gap after the element's last line, decided by what follows
110
+ * it — see the spacing rule at the top of this module.
111
+ */
112
+ export function renderElement(
113
+ element: NotificationElement,
114
+ context: RenderContext,
115
+ trailing: Spacing,
116
+ ): EmailLine[] {
117
+ switch (element.type) {
118
+ case "greeting": {
119
+ const text = element.recipientName
120
+ ? `Hi ${element.recipientName},`
121
+ : "Hi there,";
122
+ return [paragraph(escapeHtml(text), text, trailing)];
123
+ }
124
+
125
+ case "body":
126
+ // A newline inside a body separates lines of ONE paragraph, so they hug;
127
+ // two paragraphs are two body elements. See `../../content.ts`.
128
+ return spaced(
129
+ element.text.split("\n").map((line) => ({
130
+ html: escapeHtml(line),
131
+ text: line,
132
+ })),
133
+ trailing,
134
+ );
135
+
136
+ case "keyValueTable":
137
+ return spaced(element.rows.map(keyValueLine), trailing);
138
+
139
+ case "metadata": {
140
+ // The heading's `:` is presentation: `label` is "Request details", and the
141
+ // colon the recipient reads is put back here.
142
+ const heading = element.label
143
+ ? [{ html: escapeHtml(`${element.label}:`), text: `${element.label}:` }]
144
+ : [];
145
+ return spaced([...heading, ...element.rows.map(keyValueLine)], trailing);
146
+ }
147
+
148
+ case "callToAction":
149
+ return [ctaLine(element, trailing)];
150
+
151
+ case "warning": {
152
+ // Fieldless by design — the banner IS the content, and its wording is the
153
+ // renderer's (see `../../content.ts`).
154
+ const text = "🆆🅰🆁🅽🅸🅽🅶";
155
+ return [paragraph(escapeHtml(text), text, trailing)];
156
+ }
157
+
158
+ case "notice":
159
+ // One paragraph, `<br>` between lines — the lines are one unit, so unlike
160
+ // a `body` they do not become separate `<p>`s.
161
+ return [
162
+ paragraph(
163
+ element.lines.map(escapeHtml).join("<br>"),
164
+ element.lines.join("\n"),
165
+ trailing,
166
+ ),
167
+ ];
168
+
169
+ case "chatUnit": {
170
+ const { html, text } = renderChatUnit(element.items);
171
+ return [{ html, text, spacing: trailing }];
172
+ }
173
+
174
+ case "signature":
175
+ return [
176
+ signatureLine(
177
+ element.signer ?? context.brand.name,
178
+ context.brand.copyrightYear,
179
+ ),
180
+ ];
181
+
182
+ // -------------------------------------------------------------------------
183
+ // Below: elements no notification says yet.
184
+ //
185
+ // The old email layer had no component for any of these and no kind composes
186
+ // one, so — unlike everything above — there are no bytes to reproduce and the
187
+ // markup here is invented. `supports` still answers true:
188
+ // email CAN depict a list, a rule and an image, and declining them would be
189
+ // a lie about the channel. The first kind to use one should expect to style
190
+ // it properly rather than trust these.
191
+ // -------------------------------------------------------------------------
192
+
193
+ case "list":
194
+ return spaced(
195
+ element.items.map((item, i) => {
196
+ const text = `${element.ordered ? `${i + 1}.` : "•"} ${item}`;
197
+ return { html: escapeHtml(text), text };
198
+ }),
199
+ trailing,
200
+ );
201
+
202
+ case "divider":
203
+ return [
204
+ {
205
+ html: `<hr style="border: none; border-top: 1px solid #bbb; margin: ${SPACING[trailing]};">`,
206
+ text: "---",
207
+ spacing: trailing,
208
+ },
209
+ ];
210
+
211
+ case "heroImage":
212
+ return [
213
+ {
214
+ html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}" style="max-width: 100%; margin: ${SPACING[trailing]};">`,
215
+ text: element.alt,
216
+ spacing: trailing,
217
+ },
218
+ ];
219
+
220
+ default: {
221
+ const _exhaustive: never = element;
222
+ return _exhaustive;
223
+ }
224
+ }
225
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The email shells — the `<!DOCTYPE>` wrapper and the plain-text joiner.
3
+ *
4
+ * Moved verbatim from the deleted `src/email/render/blocks`
5
+ * (ADR-CONTRACTS-086). These are what make one email dual-output: `render`
6
+ * composes ONE list of `EmailLine`s and both surfaces derive from it, so the two
7
+ * can never disagree about what the email says.
8
+ *
9
+ * INVARIANTS:
10
+ * - Pure functions of their input lines. No clock, no environment, no I/O.
11
+ * - Byte-identical to what that module emitted. These strings are the email's
12
+ * actual markup; the output-parity golden asserts them character-for-character.
13
+ */
14
+
15
+ import { MONO, type Spacing } from "./constants";
16
+
17
+ /**
18
+ * One rendered line — both presentations of one thing the notification says,
19
+ * plus the gap that follows it.
20
+ *
21
+ * Named `EmailLine` rather than `Block`: `Block` was the old layer's canonical
22
+ * unit, and demoting it to a channel's internal detail is the point of
23
+ * ADR-CONTRACTS-086. One `NotificationElement` may produce several of these (a
24
+ * key/value table produces one per row).
25
+ */
26
+ export interface EmailLine {
27
+ html: string;
28
+ text: string;
29
+ spacing: Spacing;
30
+ }
31
+
32
+ /** Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell. */
33
+ export function htmlShell(lines: EmailLine[]): string {
34
+ const inner = lines
35
+ .map((line) => line.html)
36
+ .filter(Boolean)
37
+ .join("\n");
38
+ return `<!DOCTYPE html>
39
+ <html lang="en">
40
+ <head><meta charset="UTF-8"></head>
41
+ <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
42
+ <div style="max-width: 520px; margin: 0 auto;">
43
+ ${inner}
44
+ </div>
45
+ </body>
46
+ </html>`;
47
+ }
48
+
49
+ /**
50
+ * Join rendered lines into the plain-text email — separated per each line's
51
+ * spacing, with a trailing newline.
52
+ */
53
+ export function textShell(lines: EmailLine[]): string {
54
+ let out = "";
55
+ lines.forEach((line, i) => {
56
+ out += line.text;
57
+ if (i < lines.length - 1) out += line.spacing === "normal" ? "\n\n" : "\n";
58
+ });
59
+ return out.trim() + "\n";
60
+ }
@@ -0,0 +1,73 @@
1
+ # notifications/renderers/slack/
2
+
3
+ ## Purpose
4
+
5
+ The Slack channel (ADR-CONTRACTS-086) — `slackRenderer: Renderer<SlackMessage>`. A
6
+ **non-functional placeholder**.
7
+
8
+ Where `../sms` proves the content model survives the poorest channel, this proves
9
+ the other edge: a channel whose output is a tree of typed blocks — neither a
10
+ string nor email's `{ subject, text, html }`. Between the three, `Out` has been a
11
+ record, a string and a different record, which is `../../renderer.ts`'s "each
12
+ renderer returns its natural type" held by real modules rather than asserted about
13
+ the type itself.
14
+
15
+ The whole channel is one `index.ts` — the Block Kit subset, the escaping, and the
16
+ renderer. `../email` splits across seven modules because it carries real markup; a
17
+ stub that sprawls is a stub pretending.
18
+
19
+ ## What "placeholder" means here
20
+
21
+ - **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
22
+ renders; delivery and the decision to deliver are the backend's.
23
+ - **It is low fidelity.** `SlackBlock` is a minimal, hand-written subset of Block
24
+ Kit — enough to carry every element the channel admits, and no more. It is not
25
+ the Slack API's types and is not generated from them. The wording is invented
26
+ rather than relocated: there is no golden here, unlike `../email`.
27
+
28
+ The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
29
+ wire format and those are its names. That is deliberate, not a lapse in the
30
+ repo's camelCase convention.
31
+
32
+ ## The one decline
33
+
34
+ `supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
35
+ turn-taking, and flattening a conversation into a run of sections drops the
36
+ attribution that IS the meaning — which the content model names as the reason to
37
+ decline a whole element rather than approximate it.
38
+
39
+ Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
40
+ has no surface for**. That difference is the point of shipping two stubs rather
41
+ than one: same `NotificationContent`, no channel tags on it, three channels of
42
+ differing capability each keeping what they can show.
43
+
44
+ `metadata` and `notice` become `context` blocks — Slack's own device for
45
+ de-emphasised small print, which is exactly what `MetadataElement` exists to be.
46
+
47
+ ## Invariants
48
+
49
+ - PURE. `render` is a function of `(content, context)` — no clock, no
50
+ environment, no I/O. The brand and the copyright year come from `context.brand`.
51
+ `signature` is the element that used to hide a `new Date()`; here there is
52
+ nowhere to hide.
53
+ - `supports` is total over `NotificationElementType` and AGREES with `render`:
54
+ `chatUnit` is omitted, never approximated and never thrown on.
55
+ - Every user-controlled string reaching an mrkdwn surface passes through
56
+ `escapeMrkdwn` first — the same rule `../email` applies with `escapeHtml`. This
57
+ is the one thing in a low-fidelity stub that would be a real defect rather than
58
+ a fidelity gap, so `__tests__/index.test.ts` pins it.
59
+ - `renderElement` switches over all TWELVE element types, not the eleven
60
+ `supports` admits. The declined arm is unreachable; writing it out is what makes
61
+ the compiler prove the two lists agree. Do not collapse it into a `default` —
62
+ that would silently swallow a thirteenth member of the union.
63
+ - A `callToAction` with no `href` becomes text, never a button. `href` absent
64
+ means the label IS the payload (an OTP code); a button with no destination is a
65
+ lie about what the notification is asking for.
66
+ - `SlackMessage.text` is `metadata.title` — the fallback Slack shows where blocks
67
+ cannot render. The same content-level fact email spends as its subject, which is
68
+ why it lives on the content rather than in a channel's registry.
69
+ - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
70
+ lost the model; the fix for a missing fact is upstream in `compose`.
71
+ - This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
72
+ no parity fixture. It cannot change a sent email, and it must not acquire the
73
+ ability to.
@@ -0,0 +1,33 @@
1
+ # notifications/renderers/slack/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the Slack channel (ADR-CONTRACTS-086).
6
+
7
+ The channel is a placeholder, so — as in `../../sms/__tests__/` — these tests do
8
+ not pin the Block Kit wording. They pin the contract a placeholder still owes:
9
+ `supports` total and in agreement with `render`, `render` pure, the brand and year
10
+ read from `RenderContext`, and a content carrying all twelve element types
11
+ rendering without throwing.
12
+
13
+ One exception: the mrkdwn escaping IS pinned exactly. Everything else here is a
14
+ fidelity gap that a real Slack kind will close; unescaped user input would be a
15
+ real defect shipped in a published package.
16
+
17
+ ## Invariants
18
+
19
+ - The Block Kit shape is NOT the specification. When a real Slack kind lands and
20
+ reworks these blocks, updating this file is expected. When `supports` and
21
+ `render` stop agreeing, or when escaping regresses, that is a defect — keep
22
+ assertions about SHAPE separate from assertions about copy so the two failures
23
+ stay easy to tell apart.
24
+ - The "takes the hero image the SMS channel has no surface for" test is the
25
+ cross-channel claim, and it is why two stubs exist rather than one: the same
26
+ `NotificationContent`, carrying no channel tags, keeps different elements on
27
+ channels of different capability. It is not redundant with the totality loop.
28
+ - NO fake timers. The year is a field on `RenderContext` (see
29
+ `../../../__tests__/README.md`).
30
+ - `ALL_TYPES` is duplicated from the sibling suites rather than shared. The list
31
+ is the totality claim itself; importing it from one place would let a new
32
+ element type be added to the union and to the shared list in one move, with no
33
+ channel noticing.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The Slack channel is a placeholder, so what is pinned here is the contract a
3
+ * placeholder still owes — `supports` total and in agreement with `render`,
4
+ * `render` pure and reading the brand and year from the context, every element
5
+ * type rendering without throwing — and NOT the Block Kit wording, which is
6
+ * invented and expected to change when a kind is actually posted to Slack.
7
+ *
8
+ * The mrkdwn escaping IS pinned. It is the one thing here that would be a real
9
+ * defect rather than a fidelity gap.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import type {
15
+ NotificationContent,
16
+ NotificationElementType,
17
+ } from "../../../content";
18
+ import type { RenderContext } from "../../../context";
19
+ import { slackRenderer } from "../index";
20
+
21
+ const CONTEXT: RenderContext = {
22
+ brand: { name: "Company Semantics", copyrightYear: 2026 },
23
+ };
24
+
25
+ /** Every element type, so `render` is exercised over the whole union. */
26
+ const CONTENT: NotificationContent = {
27
+ metadata: { kind: "org.invite", title: "Join Acme" },
28
+ sections: [
29
+ {
30
+ elements: [
31
+ { type: "heroImage", src: "https://example.test/h.png", alt: "Acme" },
32
+ { type: "greeting", recipientName: "Ada" },
33
+ { type: "body", text: "Acme uses Company Semantics." },
34
+ { type: "keyValueTable", rows: [{ label: "Org", value: "Acme" }] },
35
+ { type: "list", items: ["Read", "Write"] },
36
+ { type: "divider" },
37
+ { type: "warning" },
38
+ {
39
+ type: "chatUnit",
40
+ items: [{ type: "message", role: "user", text: "Hello?" }],
41
+ },
42
+ {
43
+ type: "metadata",
44
+ label: "Request details",
45
+ rows: [{ label: "IP", value: "203.0.113.7" }],
46
+ },
47
+ { type: "notice", lines: ["Not expecting this?"] },
48
+ { type: "callToAction", label: "JOIN", href: "https://example.test/j" },
49
+ { type: "signature" },
50
+ ],
51
+ },
52
+ ],
53
+ };
54
+
55
+ const ALL_TYPES: NotificationElementType[] = [
56
+ "greeting",
57
+ "body",
58
+ "keyValueTable",
59
+ "callToAction",
60
+ "list",
61
+ "divider",
62
+ "warning",
63
+ "metadata",
64
+ "notice",
65
+ "chatUnit",
66
+ "signature",
67
+ "heroImage",
68
+ ];
69
+
70
+ describe("slackRenderer", () => {
71
+ it("declares a stable channel id", () => {
72
+ expect(slackRenderer.id).toBe("slack");
73
+ });
74
+
75
+ it("answers supports() for every element type — the predicate is total", () => {
76
+ for (const type of ALL_TYPES) {
77
+ expect(typeof slackRenderer.supports(type), type).toBe("boolean");
78
+ }
79
+ });
80
+
81
+ it("declines only chatUnit — Block Kit cannot depict turn-taking", () => {
82
+ for (const type of ALL_TYPES) {
83
+ expect(slackRenderer.supports(type), type).toBe(type !== "chatUnit");
84
+ }
85
+ });
86
+
87
+ it("renders a content carrying every element type without throwing", () => {
88
+ expect(() => slackRenderer.render(CONTENT, CONTEXT)).not.toThrow();
89
+ });
90
+
91
+ it("omits the declined element rather than flattening it", () => {
92
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
93
+
94
+ expect(JSON.stringify(rendered)).not.toContain("Hello?");
95
+ // Eleven admitted elements, each yielding exactly one block here.
96
+ expect(rendered.blocks).toHaveLength(11);
97
+ });
98
+
99
+ it("returns its natural type — a Block Kit record, not a string", () => {
100
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
101
+
102
+ // `text` is the fallback Slack shows where blocks cannot render. It is
103
+ // metadata.title — the same field email spends as its subject.
104
+ expect(rendered.text).toBe("Join Acme");
105
+ expect(rendered.blocks.map((block) => block.type)).toEqual([
106
+ "image",
107
+ "section",
108
+ "section",
109
+ "section",
110
+ "section",
111
+ "divider",
112
+ "section",
113
+ "context",
114
+ "context",
115
+ "actions",
116
+ "context",
117
+ ]);
118
+ });
119
+
120
+ it("takes the hero image the SMS channel has no surface for", () => {
121
+ // The two stubs exist to differ: same content, no channel tags on it, and
122
+ // each channel keeps what it can depict.
123
+ expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
124
+ type: "image",
125
+ image_url: "https://example.test/h.png",
126
+ alt_text: "Acme",
127
+ });
128
+ });
129
+
130
+ it("renders a call to action with an href as a button", () => {
131
+ expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
132
+ type: "actions",
133
+ elements: [
134
+ {
135
+ type: "button",
136
+ text: { type: "plain_text", text: "JOIN" },
137
+ url: "https://example.test/j",
138
+ },
139
+ ],
140
+ });
141
+ });
142
+
143
+ it("renders a call to action without an href as text, not a button", () => {
144
+ // `href` absent means the label IS the payload (an OTP code). A button with
145
+ // no destination would be a lie about what the notification is asking for.
146
+ const otp: NotificationContent = {
147
+ metadata: { kind: "auth.otp", title: "Your code" },
148
+ sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
149
+ };
150
+ expect(slackRenderer.render(otp, CONTEXT).blocks).toEqual([
151
+ { type: "section", text: { type: "mrkdwn", text: "*123456*" } },
152
+ ]);
153
+ });
154
+
155
+ it("escapes Slack's reserved characters in user-controlled fields", () => {
156
+ const hostile: NotificationContent = {
157
+ metadata: { kind: "org.invite", title: "Join Acme" },
158
+ sections: [
159
+ { elements: [{ type: "body", text: "a & b <c> <!channel>" }] },
160
+ ],
161
+ };
162
+ expect(slackRenderer.render(hostile, CONTEXT).blocks).toEqual([
163
+ {
164
+ type: "section",
165
+ text: {
166
+ type: "mrkdwn",
167
+ text: "a &amp; b &lt;c&gt; &lt;!channel&gt;",
168
+ },
169
+ },
170
+ ]);
171
+ });
172
+
173
+ it("reads the brand and year from context, never from a clock", () => {
174
+ // No fake timer in this file: swapping the context moves the copyright line.
175
+ const pinned: RenderContext = {
176
+ brand: { name: "Acme", copyrightYear: 1999 },
177
+ };
178
+ expect(JSON.stringify(slackRenderer.render(CONTENT, pinned))).toContain(
179
+ "© 1999 Acme",
180
+ );
181
+ });
182
+
183
+ it("lets an element's signer override the context brand", () => {
184
+ const onBehalf: NotificationContent = {
185
+ metadata: { kind: "org.invite", title: "Join Acme" },
186
+ sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
187
+ };
188
+ expect(slackRenderer.render(onBehalf, CONTEXT).blocks).toEqual([
189
+ {
190
+ type: "context",
191
+ elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
192
+ },
193
+ ]);
194
+ });
195
+
196
+ it("is pure — same inputs, same output", () => {
197
+ expect(slackRenderer.render(CONTENT, CONTEXT)).toEqual(
198
+ slackRenderer.render(CONTENT, CONTEXT),
199
+ );
200
+ });
201
+ });