@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.
- package/package.json +4 -4
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +2 -2
- package/src/index.ts +17 -11
- package/src/notifications/README.md +140 -0
- package/src/notifications/__tests__/README.md +54 -0
- package/src/notifications/__tests__/__snapshots__/README.md +32 -0
- package/src/notifications/__tests__/content.test.ts +186 -0
- package/src/notifications/__tests__/context.test.ts +72 -0
- package/src/notifications/__tests__/definition.test.ts +222 -0
- package/src/notifications/__tests__/kinds.test.ts +81 -0
- package/src/notifications/__tests__/output-parity.golden.ts +363 -0
- package/src/notifications/__tests__/output-parity.test.ts +122 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
- package/src/notifications/__tests__/renderer.test.ts +181 -0
- package/src/notifications/content.ts +249 -0
- package/src/notifications/context.ts +70 -0
- package/src/notifications/definition.ts +82 -0
- package/src/notifications/index.ts +104 -0
- package/src/notifications/kinds/README.md +57 -0
- package/src/notifications/kinds/auth-otp.ts +99 -0
- package/src/notifications/kinds/chat-shared.ts +56 -0
- package/src/notifications/kinds/company-md-access-approved.ts +57 -0
- package/src/notifications/kinds/company-md-access-denied.ts +61 -0
- package/src/notifications/kinds/company-md-access-requested.ts +65 -0
- package/src/notifications/kinds/index.ts +19 -0
- package/src/notifications/kinds/org-invite.ts +62 -0
- package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
- package/src/notifications/kinds/ownership-transfer.ts +68 -0
- package/src/notifications/kinds/security-alert.ts +78 -0
- package/src/notifications/kinds/share-granted.ts +74 -0
- package/src/notifications/kinds/unit-owner-granted.ts +95 -0
- package/src/notifications/kinds.ts +68 -0
- package/src/{email/types.ts → notifications/payloads.ts} +33 -68
- package/src/notifications/registry.ts +107 -0
- package/src/notifications/render.ts +106 -0
- package/src/notifications/renderer.ts +50 -0
- package/src/notifications/renderers/README.md +50 -0
- package/src/notifications/renderers/email/README.md +74 -0
- package/src/notifications/renderers/email/__tests__/README.md +29 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
- package/src/notifications/renderers/email/constants.ts +47 -0
- package/src/notifications/renderers/email/cta.ts +63 -0
- package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
- package/src/notifications/renderers/email/index.ts +73 -0
- package/src/notifications/renderers/email/render.ts +225 -0
- package/src/notifications/renderers/email/shells.ts +60 -0
- package/src/notifications/renderers/slack/README.md +73 -0
- package/src/notifications/renderers/slack/__tests__/README.md +33 -0
- package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
- package/src/notifications/renderers/slack/index.ts +261 -0
- package/src/notifications/renderers/sms/README.md +69 -0
- package/src/notifications/renderers/sms/__tests__/README.md +31 -0
- package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
- package/src/notifications/renderers/sms/index.ts +131 -0
- package/src/notifications/text.ts +52 -0
- package/src/email/README.md +0 -51
- package/src/email/__tests__/registry.test.ts +0 -161
- package/src/email/index.ts +0 -36
- package/src/email/registry.ts +0 -155
- package/src/email/render/auth-otp.ts +0 -71
- package/src/email/render/blocks.ts +0 -281
- package/src/email/render/chat-shared.ts +0 -35
- package/src/email/render/company-md-access-approved.ts +0 -42
- package/src/email/render/company-md-access-denied.ts +0 -43
- package/src/email/render/company-md-access-requested.ts +0 -46
- package/src/email/render/constants.ts +0 -18
- package/src/email/render/index.ts +0 -58
- package/src/email/render/org-invite.ts +0 -40
- package/src/email/render/ownership-transfer-completed.ts +0 -41
- package/src/email/render/ownership-transfer.ts +0 -42
- package/src/email/render/render-email.ts +0 -194
- package/src/email/render/security-alert.ts +0 -61
- package/src/email/render/share-granted.ts +0 -52
- package/src/email/render/unit-owner-granted.ts +0 -60
- /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 & b <c> <!channel>",
|
|
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
|
+
});
|