@company-semantics/contracts 35.1.0 → 37.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The Slack channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
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
- * It is a placeholder in two specific senses:
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
- * - **It is low fidelity.** This is a minimal, hand-written subset of Block Kit —
16
- * enough to carry every element the channel admits, and no more. It is not the
17
- * Slack API's types, it is not generated from them, and it is invented rather
18
- * than relocated: there is no golden here, unlike `../email`.
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
- * - Every user-controlled string reaching an mrkdwn surface passes through
26
- * `escapeMrkdwn` first the same rule `../email` applies with `escapeHtml`.
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
- // The Block Kit subset
63
+ // Slack's documented limits
37
64
  // =============================================================================
38
65
 
39
- /** Formatted text. `mrkdwn` is Slack's dialect, not Markdown. */
40
- export interface SlackMrkdwnText {
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
- /** De-emphasised small print. Slack's own device for secondary detail. */
83
- export interface SlackContextBlock {
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
- /** Every block this channel can emit. */
89
- export type SlackBlock =
90
- | SlackSectionBlock
91
- | SlackDividerBlock
92
- | SlackImageBlock
93
- | SlackActionsBlock
94
- | SlackContextBlock;
72
+ // =============================================================================
73
+ // One Slack message
74
+ // =============================================================================
95
75
 
96
76
  /**
97
- * One Slack message — this channel's natural output type.
77
+ * This channel's natural output type.
98
78
  *
99
- * `text` is the notification-and-fallback string Slack shows where blocks cannot
100
- * render (a push notification, a screen reader). It is `metadata.title`, which is
101
- * the same field email spends as its subject one content-level fact, two
102
- * channel-level uses, which is why it lives on the content rather than in a
103
- * channel's registry.
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: SlackBlock[];
88
+ blocks: KnownBlock[];
108
89
  }
109
90
 
110
91
  // =============================================================================
111
- // Rendering
92
+ // Surfaces
112
93
  // =============================================================================
113
94
 
114
95
  /**
115
- * Slack's three reserved characters. Everything else is literal, so — unlike
116
- * HTML — this is the whole of the escaping rule.
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 &amp; 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, "&gt;");
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): SlackSectionBlock {
127
- return { type: "section", text: { type: "mrkdwn", 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): SlackContextBlock {
132
- return { type: "context", elements: [{ type: "mrkdwn", text }] };
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
- * is free to become several blocks — which is why this returns an array where
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
- ): SlackBlock[] {
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
- // The `:` is presentation and lives in no element (see ../../kinds/README.md).
157
- return [
158
- section(
159
- element.rows
160
- .map(
161
- (row) =>
162
- `*${escapeMrkdwn(row.label)}:* ${escapeMrkdwn(row.value)}`,
163
- )
164
- .join("\n"),
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
- // text rather than a button with a fabricated destination.
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: { type: "plain_text", text: element.label },
200
+ text: plainText(element.label),
178
201
  url: element.href,
179
202
  },
180
203
  ],
181
204
  },
182
205
  ]
183
- : [section(`*${escapeMrkdwn(element.label)}*`)];
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
- section(
187
- element.items
188
- .map((item, index) =>
189
- element.ordered
190
- ? `${index + 1}. ${escapeMrkdwn(item)}`
191
- : `• ${escapeMrkdwn(item)}`,
192
- )
193
- .join("\n"),
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: content.sections
257
- .flatMap((section_) => section_.elements)
258
- .filter((element) => slackRenderer.supports(element.type))
259
- .flatMap((element) => renderElement(element, renderContext)),
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
+ };