@company-semantics/contracts 35.1.0 → 36.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 +7 -2
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +122 -11
- package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
- package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +86 -0
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +41 -30
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +19 -4
- package/src/notifications/renderers/email/render.ts +18 -15
- package/src/notifications/renderers/email/shells.ts +112 -9
- package/src/notifications/renderers/email/styles.ts +361 -0
- package/src/notifications/renderers/slack/README.md +66 -25
- package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
- package/src/notifications/renderers/slack/index.ts +192 -107
|
@@ -20,6 +20,7 @@ import { describe, expect, it } from "vitest";
|
|
|
20
20
|
|
|
21
21
|
import type {
|
|
22
22
|
NotificationContent,
|
|
23
|
+
NotificationElement,
|
|
23
24
|
NotificationElementType,
|
|
24
25
|
} from "../../../content";
|
|
25
26
|
import type { RenderContext } from "../../../context";
|
|
@@ -143,6 +144,91 @@ describe("emailRenderer", () => {
|
|
|
143
144
|
expect(html).not.toContain("<script>");
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
it("escapes a URL, so it cannot close its attribute and forge another", () => {
|
|
148
|
+
// `href` and `src` are the two values that reach the markup as a URL rather
|
|
149
|
+
// than as text, and they used to be interpolated raw — an HTML injection on
|
|
150
|
+
// its own, and since ADR-CONTRACTS-089 a style injection too: `inlineStyles`
|
|
151
|
+
// rewrites class attributes, so a URL that can write `class="csr-body"` can
|
|
152
|
+
// write any declaration in the registry into someone else's element.
|
|
153
|
+
const html = (element: NotificationElement) =>
|
|
154
|
+
emailRenderer.render(
|
|
155
|
+
{
|
|
156
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
157
|
+
sections: [{ elements: [element] }],
|
|
158
|
+
},
|
|
159
|
+
FIXTURE_CONTEXT,
|
|
160
|
+
).html;
|
|
161
|
+
|
|
162
|
+
const cta = html({
|
|
163
|
+
type: "callToAction",
|
|
164
|
+
label: "Open",
|
|
165
|
+
href: 'https://x.test/" data-forged="1',
|
|
166
|
+
});
|
|
167
|
+
// The quote survives as text, so the forged attribute never becomes one.
|
|
168
|
+
// Asserted against `data-forged="` — the ATTRIBUTE form — because the escaped
|
|
169
|
+
// payload still contains the string `data-forged=`, and a looser match would
|
|
170
|
+
// pass whether or not the fix is present.
|
|
171
|
+
expect(cta).toContain(""");
|
|
172
|
+
expect(cta).not.toMatch(/data-forged="/);
|
|
173
|
+
|
|
174
|
+
const hero = html({
|
|
175
|
+
type: "heroImage",
|
|
176
|
+
src: '/x.png" onerror="alert(1)',
|
|
177
|
+
alt: "x",
|
|
178
|
+
});
|
|
179
|
+
expect(hero).not.toMatch(/onerror="/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("makes a linked CTA's whole box the click target, an unlinked one not clickable", () => {
|
|
183
|
+
// The click target is the anchor, so it must fill the box — `display: block` +
|
|
184
|
+
// the padding on the `<a>` itself, not the cell. An OTP code has no href: it
|
|
185
|
+
// must not become an anchor at all, which is what keeps it uncopiable-as-a-link
|
|
186
|
+
// and unclickable.
|
|
187
|
+
const box = (cta: NotificationElement) =>
|
|
188
|
+
emailRenderer.render(
|
|
189
|
+
{
|
|
190
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
191
|
+
sections: [{ elements: [cta] }],
|
|
192
|
+
},
|
|
193
|
+
FIXTURE_CONTEXT,
|
|
194
|
+
).html;
|
|
195
|
+
|
|
196
|
+
const linked = box({
|
|
197
|
+
type: "callToAction",
|
|
198
|
+
label: "Open",
|
|
199
|
+
href: "https://example.com/x",
|
|
200
|
+
});
|
|
201
|
+
expect(linked).toMatch(
|
|
202
|
+
/<a [^>]*href="https:\/\/example\.com\/x"[^>]*style="display: block; padding:/,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const payload = box({ type: "callToAction", label: "123456" });
|
|
206
|
+
expect(payload).not.toContain("<a ");
|
|
207
|
+
// The cell carries the padding instead, so the code is still a padded box.
|
|
208
|
+
expect(payload).toMatch(/<td style="padding: /);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("restores the CTA's cell padding for Outlook, which cannot fill the anchor", () => {
|
|
212
|
+
// Outlook's Word engine ignores `display: block`, so the full-box target
|
|
213
|
+
// degrades to today's text click there. The MSO block must put the padding
|
|
214
|
+
// back on the cell, or Outlook renders the button cramped.
|
|
215
|
+
const { html } = emailRenderer.render(
|
|
216
|
+
{
|
|
217
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
218
|
+
sections: [
|
|
219
|
+
{
|
|
220
|
+
elements: [
|
|
221
|
+
{ type: "callToAction", label: "Join", href: "https://x.test" },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
FIXTURE_CONTEXT,
|
|
227
|
+
);
|
|
228
|
+
expect(html).toContain("<!--[if mso]>");
|
|
229
|
+
expect(html).toMatch(/\[if mso\]>.*\.cs-cta td \{ padding: .* \}/s);
|
|
230
|
+
});
|
|
231
|
+
|
|
146
232
|
it("is pure — same inputs, same bytes", () => {
|
|
147
233
|
const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
|
|
148
234
|
{
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The style registry's contract (ADR-CONTRACTS-089).
|
|
3
|
+
*
|
|
4
|
+
* `../../__tests__/render-snapshot.test.ts` already proves the html surface's
|
|
5
|
+
* bytes, and it is the authority on what the email LOOKS like. What it cannot say
|
|
6
|
+
* is WHY those bytes are safe to derive, because a snapshot passes just as happily
|
|
7
|
+
* over markup that leaks a recipe class or drops a role hook. This file asserts the
|
|
8
|
+
* two properties the derivation rests on:
|
|
9
|
+
*
|
|
10
|
+
* - a `cs-` role hook SHIPS, because `darkStyle` needs it in the markup;
|
|
11
|
+
* - a `csr-` recipe class DOES NOT, because it is spent and dropped.
|
|
12
|
+
*
|
|
13
|
+
* Get either backwards and the snapshot still passes while dark mode silently
|
|
14
|
+
* stops working, or the shipped email carries dead classes nothing styles.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import type { NotificationContent } from "../../../content";
|
|
20
|
+
import type { RenderContext } from "../../../context";
|
|
21
|
+
import { emailRenderer } from "../index";
|
|
22
|
+
import { inlineOf, inlineStyles, ruleOf, STYLE_NAMES } from "../styles";
|
|
23
|
+
|
|
24
|
+
const CONTEXT: RenderContext = {
|
|
25
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One of everything that carries a class, so the scan below has something to see. */
|
|
29
|
+
const CONTENT: NotificationContent = {
|
|
30
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
31
|
+
sections: [
|
|
32
|
+
{
|
|
33
|
+
elements: [
|
|
34
|
+
{ type: "greeting", recipientName: "Sam" },
|
|
35
|
+
{ type: "warning" },
|
|
36
|
+
{ type: "divider" },
|
|
37
|
+
{ type: "callToAction", label: "Open", href: "https://x.test/a" },
|
|
38
|
+
{ type: "callToAction", label: "123456" },
|
|
39
|
+
{
|
|
40
|
+
type: "chatUnit",
|
|
41
|
+
items: [
|
|
42
|
+
{ type: "message", role: "user", text: "hi", from: "Sam" },
|
|
43
|
+
{ type: "message", role: "assistant", text: "hello" },
|
|
44
|
+
{ type: "continuation" },
|
|
45
|
+
{ type: "callToAction", label: "Read", href: "https://x.test/b" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ type: "signature" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Every class the shipped markup actually applies. */
|
|
55
|
+
function shippedClasses(html: string): string[] {
|
|
56
|
+
return [...html.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
|
|
57
|
+
m[1].split(/\s+/),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("the style registry", () => {
|
|
62
|
+
it("ships no recipe class — they are spent by the inliner, not delivered", () => {
|
|
63
|
+
const { html } = emailRenderer.render(CONTENT, CONTEXT);
|
|
64
|
+
// The whole document, not just the class attributes: a recipe class must not
|
|
65
|
+
// survive anywhere, including in a stylesheet the html surface never wants.
|
|
66
|
+
expect(html).not.toContain("csr-");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ships every role hook, because the dark stylesheet needs it there", () => {
|
|
70
|
+
const { html } = emailRenderer.render(CONTENT, CONTEXT);
|
|
71
|
+
const shipped = shippedClasses(html);
|
|
72
|
+
expect(shipped.length).toBeGreaterThan(0);
|
|
73
|
+
// A sample across the roles this content exercises. `cs-cta-hover` is the
|
|
74
|
+
// interesting one: it is the only reason `.cs-cta` and the hover state are
|
|
75
|
+
// separate roles, and it rides on the linked CTA above.
|
|
76
|
+
for (const hook of ["cs-link", "cs-cta", "cs-cta-hover", "cs-bubble"]) {
|
|
77
|
+
expect(shipped, `${hook} never reached the markup`).toContain(hook);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("turns a recipe-only class attribute into a style, leaving no class behind", () => {
|
|
82
|
+
// The collapse that makes `<p class="csr-p-none">` render as the `<p style="…">`
|
|
83
|
+
// the channel shipped before this registry existed. A leftover `class=""` would
|
|
84
|
+
// be invisible in a client and a permanent diff in the snapshot.
|
|
85
|
+
expect(inlineStyles(`<p class="csr-p-none">x</p>`)).toBe(
|
|
86
|
+
`<p style="${inlineOf("p-none")}">x</p>`,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps role hooks and appends the style, in that order", () => {
|
|
91
|
+
// Order is not cosmetic: it is what makes the inliner reproduce the previous
|
|
92
|
+
// markup byte-for-byte rather than merely equivalently.
|
|
93
|
+
expect(inlineStyles(`<td class="cs-bubble csr-bubble-user">x</td>`)).toBe(
|
|
94
|
+
`<td class="cs-bubble" style="${inlineOf("bubble-user")}">x</td>`,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("leaves markup with no classes untouched", () => {
|
|
99
|
+
const plain = `<tr><td></td></tr>`;
|
|
100
|
+
expect(inlineStyles(plain)).toBe(plain);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("states each recipe as a rule and as an inline style, from one source", () => {
|
|
104
|
+
// The two surfaces' only difference is the shape, never the content — which is
|
|
105
|
+
// the claim that lets `ampShell` and `htmlShell` share this registry.
|
|
106
|
+
for (const name of STYLE_NAMES) {
|
|
107
|
+
expect(ruleOf(name)).toBe(`.csr-${name} { ${inlineOf(name)} }`);
|
|
108
|
+
expect(inlineOf(name).endsWith(";")).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
|
|
18
18
|
import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
|
|
19
19
|
|
|
20
|
-
import { FONT_SIZE, MONO } from "./constants";
|
|
21
20
|
import { ctaButton } from "./cta";
|
|
22
21
|
import { escapeHtml } from "./escape-html";
|
|
22
|
+
import { styleClass } from "./styles";
|
|
23
23
|
|
|
24
24
|
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
25
25
|
function wrapText(text: string, width: number): string[] {
|
|
@@ -53,6 +53,17 @@ const MESSAGE_WIDTH = 36;
|
|
|
53
53
|
* user box aligns with the assistant box. */
|
|
54
54
|
const CHAT_INDENT = " ";
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The gap under a chat row: 24px clears the bubbles, 16px hugs whatever the row
|
|
58
|
+
* is introducing (a CTA, the dots).
|
|
59
|
+
*
|
|
60
|
+
* A recipe name rather than a margin string, because a margin cannot be inline on
|
|
61
|
+
* the AMP surface (ADR-CONTRACTS-089) — and naming the two the layout actually has
|
|
62
|
+
* is what makes a third one a deliberate addition to `./styles.ts` rather than a
|
|
63
|
+
* new string appearing at a call site.
|
|
64
|
+
*/
|
|
65
|
+
type ChatRow = "chat-row-16" | "chat-row-24";
|
|
66
|
+
|
|
56
67
|
/** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
|
|
57
68
|
function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
58
69
|
const lines = wrapText(text, width);
|
|
@@ -77,8 +88,7 @@ function clampMessage(text: string): string {
|
|
|
77
88
|
/** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
|
|
78
89
|
* outer side. */
|
|
79
90
|
function chatRuleHtml(position: "top" | "bottom"): string {
|
|
80
|
-
|
|
81
|
-
return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
|
|
91
|
+
return `<hr ${styleClass(`chat-rule-${position}`, "faint")}>`;
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
/**
|
|
@@ -90,34 +100,35 @@ function chatRuleHtml(position: "top" | "bottom"): string {
|
|
|
90
100
|
*/
|
|
91
101
|
function renderBubble(
|
|
92
102
|
turn: ChatTurn,
|
|
93
|
-
|
|
103
|
+
row: ChatRow,
|
|
94
104
|
): { html: string; text: string[] } {
|
|
95
105
|
const clamped = clampMessage(turn.text);
|
|
96
106
|
const isUser = turn.role === "user";
|
|
97
107
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
const
|
|
108
|
+
// Each side draws its own avatar and hides the other's, which is what reserves
|
|
109
|
+
// both columns and keeps the two bubbles aligned.
|
|
110
|
+
const bubble = isUser ? "bubble-user" : "bubble-assistant";
|
|
111
|
+
const channel = isUser ? "chat-channel-right" : "chat-channel-left";
|
|
112
|
+
const avatar = isUser ? "chat-avatar-left-hidden" : "chat-avatar-left";
|
|
113
|
+
const kaomoji = isUser ? "chat-avatar-right" : "chat-avatar-right-hidden";
|
|
103
114
|
|
|
104
115
|
const attributionRow =
|
|
105
116
|
isUser && turn.from
|
|
106
117
|
? `
|
|
107
118
|
<tr>
|
|
108
119
|
<td></td>
|
|
109
|
-
<td
|
|
120
|
+
<td ${styleClass("chat-attribution", "meta")}>${escapeHtml(turn.from)}</td>
|
|
110
121
|
<td></td>
|
|
111
122
|
</tr>`
|
|
112
123
|
: "";
|
|
113
124
|
|
|
114
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%"
|
|
125
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass(row)}>
|
|
115
126
|
<tr>
|
|
116
|
-
<td
|
|
117
|
-
<td
|
|
118
|
-
<tr><td
|
|
127
|
+
<td ${styleClass(avatar)}>[c_S]</td>
|
|
128
|
+
<td ${styleClass(channel)}><table cellpadding="0" cellspacing="0" border="0" ${styleClass("chat-bubble-wrap")}>
|
|
129
|
+
<tr><td ${styleClass(bubble, "bubble")}>${escapeHtml(clamped)}</td></tr>
|
|
119
130
|
</table></td>
|
|
120
|
-
<td
|
|
131
|
+
<td ${styleClass(kaomoji)}>(•̀_ರ╮)</td>
|
|
121
132
|
</tr>${attributionRow}
|
|
122
133
|
</table>`;
|
|
123
134
|
|
|
@@ -149,7 +160,7 @@ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
|
|
|
149
160
|
/** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
|
|
150
161
|
* the same inline-block, so the dots span exactly the button's width). */
|
|
151
162
|
function dotsOverCtaHtml(): string {
|
|
152
|
-
return `<div
|
|
163
|
+
return `<div ${styleClass("dots-over-cta", "dots")}>⋮</div>`;
|
|
153
164
|
}
|
|
154
165
|
|
|
155
166
|
/**
|
|
@@ -164,15 +175,15 @@ function renderChatCta(
|
|
|
164
175
|
align: "left" | "right",
|
|
165
176
|
withDots: boolean,
|
|
166
177
|
): { html: string; text: string[] } {
|
|
167
|
-
const { html: btnHtml, text: btnText } = ctaButton(cta, "
|
|
178
|
+
const { html: btnHtml, text: btnText } = ctaButton(cta, "none");
|
|
168
179
|
// Dots + button share one inline-block so the dots center over the button's
|
|
169
180
|
// exact width regardless of label length.
|
|
170
|
-
const stack = `<div
|
|
171
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%"
|
|
181
|
+
const stack = `<div ${styleClass("cta-stack")}>${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
|
|
182
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-24")}>
|
|
172
183
|
<tr>
|
|
173
|
-
<td
|
|
174
|
-
<td
|
|
175
|
-
<td
|
|
184
|
+
<td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
|
|
185
|
+
<td ${styleClass(`chat-channel-${align}`)}>${stack}</td>
|
|
186
|
+
<td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
|
|
176
187
|
</tr>
|
|
177
188
|
</table>`;
|
|
178
189
|
|
|
@@ -204,11 +215,11 @@ function renderChatCta(
|
|
|
204
215
|
* case folds the dots into the CTA via `renderChatCta`, centered over the box).
|
|
205
216
|
*/
|
|
206
217
|
function renderChatDots(): { html: string; text: string[] } {
|
|
207
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%"
|
|
218
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" ${styleClass("chat-row-16")}>
|
|
208
219
|
<tr>
|
|
209
|
-
<td
|
|
210
|
-
<td
|
|
211
|
-
<td
|
|
220
|
+
<td ${styleClass("chat-avatar-left-hidden")}>[c_S]</td>
|
|
221
|
+
<td ${styleClass("dots-cell", "dots")}>⋮</td>
|
|
222
|
+
<td ${styleClass("chat-avatar-right-hidden")}>(•̀_ರ╮)</td>
|
|
212
223
|
</tr>
|
|
213
224
|
</table>`;
|
|
214
225
|
// Center the "⋮" over the message box (avatar gutter + box width + borders).
|
|
@@ -251,11 +262,11 @@ export function renderChatUnit(items: ChatUnitItem[]): {
|
|
|
251
262
|
// A bubble directly above a CTA or continuation dots gets a tighter 16px
|
|
252
263
|
// gap; else 24px.
|
|
253
264
|
const next = items[i + 1]?.type;
|
|
254
|
-
const
|
|
265
|
+
const row: ChatRow =
|
|
255
266
|
next === "callToAction" || next === "continuation"
|
|
256
|
-
? "
|
|
257
|
-
: "
|
|
258
|
-
parts.push(renderBubble(item,
|
|
267
|
+
? "chat-row-16"
|
|
268
|
+
: "chat-row-24";
|
|
269
|
+
parts.push(renderBubble(item, row));
|
|
259
270
|
});
|
|
260
271
|
|
|
261
272
|
const width = parts
|