@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,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `>> LABEL <<` button — email's answer to a `callToAction`.
|
|
3
|
+
*
|
|
4
|
+
* Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086). It
|
|
5
|
+
* lives in its own module because two callers need it: a standalone
|
|
6
|
+
* `callToAction` element (`./render`) and one inside a conversation
|
|
7
|
+
* (`./chat`), which place it at different margins.
|
|
8
|
+
*
|
|
9
|
+
* INVARIANTS:
|
|
10
|
+
* - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits.
|
|
11
|
+
* - `href` absent means the label IS the payload (an OTP code): the button must
|
|
12
|
+
* not become a link, and plain text must not print a URL under it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CallToAction } from "../../content";
|
|
16
|
+
|
|
17
|
+
import { FONT_SIZE, MONO } from "./constants";
|
|
18
|
+
import { escapeHtml } from "./escape-html";
|
|
19
|
+
|
|
20
|
+
/** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
|
|
21
|
+
const CTA_BOX_PAD = 3;
|
|
22
|
+
|
|
23
|
+
/** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
|
|
24
|
+
function asciiCtaBox(label: string): string {
|
|
25
|
+
const pad = " ".repeat(CTA_BOX_PAD);
|
|
26
|
+
const inner = `${pad}>> ${label} <<${pad}`;
|
|
27
|
+
const border = `*${"-".repeat(inner.length)}*`;
|
|
28
|
+
return [border, `|${inner}|`, border].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
|
|
33
|
+
* the table. A standalone element wraps it as its own line; a chat unit embeds
|
|
34
|
+
* it.
|
|
35
|
+
*/
|
|
36
|
+
export function ctaButton(
|
|
37
|
+
cta: CallToAction,
|
|
38
|
+
margin: string,
|
|
39
|
+
): { html: string; text: string } {
|
|
40
|
+
const { label, href } = cta;
|
|
41
|
+
|
|
42
|
+
const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
|
|
43
|
+
const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
|
|
44
|
+
|
|
45
|
+
// Underline only the label text (not the chevrons/spaces), and only when linked.
|
|
46
|
+
const labelHtml = href
|
|
47
|
+
? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
|
|
48
|
+
: escapeHtml(label);
|
|
49
|
+
const chevrons = `>> ${labelHtml} <<`;
|
|
50
|
+
const inner = href
|
|
51
|
+
? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
|
|
52
|
+
: chevrons;
|
|
53
|
+
|
|
54
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
|
|
55
|
+
<tr><td style="${tdStyle}">
|
|
56
|
+
${inner}
|
|
57
|
+
</td></tr>
|
|
58
|
+
</table>`;
|
|
59
|
+
|
|
60
|
+
const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
|
|
61
|
+
|
|
62
|
+
return { html, text };
|
|
63
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTML escaping for email
|
|
2
|
+
* HTML escaping for the email channel.
|
|
3
|
+
*
|
|
4
|
+
* Moved here from the deleted `src/email/render/escape-html`
|
|
5
|
+
* (ADR-CONTRACTS-086): escaping is one channel's concern, and the channel now
|
|
6
|
+
* owns it.
|
|
3
7
|
*
|
|
4
8
|
* INVARIANT: every user-controlled field in an HTML variant MUST pass through
|
|
5
9
|
* this before interpolation (prevents XSS in email clients).
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email channel (ADR-CONTRACTS-086).
|
|
3
|
+
*
|
|
4
|
+
* `emailRenderer` is the first `Renderer` — email demoted from "the shape of the
|
|
5
|
+
* whole layer" to one channel among several. Everything that used to BE the
|
|
6
|
+
* render layer (the shells, the styling, the escaping, the box art) is now this
|
|
7
|
+
* directory's private business; what it exposes is a renderer and the type it
|
|
8
|
+
* returns. `src/email/` is gone, and this is what replaced it.
|
|
9
|
+
*
|
|
10
|
+
* INVARIANTS:
|
|
11
|
+
* - Pure. `render` is a function of `(content, context)` — no clock, no
|
|
12
|
+
* environment, no I/O. The copyright year comes from
|
|
13
|
+
* `context.brand.copyrightYear`.
|
|
14
|
+
* - Output is locked by `../../__tests__/render-snapshot.test.ts`. Byte-identity
|
|
15
|
+
* with the old `src/email/render` was the whole constraint of the migration
|
|
16
|
+
* and it held; ADR-CONTRACTS-087 discharged that proof once it shipped, so
|
|
17
|
+
* this channel's markup is now free to change — reviewably, via the snapshot.
|
|
18
|
+
* - `supports` answers true for every element type: email is the rich channel,
|
|
19
|
+
* and there is nothing in the content model it cannot depict.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { NotificationContent, NotificationElement } from "../../content";
|
|
23
|
+
import type { RenderContext } from "../../context";
|
|
24
|
+
import type { Renderer } from "../../renderer";
|
|
25
|
+
|
|
26
|
+
import type { Spacing } from "./constants";
|
|
27
|
+
import { renderElement } from "./render";
|
|
28
|
+
import { type EmailLine, htmlShell, textShell } from "./shells";
|
|
29
|
+
|
|
30
|
+
/** One rendered email — the email channel's natural output type. */
|
|
31
|
+
export interface RenderedEmail {
|
|
32
|
+
/** Subject line — the notification's `metadata.title`. */
|
|
33
|
+
subject: string;
|
|
34
|
+
/** Plain-text body. */
|
|
35
|
+
text: string;
|
|
36
|
+
/** HTML body (every kind is dual-output). */
|
|
37
|
+
html: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The gap after an element's last line.
|
|
42
|
+
*
|
|
43
|
+
* `signature` owns the blank line above its own rule, so whatever precedes it
|
|
44
|
+
* carries no trailing gap — the one positional rule the old templates stated
|
|
45
|
+
* out loud (every `footer(..., "none")` call, and `security.alert`'s Time row).
|
|
46
|
+
*/
|
|
47
|
+
function trailingSpacing(
|
|
48
|
+
elements: NotificationElement[],
|
|
49
|
+
index: number,
|
|
50
|
+
): Spacing {
|
|
51
|
+
return elements[index + 1]?.type === "signature" ? "none" : "normal";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Email. Sections are one body — the seam email declines to split on. */
|
|
55
|
+
export const emailRenderer: Renderer<RenderedEmail> = {
|
|
56
|
+
id: "email",
|
|
57
|
+
supports: () => true,
|
|
58
|
+
render: (content: NotificationContent, context: RenderContext) => {
|
|
59
|
+
const elements = content.sections
|
|
60
|
+
.flatMap((section) => section.elements)
|
|
61
|
+
.filter((element) => emailRenderer.supports(element.type));
|
|
62
|
+
|
|
63
|
+
const lines: EmailLine[] = elements.flatMap((element, index) =>
|
|
64
|
+
renderElement(element, context, trailingSpacing(elements, index)),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
subject: content.metadata.title,
|
|
69
|
+
text: textShell(lines),
|
|
70
|
+
html: htmlShell(lines),
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each `NotificationElement` looks like as email (ADR-CONTRACTS-086).
|
|
3
|
+
*
|
|
4
|
+
* This is the deleted `src/email/render/blocks` read the other way round. That
|
|
5
|
+
* module offered templates a component per intent (`greeting()`, `keyValue()`,
|
|
6
|
+
* `footer()`) and each template chose which to call; here the CONTENT names the
|
|
7
|
+
* intent and this module is the only thing that knows the markup. The strings
|
|
8
|
+
* started as the same strings, byte-for-byte; they are free to diverge now
|
|
9
|
+
* (ADR-CONTRACTS-087) and `../../__tests__/render-snapshot.test.ts` is what
|
|
10
|
+
* makes each divergence a reviewed one.
|
|
11
|
+
*
|
|
12
|
+
* **Spacing is derived, because the content model does not carry it.** The old
|
|
13
|
+
* layer passed a `Spacing` per block, and `../../kinds` deliberately dropped it:
|
|
14
|
+
* a margin is a channel's answer. So this module has to re-derive one, and it
|
|
15
|
+
* does it with a single rule:
|
|
16
|
+
*
|
|
17
|
+
* > An element renders as one or more lines. Lines WITHIN an element hug
|
|
18
|
+
* > (`"tight"`); the element's LAST line carries the trailing gap, which is
|
|
19
|
+
* > `"normal"` — or `"none"` when a signature follows, because `signature`
|
|
20
|
+
* > owns the blank above its own rule.
|
|
21
|
+
*
|
|
22
|
+
* That rule reproduces every hand-authored spacing choice in the old templates,
|
|
23
|
+
* which is not a coincidence: "these lines are one thing, that gap separates two
|
|
24
|
+
* things" is what the authors were expressing, and the element boundaries are
|
|
25
|
+
* where they were expressing it. The one place it did NOT fall out was
|
|
26
|
+
* `security.alert`'s two-sentence paragraph, and the fix was to say so in the
|
|
27
|
+
* content (one `body` with a newline) rather than to special-case a kind here —
|
|
28
|
+
* see `../../kinds/security-alert`.
|
|
29
|
+
*
|
|
30
|
+
* INVARIANTS:
|
|
31
|
+
* - Pure. The copyright year and the brand come from `RenderContext`, never from
|
|
32
|
+
* a clock — retiring the `new Date().getFullYear()` inside the old
|
|
33
|
+
* `signature()`.
|
|
34
|
+
* - The `:` between a label and its value is PRESENTATION and lives in no
|
|
35
|
+
* element (see `../../kinds/README.md`). This module puts it back, on
|
|
36
|
+
* key/value rows and on the metadata heading.
|
|
37
|
+
* - Values arrive pre-formatted (dates, title-casing, truncation are `compose`'s
|
|
38
|
+
* work). Nothing here re-formats a value.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
CallToAction,
|
|
43
|
+
KeyValueRow,
|
|
44
|
+
NotificationElement,
|
|
45
|
+
} from "../../content";
|
|
46
|
+
import type { RenderContext } from "../../context";
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
COMPANY_URL,
|
|
50
|
+
DESTRUCTIVE,
|
|
51
|
+
FONT_SIZE,
|
|
52
|
+
MONO,
|
|
53
|
+
SPACING,
|
|
54
|
+
type Spacing,
|
|
55
|
+
} from "./constants";
|
|
56
|
+
import { renderChatUnit } from "./chat";
|
|
57
|
+
import { ctaButton } from "./cta";
|
|
58
|
+
import { escapeHtml } from "./escape-html";
|
|
59
|
+
import type { EmailLine } from "./shells";
|
|
60
|
+
|
|
61
|
+
/** One `<p>` — the primitive every prose-ish element is built from. */
|
|
62
|
+
function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
|
|
63
|
+
return {
|
|
64
|
+
html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
|
|
65
|
+
text,
|
|
66
|
+
spacing,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Apply the spacing rule to one element's lines: everything but the last hugs,
|
|
72
|
+
* the last carries the element's trailing gap.
|
|
73
|
+
*/
|
|
74
|
+
function spaced(
|
|
75
|
+
lines: Array<{ html: string; text: string }>,
|
|
76
|
+
trailing: Spacing,
|
|
77
|
+
): EmailLine[] {
|
|
78
|
+
return lines.map((line, i) =>
|
|
79
|
+
paragraph(
|
|
80
|
+
line.html,
|
|
81
|
+
line.text,
|
|
82
|
+
i === lines.length - 1 ? trailing : "tight",
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `Label: value` — the separator the content model does not carry. */
|
|
88
|
+
function keyValueLine(row: KeyValueRow): { html: string; text: string } {
|
|
89
|
+
const text = `${row.label}: ${row.value}`;
|
|
90
|
+
return { html: escapeHtml(text), text };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The standalone `>> LABEL <<` box. */
|
|
94
|
+
function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
|
|
95
|
+
const { html, text } = ctaButton(cta, SPACING[trailing]);
|
|
96
|
+
return { html, text, spacing: trailing };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The trailing sign-off — a blank line, the `/* EOM *\/` rule, then the signer. */
|
|
100
|
+
function signatureLine(signer: string, year: number): EmailLine {
|
|
101
|
+
const line = `ⓒ ${year} • ${signer}`;
|
|
102
|
+
return {
|
|
103
|
+
html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${COMPANY_URL}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(COMPANY_URL)}</a></p>`,
|
|
104
|
+
text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
|
|
105
|
+
spacing: "none",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render one element to its email lines.
|
|
111
|
+
*
|
|
112
|
+
* `trailing` is the gap after the element's last line, decided by what follows
|
|
113
|
+
* it — see the spacing rule at the top of this module.
|
|
114
|
+
*/
|
|
115
|
+
export function renderElement(
|
|
116
|
+
element: NotificationElement,
|
|
117
|
+
context: RenderContext,
|
|
118
|
+
trailing: Spacing,
|
|
119
|
+
): EmailLine[] {
|
|
120
|
+
switch (element.type) {
|
|
121
|
+
case "greeting": {
|
|
122
|
+
const text = element.recipientName
|
|
123
|
+
? `Hi ${element.recipientName},`
|
|
124
|
+
: "Hi there,";
|
|
125
|
+
return [paragraph(escapeHtml(text), text, trailing)];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "body":
|
|
129
|
+
// A newline inside a body separates lines of ONE paragraph, so they hug;
|
|
130
|
+
// two paragraphs are two body elements. See `../../content.ts`.
|
|
131
|
+
return spaced(
|
|
132
|
+
element.text.split("\n").map((line) => ({
|
|
133
|
+
html: escapeHtml(line),
|
|
134
|
+
text: line,
|
|
135
|
+
})),
|
|
136
|
+
trailing,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
case "keyValueTable":
|
|
140
|
+
return spaced(element.rows.map(keyValueLine), trailing);
|
|
141
|
+
|
|
142
|
+
case "metadata": {
|
|
143
|
+
// The heading's `:` is presentation: `label` is "Request details", and the
|
|
144
|
+
// colon the recipient reads is put back here.
|
|
145
|
+
const heading = element.label
|
|
146
|
+
? [{ html: escapeHtml(`${element.label}:`), text: `${element.label}:` }]
|
|
147
|
+
: [];
|
|
148
|
+
return spaced([...heading, ...element.rows.map(keyValueLine)], trailing);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
case "callToAction":
|
|
152
|
+
return [ctaLine(element, trailing)];
|
|
153
|
+
|
|
154
|
+
case "warning": {
|
|
155
|
+
// Fieldless by design — the banner IS the content, and its wording is the
|
|
156
|
+
// renderer's (see `../../content.ts`).
|
|
157
|
+
//
|
|
158
|
+
// The colour is this channel's answer to "this is a security notice", and
|
|
159
|
+
// only the HTML surface can give it: plain text has no colour, so there
|
|
160
|
+
// the banner's own glyphs carry the whole signal.
|
|
161
|
+
const text = "🆆🅰🆁🅽🅸🅽🅶";
|
|
162
|
+
return [
|
|
163
|
+
paragraph(
|
|
164
|
+
`<span style="color: ${DESTRUCTIVE};">${escapeHtml(text)}</span>`,
|
|
165
|
+
text,
|
|
166
|
+
trailing,
|
|
167
|
+
),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "notice":
|
|
172
|
+
// One paragraph, `<br>` between lines — the lines are one unit, so unlike
|
|
173
|
+
// a `body` they do not become separate `<p>`s.
|
|
174
|
+
return [
|
|
175
|
+
paragraph(
|
|
176
|
+
element.lines.map(escapeHtml).join("<br>"),
|
|
177
|
+
element.lines.join("\n"),
|
|
178
|
+
trailing,
|
|
179
|
+
),
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
case "chatUnit": {
|
|
183
|
+
const { html, text } = renderChatUnit(element.items);
|
|
184
|
+
return [{ html, text, spacing: trailing }];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case "signature":
|
|
188
|
+
return [
|
|
189
|
+
signatureLine(
|
|
190
|
+
element.signer ?? context.brand.name,
|
|
191
|
+
context.brand.copyrightYear,
|
|
192
|
+
),
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// -------------------------------------------------------------------------
|
|
196
|
+
// Below: elements no notification says yet.
|
|
197
|
+
//
|
|
198
|
+
// The old email layer had no component for any of these and no kind composes
|
|
199
|
+
// one, so — unlike everything above — there are no bytes to reproduce and the
|
|
200
|
+
// markup here is invented. `supports` still answers true:
|
|
201
|
+
// email CAN depict a list, a rule and an image, and declining them would be
|
|
202
|
+
// a lie about the channel. The first kind to use one should expect to style
|
|
203
|
+
// it properly rather than trust these.
|
|
204
|
+
// -------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
case "list":
|
|
207
|
+
return spaced(
|
|
208
|
+
element.items.map((item, i) => {
|
|
209
|
+
const text = `${element.ordered ? `${i + 1}.` : "•"} ${item}`;
|
|
210
|
+
return { html: escapeHtml(text), text };
|
|
211
|
+
}),
|
|
212
|
+
trailing,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
case "divider":
|
|
216
|
+
return [
|
|
217
|
+
{
|
|
218
|
+
html: `<hr style="border: none; border-top: 1px solid #bbb; margin: ${SPACING[trailing]};">`,
|
|
219
|
+
text: "---",
|
|
220
|
+
spacing: trailing,
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
case "heroImage":
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}" style="max-width: 100%; margin: ${SPACING[trailing]};">`,
|
|
228
|
+
text: element.alt,
|
|
229
|
+
spacing: trailing,
|
|
230
|
+
},
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
default: {
|
|
234
|
+
const _exhaustive: never = element;
|
|
235
|
+
return _exhaustive;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email shells — the `<!DOCTYPE>` wrapper and the plain-text joiner.
|
|
3
|
+
*
|
|
4
|
+
* Moved verbatim from the deleted `src/email/render/blocks`
|
|
5
|
+
* (ADR-CONTRACTS-086). These are what make one email dual-output: `render`
|
|
6
|
+
* composes ONE list of `EmailLine`s and both surfaces derive from it, so the two
|
|
7
|
+
* can never disagree about what the email says.
|
|
8
|
+
*
|
|
9
|
+
* INVARIANTS:
|
|
10
|
+
* - Pure functions of their input lines. No clock, no environment, no I/O.
|
|
11
|
+
* - These strings are the email's actual markup, and
|
|
12
|
+
* `../../__tests__/render-snapshot.test.ts` asserts them
|
|
13
|
+
* character-for-character.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { MONO, type Spacing } from "./constants";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One rendered line — both presentations of one thing the notification says,
|
|
20
|
+
* plus the gap that follows it.
|
|
21
|
+
*
|
|
22
|
+
* Named `EmailLine` rather than `Block`: `Block` was the old layer's canonical
|
|
23
|
+
* unit, and demoting it to a channel's internal detail is the point of
|
|
24
|
+
* ADR-CONTRACTS-086. One `NotificationElement` may produce several of these (a
|
|
25
|
+
* key/value table produces one per row).
|
|
26
|
+
*/
|
|
27
|
+
export interface EmailLine {
|
|
28
|
+
html: string;
|
|
29
|
+
text: string;
|
|
30
|
+
spacing: Spacing;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell. */
|
|
34
|
+
export function htmlShell(lines: EmailLine[]): string {
|
|
35
|
+
const inner = lines
|
|
36
|
+
.map((line) => line.html)
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.join("\n");
|
|
39
|
+
return `<!DOCTYPE html>
|
|
40
|
+
<html lang="en">
|
|
41
|
+
<head><meta charset="UTF-8"></head>
|
|
42
|
+
<body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
|
|
43
|
+
<div style="max-width: 520px; margin: 0 auto;">
|
|
44
|
+
${inner}
|
|
45
|
+
</div>
|
|
46
|
+
</body>
|
|
47
|
+
</html>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Join rendered lines into the plain-text email — separated per each line's
|
|
52
|
+
* spacing, with a trailing newline.
|
|
53
|
+
*/
|
|
54
|
+
export function textShell(lines: EmailLine[]): string {
|
|
55
|
+
let out = "";
|
|
56
|
+
lines.forEach((line, i) => {
|
|
57
|
+
out += line.text;
|
|
58
|
+
if (i < lines.length - 1) out += line.spacing === "normal" ? "\n\n" : "\n";
|
|
59
|
+
});
|
|
60
|
+
return out.trim() + "\n";
|
|
61
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# notifications/renderers/slack/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The Slack channel (ADR-CONTRACTS-086) — `slackRenderer: Renderer<SlackMessage>`. A
|
|
6
|
+
**non-functional placeholder**.
|
|
7
|
+
|
|
8
|
+
Where `../sms` proves the content model survives the poorest channel, this proves
|
|
9
|
+
the other edge: a channel whose output is a tree of typed blocks — neither a
|
|
10
|
+
string nor email's `{ subject, text, html }`. Between the three, `Out` has been a
|
|
11
|
+
record, a string and a different record, which is `../../renderer.ts`'s "each
|
|
12
|
+
renderer returns its natural type" held by real modules rather than asserted about
|
|
13
|
+
the type itself.
|
|
14
|
+
|
|
15
|
+
The whole channel is one `index.ts` — the Block Kit subset, the escaping, and the
|
|
16
|
+
renderer. `../email` splits across seven modules because it carries real markup; a
|
|
17
|
+
stub that sprawls is a stub pretending.
|
|
18
|
+
|
|
19
|
+
## What "placeholder" means here
|
|
20
|
+
|
|
21
|
+
- **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
|
|
22
|
+
renders; delivery and the decision to deliver are the backend's.
|
|
23
|
+
- **It is low fidelity.** `SlackBlock` is a minimal, hand-written subset of Block
|
|
24
|
+
Kit — enough to carry every element the channel admits, and no more. It is not
|
|
25
|
+
the Slack API's types and is not generated from them. The wording is invented
|
|
26
|
+
rather than relocated: there is no golden here, unlike `../email`.
|
|
27
|
+
|
|
28
|
+
The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
|
|
29
|
+
wire format and those are its names. That is deliberate, not a lapse in the
|
|
30
|
+
repo's camelCase convention.
|
|
31
|
+
|
|
32
|
+
## The one decline
|
|
33
|
+
|
|
34
|
+
`supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
|
|
35
|
+
turn-taking, and flattening a conversation into a run of sections drops the
|
|
36
|
+
attribution that IS the meaning — which the content model names as the reason to
|
|
37
|
+
decline a whole element rather than approximate it.
|
|
38
|
+
|
|
39
|
+
Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
|
|
40
|
+
has no surface for**. That difference is the point of shipping two stubs rather
|
|
41
|
+
than one: same `NotificationContent`, no channel tags on it, three channels of
|
|
42
|
+
differing capability each keeping what they can show.
|
|
43
|
+
|
|
44
|
+
`metadata` and `notice` become `context` blocks — Slack's own device for
|
|
45
|
+
de-emphasised small print, which is exactly what `MetadataElement` exists to be.
|
|
46
|
+
|
|
47
|
+
## Invariants
|
|
48
|
+
|
|
49
|
+
- PURE. `render` is a function of `(content, context)` — no clock, no
|
|
50
|
+
environment, no I/O. The brand and the copyright year come from `context.brand`.
|
|
51
|
+
`signature` is the element that used to hide a `new Date()`; here there is
|
|
52
|
+
nowhere to hide.
|
|
53
|
+
- `supports` is total over `NotificationElementType` and AGREES with `render`:
|
|
54
|
+
`chatUnit` is omitted, never approximated and never thrown on.
|
|
55
|
+
- Every user-controlled string reaching an mrkdwn surface passes through
|
|
56
|
+
`escapeMrkdwn` first — the same rule `../email` applies with `escapeHtml`. This
|
|
57
|
+
is the one thing in a low-fidelity stub that would be a real defect rather than
|
|
58
|
+
a fidelity gap, so `__tests__/index.test.ts` pins it.
|
|
59
|
+
- `renderElement` switches over all TWELVE element types, not the eleven
|
|
60
|
+
`supports` admits. The declined arm is unreachable; writing it out is what makes
|
|
61
|
+
the compiler prove the two lists agree. Do not collapse it into a `default` —
|
|
62
|
+
that would silently swallow a thirteenth member of the union.
|
|
63
|
+
- A `callToAction` with no `href` becomes text, never a button. `href` absent
|
|
64
|
+
means the label IS the payload (an OTP code); a button with no destination is a
|
|
65
|
+
lie about what the notification is asking for.
|
|
66
|
+
- `SlackMessage.text` is `metadata.title` — the fallback Slack shows where blocks
|
|
67
|
+
cannot render. The same content-level fact email spends as its subject, which is
|
|
68
|
+
why it lives on the content rather than in a channel's registry.
|
|
69
|
+
- Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
70
|
+
lost the model; the fix for a missing fact is upstream in `compose`.
|
|
71
|
+
- This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
|
|
72
|
+
no parity fixture. It cannot change a sent email, and it must not acquire the
|
|
73
|
+
ability to.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# notifications/renderers/slack/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Tests for the Slack channel (ADR-CONTRACTS-086).
|
|
6
|
+
|
|
7
|
+
The channel is a placeholder, so — as in `../../sms/__tests__/` — these tests do
|
|
8
|
+
not pin the Block Kit wording. They pin the contract a placeholder still owes:
|
|
9
|
+
`supports` total and in agreement with `render`, `render` pure, the brand and year
|
|
10
|
+
read from `RenderContext`, and a content carrying all twelve element types
|
|
11
|
+
rendering without throwing.
|
|
12
|
+
|
|
13
|
+
One exception: the mrkdwn escaping IS pinned exactly. Everything else here is a
|
|
14
|
+
fidelity gap that a real Slack kind will close; unescaped user input would be a
|
|
15
|
+
real defect shipped in a published package.
|
|
16
|
+
|
|
17
|
+
## Invariants
|
|
18
|
+
|
|
19
|
+
- The Block Kit shape is NOT the specification. When a real Slack kind lands and
|
|
20
|
+
reworks these blocks, updating this file is expected. When `supports` and
|
|
21
|
+
`render` stop agreeing, or when escaping regresses, that is a defect — keep
|
|
22
|
+
assertions about SHAPE separate from assertions about copy so the two failures
|
|
23
|
+
stay easy to tell apart.
|
|
24
|
+
- The "takes the hero image the SMS channel has no surface for" test is the
|
|
25
|
+
cross-channel claim, and it is why two stubs exist rather than one: the same
|
|
26
|
+
`NotificationContent`, carrying no channel tags, keeps different elements on
|
|
27
|
+
channels of different capability. It is not redundant with the totality loop.
|
|
28
|
+
- NO fake timers. The year is a field on `RenderContext` (see
|
|
29
|
+
`../../../__tests__/README.md`).
|
|
30
|
+
- `ALL_TYPES` is duplicated from the sibling suites rather than shared. The list
|
|
31
|
+
is the totality claim itself; importing it from one place would let a new
|
|
32
|
+
element type be added to the union and to the shared list in one move, with no
|
|
33
|
+
channel noticing.
|