@company-semantics/contracts 35.1.0 → 36.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 +7 -2
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +122 -11
- package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
- package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +86 -0
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +41 -30
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +19 -4
- package/src/notifications/renderers/email/render.ts +18 -15
- package/src/notifications/renderers/email/shells.ts +112 -9
- package/src/notifications/renderers/email/styles.ts +361 -0
- package/src/notifications/renderers/slack/README.md +66 -25
- package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
- package/src/notifications/renderers/slack/index.ts +192 -107
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Slack channel (ADR-CONTRACTS-086
|
|
2
|
+
* The Slack channel (ADR-CONTRACTS-086, ADR-CONTRACTS-090).
|
|
3
3
|
*
|
|
4
4
|
* Where `../sms` proves the content model survives the poorest channel, this one
|
|
5
5
|
* proves the other edge: a channel whose output is neither a string nor email's
|
|
@@ -8,112 +8,101 @@
|
|
|
8
8
|
* `../../renderer.ts` states in prose ("each renderer returns its natural type",
|
|
9
9
|
* no `ChannelOutput` supertype) held by real modules rather than asserted.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* The blocks are Slack's OWN vocabulary: `@slack/types` is the published type
|
|
12
|
+
* package behind `node-slack-sdk`, so `KnownBlock` is the same union Slack's API
|
|
13
|
+
* validates against. This channel used to hand-write a five-block approximation
|
|
14
|
+
* of Block Kit; that is what ADR-CONTRACTS-090 retires. A renderer whose output
|
|
15
|
+
* type is invented can drift from the wire format silently, and no test in this
|
|
16
|
+
* package would notice — the compiler is the only thing that can, and it can only
|
|
17
|
+
* help if the types are theirs.
|
|
18
|
+
*
|
|
19
|
+
* It is STILL non-functional in the one sense that matters most:
|
|
12
20
|
*
|
|
13
21
|
* - **It does not send.** No token, no channel id, no `chat.postMessage`. A
|
|
14
|
-
* renderer renders; delivery and the decision to deliver are the backend's
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
22
|
+
* renderer renders; delivery and the decision to deliver are the backend's
|
|
23
|
+
* (`SlackChannelDelivery` throws `ChannelNotImplementedError` by design).
|
|
24
|
+
*
|
|
25
|
+
* What it is no longer: low fidelity. The copy is still invented rather than
|
|
26
|
+
* relocated from a shipped Slack app — there is no golden here the way `../email`
|
|
27
|
+
* has one — but the SHAPE is legitimate Block Kit and the `__tests__` pin it.
|
|
19
28
|
*
|
|
20
29
|
* INVARIANTS:
|
|
21
30
|
* - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
|
|
22
31
|
* The brand and the copyright year come from `context.brand`.
|
|
23
32
|
* - `supports` is total, and agrees with `render`: `chatUnit` is omitted, never
|
|
24
33
|
* approximated and never thrown on.
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
34
|
+
* - Escaping follows the SURFACE, not the string. `mrkdwn` is Slack's dialect and
|
|
35
|
+
* every user-controlled string reaching one passes through `escapeMrkdwn`;
|
|
36
|
+
* `plain_text` and `rich_text` are literal surfaces and must NOT be escaped, or
|
|
37
|
+
* the reader sees `&`. See `escapeMrkdwn`.
|
|
38
|
+
* - Slack's documented limits are enforced here, not left to the API to reject:
|
|
39
|
+
* a header truncates at 150 chars, section fields chunk at 10.
|
|
27
40
|
* - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
28
41
|
* lost the model — the fix for a missing fact is upstream in `compose`.
|
|
29
42
|
*/
|
|
30
43
|
|
|
44
|
+
import type {
|
|
45
|
+
ActionsBlock,
|
|
46
|
+
ContextBlock,
|
|
47
|
+
DividerBlock,
|
|
48
|
+
HeaderBlock,
|
|
49
|
+
ImageBlock,
|
|
50
|
+
KnownBlock,
|
|
51
|
+
MrkdwnElement,
|
|
52
|
+
PlainTextElement,
|
|
53
|
+
RichTextBlock,
|
|
54
|
+
RichTextSection,
|
|
55
|
+
SectionBlock,
|
|
56
|
+
} from "@slack/types";
|
|
57
|
+
|
|
31
58
|
import type { NotificationContent, NotificationElement } from "../../content";
|
|
32
59
|
import type { RenderContext } from "../../context";
|
|
33
60
|
import type { Renderer } from "../../renderer";
|
|
34
61
|
|
|
35
62
|
// =============================================================================
|
|
36
|
-
//
|
|
63
|
+
// Slack's documented limits
|
|
37
64
|
// =============================================================================
|
|
38
65
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
41
|
-
type: "mrkdwn";
|
|
42
|
-
text: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Literal text — Slack forbids `mrkdwn` inside a button label. */
|
|
46
|
-
export interface SlackPlainText {
|
|
47
|
-
type: "plain_text";
|
|
48
|
-
text: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** A paragraph. The workhorse block: most elements land here. */
|
|
52
|
-
export interface SlackSectionBlock {
|
|
53
|
-
type: "section";
|
|
54
|
-
text: SlackMrkdwnText;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** A rule. The one element with a native counterpart. */
|
|
58
|
-
export interface SlackDividerBlock {
|
|
59
|
-
type: "divider";
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** An image. Field names are Slack's wire format, hence the snake_case. */
|
|
63
|
-
export interface SlackImageBlock {
|
|
64
|
-
type: "image";
|
|
65
|
-
image_url: string;
|
|
66
|
-
alt_text: string;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** A button. `url` absent makes it inert — see `renderElement`'s `callToAction`. */
|
|
70
|
-
export interface SlackButtonElement {
|
|
71
|
-
type: "button";
|
|
72
|
-
text: SlackPlainText;
|
|
73
|
-
url?: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** A row of buttons. */
|
|
77
|
-
export interface SlackActionsBlock {
|
|
78
|
-
type: "actions";
|
|
79
|
-
elements: SlackButtonElement[];
|
|
80
|
-
}
|
|
66
|
+
/** A `header` block's text. Slack rejects the message beyond this. */
|
|
67
|
+
const HEADER_MAX_CHARS = 150;
|
|
81
68
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
type: "context";
|
|
85
|
-
elements: SlackMrkdwnText[];
|
|
86
|
-
}
|
|
69
|
+
/** Fields per `section`. A longer table chunks across sections rather than 400s. */
|
|
70
|
+
const SECTION_MAX_FIELDS = 10;
|
|
87
71
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
| SlackDividerBlock
|
|
92
|
-
| SlackImageBlock
|
|
93
|
-
| SlackActionsBlock
|
|
94
|
-
| SlackContextBlock;
|
|
72
|
+
// =============================================================================
|
|
73
|
+
// One Slack message
|
|
74
|
+
// =============================================================================
|
|
95
75
|
|
|
96
76
|
/**
|
|
97
|
-
*
|
|
77
|
+
* This channel's natural output type.
|
|
98
78
|
*
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
* channel
|
|
79
|
+
* `blocks` is `KnownBlock[]` — Slack's own union, not ours. `text` is the
|
|
80
|
+
* notification-and-fallback string Slack shows where blocks cannot render (a push
|
|
81
|
+
* notification, a screen reader). It is `metadata.title`, which is the same field
|
|
82
|
+
* email spends as its subject and this channel ALSO spends as its `header` block:
|
|
83
|
+
* one content-level fact, three channel-level uses, which is why it lives on the
|
|
84
|
+
* content rather than in a channel's registry.
|
|
104
85
|
*/
|
|
105
86
|
export interface SlackMessage {
|
|
106
87
|
text: string;
|
|
107
|
-
blocks:
|
|
88
|
+
blocks: KnownBlock[];
|
|
108
89
|
}
|
|
109
90
|
|
|
110
91
|
// =============================================================================
|
|
111
|
-
//
|
|
92
|
+
// Surfaces
|
|
112
93
|
// =============================================================================
|
|
113
94
|
|
|
114
95
|
/**
|
|
115
|
-
* Slack's three reserved characters
|
|
116
|
-
*
|
|
96
|
+
* Slack's three reserved characters, for `mrkdwn` surfaces ONLY.
|
|
97
|
+
*
|
|
98
|
+
* Everything else is literal, so — unlike HTML — this is the whole of the rule.
|
|
99
|
+
*
|
|
100
|
+
* ⚠️ Applying this to a `plain_text` or `rich_text` surface is a DEFECT, not
|
|
101
|
+
* belt-and-braces: those surfaces do not parse `&`/`<`/`>`, so an escaped string
|
|
102
|
+
* renders the entity itself and the reader sees `Acme & Co`. The old
|
|
103
|
+
* hand-written subset could not express the distinction because it had no literal
|
|
104
|
+
* surfaces beyond a button label; `header` and `rich_text` make it load-bearing.
|
|
105
|
+
* `__tests__/index.test.ts` pins both halves.
|
|
117
106
|
*/
|
|
118
107
|
function escapeMrkdwn(value: string): string {
|
|
119
108
|
return value
|
|
@@ -122,21 +111,49 @@ function escapeMrkdwn(value: string): string {
|
|
|
122
111
|
.replace(/>/g, ">");
|
|
123
112
|
}
|
|
124
113
|
|
|
114
|
+
/** An mrkdwn text object, from an already-escaped string. */
|
|
115
|
+
function mrkdwn(text: string): MrkdwnElement {
|
|
116
|
+
return { type: "mrkdwn", text };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A literal text object. Never escaped — see `escapeMrkdwn`. */
|
|
120
|
+
function plainText(text: string): PlainTextElement {
|
|
121
|
+
return { type: "plain_text", text };
|
|
122
|
+
}
|
|
123
|
+
|
|
125
124
|
/** A section carrying pre-escaped mrkdwn. */
|
|
126
|
-
function section(text: string):
|
|
127
|
-
return { type: "section", text:
|
|
125
|
+
function section(text: string): SectionBlock {
|
|
126
|
+
return { type: "section", text: mrkdwn(text) };
|
|
128
127
|
}
|
|
129
128
|
|
|
130
129
|
/** A context block carrying pre-escaped mrkdwn. */
|
|
131
|
-
function context(text: string):
|
|
132
|
-
return { type: "context", elements: [
|
|
130
|
+
function context(text: string): ContextBlock {
|
|
131
|
+
return { type: "context", elements: [mrkdwn(text)] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A rich-text section wrapping literal text. */
|
|
135
|
+
function richTextSection(text: string): RichTextSection {
|
|
136
|
+
return { type: "rich_text_section", elements: [{ type: "text", text }] };
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
/** Split into chunks of at most `size`. Slack caps several collections. */
|
|
140
|
+
function chunk<T>(items: T[], size: number): T[][] {
|
|
141
|
+
const chunks: T[][] = [];
|
|
142
|
+
for (let index = 0; index < items.length; index += size) {
|
|
143
|
+
chunks.push(items.slice(index, index + size));
|
|
144
|
+
}
|
|
145
|
+
return chunks;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// =============================================================================
|
|
149
|
+
// Rendering
|
|
150
|
+
// =============================================================================
|
|
151
|
+
|
|
135
152
|
/**
|
|
136
153
|
* One element as zero or more blocks.
|
|
137
154
|
*
|
|
138
|
-
* Zero and many are both real here — `chatUnit` yields nothing, and an element
|
|
139
|
-
*
|
|
155
|
+
* Zero and many are both real here — `chatUnit` yields nothing, and an element is
|
|
156
|
+
* free to become several blocks — which is why this returns an array where
|
|
140
157
|
* `../sms`'s equivalent returns a string. The switch is total over all twelve
|
|
141
158
|
* types rather than the eleven `supports` admits: the declined arm is unreachable
|
|
142
159
|
* (`render` filters on `supports` first), but writing it out is what makes the
|
|
@@ -146,27 +163,33 @@ function context(text: string): SlackContextBlock {
|
|
|
146
163
|
function renderElement(
|
|
147
164
|
element: NotificationElement,
|
|
148
165
|
renderContext: RenderContext,
|
|
149
|
-
):
|
|
166
|
+
): KnownBlock[] {
|
|
150
167
|
switch (element.type) {
|
|
151
168
|
case "greeting":
|
|
152
169
|
return [section(`Hi ${escapeMrkdwn(element.recipientName ?? "there")},`)];
|
|
153
170
|
case "body":
|
|
154
171
|
return [section(escapeMrkdwn(element.text))];
|
|
155
172
|
case "keyValueTable":
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
173
|
+
// `fields` is Slack's own two-column device for exactly this — facts that
|
|
174
|
+
// belong together, laid out side by side. The old subset had no `fields`,
|
|
175
|
+
// so it flattened the table into `*label:* value` lines and lost the
|
|
176
|
+
// grouping that `KeyValueTable` exists to carry. The `:` was presentation
|
|
177
|
+
// and lives in no element (see ../../kinds/README.md); Slack's answer is a
|
|
178
|
+
// bold label above its value, so the colon is simply gone.
|
|
179
|
+
//
|
|
180
|
+
// Slack caps a section at ten fields; a longer table becomes more sections
|
|
181
|
+
// rather than an API rejection.
|
|
182
|
+
return chunk(element.rows, SECTION_MAX_FIELDS).map((rows) => ({
|
|
183
|
+
type: "section",
|
|
184
|
+
fields: rows.map((row) =>
|
|
185
|
+
mrkdwn(`*${escapeMrkdwn(row.label)}*\n${escapeMrkdwn(row.value)}`),
|
|
165
186
|
),
|
|
166
|
-
|
|
187
|
+
}));
|
|
167
188
|
case "callToAction":
|
|
168
189
|
// `href` absent means the label IS the payload (an OTP code), so it becomes
|
|
169
|
-
//
|
|
190
|
+
// a preformatted block — Slack's code-block surface, which renders it in a
|
|
191
|
+
// monospaced box the reader can select cleanly — rather than a button with
|
|
192
|
+
// a fabricated destination. `rich_text` is a LITERAL surface: no escaping.
|
|
170
193
|
return element.href
|
|
171
194
|
? [
|
|
172
195
|
{
|
|
@@ -174,24 +197,38 @@ function renderElement(
|
|
|
174
197
|
elements: [
|
|
175
198
|
{
|
|
176
199
|
type: "button",
|
|
177
|
-
text:
|
|
200
|
+
text: plainText(element.label),
|
|
178
201
|
url: element.href,
|
|
179
202
|
},
|
|
180
203
|
],
|
|
181
204
|
},
|
|
182
205
|
]
|
|
183
|
-
: [
|
|
206
|
+
: [
|
|
207
|
+
{
|
|
208
|
+
type: "rich_text",
|
|
209
|
+
elements: [
|
|
210
|
+
{
|
|
211
|
+
type: "rich_text_preformatted",
|
|
212
|
+
elements: [{ type: "text", text: element.label }],
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
];
|
|
184
217
|
case "list":
|
|
218
|
+
// A real `rich_text_list`, not bullet characters glued onto a string. Slack
|
|
219
|
+
// draws the markers, the indentation and the numbering; `ordered` maps onto
|
|
220
|
+
// its own `style`. Literal surface — no escaping.
|
|
185
221
|
return [
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
222
|
+
{
|
|
223
|
+
type: "rich_text",
|
|
224
|
+
elements: [
|
|
225
|
+
{
|
|
226
|
+
type: "rich_text_list",
|
|
227
|
+
style: element.ordered ? "ordered" : "bullet",
|
|
228
|
+
elements: element.items.map(richTextSection),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
195
232
|
];
|
|
196
233
|
case "divider":
|
|
197
234
|
return [{ type: "divider" }];
|
|
@@ -233,6 +270,33 @@ function renderElement(
|
|
|
233
270
|
}
|
|
234
271
|
}
|
|
235
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The `header` block — `metadata.title`, the same fact email spends as its
|
|
275
|
+
* subject.
|
|
276
|
+
*
|
|
277
|
+
* A real Slack message leads with its headline; the old subset spent the title
|
|
278
|
+
* only on the invisible `text` fallback, so the message opened straight into "Hi
|
|
279
|
+
* there," and the reader had to infer what it was about from the body. Both uses
|
|
280
|
+
* are correct and neither replaces the other: `text` is what a push notification
|
|
281
|
+
* and a screen reader get, `header` is what the message looks like.
|
|
282
|
+
*
|
|
283
|
+
* `plain_text` is a literal surface AND Slack's only option here — a header does
|
|
284
|
+
* not parse mrkdwn — so the title is passed through unescaped and truncated to
|
|
285
|
+
* Slack's documented 150.
|
|
286
|
+
*/
|
|
287
|
+
function renderHeader(content: NotificationContent): HeaderBlock[] {
|
|
288
|
+
const title = content.metadata.title.trim();
|
|
289
|
+
if (!title) return [];
|
|
290
|
+
return [
|
|
291
|
+
{ type: "header", text: plainText(truncate(title, HEADER_MAX_CHARS)) },
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Cut to `max` characters, marking the cut so a clipped headline reads as one. */
|
|
296
|
+
function truncate(value: string, max: number): string {
|
|
297
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
298
|
+
}
|
|
299
|
+
|
|
236
300
|
/**
|
|
237
301
|
* Slack. Sections are one message — a channel MAY split on them (three messages
|
|
238
302
|
* in a thread is a real Slack shape) but choosing to is a delivery decision, and
|
|
@@ -253,9 +317,30 @@ export const slackRenderer: Renderer<SlackMessage> = {
|
|
|
253
317
|
supports: (elementType) => elementType !== "chatUnit",
|
|
254
318
|
render: (content: NotificationContent, renderContext: RenderContext) => ({
|
|
255
319
|
text: content.metadata.title,
|
|
256
|
-
blocks:
|
|
257
|
-
|
|
258
|
-
.
|
|
259
|
-
|
|
320
|
+
blocks: [
|
|
321
|
+
...renderHeader(content),
|
|
322
|
+
...content.sections
|
|
323
|
+
.flatMap((section_) => section_.elements)
|
|
324
|
+
.filter((element) => slackRenderer.supports(element.type))
|
|
325
|
+
.flatMap((element) => renderElement(element, renderContext)),
|
|
326
|
+
],
|
|
260
327
|
}),
|
|
261
328
|
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Re-exported so a consumer can name this channel's blocks without taking its own
|
|
332
|
+
* dependency on `@slack/types`. These are Slack's types, passed through — NOT a
|
|
333
|
+
* subset of our own. Widen this list rather than redeclaring a shape.
|
|
334
|
+
*/
|
|
335
|
+
export type {
|
|
336
|
+
ActionsBlock,
|
|
337
|
+
ContextBlock,
|
|
338
|
+
DividerBlock,
|
|
339
|
+
HeaderBlock,
|
|
340
|
+
ImageBlock,
|
|
341
|
+
KnownBlock,
|
|
342
|
+
MrkdwnElement,
|
|
343
|
+
PlainTextElement,
|
|
344
|
+
RichTextBlock,
|
|
345
|
+
SectionBlock,
|
|
346
|
+
};
|