@company-semantics/contracts 34.0.0 → 35.1.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/index.ts +17 -11
- package/src/notifications/README.md +142 -0
- package/src/notifications/__tests__/README.md +49 -0
- package/src/notifications/__tests__/__snapshots__/README.md +39 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
- 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__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +80 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -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 +78 -0
- package/src/notifications/renderers/email/__tests__/README.md +32 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
- package/src/notifications/renderers/email/constants.ts +64 -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 +238 -0
- package/src/notifications/renderers/email/shells.ts +61 -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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Slack channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
|
|
3
|
+
*
|
|
4
|
+
* Where `../sms` proves the content model survives the poorest channel, this one
|
|
5
|
+
* proves the other edge: a channel whose output is neither a string nor email's
|
|
6
|
+
* `{ subject, text, html }`, but a tree of typed blocks. Between the three, `Out`
|
|
7
|
+
* has been a record, a string and a different record — which is the seam property
|
|
8
|
+
* `../../renderer.ts` states in prose ("each renderer returns its natural type",
|
|
9
|
+
* no `ChannelOutput` supertype) held by real modules rather than asserted.
|
|
10
|
+
*
|
|
11
|
+
* It is a placeholder in two specific senses:
|
|
12
|
+
*
|
|
13
|
+
* - **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`.
|
|
19
|
+
*
|
|
20
|
+
* INVARIANTS:
|
|
21
|
+
* - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
|
|
22
|
+
* The brand and the copyright year come from `context.brand`.
|
|
23
|
+
* - `supports` is total, and agrees with `render`: `chatUnit` is omitted, never
|
|
24
|
+
* 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`.
|
|
27
|
+
* - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
28
|
+
* lost the model — the fix for a missing fact is upstream in `compose`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { NotificationContent, NotificationElement } from "../../content";
|
|
32
|
+
import type { RenderContext } from "../../context";
|
|
33
|
+
import type { Renderer } from "../../renderer";
|
|
34
|
+
|
|
35
|
+
// =============================================================================
|
|
36
|
+
// The Block Kit subset
|
|
37
|
+
// =============================================================================
|
|
38
|
+
|
|
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
|
+
}
|
|
81
|
+
|
|
82
|
+
/** De-emphasised small print. Slack's own device for secondary detail. */
|
|
83
|
+
export interface SlackContextBlock {
|
|
84
|
+
type: "context";
|
|
85
|
+
elements: SlackMrkdwnText[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Every block this channel can emit. */
|
|
89
|
+
export type SlackBlock =
|
|
90
|
+
| SlackSectionBlock
|
|
91
|
+
| SlackDividerBlock
|
|
92
|
+
| SlackImageBlock
|
|
93
|
+
| SlackActionsBlock
|
|
94
|
+
| SlackContextBlock;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One Slack message — this channel's natural output type.
|
|
98
|
+
*
|
|
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.
|
|
104
|
+
*/
|
|
105
|
+
export interface SlackMessage {
|
|
106
|
+
text: string;
|
|
107
|
+
blocks: SlackBlock[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// Rendering
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Slack's three reserved characters. Everything else is literal, so — unlike
|
|
116
|
+
* HTML — this is the whole of the escaping rule.
|
|
117
|
+
*/
|
|
118
|
+
function escapeMrkdwn(value: string): string {
|
|
119
|
+
return value
|
|
120
|
+
.replace(/&/g, "&")
|
|
121
|
+
.replace(/</g, "<")
|
|
122
|
+
.replace(/>/g, ">");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A section carrying pre-escaped mrkdwn. */
|
|
126
|
+
function section(text: string): SlackSectionBlock {
|
|
127
|
+
return { type: "section", text: { type: "mrkdwn", text } };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A context block carrying pre-escaped mrkdwn. */
|
|
131
|
+
function context(text: string): SlackContextBlock {
|
|
132
|
+
return { type: "context", elements: [{ type: "mrkdwn", text }] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* One element as zero or more blocks.
|
|
137
|
+
*
|
|
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
|
|
140
|
+
* `../sms`'s equivalent returns a string. The switch is total over all twelve
|
|
141
|
+
* types rather than the eleven `supports` admits: the declined arm is unreachable
|
|
142
|
+
* (`render` filters on `supports` first), but writing it out is what makes the
|
|
143
|
+
* compiler prove the two lists agree. A `default` arm would silently swallow a
|
|
144
|
+
* thirteenth member of the union.
|
|
145
|
+
*/
|
|
146
|
+
function renderElement(
|
|
147
|
+
element: NotificationElement,
|
|
148
|
+
renderContext: RenderContext,
|
|
149
|
+
): SlackBlock[] {
|
|
150
|
+
switch (element.type) {
|
|
151
|
+
case "greeting":
|
|
152
|
+
return [section(`Hi ${escapeMrkdwn(element.recipientName ?? "there")},`)];
|
|
153
|
+
case "body":
|
|
154
|
+
return [section(escapeMrkdwn(element.text))];
|
|
155
|
+
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"),
|
|
165
|
+
),
|
|
166
|
+
];
|
|
167
|
+
case "callToAction":
|
|
168
|
+
// `href` absent means the label IS the payload (an OTP code), so it becomes
|
|
169
|
+
// text rather than a button with a fabricated destination.
|
|
170
|
+
return element.href
|
|
171
|
+
? [
|
|
172
|
+
{
|
|
173
|
+
type: "actions",
|
|
174
|
+
elements: [
|
|
175
|
+
{
|
|
176
|
+
type: "button",
|
|
177
|
+
text: { type: "plain_text", text: element.label },
|
|
178
|
+
url: element.href,
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
]
|
|
183
|
+
: [section(`*${escapeMrkdwn(element.label)}*`)];
|
|
184
|
+
case "list":
|
|
185
|
+
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
|
+
),
|
|
195
|
+
];
|
|
196
|
+
case "divider":
|
|
197
|
+
return [{ type: "divider" }];
|
|
198
|
+
case "warning":
|
|
199
|
+
// Fieldless by design: the banner IS the content and its prominence is the
|
|
200
|
+
// channel's to choose. Slack's answer is an emoji and bold text.
|
|
201
|
+
return [section(":warning: *Security notice*")];
|
|
202
|
+
case "metadata":
|
|
203
|
+
// A context block, because this element exists to BE de-emphasisable —
|
|
204
|
+
// diagnostic detail about the request, distinct from the facts themselves.
|
|
205
|
+
return [
|
|
206
|
+
context(
|
|
207
|
+
[
|
|
208
|
+
element.label ? `*${escapeMrkdwn(element.label)}*` : "",
|
|
209
|
+
...element.rows.map(
|
|
210
|
+
(row) => `${escapeMrkdwn(row.label)}: ${escapeMrkdwn(row.value)}`,
|
|
211
|
+
),
|
|
212
|
+
]
|
|
213
|
+
.filter(Boolean)
|
|
214
|
+
.join(" · "),
|
|
215
|
+
),
|
|
216
|
+
];
|
|
217
|
+
case "notice":
|
|
218
|
+
return [context(element.lines.map(escapeMrkdwn).join("\n"))];
|
|
219
|
+
case "signature":
|
|
220
|
+
// Both the signer fallback and the year come from the context. This is the
|
|
221
|
+
// element that used to hide a `new Date()`; here there is nowhere to hide.
|
|
222
|
+
return [
|
|
223
|
+
context(
|
|
224
|
+
`© ${renderContext.brand.copyrightYear} ${escapeMrkdwn(
|
|
225
|
+
element.signer ?? renderContext.brand.name,
|
|
226
|
+
)}`,
|
|
227
|
+
),
|
|
228
|
+
];
|
|
229
|
+
case "heroImage":
|
|
230
|
+
return [{ type: "image", image_url: element.src, alt_text: element.alt }];
|
|
231
|
+
case "chatUnit":
|
|
232
|
+
return [];
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Slack. Sections are one message — a channel MAY split on them (three messages
|
|
238
|
+
* in a thread is a real Slack shape) but choosing to is a delivery decision, and
|
|
239
|
+
* delivery is not this package's.
|
|
240
|
+
*/
|
|
241
|
+
export const slackRenderer: Renderer<SlackMessage> = {
|
|
242
|
+
id: "slack",
|
|
243
|
+
/**
|
|
244
|
+
* One decline: `chatUnit`.
|
|
245
|
+
*
|
|
246
|
+
* Block Kit has no vocabulary for turn-taking. Flattening a conversation into a
|
|
247
|
+
* run of sections would drop the attribution that IS the meaning, which the
|
|
248
|
+
* content model names as the reason to decline the whole element rather than
|
|
249
|
+
* approximate it. Everything else Slack can genuinely depict — including the
|
|
250
|
+
* hero image `../sms` has no surface for, which is the point of having two
|
|
251
|
+
* stubs rather than one.
|
|
252
|
+
*/
|
|
253
|
+
supports: (elementType) => elementType !== "chatUnit",
|
|
254
|
+
render: (content: NotificationContent, renderContext: RenderContext) => ({
|
|
255
|
+
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)),
|
|
260
|
+
}),
|
|
261
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# notifications/renderers/sms/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The SMS channel (ADR-CONTRACTS-086) — `smsRenderer: Renderer<string>`. A
|
|
6
|
+
**non-functional placeholder**.
|
|
7
|
+
|
|
8
|
+
`../email` could satisfy `Renderer` by construction: the seam was drawn around it,
|
|
9
|
+
so it proves nothing about whether the seam generalises. SMS is the test. It is
|
|
10
|
+
the poorest channel this vocabulary will ever meet — no markup, no images, no
|
|
11
|
+
layout, one string — and if `NotificationContent` survives being projected onto it
|
|
12
|
+
without a new element, a new field or a channel tag, then the content model is
|
|
13
|
+
channel-agnostic rather than email-shaped with a `Renderer` label on it.
|
|
14
|
+
|
|
15
|
+
The whole channel is one `index.ts`. `../email` splits across seven modules
|
|
16
|
+
because it carries real markup; a stub that sprawls is a stub pretending.
|
|
17
|
+
|
|
18
|
+
## What "placeholder" means here
|
|
19
|
+
|
|
20
|
+
Two senses, neither of them a TODO to be closed by tightening this file:
|
|
21
|
+
|
|
22
|
+
- **It does not send.** No delivery, no segmentation into 160-character parts, no
|
|
23
|
+
phone number. A renderer renders; the rest is the backend's, and the moment it
|
|
24
|
+
appears here the seam has leaked.
|
|
25
|
+
- **It is low fidelity.** The wording is invented, not relocated. Unlike
|
|
26
|
+
`../email` there are no bytes to reproduce and no golden to answer to, so the
|
|
27
|
+
first kind actually sent over SMS should expect to author its copy properly
|
|
28
|
+
rather than trust these strings.
|
|
29
|
+
|
|
30
|
+
What is NOT provisional is the shape: `Out = string`, `supports` total, `render`
|
|
31
|
+
pure. Those are the seam's claims, and this module is one of the two places they
|
|
32
|
+
are stated by real code instead of a test double.
|
|
33
|
+
|
|
34
|
+
## The three declines
|
|
35
|
+
|
|
36
|
+
`supports` answers `false` for `heroImage`, `chatUnit` and `divider`. Each is a
|
|
37
|
+
fact about the channel, never an edit of the notification:
|
|
38
|
+
|
|
39
|
+
| element | why |
|
|
40
|
+
| ----------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
41
|
+
| `heroImage` | There is no image surface. Substituting `alt` would approximate the element, which answering `false` forbids. |
|
|
42
|
+
| `chatUnit` | The turn-taking IS the meaning; a flat run of text cannot attribute a turn. The model says decline rather than flatten. |
|
|
43
|
+
| `divider` | A thematic break is a visual device and carries no content by design. SMS has no visual vocabulary to draw one with. |
|
|
44
|
+
|
|
45
|
+
Everything else is the universal subset — what survives on the poorest channel.
|
|
46
|
+
Note that `metadata` and `notice` are NOT declined: they are depictable, and
|
|
47
|
+
dropping them to save characters would be an editorial choice smuggled in as a
|
|
48
|
+
capability claim. Capability is "can this channel depict it", not "should it".
|
|
49
|
+
|
|
50
|
+
## Invariants
|
|
51
|
+
|
|
52
|
+
- PURE. `render` is a function of `(content, context)` — no clock, no
|
|
53
|
+
environment, no I/O. The brand comes from `context.brand`.
|
|
54
|
+
- `supports` is total over `NotificationElementType` and AGREES with `render`: a
|
|
55
|
+
declined element is omitted, never approximated and never thrown on.
|
|
56
|
+
`__tests__/index.test.ts` asserts the whole rendered string for exactly this
|
|
57
|
+
reason — a decline is only visible in what is absent.
|
|
58
|
+
- `renderElement` switches over all TWELVE element types, not the nine `supports`
|
|
59
|
+
admits. The declined arms are unreachable; writing them out is what makes the
|
|
60
|
+
compiler prove the two lists agree. Do not collapse them into a `default` —
|
|
61
|
+
that would silently accept a thirteenth member of the union.
|
|
62
|
+
- The signature carries no copyright year. A copyright line is a footer device and
|
|
63
|
+
an SMS has no footer — a fidelity choice, not a purity hole. The brand still
|
|
64
|
+
comes from the context.
|
|
65
|
+
- Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
66
|
+
lost the model; the fix for a missing fact is upstream in `compose`.
|
|
67
|
+
- This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
|
|
68
|
+
no parity fixture. It cannot change a sent email, and it must not acquire the
|
|
69
|
+
ability to.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# notifications/renderers/sms/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Tests for the SMS channel (ADR-CONTRACTS-086).
|
|
6
|
+
|
|
7
|
+
The channel is a placeholder, which changes what these tests are for. There is no
|
|
8
|
+
golden and no bytes to reproduce, so `index.test.ts` does not pin the wording —
|
|
9
|
+
that is invented and expected to change the first time a kind is really sent over
|
|
10
|
+
SMS. It pins the contract a placeholder still owes: `supports` total and in
|
|
11
|
+
agreement with `render`, `render` pure, the brand read from `RenderContext`, and a
|
|
12
|
+
content carrying all twelve element types going through without throwing.
|
|
13
|
+
|
|
14
|
+
## Invariants
|
|
15
|
+
|
|
16
|
+
- The wording is NOT the specification. When a real SMS kind lands and rewrites
|
|
17
|
+
these strings, updating this file is expected and cheap. When `supports` and
|
|
18
|
+
`render` stop agreeing, that is a defect — the two kinds of failure must stay
|
|
19
|
+
easy to tell apart, so keep assertions about SHAPE separate from assertions
|
|
20
|
+
about copy.
|
|
21
|
+
- The full-string assertion in "omits declined elements" is deliberate and must
|
|
22
|
+
stay full-string. A decline is only observable in what is ABSENT — a partial
|
|
23
|
+
match cannot see an approximated hero image or a flattened chat turn.
|
|
24
|
+
- NO fake timers. The year is a field on `RenderContext`, and keeping every test
|
|
25
|
+
in this domain clock-free is the improvement ADR-CONTRACTS-086 was built to
|
|
26
|
+
deliver — do not reintroduce them (see `../../../__tests__/README.md`).
|
|
27
|
+
- `ALL_TYPES` is duplicated from `../../../__tests__/renderer.test.ts` and
|
|
28
|
+
`../../email/__tests__/render.test.ts` rather than shared. The list is the
|
|
29
|
+
totality claim itself; importing it from one place would let a new element type
|
|
30
|
+
be added to the union and to the shared list in one move, with no channel
|
|
31
|
+
noticing.
|