@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
|
@@ -10,21 +10,76 @@ deleted, what replaced it. That layer offered templates a component per intent
|
|
|
10
10
|
here the CONTENT names the intent and this directory is the only thing that knows
|
|
11
11
|
the markup. The strings are the same strings.
|
|
12
12
|
|
|
13
|
-
| module | what it holds
|
|
14
|
-
| ---------------- |
|
|
15
|
-
| `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type
|
|
16
|
-
| `render.ts` | each `NotificationElement` → its `<p>`/table markup, and spacing
|
|
17
|
-
| `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art
|
|
18
|
-
| `cta.ts` | the `>> LABEL <<` button, shared by a standalone CTA and a chat one
|
|
19
|
-
| `shells.ts` | `htmlShell` / `textShell` — what makes one email
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
13
|
+
| module | what it holds |
|
|
14
|
+
| ---------------- | -------------------------------------------------------------------------------------- |
|
|
15
|
+
| `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type |
|
|
16
|
+
| `render.ts` | each `NotificationElement` → its class-only `<p>`/table markup, and spacing |
|
|
17
|
+
| `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art |
|
|
18
|
+
| `cta.ts` | the `>> LABEL <<` button, shared by a standalone CTA and a chat one |
|
|
19
|
+
| `shells.ts` | `htmlShell` / `ampShell` / `textShell` — what makes one email three surfaces |
|
|
20
|
+
| `styles.ts` | every declaration, as recipes — plus `inlineStyles` and `ampify` |
|
|
21
|
+
| `colors.ts` | every colour, by role, in every scheme — and the dark stylesheet |
|
|
22
|
+
| `constants.ts` | typography and layout (`MONO_DECL`, `FONT_SIZE`, `SPACING`, `CTA_PAD`) + `COMPANY_URL` |
|
|
23
|
+
| `escape-html.ts` | `escapeHtml` |
|
|
22
24
|
|
|
23
25
|
`constants.ts` and `escape-html.ts` moved here from the old `src/email/render`
|
|
24
26
|
before it was deleted. The brand NAME is deliberately not among them: it is
|
|
25
27
|
`../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
|
|
26
28
|
and renderers read it from `context.brand`.
|
|
27
29
|
|
|
30
|
+
## Three surfaces, one markup (ADR-CONTRACTS-089)
|
|
31
|
+
|
|
32
|
+
An email is a `multipart/alternative`: `text/plain`, `text/html` and
|
|
33
|
+
`text/x-amp-html` are three presentations of ONE message, and `RenderedEmail`
|
|
34
|
+
carries all three. AMP is not a fourth channel — a recipient gets one email.
|
|
35
|
+
|
|
36
|
+
The catch is that the two HTML-ish surfaces disagree about styling at the root.
|
|
37
|
+
AMP4EMAIL forbids inline `style` attributes and `!important`; the html surface is
|
|
38
|
+
built from both, deliberately (see Colour below). So **the markup belongs to
|
|
39
|
+
neither**: `render.ts`, `cta.ts` and `chat.ts` emit CLASS-ONLY markup, and each
|
|
40
|
+
shell spends `styles.ts` its own way —
|
|
41
|
+
|
|
42
|
+
- `htmlShell` runs `inlineStyles`, which turns recipe classes back into the exact
|
|
43
|
+
inline styles this channel has always shipped;
|
|
44
|
+
- `ampShell` runs `ampify` (the `<img>` → `<amp-img>` swap) and states the same
|
|
45
|
+
recipes as `<style amp-custom>`.
|
|
46
|
+
|
|
47
|
+
`EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
|
|
48
|
+
and a surface is a way of SPENDING the lines rather than a thing a line carries.
|
|
49
|
+
The cost is that `EmailLine.html` is now an intermediate representation —
|
|
50
|
+
`render.ts` no longer reads as the bytes it emits, and the snapshot is where the
|
|
51
|
+
real markup is legible.
|
|
52
|
+
|
|
53
|
+
**What AMP is for:** `:hover`. ADR-CONTRACTS-088 bought a hover state that Gmail
|
|
54
|
+
does not honour, and Gmail is most opens. AMP is where that rule reaches people.
|
|
55
|
+
|
|
56
|
+
**What AMP does not have:** dark. AMP4EMAIL disallows the `prefers-color-scheme`
|
|
57
|
+
media feature outright. It costs nothing — see Colour.
|
|
58
|
+
|
|
59
|
+
`pnpm validate:amp` runs the real AMP validator. `__tests__/amp.test.ts` asserts
|
|
60
|
+
the constraints we KNOW on every run, which is not the same claim: it passed once
|
|
61
|
+
while every fixture was invalid.
|
|
62
|
+
|
|
63
|
+
## Colour — the thing this channel has and the others do not
|
|
64
|
+
|
|
65
|
+
`colors.ts` is the only place a colour lives, and it is the channel's own rather
|
|
66
|
+
than the layer's. Email hand-authors colour because it is the only channel with
|
|
67
|
+
no client-side semantic vocabulary to lean on: `../slack` says `:warning:` and
|
|
68
|
+
`context` and lets Slack's client theme them (Block Kit exposes no author-set
|
|
69
|
+
colour at all), and `../sms` has none to have. A shared `notifications/colors.ts`
|
|
70
|
+
would look DRY and would put a channel's presentation back inside the
|
|
71
|
+
channel-agnostic layer — the coupling `../../renderer.ts` exists to prevent. A
|
|
72
|
+
channel that needs colour gets its own file next to its own renderer.
|
|
73
|
+
|
|
74
|
+
Colour is named by ROLE, and a scheme owes an answer to every role. Roles are
|
|
75
|
+
meaning, not coincidence: `border`, `bubble`, `meta` and `dots` all answer `#666`
|
|
76
|
+
in light and are still four roles, because a CTA's border and a chat bubble's
|
|
77
|
+
fill have no reason to move together.
|
|
78
|
+
|
|
79
|
+
The channel renders in **one or more schemes** — two today (`light`, `dark`), and
|
|
80
|
+
callers ask `palette(scheme)` rather than importing a concrete palette, so a
|
|
81
|
+
third is an entry in a table rather than an edit to every call site.
|
|
82
|
+
|
|
28
83
|
## Spacing — the thing the content model does not carry
|
|
29
84
|
|
|
30
85
|
The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
|
|
@@ -48,12 +103,16 @@ expect to style it properly rather than trust the placeholder.
|
|
|
48
103
|
|
|
49
104
|
## Invariants
|
|
50
105
|
|
|
51
|
-
- Output is
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
106
|
+
- Output is locked by `../../__tests__/render-snapshot.test.ts`, across every
|
|
107
|
+
fixture in `../../__tests__/fixtures.ts` — a diff there means a real sent email
|
|
108
|
+
changed, so review it before regenerating. Byte-identity with the old
|
|
109
|
+
`src/email/render` was the whole constraint of the migration; it held, shipped
|
|
110
|
+
in 35.0.0, and ADR-CONTRACTS-087 retired the golden that proved it, because
|
|
111
|
+
that proof could not double as permission to change the design.
|
|
112
|
+
- `__tests__/render.test.ts` asserts the rules that outlive any redesign — every
|
|
113
|
+
element type supported, subject from the content's title, year and signer from
|
|
114
|
+
context, the `:` restored, user content escaped. Markup belongs in the
|
|
115
|
+
snapshot, not there.
|
|
57
116
|
- PURE. `render` is a function of `(content, context)`: no clock, no environment,
|
|
58
117
|
no I/O. The copyright year comes from `context.brand.copyrightYear`, which is
|
|
59
118
|
what retires the `new Date().getFullYear()` hidden inside the old
|
|
@@ -67,8 +126,64 @@ expect to style it properly rather than trust the placeholder.
|
|
|
67
126
|
- Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
|
|
68
127
|
work. Nothing here re-formats a value.
|
|
69
128
|
- Every user-controlled field in the HTML surface passes through `escapeHtml`
|
|
70
|
-
before interpolation
|
|
71
|
-
|
|
129
|
+
before interpolation — INCLUDING `href` and `src`, which reach the markup as
|
|
130
|
+
URLs rather than as text and were interpolated raw until ADR-CONTRACTS-089. A
|
|
131
|
+
URL that can close its attribute can forge another; under `inlineStyles` it
|
|
132
|
+
could also forge a `csr-` class and write any declaration in the registry into
|
|
133
|
+
someone else's element. That is also what makes the regex in `inlineStyles` safe:
|
|
134
|
+
nothing that reaches it can contain an unescaped `"`.
|
|
135
|
+
- The markup is CLASS-ONLY. `cs-` role classes SHIP (the dark stylesheet needs the
|
|
136
|
+
hook); `csr-` recipe classes DO NOT — `inlineStyles` spends and drops them. That
|
|
137
|
+
prefix split is a mechanism, not a convention: it is what lets one markup serve
|
|
138
|
+
two surfaces, and it is why `__tests__/colors.test.ts` needs no filter — it scans
|
|
139
|
+
rendered output, where recipe classes no longer exist.
|
|
140
|
+
- `MONO_DECL` / `FONT_SIZE` / `SPACING` / `CTA_PAD` are this channel's own and are
|
|
141
|
+
NOT exported
|
|
72
142
|
from the domain barrel: a margin is not vocabulary. They were duplicated from
|
|
73
143
|
the old `email/render/blocks`'s private copies while both layers existed; that
|
|
74
144
|
duplication ended with the directory, and these are now the only copies.
|
|
145
|
+
`colors.ts` is unexported for the same reason — a colour is not vocabulary
|
|
146
|
+
either.
|
|
147
|
+
- NO module here states a colour. Every colour comes from `colors.ts` via a role;
|
|
148
|
+
`__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
|
|
149
|
+
including one in a comment, because a comment naming a literal goes stale the
|
|
150
|
+
first time the palette is tuned.
|
|
151
|
+
- On the HTML surface, light is inline and dark is the `<style>` block, and that is
|
|
152
|
+
the mechanism rather than duplication: one source each for two schemes, both from
|
|
153
|
+
`colors.ts`. Every dark declaration carries `!important` because the inline light
|
|
154
|
+
colour would otherwise win on specificity and dark would silently do nothing. On
|
|
155
|
+
the AMP surface there is no inline style, so the recipes carry light and source
|
|
156
|
+
order alone would resolve a cascade — which is moot, because AMP has no dark.
|
|
157
|
+
- `:hover` is the one exception, because it has no inline form: BOTH schemes state
|
|
158
|
+
it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
|
|
159
|
+
order). On the HTML surface it is opportunistic — Apple Mail honours it, Gmail and
|
|
160
|
+
classic Outlook do not — and needs no fallback, because without it the button
|
|
161
|
+
simply rests. It is also the ONE rule the AMP surface shares, via
|
|
162
|
+
`hoverStyle(false)`; AMP forbids `!important`, which is why that template takes a
|
|
163
|
+
parameter and `DARK_STYLE` does not.
|
|
164
|
+
- The AMP surface has NO dark scheme, and loses nothing by it. AMP4EMAIL disallows
|
|
165
|
+
the `prefers-color-scheme` media feature (checked against the AMP validator, with
|
|
166
|
+
and without `data-css-strict`), so it cannot. The audiences are disjoint: dark
|
|
167
|
+
reaches Apple Mail, iOS Mail and Outlook for Mac; AMP reaches Gmail, Yahoo and
|
|
168
|
+
Mail.ru — the force-inverting clients that never received our dark colours anyway.
|
|
169
|
+
AMP swaps a scheme its readers never got for a hover state they never got.
|
|
170
|
+
- Role hooks ride the AMP surface unstyled (`cs-faint`, `cs-bubble`, `cs-cta`,
|
|
171
|
+
`cs-link`), because their only rules live in `DARK_STYLE`. Their colours arrive
|
|
172
|
+
via each recipe's `roleDecl`, so this is dead bytes rather than a bug — the price
|
|
173
|
+
of one markup. `.cs-cta-hover` is the exception: it is the feature.
|
|
174
|
+
- A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
|
|
175
|
+
payload (an OTP code), and a hover state on it would promise a click that does
|
|
176
|
+
not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
|
|
177
|
+
- A linked CTA's whole box is the click target: the padding sits on the `<a>`
|
|
178
|
+
(`display: block`), not the cell. Outlook ignores `display`, so an
|
|
179
|
+
`<!--[if mso]>` block restores the cell padding there and the box degrades to
|
|
180
|
+
today's text click. The padding is one constant shared by the anchor, the cell,
|
|
181
|
+
and the MSO block — they must agree.
|
|
182
|
+
- The `color-scheme` metas and `DARK_STYLE` ship together. The declaration is a
|
|
183
|
+
promise: made alone it stops Apple Mail protecting our colours while leaving
|
|
184
|
+
light text on a dark background — worse than declaring light-only. This channel
|
|
185
|
+
WAS light-only by construction until ADR-CONTRACTS-088; it is not any more.
|
|
186
|
+
- Dark reaches Apple Mail, iOS Mail and Outlook for Mac. Gmail and Outlook
|
|
187
|
+
force-invert and are unreachable — the dark path is additive, and a client that
|
|
188
|
+
strips `<style>` keeps the inline light colours, which is the status quo rather
|
|
189
|
+
than a degradation.
|
|
@@ -4,26 +4,29 @@
|
|
|
4
4
|
|
|
5
5
|
Tests for the email channel (ADR-CONTRACTS-086).
|
|
6
6
|
|
|
7
|
-
`render.test.ts`
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
`render.test.ts` pins the renderer's behavioural rules — the claims that hold no
|
|
8
|
+
matter how the email is styled: that `supports` is total, that the subject comes
|
|
9
|
+
from the content's title, that the year and signer come from `RenderContext`
|
|
10
|
+
rather than a clock, that the `:` separator the content model drops is put back,
|
|
11
|
+
and that user-controlled fields are escaped. It also renders every fixture in
|
|
12
|
+
`../../../__tests__/fixtures.ts` through `NOTIFICATION_DEFINITIONS`, which proves
|
|
13
|
+
each kind composes and renders on both surfaces at all.
|
|
14
|
+
|
|
15
|
+
The MARKUP is not here. `../../../__tests__/render-snapshot.test.ts` locks that.
|
|
13
16
|
|
|
14
17
|
## Invariants
|
|
15
18
|
|
|
16
|
-
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
promise. It is never regenerated to make this file pass — and since that layer
|
|
20
|
-
is now deleted, the golden is the only record of those bytes, so regenerating
|
|
21
|
-
it destroys the evidence rather than updating it.
|
|
19
|
+
- Rules here, markup there. A test in this file that asserts what the HTML looks
|
|
20
|
+
like is in the wrong file: it belongs in the snapshot, where a redesign shows
|
|
21
|
+
up as a reviewable diff instead of a wall of broken assertions.
|
|
22
22
|
- NO fake timers. The old layer's `signature()` read the wall clock, which is why
|
|
23
23
|
every render test had to freeze it; here the year is a field on
|
|
24
24
|
`RenderContext`, and keeping this file clock-free is the improvement being
|
|
25
25
|
locked in.
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
- This file used to assert every fixture byte-for-byte against a frozen golden of
|
|
27
|
+
what `src/email/render` emitted at 32.0.0 — the migration's "no rendered email
|
|
28
|
+
changed" promise, checked here at the RENDERER and in a companion suite at the
|
|
29
|
+
ENTRY POINT (`renderToChannel`). The promise held and shipped in 35.0.0;
|
|
30
|
+
ADR-CONTRACTS-087 retired both, because byte-equality with a deleted layer
|
|
31
|
+
cannot survive email design being deliberately changed. The bytes are in git at
|
|
32
|
+
`v35.0.0`.
|
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
});
|