@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,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.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SMS channel is a placeholder, so what is worth pinning is NOT its wording —
|
|
3
|
+
* that is invented and expected to change when a kind is actually sent over SMS.
|
|
4
|
+
* What is pinned here is the contract a placeholder still owes: that `supports` is
|
|
5
|
+
* total, that it AGREES with `render`, that `render` is pure and reads the brand
|
|
6
|
+
* from the context, and that a content carrying all twelve element types goes
|
|
7
|
+
* through without throwing.
|
|
8
|
+
*
|
|
9
|
+
* The one assertion on exact bytes exists to prove the declines are silent — the
|
|
10
|
+
* only way to see "omitted, never approximated" is to look at the whole string.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from "vitest";
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
NotificationContent,
|
|
17
|
+
NotificationElementType,
|
|
18
|
+
} from "../../../content";
|
|
19
|
+
import type { RenderContext } from "../../../context";
|
|
20
|
+
import { smsRenderer } from "../index";
|
|
21
|
+
|
|
22
|
+
const CONTEXT: RenderContext = {
|
|
23
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Every element type, so `render` is exercised over the whole union. */
|
|
27
|
+
const CONTENT: NotificationContent = {
|
|
28
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
29
|
+
sections: [
|
|
30
|
+
{
|
|
31
|
+
elements: [
|
|
32
|
+
{ type: "heroImage", src: "https://example.test/h.png", alt: "Acme" },
|
|
33
|
+
{ type: "greeting", recipientName: "Ada" },
|
|
34
|
+
{ type: "body", text: "Acme uses Company Semantics." },
|
|
35
|
+
{
|
|
36
|
+
type: "keyValueTable",
|
|
37
|
+
rows: [{ label: "Org", value: "Acme" }],
|
|
38
|
+
},
|
|
39
|
+
{ type: "list", items: ["Read", "Write"] },
|
|
40
|
+
{ type: "divider" },
|
|
41
|
+
{ type: "warning" },
|
|
42
|
+
{
|
|
43
|
+
type: "chatUnit",
|
|
44
|
+
items: [{ type: "message", role: "user", text: "Hello?" }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "metadata",
|
|
48
|
+
label: "Request details",
|
|
49
|
+
rows: [{ label: "IP", value: "203.0.113.7" }],
|
|
50
|
+
},
|
|
51
|
+
{ type: "notice", lines: ["Not expecting this?", "No action needed."] },
|
|
52
|
+
{ type: "callToAction", label: "JOIN", href: "https://example.test/j" },
|
|
53
|
+
{ type: "signature" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ALL_TYPES: NotificationElementType[] = [
|
|
60
|
+
"greeting",
|
|
61
|
+
"body",
|
|
62
|
+
"keyValueTable",
|
|
63
|
+
"callToAction",
|
|
64
|
+
"list",
|
|
65
|
+
"divider",
|
|
66
|
+
"warning",
|
|
67
|
+
"metadata",
|
|
68
|
+
"notice",
|
|
69
|
+
"chatUnit",
|
|
70
|
+
"signature",
|
|
71
|
+
"heroImage",
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const DECLINED: NotificationElementType[] = [
|
|
75
|
+
"heroImage",
|
|
76
|
+
"chatUnit",
|
|
77
|
+
"divider",
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
describe("smsRenderer", () => {
|
|
81
|
+
it("declares a stable channel id", () => {
|
|
82
|
+
expect(smsRenderer.id).toBe("sms");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("answers supports() for every element type — the predicate is total", () => {
|
|
86
|
+
for (const type of ALL_TYPES) {
|
|
87
|
+
expect(typeof smsRenderer.supports(type), type).toBe("boolean");
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("declines exactly the elements it has no surface for", () => {
|
|
92
|
+
for (const type of ALL_TYPES) {
|
|
93
|
+
expect(smsRenderer.supports(type), type).toBe(!DECLINED.includes(type));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders a content carrying every element type without throwing", () => {
|
|
98
|
+
expect(() => smsRenderer.render(CONTENT, CONTEXT)).not.toThrow();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("omits declined elements rather than approximating them", () => {
|
|
102
|
+
const rendered = smsRenderer.render(CONTENT, CONTEXT);
|
|
103
|
+
|
|
104
|
+
// The whole string, because the claim is about what is ABSENT: no alt text
|
|
105
|
+
// standing in for the hero image, no flattened chat turn, no "---" rule.
|
|
106
|
+
expect(rendered).toBe(
|
|
107
|
+
[
|
|
108
|
+
"Hi Ada,",
|
|
109
|
+
"Acme uses Company Semantics.",
|
|
110
|
+
"Org: Acme",
|
|
111
|
+
"- Read",
|
|
112
|
+
"- Write",
|
|
113
|
+
"Security notice.",
|
|
114
|
+
"Request details:",
|
|
115
|
+
"IP: 203.0.113.7",
|
|
116
|
+
"Not expecting this? No action needed.",
|
|
117
|
+
"JOIN: https://example.test/j",
|
|
118
|
+
"- Company Semantics",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
);
|
|
121
|
+
expect(rendered).not.toContain("Acme uses Company Semantics.\n---");
|
|
122
|
+
expect(rendered).not.toContain("Hello?");
|
|
123
|
+
expect(rendered).not.toContain("example.test/h.png");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns its natural type — a string, not a record", () => {
|
|
127
|
+
expect(typeof smsRenderer.render(CONTENT, CONTEXT)).toBe("string");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("reads the brand from context, never from a clock or a constant", () => {
|
|
131
|
+
// No fake timer in this file, and no COMPANY_NAME import: swapping the
|
|
132
|
+
// context is enough to move the sign-off.
|
|
133
|
+
const pinned: RenderContext = {
|
|
134
|
+
brand: { name: "Acme", copyrightYear: 1999 },
|
|
135
|
+
};
|
|
136
|
+
expect(smsRenderer.render(CONTENT, pinned)).toContain("- Acme");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("lets an element's signer override the context brand", () => {
|
|
140
|
+
const onBehalf: NotificationContent = {
|
|
141
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
142
|
+
sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
|
|
143
|
+
};
|
|
144
|
+
expect(smsRenderer.render(onBehalf, CONTEXT)).toBe("- Grace");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("renders a call to action without an href as a bare label", () => {
|
|
148
|
+
// `href` absent means the label IS the payload (an OTP code). A channel must
|
|
149
|
+
// not fabricate a destination for it.
|
|
150
|
+
const otp: NotificationContent = {
|
|
151
|
+
metadata: { kind: "auth.otp", title: "Your code" },
|
|
152
|
+
sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
|
|
153
|
+
};
|
|
154
|
+
expect(smsRenderer.render(otp, CONTEXT)).toBe("123456");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("is pure — same inputs, same bytes", () => {
|
|
158
|
+
expect(smsRenderer.render(CONTENT, CONTEXT)).toBe(
|
|
159
|
+
smsRenderer.render(CONTENT, CONTEXT),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SMS channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
|
|
3
|
+
*
|
|
4
|
+
* This renderer exists to prove a claim `renderers/README.md` makes: a channel is
|
|
5
|
+
* added by writing a directory here, touching no element, no kind and no
|
|
6
|
+
* definition. Email could satisfy `Renderer` by construction — the seam was drawn
|
|
7
|
+
* around it. SMS could not: it is the poorest channel this vocabulary will ever
|
|
8
|
+
* meet, so if the content model survives being projected onto a plain string with
|
|
9
|
+
* no markup, no images and no layout, the model is channel-agnostic rather than
|
|
10
|
+
* email-shaped with a `Renderer` label on it.
|
|
11
|
+
*
|
|
12
|
+
* It is a placeholder in two specific senses, and neither is a TODO to be closed
|
|
13
|
+
* by tightening this file:
|
|
14
|
+
*
|
|
15
|
+
* - **It does not send.** A renderer renders. No delivery, no segmentation into
|
|
16
|
+
* 160-character parts, no phone number — those are the backend's, and the
|
|
17
|
+
* moment they appear here the seam has leaked.
|
|
18
|
+
* - **It is low fidelity.** The wording below is invented, not relocated: unlike
|
|
19
|
+
* `../email`, there are no bytes to reproduce and no golden to answer to. The
|
|
20
|
+
* first kind actually sent over SMS should expect to author its copy properly
|
|
21
|
+
* rather than trust these strings.
|
|
22
|
+
*
|
|
23
|
+
* `Out` is `string`, not a record. That is the second seam property, stated by a
|
|
24
|
+
* real module rather than a test double: email returns `{ subject, text, html }`
|
|
25
|
+
* and an SMS is one string, and neither is coerced toward the other.
|
|
26
|
+
*
|
|
27
|
+
* INVARIANTS:
|
|
28
|
+
* - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
|
|
29
|
+
* The brand comes from `context.brand`.
|
|
30
|
+
* - `supports` is total, and agrees with `render`: the three element types it
|
|
31
|
+
* declines are omitted, never approximated and never thrown on.
|
|
32
|
+
* - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
33
|
+
* lost the model — the fix for a missing fact is upstream in `compose`.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { NotificationContent, NotificationElement } from "../../content";
|
|
37
|
+
import type { RenderContext } from "../../context";
|
|
38
|
+
import type { Renderer } from "../../renderer";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One element as one run of plain text, or `""` for nothing to say.
|
|
42
|
+
*
|
|
43
|
+
* The switch is total over all twelve element types rather than over the nine
|
|
44
|
+
* `supports` admits: the declined arms are unreachable — `render` filters on
|
|
45
|
+
* `supports` before calling this — but writing them out is what makes the
|
|
46
|
+
* compiler prove the two lists agree. A `default` arm would silently accept a
|
|
47
|
+
* thirteenth member of the union, which is precisely the drift this shape
|
|
48
|
+
* prevents.
|
|
49
|
+
*/
|
|
50
|
+
function renderElement(
|
|
51
|
+
element: NotificationElement,
|
|
52
|
+
context: RenderContext,
|
|
53
|
+
): string {
|
|
54
|
+
switch (element.type) {
|
|
55
|
+
case "greeting":
|
|
56
|
+
return `Hi ${element.recipientName ?? "there"},`;
|
|
57
|
+
case "body":
|
|
58
|
+
return element.text;
|
|
59
|
+
case "keyValueTable":
|
|
60
|
+
// The `:` is presentation and lives in no element (see ../../kinds/README.md).
|
|
61
|
+
return element.rows.map((row) => `${row.label}: ${row.value}`).join("\n");
|
|
62
|
+
case "callToAction":
|
|
63
|
+
// `href` absent means the label IS the payload (an OTP code). Rendering it
|
|
64
|
+
// as a bare label is the only honest option — a channel must not fabricate
|
|
65
|
+
// a destination for it.
|
|
66
|
+
return element.href ? `${element.label}: ${element.href}` : element.label;
|
|
67
|
+
case "list":
|
|
68
|
+
return element.items
|
|
69
|
+
.map((item, index) =>
|
|
70
|
+
element.ordered ? `${index + 1}. ${item}` : `- ${item}`,
|
|
71
|
+
)
|
|
72
|
+
.join("\n");
|
|
73
|
+
case "warning":
|
|
74
|
+
// Fieldless by design: the banner IS the content, and its wording belongs
|
|
75
|
+
// to the channel. A text message has no banner, so it has a sentence.
|
|
76
|
+
return "Security notice.";
|
|
77
|
+
case "metadata":
|
|
78
|
+
return [
|
|
79
|
+
element.label ? `${element.label}:` : "",
|
|
80
|
+
...element.rows.map((row) => `${row.label}: ${row.value}`),
|
|
81
|
+
]
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join("\n");
|
|
84
|
+
case "notice":
|
|
85
|
+
// Joined with a space, not a newline: the lines are one closing remark, and
|
|
86
|
+
// an SMS has no vertical budget to spend separating them.
|
|
87
|
+
return element.lines.join(" ");
|
|
88
|
+
case "signature":
|
|
89
|
+
// The brand comes from the context — never a constant here — but the year
|
|
90
|
+
// does not appear: a copyright line is a footer device and an SMS has no
|
|
91
|
+
// footer. Dropping it is a fidelity choice, not a purity hole.
|
|
92
|
+
return `- ${element.signer ?? context.brand.name}`;
|
|
93
|
+
case "divider":
|
|
94
|
+
case "heroImage":
|
|
95
|
+
case "chatUnit":
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* SMS. Sections are one message — the seam SMS declines to split on, for now:
|
|
102
|
+
* splitting one notification into several texts is a delivery decision, and
|
|
103
|
+
* delivery is not this package's.
|
|
104
|
+
*/
|
|
105
|
+
export const smsRenderer: Renderer<string> = {
|
|
106
|
+
id: "sms",
|
|
107
|
+
/**
|
|
108
|
+
* The universal subset — what survives on the poorest channel.
|
|
109
|
+
*
|
|
110
|
+
* Three declines, each a fact about the channel rather than an edit of the
|
|
111
|
+
* notification:
|
|
112
|
+
* - `heroImage` — there is no image surface. Substituting `alt` would be
|
|
113
|
+
* approximating an element, which `supports` answering `false` forbids.
|
|
114
|
+
* - `chatUnit` — the turn-taking IS the meaning, and a flat run of text cannot
|
|
115
|
+
* attribute a turn. The content model says decline the whole element rather
|
|
116
|
+
* than flatten it.
|
|
117
|
+
* - `divider` — a thematic break is a visual device and carries no content by
|
|
118
|
+
* design. SMS has no visual vocabulary to draw it with.
|
|
119
|
+
*/
|
|
120
|
+
supports: (elementType) =>
|
|
121
|
+
elementType !== "heroImage" &&
|
|
122
|
+
elementType !== "chatUnit" &&
|
|
123
|
+
elementType !== "divider",
|
|
124
|
+
render: (content: NotificationContent, context: RenderContext) =>
|
|
125
|
+
content.sections
|
|
126
|
+
.flatMap((section) => section.elements)
|
|
127
|
+
.filter((element) => smsRenderer.supports(element.type))
|
|
128
|
+
.map((element) => renderElement(element, context))
|
|
129
|
+
.filter(Boolean)
|
|
130
|
+
.join("\n"),
|
|
131
|
+
};
|