@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.
- package/package.json +4 -1
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +615 -215
- package/src/notifications/content.ts +14 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +120 -11
- 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 +42 -30
- package/src/notifications/renderers/email/colors.ts +345 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +58 -10
- package/src/notifications/renderers/email/index.ts +15 -3
- package/src/notifications/renderers/email/render.ts +16 -15
- package/src/notifications/renderers/email/shells.ts +49 -9
- package/src/notifications/renderers/email/styles.ts +319 -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
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
The Slack channel (ADR-CONTRACTS-086) —
|
|
6
|
-
|
|
5
|
+
The Slack channel (ADR-CONTRACTS-086, ADR-CONTRACTS-090) —
|
|
6
|
+
`slackRenderer: Renderer<SlackMessage>`.
|
|
7
7
|
|
|
8
8
|
Where `../sms` proves the content model survives the poorest channel, this proves
|
|
9
9
|
the other edge: a channel whose output is a tree of typed blocks — neither a
|
|
@@ -12,23 +12,52 @@ record, a string and a different record, which is `../../renderer.ts`'s "each
|
|
|
12
12
|
renderer returns its natural type" held by real modules rather than asserted about
|
|
13
13
|
the type itself.
|
|
14
14
|
|
|
15
|
-
The whole channel is one `index.ts` — the
|
|
16
|
-
renderer. `../email` splits across seven modules because it carries real markup;
|
|
17
|
-
|
|
15
|
+
The whole channel is one `index.ts` — the surfaces, the escaping, and the
|
|
16
|
+
renderer. `../email` splits across seven modules because it carries real markup;
|
|
17
|
+
this one composes blocks and has no markup to own.
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## The blocks are Slack's, not ours
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
`SlackMessage.blocks` is `KnownBlock[]` from **`@slack/types`** — the published
|
|
22
|
+
type package behind `node-slack-sdk`, so it is the same union Slack's API
|
|
23
|
+
validates against. This channel used to hand-write a five-block approximation;
|
|
24
|
+
ADR-CONTRACTS-090 retires it. A renderer whose output type is invented can drift
|
|
25
|
+
from the wire format silently and nothing in this package would notice — the
|
|
26
|
+
compiler is the only thing that can, and only if the types are theirs.
|
|
27
|
+
|
|
28
|
+
`index.ts` re-exports the block types it uses, so a consumer can name them without
|
|
29
|
+
taking its own dependency on `@slack/types`. Widen that list rather than
|
|
30
|
+
redeclaring a shape.
|
|
27
31
|
|
|
28
32
|
The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
|
|
29
33
|
wire format and those are its names. That is deliberate, not a lapse in the
|
|
30
34
|
repo's camelCase convention.
|
|
31
35
|
|
|
36
|
+
## What is still non-functional
|
|
37
|
+
|
|
38
|
+
- **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
|
|
39
|
+
renders; delivery and the decision to deliver are the backend's —
|
|
40
|
+
`SlackChannelDelivery` throws `ChannelNotImplementedError` by design.
|
|
41
|
+
- **The copy is invented**, not relocated from a shipped Slack app. There is no
|
|
42
|
+
golden here the way `../email` has one. The SHAPE is legitimate Block Kit and
|
|
43
|
+
`__tests__/index.test.ts` pins it; the WORDING is expected to change when a kind
|
|
44
|
+
is actually posted to Slack.
|
|
45
|
+
|
|
46
|
+
## Element → block
|
|
47
|
+
|
|
48
|
+
| Element | Block |
|
|
49
|
+
| --------------------------------- | ---------------------------------------------------------- |
|
|
50
|
+
| `metadata.title` | `header` (+ the `text` fallback) |
|
|
51
|
+
| `greeting`, `body`, `warning` | `section` (mrkdwn) |
|
|
52
|
+
| `keyValueTable` | `section.fields` — Slack's two-column facts, chunked at 10 |
|
|
53
|
+
| `callToAction` **with** `href` | `actions` + `button` |
|
|
54
|
+
| `callToAction` **without** `href` | `rich_text` › `rich_text_preformatted` |
|
|
55
|
+
| `list` | `rich_text` › `rich_text_list` (`bullet` / `ordered`) |
|
|
56
|
+
| `divider` | `divider` |
|
|
57
|
+
| `metadata`, `notice`, `signature` | `context` |
|
|
58
|
+
| `heroImage` | `image` |
|
|
59
|
+
| `chatUnit` | _declined_ |
|
|
60
|
+
|
|
32
61
|
## The one decline
|
|
33
62
|
|
|
34
63
|
`supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
|
|
@@ -37,9 +66,9 @@ attribution that IS the meaning — which the content model names as the reason
|
|
|
37
66
|
decline a whole element rather than approximate it.
|
|
38
67
|
|
|
39
68
|
Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
|
|
40
|
-
has no surface for**. That difference is the point of shipping
|
|
41
|
-
than one: same `NotificationContent`, no channel tags on it, three channels
|
|
42
|
-
differing capability each keeping what they can show.
|
|
69
|
+
has no surface for**. That difference is the point of shipping three channels
|
|
70
|
+
rather than one: same `NotificationContent`, no channel tags on it, three channels
|
|
71
|
+
of differing capability each keeping what they can show.
|
|
43
72
|
|
|
44
73
|
`metadata` and `notice` become `context` blocks — Slack's own device for
|
|
45
74
|
de-emphasised small print, which is exactly what `MetadataElement` exists to be.
|
|
@@ -52,20 +81,32 @@ de-emphasised small print, which is exactly what `MetadataElement` exists to be.
|
|
|
52
81
|
nowhere to hide.
|
|
53
82
|
- `supports` is total over `NotificationElementType` and AGREES with `render`:
|
|
54
83
|
`chatUnit` is omitted, never approximated and never thrown on.
|
|
55
|
-
-
|
|
56
|
-
`escapeMrkdwn`
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
- **Escaping follows the SURFACE, not the string.** Every user-controlled string
|
|
85
|
+
reaching an **mrkdwn** surface passes through `escapeMrkdwn` — the same rule
|
|
86
|
+
`../email` applies with `escapeHtml`. But `plain_text` (header, button label)
|
|
87
|
+
and `rich_text` (the OTP code, list items) are **literal**: escaping them is a
|
|
88
|
+
DEFECT, because those surfaces do not parse the entities and the reader sees
|
|
89
|
+
`Acme & Co`. The old subset had no literal surface beyond a button label, so
|
|
90
|
+
the distinction only became load-bearing with `header` and `rich_text`. The
|
|
91
|
+
compiler cannot help — both surfaces are `string` — so
|
|
92
|
+
`__tests__/index.test.ts` pins both halves.
|
|
93
|
+
- Slack's documented LIMITS are enforced here, not left to the API to reject: a
|
|
94
|
+
header truncates at 150 characters, `section.fields` chunks at 10. These are
|
|
95
|
+
400s from `chat.postMessage`, not fidelity gaps.
|
|
59
96
|
- `renderElement` switches over all TWELVE element types, not the eleven
|
|
60
97
|
`supports` admits. The declined arm is unreachable; writing it out is what makes
|
|
61
98
|
the compiler prove the two lists agree. Do not collapse it into a `default` —
|
|
62
99
|
that would silently swallow a thirteenth member of the union.
|
|
63
|
-
- A `callToAction` with no `href` becomes
|
|
64
|
-
means the label IS the payload (an OTP code); a button with no
|
|
65
|
-
lie about what the notification is asking for.
|
|
66
|
-
- `
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
- A `callToAction` with no `href` becomes a code block, never a button. `href`
|
|
101
|
+
absent means the label IS the payload (an OTP code); a button with no
|
|
102
|
+
destination is a lie about what the notification is asking for.
|
|
103
|
+
- `metadata.title` is spent TWICE and neither use replaces the other:
|
|
104
|
+
`SlackMessage.text` is the fallback Slack shows where blocks cannot render (a
|
|
105
|
+
push notification, a screen reader), and the `header` block is what the message
|
|
106
|
+
looks like. It is the same content-level fact email spends as its subject —
|
|
107
|
+
three channel uses of one fact, which is why it lives on the content rather than
|
|
108
|
+
in a channel's registry. Only the block carries the 150-char limit; the fallback
|
|
109
|
+
is untruncated.
|
|
69
110
|
- Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
70
111
|
lost the model; the fix for a missing fact is upstream in `compose`.
|
|
71
112
|
- This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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.
|
|
2
|
+
* What this pins, now that the blocks are Slack's own vocabulary rather than a
|
|
3
|
+
* hand-written approximation (ADR-CONTRACTS-090):
|
|
7
4
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* - the contract every renderer owes — `supports` total and in agreement with
|
|
6
|
+
* `render`, `render` pure and reading the brand and year from the context,
|
|
7
|
+
* every element type rendering without throwing;
|
|
8
|
+
* - the ESCAPING RULE, both halves. mrkdwn escapes; `plain_text` and `rich_text`
|
|
9
|
+
* must not. This is the defect the new literal surfaces made possible, and the
|
|
10
|
+
* compiler cannot catch it — both surfaces are `string`.
|
|
11
|
+
* - the SHAPE — that each element reaches the Block Kit primitive it claims to.
|
|
12
|
+
* `@slack/types` proves a block is well-formed; only a test proves a `list`
|
|
13
|
+
* became a `rich_text_list` rather than a section full of bullet characters.
|
|
14
|
+
* - Slack's documented LIMITS, which are rejections at the API rather than
|
|
15
|
+
* fidelity gaps: header ≤150, section fields ≤10.
|
|
16
|
+
*
|
|
17
|
+
* Still NOT pinned: the wording. It is invented rather than relocated from a
|
|
18
|
+
* shipped Slack app, and it is expected to change when a kind is actually posted.
|
|
10
19
|
*/
|
|
11
20
|
|
|
12
21
|
import { describe, expect, it } from "vitest";
|
|
@@ -67,6 +76,17 @@ const ALL_TYPES: NotificationElementType[] = [
|
|
|
67
76
|
"heroImage",
|
|
68
77
|
];
|
|
69
78
|
|
|
79
|
+
/** One content carrying one element — the shape assertions read better alone. */
|
|
80
|
+
function only(
|
|
81
|
+
element: NotificationContent["sections"][number]["elements"][number],
|
|
82
|
+
title = "Untitled",
|
|
83
|
+
): NotificationContent {
|
|
84
|
+
return {
|
|
85
|
+
metadata: { kind: "org.invite", title },
|
|
86
|
+
sections: [{ elements: [element] }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
70
90
|
describe("slackRenderer", () => {
|
|
71
91
|
it("declares a stable channel id", () => {
|
|
72
92
|
expect(slackRenderer.id).toBe("slack");
|
|
@@ -92,22 +112,22 @@ describe("slackRenderer", () => {
|
|
|
92
112
|
const rendered = slackRenderer.render(CONTENT, CONTEXT);
|
|
93
113
|
|
|
94
114
|
expect(JSON.stringify(rendered)).not.toContain("Hello?");
|
|
95
|
-
// Eleven admitted elements, each yielding exactly one block here.
|
|
96
|
-
expect(rendered.blocks).toHaveLength(11);
|
|
97
115
|
});
|
|
98
116
|
|
|
99
117
|
it("returns its natural type — a Block Kit record, not a string", () => {
|
|
100
118
|
const rendered = slackRenderer.render(CONTENT, CONTEXT);
|
|
101
119
|
|
|
102
120
|
// `text` is the fallback Slack shows where blocks cannot render. It is
|
|
103
|
-
// metadata.title — the same field email spends as its subject
|
|
121
|
+
// metadata.title — the same field email spends as its subject, and this
|
|
122
|
+
// channel ALSO leads with as a header.
|
|
104
123
|
expect(rendered.text).toBe("Join Acme");
|
|
105
124
|
expect(rendered.blocks.map((block) => block.type)).toEqual([
|
|
125
|
+
"header",
|
|
106
126
|
"image",
|
|
107
127
|
"section",
|
|
108
128
|
"section",
|
|
109
129
|
"section",
|
|
110
|
-
"
|
|
130
|
+
"rich_text",
|
|
111
131
|
"divider",
|
|
112
132
|
"section",
|
|
113
133
|
"context",
|
|
@@ -117,8 +137,127 @@ describe("slackRenderer", () => {
|
|
|
117
137
|
]);
|
|
118
138
|
});
|
|
119
139
|
|
|
140
|
+
// ===========================================================================
|
|
141
|
+
// The header — metadata.title's third spend
|
|
142
|
+
// ===========================================================================
|
|
143
|
+
|
|
144
|
+
it("leads with a header block carrying the title", () => {
|
|
145
|
+
expect(slackRenderer.render(CONTENT, CONTEXT).blocks[0]).toEqual({
|
|
146
|
+
type: "header",
|
|
147
|
+
text: { type: "plain_text", text: "Join Acme" },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("spends the title on BOTH the header and the fallback", () => {
|
|
152
|
+
// Neither use replaces the other: `text` is what a push notification and a
|
|
153
|
+
// screen reader get, the header is what the message looks like.
|
|
154
|
+
const rendered = slackRenderer.render(CONTENT, CONTEXT);
|
|
155
|
+
|
|
156
|
+
expect(rendered.text).toBe("Join Acme");
|
|
157
|
+
expect(JSON.stringify(rendered.blocks[0])).toContain("Join Acme");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("truncates a header at Slack's documented 150 characters", () => {
|
|
161
|
+
const rendered = slackRenderer.render(
|
|
162
|
+
only({ type: "body", text: "x" }, "A".repeat(200)),
|
|
163
|
+
CONTEXT,
|
|
164
|
+
);
|
|
165
|
+
const header = rendered.blocks[0];
|
|
166
|
+
|
|
167
|
+
expect(header.type).toBe("header");
|
|
168
|
+
expect(header).toMatchObject({
|
|
169
|
+
text: { text: `${"A".repeat(149)}…` },
|
|
170
|
+
});
|
|
171
|
+
// The FALLBACK is not truncated — only the block has the limit.
|
|
172
|
+
expect(rendered.text).toHaveLength(200);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("emits no header when the title is empty — not an empty block", () => {
|
|
176
|
+
const rendered = slackRenderer.render(
|
|
177
|
+
only({ type: "body", text: "x" }, " "),
|
|
178
|
+
CONTEXT,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(rendered.blocks.map((block) => block.type)).toEqual(["section"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ===========================================================================
|
|
185
|
+
// Shape — each element reaches its real Block Kit primitive
|
|
186
|
+
// ===========================================================================
|
|
187
|
+
|
|
188
|
+
it("renders a keyValueTable as section fields, Slack's two-column device", () => {
|
|
189
|
+
const rendered = slackRenderer.render(
|
|
190
|
+
only({
|
|
191
|
+
type: "keyValueTable",
|
|
192
|
+
rows: [
|
|
193
|
+
{ label: "Status", value: "Valid" },
|
|
194
|
+
{ label: "Expires in", value: "10 minutes" },
|
|
195
|
+
],
|
|
196
|
+
}),
|
|
197
|
+
CONTEXT,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(rendered.blocks[1]).toEqual({
|
|
201
|
+
type: "section",
|
|
202
|
+
fields: [
|
|
203
|
+
{ type: "mrkdwn", text: "*Status*\nValid" },
|
|
204
|
+
{ type: "mrkdwn", text: "*Expires in*\n10 minutes" },
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("chunks a keyValueTable at ten fields — Slack rejects an eleventh", () => {
|
|
210
|
+
const rows = Array.from({ length: 23 }, (_, index) => ({
|
|
211
|
+
label: `L${index}`,
|
|
212
|
+
value: `V${index}`,
|
|
213
|
+
}));
|
|
214
|
+
const blocks = slackRenderer
|
|
215
|
+
.render(only({ type: "keyValueTable", rows }), CONTEXT)
|
|
216
|
+
.blocks.filter((block) => block.type === "section");
|
|
217
|
+
|
|
218
|
+
expect(blocks.map((block) => block.fields?.length)).toEqual([10, 10, 3]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("renders a list as a real rich_text_list, not bullet characters", () => {
|
|
222
|
+
const rendered = slackRenderer.render(
|
|
223
|
+
only({ type: "list", items: ["Read", "Write"] }),
|
|
224
|
+
CONTEXT,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
expect(rendered.blocks[1]).toEqual({
|
|
228
|
+
type: "rich_text",
|
|
229
|
+
elements: [
|
|
230
|
+
{
|
|
231
|
+
type: "rich_text_list",
|
|
232
|
+
style: "bullet",
|
|
233
|
+
elements: [
|
|
234
|
+
{
|
|
235
|
+
type: "rich_text_section",
|
|
236
|
+
elements: [{ type: "text", text: "Read" }],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
type: "rich_text_section",
|
|
240
|
+
elements: [{ type: "text", text: "Write" }],
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
},
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("maps an ordered list onto Slack's own ordered style", () => {
|
|
249
|
+
const rendered = slackRenderer.render(
|
|
250
|
+
only({ type: "list", items: ["First"], ordered: true }),
|
|
251
|
+
CONTEXT,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(rendered.blocks[1]).toMatchObject({
|
|
255
|
+
elements: [{ type: "rich_text_list", style: "ordered" }],
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
120
259
|
it("takes the hero image the SMS channel has no surface for", () => {
|
|
121
|
-
// The
|
|
260
|
+
// The channels exist to differ: same content, no channel tags on it, and
|
|
122
261
|
// each channel keeps what it can depict.
|
|
123
262
|
expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
|
|
124
263
|
type: "image",
|
|
@@ -140,36 +279,106 @@ describe("slackRenderer", () => {
|
|
|
140
279
|
});
|
|
141
280
|
});
|
|
142
281
|
|
|
143
|
-
it("renders a call to action without an href as
|
|
282
|
+
it("renders a call to action without an href as a code block, not a button", () => {
|
|
144
283
|
// `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
|
|
284
|
+
// no destination would be a lie about what the notification is asking for;
|
|
285
|
+
// `rich_text_preformatted` is Slack's own device for a selectable code.
|
|
146
286
|
const otp: NotificationContent = {
|
|
147
287
|
metadata: { kind: "auth.otp", title: "Your code" },
|
|
148
288
|
sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
|
|
149
289
|
};
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
290
|
+
|
|
291
|
+
expect(slackRenderer.render(otp, CONTEXT).blocks[1]).toEqual({
|
|
292
|
+
type: "rich_text",
|
|
293
|
+
elements: [
|
|
294
|
+
{
|
|
295
|
+
type: "rich_text_preformatted",
|
|
296
|
+
elements: [{ type: "text", text: "123456" }],
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ===========================================================================
|
|
303
|
+
// Escaping — the rule follows the SURFACE, not the string
|
|
304
|
+
// ===========================================================================
|
|
305
|
+
|
|
306
|
+
it("escapes Slack's reserved characters on an mrkdwn surface", () => {
|
|
307
|
+
const rendered = slackRenderer.render(
|
|
308
|
+
only({ type: "body", text: "a & b <c> <!channel>" }),
|
|
309
|
+
CONTEXT,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
expect(rendered.blocks[1]).toEqual({
|
|
313
|
+
type: "section",
|
|
314
|
+
text: { type: "mrkdwn", text: "a & b <c> <!channel>" },
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("does NOT escape a plain_text surface — a header is literal", () => {
|
|
319
|
+
// Escaping here is a real defect, not belt-and-braces: `plain_text` does not
|
|
320
|
+
// parse the entities, so the reader would see `Acme & Co`.
|
|
321
|
+
const rendered = slackRenderer.render(
|
|
322
|
+
only({ type: "body", text: "x" }, "Acme & Co <the org>"),
|
|
323
|
+
CONTEXT,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
expect(rendered.blocks[0]).toEqual({
|
|
327
|
+
type: "header",
|
|
328
|
+
text: { type: "plain_text", text: "Acme & Co <the org>" },
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("does NOT escape a plain_text surface — a button label is literal", () => {
|
|
333
|
+
const rendered = slackRenderer.render(
|
|
334
|
+
only({
|
|
335
|
+
type: "callToAction",
|
|
336
|
+
label: "Join Acme & Co",
|
|
337
|
+
href: "https://e.test",
|
|
338
|
+
}),
|
|
339
|
+
CONTEXT,
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
expect(rendered.blocks[1]).toMatchObject({
|
|
343
|
+
elements: [{ text: { type: "plain_text", text: "Join Acme & Co" } }],
|
|
344
|
+
});
|
|
153
345
|
});
|
|
154
346
|
|
|
155
|
-
it("
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
347
|
+
it("does NOT escape a rich_text surface — the OTP is literal", () => {
|
|
348
|
+
const rendered = slackRenderer.render(
|
|
349
|
+
only({ type: "callToAction", label: "a&b<c>" }),
|
|
350
|
+
CONTEXT,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
expect(rendered.blocks[1]).toMatchObject({
|
|
354
|
+
elements: [
|
|
355
|
+
{
|
|
356
|
+
type: "rich_text_preformatted",
|
|
357
|
+
elements: [{ type: "text", text: "a&b<c>" }],
|
|
358
|
+
},
|
|
160
359
|
],
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("does NOT escape a rich_text surface — list items are literal", () => {
|
|
364
|
+
const rendered = slackRenderer.render(
|
|
365
|
+
only({ type: "list", items: ["a & b"] }),
|
|
366
|
+
CONTEXT,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
expect(rendered.blocks[1]).toMatchObject({
|
|
370
|
+
elements: [
|
|
371
|
+
{
|
|
372
|
+
elements: [{ elements: [{ type: "text", text: "a & b" }] }],
|
|
168
373
|
},
|
|
169
|
-
|
|
170
|
-
|
|
374
|
+
],
|
|
375
|
+
});
|
|
171
376
|
});
|
|
172
377
|
|
|
378
|
+
// ===========================================================================
|
|
379
|
+
// The seam properties
|
|
380
|
+
// ===========================================================================
|
|
381
|
+
|
|
173
382
|
it("reads the brand and year from context, never from a clock", () => {
|
|
174
383
|
// No fake timer in this file: swapping the context moves the copyright line.
|
|
175
384
|
const pinned: RenderContext = {
|
|
@@ -181,16 +390,15 @@ describe("slackRenderer", () => {
|
|
|
181
390
|
});
|
|
182
391
|
|
|
183
392
|
it("lets an element's signer override the context brand", () => {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
]);
|
|
393
|
+
const rendered = slackRenderer.render(
|
|
394
|
+
only({ type: "signature", signer: "Grace" }),
|
|
395
|
+
CONTEXT,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
expect(rendered.blocks[1]).toEqual({
|
|
399
|
+
type: "context",
|
|
400
|
+
elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
|
|
401
|
+
});
|
|
194
402
|
});
|
|
195
403
|
|
|
196
404
|
it("is pure — same inputs, same output", () => {
|