@company-semantics/contracts 36.0.0 → 37.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 +2 -4
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -1965
- package/src/notifications/content.ts +5 -4
- package/src/notifications/renderers/email/README.md +45 -47
- package/src/notifications/renderers/email/__tests__/styles.test.ts +5 -5
- package/src/notifications/renderers/email/chat.ts +5 -4
- package/src/notifications/renderers/email/colors.ts +25 -22
- package/src/notifications/renderers/email/cta.ts +6 -8
- package/src/notifications/renderers/email/index.ts +10 -13
- package/src/notifications/renderers/email/render.ts +2 -4
- package/src/notifications/renderers/email/shells.ts +15 -78
- package/src/notifications/renderers/email/styles.ts +10 -52
- package/src/notifications/renderers/email/__tests__/amp.test.ts +0 -217
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The AMP surface's constraints (ADR-CONTRACTS-089).
|
|
3
|
-
*
|
|
4
|
-
* **What this file can and cannot claim.** AMP validity is defined by the AMP
|
|
5
|
-
* validator, and the validator fetches itself over the network — so it cannot live
|
|
6
|
-
* in this package's pure suite and runs behind `pnpm validate:amp` instead. What is
|
|
7
|
-
* asserted here is narrower and honest: *the constraints we know*, checked over
|
|
8
|
-
* every fixture. A green run here means "we did not do any of the things we know
|
|
9
|
-
* AMP forbids", not "this is valid AMP".
|
|
10
|
-
*
|
|
11
|
-
* The constraints are structural rather than cosmetic. Each one is a thing the html
|
|
12
|
-
* surface does deliberately and this surface must not do at all — which is why the
|
|
13
|
-
* markup belongs to neither and both are derived from `../styles.ts`.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, expect, it } from "vitest";
|
|
17
|
-
|
|
18
|
-
import { hoverStyle } from "../colors";
|
|
19
|
-
import { NOTIFICATION_FIXTURES, fixtureKey } from "../../../__tests__/fixtures";
|
|
20
|
-
import type { RenderContext } from "../../../context";
|
|
21
|
-
import { NOTIFICATION_DEFINITIONS } from "../../../registry";
|
|
22
|
-
import { emailRenderer } from "../index";
|
|
23
|
-
import { ampify } from "../styles";
|
|
24
|
-
|
|
25
|
-
const CONTEXT: RenderContext = {
|
|
26
|
-
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
/** Gmail's `amp-custom` ceiling. */
|
|
30
|
-
const AMP_CUSTOM_LIMIT = 75_000;
|
|
31
|
-
|
|
32
|
-
/** Every fixture, rendered once. */
|
|
33
|
-
const RENDERED = NOTIFICATION_FIXTURES.map((fixture) => {
|
|
34
|
-
const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
|
|
35
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
-
const content = definition.compose(fixture.payload as any, CONTEXT);
|
|
37
|
-
return {
|
|
38
|
-
key: fixtureKey(fixture.kind, fixture.name),
|
|
39
|
-
...emailRenderer.render(content, CONTEXT),
|
|
40
|
-
};
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
/** The `<style amp-custom>` block's contents. */
|
|
44
|
-
function ampCustom(amp: string): string {
|
|
45
|
-
const open = "<style amp-custom>";
|
|
46
|
-
const start = amp.indexOf(open);
|
|
47
|
-
return amp.slice(start + open.length, amp.indexOf("</style></head>"));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Everything a reader would actually read, tags removed. */
|
|
51
|
-
function visibleText(html: string): string {
|
|
52
|
-
return html
|
|
53
|
-
.replace(/<style[^>]*>[\s\S]*?<\/style>/g, "")
|
|
54
|
-
.replace(/<script[\s\S]*?<\/script>/g, "")
|
|
55
|
-
.replace(/<!--[\s\S]*?-->/g, "")
|
|
56
|
-
.replace(/<[^>]+>/g, " ")
|
|
57
|
-
.replace(/\s+/g, " ")
|
|
58
|
-
.trim();
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
describe("the AMP surface", () => {
|
|
62
|
-
it("emits every fixture", () => {
|
|
63
|
-
// Guards every loop below: a fixture list that silently emptied would turn
|
|
64
|
-
// each of them into a vacuous pass.
|
|
65
|
-
expect(RENDERED.length).toBeGreaterThan(0);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
describe.each(RENDERED)("$key", ({ amp }) => {
|
|
69
|
-
it("declares itself AMP, with the runtime and the boilerplate", () => {
|
|
70
|
-
expect(amp.startsWith("<!DOCTYPE html>\n<html amp4email")).toBe(true);
|
|
71
|
-
expect(amp).toContain(
|
|
72
|
-
'<script async src="https://cdn.ampproject.org/v0.js"></script>',
|
|
73
|
-
);
|
|
74
|
-
// The boilerplate hides the body until the runtime unhides it — which is
|
|
75
|
-
// why an AMP document that cannot run scripts renders blank rather than
|
|
76
|
-
// unstyled.
|
|
77
|
-
expect(amp).toContain(
|
|
78
|
-
"<style amp4email-boilerplate>body{visibility:hidden}</style>",
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("carries no inline style — the constraint the whole split exists for", () => {
|
|
83
|
-
expect(amp).not.toContain('style="');
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("carries no !important, which amp-custom forbids", () => {
|
|
87
|
-
expect(ampCustom(amp)).not.toContain("!important");
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it("carries no conditional comment", () => {
|
|
91
|
-
// `MSO_CTA_STYLE` is `htmlShell`'s business. It lives in that shell's
|
|
92
|
-
// `<head>`, never in an `EmailLine`, so this holds by construction rather
|
|
93
|
-
// than by stripping.
|
|
94
|
-
expect(amp).not.toContain("<!--[if");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("has exactly one amp-custom stylesheet, in the head", () => {
|
|
98
|
-
expect(amp.match(/<style amp-custom>/g)).toHaveLength(1);
|
|
99
|
-
expect(amp.indexOf("<style amp-custom>")).toBeLessThan(
|
|
100
|
-
amp.indexOf("</head>"),
|
|
101
|
-
);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("fits Gmail's amp-custom budget", () => {
|
|
105
|
-
expect(ampCustom(amp).length).toBeLessThan(AMP_CUSTOM_LIMIT);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("draws images as amp-img, never img", () => {
|
|
109
|
-
expect(amp).not.toMatch(/<img\s/);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it("styles every recipe class it applies", () => {
|
|
113
|
-
// The end-to-end direction: start from the output, so a recipe class that
|
|
114
|
-
// reaches the markup with no rule behind it is caught. On this surface a
|
|
115
|
-
// recipe is the ONLY thing carrying an element's appearance — `htmlShell`
|
|
116
|
-
// spends them inline and drops them; here they must all land as rules, or
|
|
117
|
-
// the element renders naked.
|
|
118
|
-
//
|
|
119
|
-
// Role hooks (`cs-`) are deliberately NOT required to have one. They ride
|
|
120
|
-
// along because the markup is shared with the html surface, where
|
|
121
|
-
// `DARK_STYLE` needs them — and dark is exactly what this surface cannot
|
|
122
|
-
// have. So `cs-faint`/`cs-bubble`/`cs-cta`/`cs-link` are inert here: their
|
|
123
|
-
// colours already arrived via their recipe's `roleDecl`. That is the price
|
|
124
|
-
// of one markup, and it is bytes rather than correctness. The one role rule
|
|
125
|
-
// this surface DOES carry is asserted separately below — it is the feature.
|
|
126
|
-
const body = amp.slice(amp.indexOf("<body"));
|
|
127
|
-
const applied = new Set(
|
|
128
|
-
[...body.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
|
|
129
|
-
m[1].split(/\s+/),
|
|
130
|
-
),
|
|
131
|
-
);
|
|
132
|
-
const recipes = [...applied].filter((c) => c.startsWith("csr-"));
|
|
133
|
-
expect(recipes.length).toBeGreaterThan(0);
|
|
134
|
-
const sheet = ampCustom(amp);
|
|
135
|
-
for (const className of recipes) {
|
|
136
|
-
expect(sheet, `${className} is applied but never styled`).toMatch(
|
|
137
|
-
new RegExp(`\\.${className} \\{`),
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("says exactly what the html surface says", () => {
|
|
144
|
-
// The claim `EmailLine` exists to make, now across three surfaces instead of
|
|
145
|
-
// two: the parts of a `multipart/alternative` are ALTERNATIVES, so a client
|
|
146
|
-
// choosing between them must not change what the notification said. This is
|
|
147
|
-
// what a second hand-authored AMP template could never promise.
|
|
148
|
-
for (const { key, html, amp } of RENDERED) {
|
|
149
|
-
expect(visibleText(amp), `${key} says something else in AMP`).toBe(
|
|
150
|
-
visibleText(html),
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("states the hover rule without !important, and the html surface with", () => {
|
|
156
|
-
// The one rule both surfaces carry, and the only thing they disagree on: the
|
|
157
|
-
// html surface must beat an inline style, this one has none to beat and
|
|
158
|
-
// forbids the keyword. One template, one parameter; see `../colors.ts`.
|
|
159
|
-
expect(hoverStyle(true)).toContain("!important");
|
|
160
|
-
expect(hoverStyle(false)).not.toContain("!important");
|
|
161
|
-
// The same rule, not merely two rules that both exist.
|
|
162
|
-
expect(hoverStyle(true).replaceAll(" !important", "")).toBe(
|
|
163
|
-
hoverStyle(false),
|
|
164
|
-
);
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it("carries the hover rule — the reason this surface exists at all", () => {
|
|
168
|
-
// ADR-CONTRACTS-088 bought a `:hover` state that Gmail does not honour, and
|
|
169
|
-
// Gmail is most opens. This surface is where that rule finally reaches a
|
|
170
|
-
// recipient, so its presence is the feature, not an implementation detail.
|
|
171
|
-
const { amp } = RENDERED[0];
|
|
172
|
-
expect(ampCustom(amp)).toContain(".cs-cta-hover:hover");
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it("carries no dark scheme, because AMP disallows the media feature", () => {
|
|
176
|
-
// NOT an omission to fix later: `prefers-color-scheme` is a disallowed media
|
|
177
|
-
// feature in amp-custom (verified against the AMP validator, with and without
|
|
178
|
-
// `data-css-strict`), so this surface cannot have dark in any form. It costs
|
|
179
|
-
// nothing — the dark block's clients (Apple Mail, iOS Mail, Outlook for Mac)
|
|
180
|
-
// and this surface's clients (Gmail, Yahoo, Mail.ru) are disjoint sets, and
|
|
181
|
-
// the latter force-invert rather than read a stylesheet.
|
|
182
|
-
//
|
|
183
|
-
// This test exists so that "add dark mode to AMP" fails here with the reason,
|
|
184
|
-
// rather than in `pnpm validate:amp` with a CSS syntax error.
|
|
185
|
-
for (const { key, amp } of RENDERED) {
|
|
186
|
-
expect(amp, `${key} would be invalid AMP`).not.toContain(
|
|
187
|
-
"prefers-color-scheme",
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
describe("ampify", () => {
|
|
193
|
-
it("gives a sized image explicit layout", () => {
|
|
194
|
-
expect(
|
|
195
|
-
ampify(
|
|
196
|
-
'<img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal">',
|
|
197
|
-
),
|
|
198
|
-
).toBe(
|
|
199
|
-
'<amp-img src="a.png" alt="A" width="600" height="200" class="csr-hero-normal" layout="intrinsic"></amp-img>',
|
|
200
|
-
);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("degrades an unsized image to its alt text, keeping its spacing", () => {
|
|
204
|
-
// `../../content.ts` makes dimensions optional, so this surface must have an
|
|
205
|
-
// answer for their absence. `alt` is required exactly so that a channel with
|
|
206
|
-
// nothing else has something to say.
|
|
207
|
-
expect(
|
|
208
|
-
ampify('<img src="a.png" alt="A logo" class="csr-hero-tight">'),
|
|
209
|
-
).toBe('<p class="csr-p-tight">A logo</p>');
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("leaves markup with no image alone", () => {
|
|
213
|
-
const plain = '<p class="csr-p-none">x</p>';
|
|
214
|
-
expect(ampify(plain)).toBe(plain);
|
|
215
|
-
});
|
|
216
|
-
});
|
|
217
|
-
});
|