@company-semantics/contracts 35.0.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/README.md +21 -19
- package/src/notifications/__tests__/README.md +42 -47
- package/src/notifications/__tests__/__snapshots__/README.md +15 -8
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/__tests__/context.test.ts +5 -5
- package/src/notifications/__tests__/definition.test.ts +2 -2
- package/src/notifications/__tests__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +2 -3
- package/src/notifications/__tests__/registry.test.ts +27 -27
- package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/kinds/README.md +2 -2
- package/src/notifications/render.ts +6 -6
- package/src/notifications/renderers/email/README.md +132 -17
- package/src/notifications/renderers/email/__tests__/README.md +19 -16
- 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 +130 -36
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +44 -32
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -3
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +23 -8
- package/src/notifications/renderers/email/render.ts +31 -15
- package/src/notifications/renderers/email/shells.ts +115 -11
- 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
- package/src/notifications/text.ts +2 -2
- package/src/notifications/__tests__/output-parity.golden.ts +0 -363
- package/src/notifications/__tests__/output-parity.test.ts +0 -122
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
13
13
|
|
|
14
14
|
import { COMPANY_NAME, createRenderContext } from "../context";
|
|
15
|
-
import {
|
|
15
|
+
import { FIXTURE_CLOCK } from "./fixtures";
|
|
16
16
|
|
|
17
|
-
const FROZEN_YEAR = new Date(
|
|
17
|
+
const FROZEN_YEAR = new Date(FIXTURE_CLOCK).getUTCFullYear();
|
|
18
18
|
|
|
19
19
|
beforeAll(() => {
|
|
20
20
|
vi.useFakeTimers();
|
|
21
|
-
vi.setSystemTime(new Date(
|
|
21
|
+
vi.setSystemTime(new Date(FIXTURE_CLOCK));
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
afterAll(() => {
|
|
@@ -38,7 +38,7 @@ describe("createRenderContext", () => {
|
|
|
38
38
|
it("defaults the year from the clock", () => {
|
|
39
39
|
vi.setSystemTime(new Date("2030-03-04T05:06:07.000Z"));
|
|
40
40
|
expect(createRenderContext().brand.copyrightYear).toBe(2030);
|
|
41
|
-
vi.setSystemTime(new Date(
|
|
41
|
+
vi.setSystemTime(new Date(FIXTURE_CLOCK));
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
it("overrides each brand field independently", () => {
|
|
@@ -55,7 +55,7 @@ describe("createRenderContext", () => {
|
|
|
55
55
|
const before = createRenderContext(pinned);
|
|
56
56
|
vi.setSystemTime(new Date("2031-01-01T00:00:00.000Z"));
|
|
57
57
|
expect(createRenderContext(pinned)).toEqual(before);
|
|
58
|
-
vi.setSystemTime(new Date(
|
|
58
|
+
vi.setSystemTime(new Date(FIXTURE_CLOCK));
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it("returns a fresh context per call — callers cannot alias each other", () => {
|
|
@@ -31,7 +31,7 @@ import type { NotificationContent, NotificationElement } from "../content";
|
|
|
31
31
|
import { createRenderContext } from "../context";
|
|
32
32
|
import type { NotificationKind } from "../kinds";
|
|
33
33
|
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
34
|
-
import {
|
|
34
|
+
import { NOTIFICATION_FIXTURES } from "./fixtures";
|
|
35
35
|
|
|
36
36
|
const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
37
37
|
|
|
@@ -40,7 +40,7 @@ function compose(
|
|
|
40
40
|
kind: NotificationKind,
|
|
41
41
|
context = CONTEXT,
|
|
42
42
|
): NotificationContent {
|
|
43
|
-
const fixture =
|
|
43
|
+
const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
|
|
44
44
|
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
45
45
|
const fn = NOTIFICATION_DEFINITIONS[kind].compose as (
|
|
46
46
|
p: unknown,
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification fixture corpus: every implemented kind × representative
|
|
3
|
+
* payload. Shared by `./render-snapshot.test.ts`, `./definition.test.ts`,
|
|
4
|
+
* `./registry.test.ts` and `../renderers/email/__tests__/render.test.ts`, so a
|
|
5
|
+
* kind added to the registry is covered everywhere by being added once here.
|
|
6
|
+
*
|
|
7
|
+
* WAS `output-parity.golden.ts` (ADR-CONTRACTS-087). These same inputs used to
|
|
8
|
+
* carry a frozen `OUTPUT_PARITY_GOLDEN` of the `{ subject, text, html }` the
|
|
9
|
+
* email layer emitted at 32.0.0, asserted byte-for-byte to prove the
|
|
10
|
+
* multi-channel generalisation (ADR-CONTRACTS-086) changed no output. That proof
|
|
11
|
+
* passed and shipped in 35.0.0, which discharged it: it was a claim about a
|
|
12
|
+
* migration, not a freeze on email design. The bytes were retired rather than
|
|
13
|
+
* rewritten — editing them to match a deliberate redesign would have made the
|
|
14
|
+
* file assert something false about a layer that no longer exists. They remain
|
|
15
|
+
* readable in git at `v35.0.0`.
|
|
16
|
+
*
|
|
17
|
+
* `./render-snapshot.test.ts` is now the fence: it locks the same fixtures'
|
|
18
|
+
* rendered output, and unlike the golden it is regenerable on purpose, because
|
|
19
|
+
* changing how email looks is now an ordinary reviewed diff.
|
|
20
|
+
*
|
|
21
|
+
* Capture-time options are not recorded. The old `renderEmail` took a
|
|
22
|
+
* `RenderEmailOptions`, and one fixture set `includeRequestMetadata: true`; the
|
|
23
|
+
* entry point has no equivalent because `auth.otp`'s `compose` gates request
|
|
24
|
+
* details on whether the payload carries them — and that fixture's payload
|
|
25
|
+
* carries both, so the gate opens anyway.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { NotificationKind } from "../kinds";
|
|
29
|
+
import type { NotificationPayloads } from "../payloads";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The instant every fixture renders under.
|
|
33
|
+
*
|
|
34
|
+
* A pinned year, not a frozen clock. The copyright year is a value on
|
|
35
|
+
* `RenderContext`, so a test states this instant instead of stubbing time —
|
|
36
|
+
* which is the impurity ADR-CONTRACTS-086 retired. Pinned rather than left to
|
|
37
|
+
* the wall clock so no snapshot is due to fail on New Year's Day.
|
|
38
|
+
*/
|
|
39
|
+
export const FIXTURE_CLOCK = "2026-07-15T00:00:00.000Z";
|
|
40
|
+
|
|
41
|
+
/** One case: a kind rendered with a representative payload. */
|
|
42
|
+
export interface NotificationFixture {
|
|
43
|
+
readonly kind: NotificationKind;
|
|
44
|
+
/** Variant label, unique within a kind. */
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly payload: NotificationPayloads[NotificationKind];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Stable label for a fixture — also its snapshot key. */
|
|
50
|
+
export function fixtureKey(kind: NotificationKind, name: string): string {
|
|
51
|
+
return `${kind} · ${name}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fixtures: NotificationFixture[] = [];
|
|
55
|
+
function add<K extends NotificationKind>(
|
|
56
|
+
kind: K,
|
|
57
|
+
name: string,
|
|
58
|
+
payload: NotificationPayloads[K],
|
|
59
|
+
): void {
|
|
60
|
+
fixtures.push({ kind, name, payload });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const APP = "https://app.companysemantics.ai";
|
|
64
|
+
|
|
65
|
+
add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
|
|
66
|
+
add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
|
|
67
|
+
add("auth.otp", "With request metadata", {
|
|
68
|
+
otp: "246810",
|
|
69
|
+
expiresInMinutes: 5,
|
|
70
|
+
requestIp: "203.0.113.4",
|
|
71
|
+
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
add("org.invite", "Admin", {
|
|
75
|
+
inviterName: "Alex Rivera",
|
|
76
|
+
orgName: "Acme Corp",
|
|
77
|
+
role: "admin",
|
|
78
|
+
acceptUrl: `${APP}/invite/abc123`,
|
|
79
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
80
|
+
});
|
|
81
|
+
add("org.invite", "Member", {
|
|
82
|
+
inviterName: "Alex Rivera",
|
|
83
|
+
orgName: "Acme Corp",
|
|
84
|
+
role: "member",
|
|
85
|
+
acceptUrl: `${APP}/invite/def456`,
|
|
86
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
add("org.unit_owner_granted", "Unit owner + message", {
|
|
90
|
+
granterName: "Jordan Lee",
|
|
91
|
+
recipientName: "Sam Chen",
|
|
92
|
+
orgName: "Acme Corp",
|
|
93
|
+
unitName: "Platform Engineering",
|
|
94
|
+
roleLabel: "Unit owner",
|
|
95
|
+
ctaUrl: `${APP}/org/unit/42`,
|
|
96
|
+
message: "Welcome aboard — glad to have you owning this team.",
|
|
97
|
+
});
|
|
98
|
+
add("org.unit_owner_granted", "Delegate with expiry", {
|
|
99
|
+
granterName: "Jordan Lee",
|
|
100
|
+
orgName: "Acme Corp",
|
|
101
|
+
unitName: "Platform Engineering",
|
|
102
|
+
roleLabel: "Delegate",
|
|
103
|
+
ctaUrl: `${APP}/org/unit/42`,
|
|
104
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
add("org.ownership_transfer", "With note + from", {
|
|
108
|
+
orgName: "Acme Corp",
|
|
109
|
+
acceptUrl: `${APP}/owner-transfer/accept/tok123`,
|
|
110
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
111
|
+
note: "Handing this over as I move to an advisory role.",
|
|
112
|
+
fromName: "Jordan Lee",
|
|
113
|
+
});
|
|
114
|
+
add("org.ownership_transfer", "No note (admin)", {
|
|
115
|
+
orgName: "Acme Corp",
|
|
116
|
+
acceptUrl: `${APP}/owner-transfer/accept/tok456`,
|
|
117
|
+
expiresAt: "2026-06-13T00:00:00.000Z",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
add("org.ownership_transfer_completed", "Default", {
|
|
121
|
+
orgName: "Acme Corp",
|
|
122
|
+
newOwnerEmail: "new.owner@acme.com",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
add("security.alert", "Excessive OTP requests", {
|
|
126
|
+
alertType: "excessive_otp_requests",
|
|
127
|
+
details: "12 login codes requested in 5 minutes",
|
|
128
|
+
timestamp: "2026-07-12T14:30:00Z",
|
|
129
|
+
});
|
|
130
|
+
add("security.alert", "Unusual login location", {
|
|
131
|
+
alertType: "unusual_login_location",
|
|
132
|
+
details: "Login from Berlin, DE",
|
|
133
|
+
timestamp: "2026-07-12T14:30:00Z",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const CHAT_BASE = {
|
|
137
|
+
sharedByName: "Alex Rivera",
|
|
138
|
+
visibility: "private",
|
|
139
|
+
} as const;
|
|
140
|
+
add("chat.shared", "Default", {
|
|
141
|
+
...CHAT_BASE,
|
|
142
|
+
chatTitle: "API design discussion",
|
|
143
|
+
shareUrl: `${APP}/share/abc123`,
|
|
144
|
+
previewText:
|
|
145
|
+
"Here are my thoughts on the REST API design for the new authentication endpoints.",
|
|
146
|
+
});
|
|
147
|
+
add("chat.shared", "No preview", {
|
|
148
|
+
...CHAT_BASE,
|
|
149
|
+
chatTitle: "Quick sync notes",
|
|
150
|
+
shareUrl: `${APP}/share/xyz789`,
|
|
151
|
+
});
|
|
152
|
+
add("chat.shared", "Long title", {
|
|
153
|
+
...CHAT_BASE,
|
|
154
|
+
visibility: "public",
|
|
155
|
+
chatTitle:
|
|
156
|
+
"This is a very long chat title that should be truncated to fit the layout properly",
|
|
157
|
+
shareUrl: `${APP}/share/long456`,
|
|
158
|
+
previewText: "The assistant response preview text.",
|
|
159
|
+
});
|
|
160
|
+
add("chat.shared", "Long preview", {
|
|
161
|
+
...CHAT_BASE,
|
|
162
|
+
chatTitle: "Code review feedback",
|
|
163
|
+
shareUrl: `${APP}/share/preview789`,
|
|
164
|
+
previewText:
|
|
165
|
+
"I reviewed the pull request and found several areas that need improvement. The authentication logic needs better error handling, and the database queries could be optimized.",
|
|
166
|
+
});
|
|
167
|
+
add("chat.shared", "Short", {
|
|
168
|
+
...CHAT_BASE,
|
|
169
|
+
chatTitle: "Hi",
|
|
170
|
+
shareUrl: `${APP}/share/short123`,
|
|
171
|
+
previewText: "Hello!",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
add("share.granted", "Document · editor · message", {
|
|
175
|
+
granterName: "Jordan Lee",
|
|
176
|
+
recipientName: "Sam Chen",
|
|
177
|
+
entityLabel: "document",
|
|
178
|
+
entityTitle: "Q3 Roadmap",
|
|
179
|
+
accessLevel: "editor",
|
|
180
|
+
ctaUrl: `${APP}/doc/roadmap`,
|
|
181
|
+
message: "Take a look before Thursday's planning.",
|
|
182
|
+
});
|
|
183
|
+
add("share.granted", "Meeting · viewer · no title", {
|
|
184
|
+
granterName: "Jordan Lee",
|
|
185
|
+
entityLabel: "meeting",
|
|
186
|
+
accessLevel: "viewer",
|
|
187
|
+
ctaUrl: `${APP}/meeting/8842`,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
add("companyMd.access_requested", "With message", {
|
|
191
|
+
requesterName: "Sam Chen",
|
|
192
|
+
docTitle: "Engineering Handbook",
|
|
193
|
+
message: "I'd like to contribute the onboarding section.",
|
|
194
|
+
reviewUrl: `${APP}/doc/handbook?request=req_123`,
|
|
195
|
+
});
|
|
196
|
+
add("companyMd.access_requested", "No message", {
|
|
197
|
+
requesterName: "Sam Chen",
|
|
198
|
+
docTitle: "Engineering Handbook",
|
|
199
|
+
reviewUrl: `${APP}/doc/handbook?request=req_456`,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
add("companyMd.access_request_approved", "Editor", {
|
|
203
|
+
approverName: "Jordan Lee",
|
|
204
|
+
docTitle: "Engineering Handbook",
|
|
205
|
+
accessLevel: "editor",
|
|
206
|
+
docUrl: `${APP}/doc/handbook`,
|
|
207
|
+
});
|
|
208
|
+
add("companyMd.access_request_approved", "Viewer", {
|
|
209
|
+
approverName: "Jordan Lee",
|
|
210
|
+
docTitle: "Engineering Handbook",
|
|
211
|
+
accessLevel: "viewer",
|
|
212
|
+
docUrl: `${APP}/doc/handbook`,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
add("companyMd.access_request_denied", "With reason", {
|
|
216
|
+
approverName: "Jordan Lee",
|
|
217
|
+
docTitle: "Engineering Handbook",
|
|
218
|
+
reason: "This doc is limited to the platform team for now.",
|
|
219
|
+
});
|
|
220
|
+
add("companyMd.access_request_denied", "No reason", {
|
|
221
|
+
approverName: "Jordan Lee",
|
|
222
|
+
docTitle: "Engineering Handbook",
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/** The corpus — every implemented kind × representative variants. */
|
|
226
|
+
export const NOTIFICATION_FIXTURES: readonly NotificationFixture[] = fixtures;
|
|
@@ -17,9 +17,8 @@
|
|
|
17
17
|
* union member is added without being named here;
|
|
18
18
|
* - the frozen list in the first test, which fails at runtime if one is removed;
|
|
19
19
|
* - `./registry.test.ts`, which pins a definition for every member;
|
|
20
|
-
* - `./
|
|
21
|
-
*
|
|
22
|
-
* kinds email could render": every kind here has bytes that layer produced.
|
|
20
|
+
* - `./render-snapshot.test.ts`, which pins rendered output for every member.
|
|
21
|
+
* That is what carries "these are exactly the kinds email can render".
|
|
23
22
|
*
|
|
24
23
|
* The DIVERGENCE is the easiest thing in this change to mistake for an omission,
|
|
25
24
|
* so it keeps a test of its own. `auth.magic_link` was a registered subject with
|
|
@@ -28,11 +28,7 @@ import {
|
|
|
28
28
|
isValidNotificationKind,
|
|
29
29
|
NOTIFICATION_DEFINITIONS,
|
|
30
30
|
} from "../registry";
|
|
31
|
-
import {
|
|
32
|
-
fixtureKey,
|
|
33
|
-
OUTPUT_PARITY_FIXTURES,
|
|
34
|
-
OUTPUT_PARITY_GOLDEN,
|
|
35
|
-
} from "./output-parity.golden";
|
|
31
|
+
import { fixtureKey, NOTIFICATION_FIXTURES } from "./fixtures";
|
|
36
32
|
|
|
37
33
|
const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
|
|
38
34
|
|
|
@@ -45,7 +41,7 @@ const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
|
|
|
45
41
|
* so a second set of inputs would be a second thing to keep true.
|
|
46
42
|
*/
|
|
47
43
|
function payloadFor(kind: NotificationKind): unknown {
|
|
48
|
-
const fixture =
|
|
44
|
+
const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
|
|
49
45
|
if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
|
|
50
46
|
return fixture.payload;
|
|
51
47
|
}
|
|
@@ -112,28 +108,32 @@ describe("NOTIFICATION_DEFINITIONS", () => {
|
|
|
112
108
|
}
|
|
113
109
|
});
|
|
114
110
|
|
|
115
|
-
it("titles every kind
|
|
116
|
-
// The
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
111
|
+
it("titles every kind", () => {
|
|
112
|
+
// The title is what the email channel turns into a subject, and the
|
|
113
|
+
// interpolation `compose` does (the `{orgName}` fill, and
|
|
114
|
+
// unit_owner_granted's derived `roleWord`) is easy to break silently — a
|
|
115
|
+
// template literal that stops filling yields a plausible-looking title with
|
|
116
|
+
// a hole in it. Snapshotting every fixture's title makes that a reviewable
|
|
117
|
+
// diff, and isolates the claim at COMPOSE: `./render-snapshot.test.ts`
|
|
118
|
+
// covers the same titles once the renderer has had them.
|
|
121
119
|
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
120
|
+
// These titles were asserted against the frozen 32.0.0 golden's subjects
|
|
121
|
+
// until ADR-CONTRACTS-087 retired it — the values below are those subjects,
|
|
122
|
+
// recaptured here as an ordinary snapshot. The migration proof they served
|
|
123
|
+
// was discharged in 35.0.0.
|
|
124
|
+
const titles = Object.fromEntries(
|
|
125
|
+
NOTIFICATION_FIXTURES.map((fixture) => {
|
|
126
|
+
const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
|
|
127
|
+
p: unknown,
|
|
128
|
+
c: typeof CONTEXT,
|
|
129
|
+
) => { metadata: { title: string } };
|
|
130
|
+
return [
|
|
131
|
+
fixtureKey(fixture.kind, fixture.name),
|
|
132
|
+
compose(fixture.payload, CONTEXT).metadata.title,
|
|
133
|
+
];
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
expect(titles).toMatchSnapshot();
|
|
137
137
|
});
|
|
138
138
|
});
|
|
139
139
|
|
|
@@ -13,11 +13,15 @@
|
|
|
13
13
|
* every snapshot key still resolves. That is the whole claim of the move: the
|
|
14
14
|
* keys still match, so the bytes are still the bytes.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* This file used to be the reviewable companion to `./output-parity.test.ts`,
|
|
17
|
+
* which was the load-bearing one: a snapshot can be regenerated with a
|
|
18
|
+
* keystroke, a committed golden cannot, so the golden was the proof and this was
|
|
19
|
+
* the documentation. ADR-CONTRACTS-087 retired that golden once its migration
|
|
20
|
+
* proof shipped in 35.0.0 — so this file is now BOTH. There is no longer a
|
|
21
|
+
* second oracle to catch a snapshot refreshed on autopilot; the diff you read
|
|
22
|
+
* before typing `vitest -u` is the control.
|
|
19
23
|
*
|
|
20
|
-
* The clock is pinned to `
|
|
24
|
+
* The clock is pinned to `FIXTURE_CLOCK`'s year rather than left to run.
|
|
21
25
|
* These snapshots used to be captured under the wall clock — with the copyright
|
|
22
26
|
* year baked into every one of them — so they were due to fail on the next New
|
|
23
27
|
* Year's Day for calendar reasons alone. The year is a value in `RenderContext`
|
|
@@ -32,11 +36,11 @@ import type { NotificationPayloads } from "../payloads";
|
|
|
32
36
|
import { renderEmail } from "../render";
|
|
33
37
|
import { NOTIFICATION_DEFINITIONS } from "../registry";
|
|
34
38
|
import type { RenderedEmail } from "../renderers/email";
|
|
35
|
-
import {
|
|
39
|
+
import { FIXTURE_CLOCK } from "./fixtures";
|
|
36
40
|
|
|
37
41
|
/** Pinned so no wall-clock year can reach a snapshot. */
|
|
38
42
|
const CONTEXT: RenderContextOverrides = {
|
|
39
|
-
brand: { copyrightYear: new Date(
|
|
43
|
+
brand: { copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear() },
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
interface Fixture {
|
|
@@ -138,11 +138,23 @@ export interface Signature {
|
|
|
138
138
|
signer?: string;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* A leading image. `alt` is required — a text-only channel has nothing else.
|
|
143
|
+
*
|
|
144
|
+
* `width`/`height` are the image's intrinsic pixel dimensions. They are optional
|
|
145
|
+
* because a channel that can measure an image itself does not need to be told, and
|
|
146
|
+
* requiring them would put a rendering constraint into channel-agnostic content.
|
|
147
|
+
* But a channel MAY be unable to draw one without them: AMP's `<amp-img>` requires
|
|
148
|
+
* explicit dimensions to reserve layout before the image loads, so the email
|
|
149
|
+
* channel's AMP surface degrades to the `alt` text when they are absent
|
|
150
|
+
* (ADR-CONTRACTS-089). Supplying them is what makes the image drawable everywhere.
|
|
151
|
+
*/
|
|
142
152
|
export interface HeroImage {
|
|
143
153
|
type: "heroImage";
|
|
144
154
|
src: string;
|
|
145
155
|
alt: string;
|
|
156
|
+
width?: number;
|
|
157
|
+
height?: number;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
// =============================================================================
|
|
@@ -58,17 +58,23 @@ export type { RenderedEmail } from "./renderers/email";
|
|
|
58
58
|
|
|
59
59
|
export { emailRenderer } from "./renderers/email";
|
|
60
60
|
|
|
61
|
+
// The `Slack*` block types this barrel used to export were a hand-written subset
|
|
62
|
+
// of Block Kit and are GONE (ADR-CONTRACTS-090). Slack's own `@slack/types` is
|
|
63
|
+
// the vocabulary now, re-exported here so a consumer need not depend on it
|
|
64
|
+
// directly: `SlackBlock` -> `KnownBlock`, `SlackSectionBlock` -> `SectionBlock`,
|
|
65
|
+
// `SlackMrkdwnText` -> `MrkdwnElement`, `SlackPlainText` -> `PlainTextElement`.
|
|
61
66
|
export type {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
ActionsBlock,
|
|
68
|
+
ContextBlock,
|
|
69
|
+
DividerBlock,
|
|
70
|
+
HeaderBlock,
|
|
71
|
+
ImageBlock,
|
|
72
|
+
KnownBlock,
|
|
73
|
+
MrkdwnElement,
|
|
74
|
+
PlainTextElement,
|
|
75
|
+
RichTextBlock,
|
|
76
|
+
SectionBlock,
|
|
68
77
|
SlackMessage,
|
|
69
|
-
SlackMrkdwnText,
|
|
70
|
-
SlackPlainText,
|
|
71
|
-
SlackSectionBlock,
|
|
72
78
|
} from "./renderers/slack";
|
|
73
79
|
|
|
74
80
|
export { slackRenderer } from "./renderers/slack";
|
|
@@ -50,8 +50,8 @@ template literal the compiler checks.
|
|
|
50
50
|
- Definitions take `formatExpiry`, `titleCase`, `NOTICE` and `ACCESS_PHRASE` from
|
|
51
51
|
`../text` — never from a renderer. They are content (phrasing and display
|
|
52
52
|
format), not markup, so they sit beside the kinds rather than inside the
|
|
53
|
-
channel that used to own them.
|
|
54
|
-
|
|
53
|
+
channel that used to own them. `../__tests__/render-snapshot.test.ts` locks
|
|
54
|
+
these strings; a second copy of one is the exact failure this migration exists
|
|
55
55
|
to prevent.
|
|
56
56
|
- A file per kind, named after its old `email/render` counterpart. Kinds do not
|
|
57
57
|
share a module — the point is that a kind is self-contained.
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* `renderToChannel` is the whole pipeline in one call: look the kind up in
|
|
5
5
|
* `NOTIFICATION_DEFINITIONS`, `compose` its payload into channel-agnostic
|
|
6
6
|
* `NotificationContent`, hand that to a `Renderer`. Every piece already existed
|
|
7
|
-
*
|
|
8
|
-
* module is the wiring, not a rendering change.
|
|
7
|
+
* when this was written — the module is the wiring, not a rendering change.
|
|
9
8
|
*
|
|
10
9
|
* What it deliberately does NOT do is dispatch. The old `renderEmail` was a
|
|
11
10
|
* twelve-arm `switch` that threw on the one arm it did not have; here the
|
|
@@ -30,10 +29,11 @@
|
|
|
30
29
|
* only to build one when the caller supplies none, and only via
|
|
31
30
|
* `createRenderContext` — the single sanctioned clock read in this domain.
|
|
32
31
|
* Pass a context to get reproducible bytes.
|
|
33
|
-
* - Output
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
32
|
+
* - Output is locked by `__tests__/render-snapshot.test.ts`, across every fixture
|
|
33
|
+
* in `__tests__/fixtures.ts`. A diff there means a real sent email changed:
|
|
34
|
+
* review it, then regenerate. This rewiring was originally proven safe by
|
|
35
|
+
* byte-equality with the old email layer; that proof shipped in 35.0.0 and was
|
|
36
|
+
* retired with the golden (ADR-CONTRACTS-087).
|
|
37
37
|
* - A kind's `defaults` layer UNDER the caller's wishes, never over them. A full
|
|
38
38
|
* `RenderContext` has no gaps left to fill, so it wins outright; partial
|
|
39
39
|
* overrides are filled from `defaults` first and the package default last.
|