@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
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* - Pure. `render` is a function of `(content, context)` — no clock, no
|
|
12
12
|
* environment, no I/O. The copyright year comes from
|
|
13
13
|
* `context.brand.copyrightYear`.
|
|
14
|
-
* - Output is
|
|
15
|
-
* the whole constraint of
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
18
|
* - `supports` answers true for every element type: email is the rich channel,
|
|
19
19
|
* and there is nothing in the content model it cannot depict.
|
|
20
20
|
*/
|
|
@@ -25,16 +25,30 @@ import type { Renderer } from "../../renderer";
|
|
|
25
25
|
|
|
26
26
|
import type { Spacing } from "./constants";
|
|
27
27
|
import { renderElement } from "./render";
|
|
28
|
-
import { type EmailLine, htmlShell, textShell } from "./shells";
|
|
28
|
+
import { ampShell, type EmailLine, htmlShell, textShell } from "./shells";
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/**
|
|
31
|
+
* One rendered email — the email channel's natural output type.
|
|
32
|
+
*
|
|
33
|
+
* Three bodies, because an email IS a `multipart/alternative`: one message, whose
|
|
34
|
+
* parts a client chooses between. This is NOT three channels — a recipient gets one
|
|
35
|
+
* email — which is why AMP lives here rather than behind a second `Renderer`
|
|
36
|
+
* (ADR-CONTRACTS-089). Assembling the parts is delivery's job, and delivery is not
|
|
37
|
+
* contracts': today the backend sends `text` and `html` over SES's simple send and
|
|
38
|
+
* `amp` goes nowhere.
|
|
39
|
+
*/
|
|
31
40
|
export interface RenderedEmail {
|
|
32
41
|
/** Subject line — the notification's `metadata.title`. */
|
|
33
42
|
subject: string;
|
|
34
|
-
/** Plain-text body. */
|
|
43
|
+
/** Plain-text body (`text/plain`). */
|
|
35
44
|
text: string;
|
|
36
|
-
/** HTML body (every kind is
|
|
45
|
+
/** HTML body (`text/html`) — every kind is multi-output. */
|
|
37
46
|
html: string;
|
|
47
|
+
/**
|
|
48
|
+
* AMP4EMAIL body (`text/x-amp-html`) — read by Gmail, Yahoo and Mail.ru, and
|
|
49
|
+
* the only surface where the CTA's `:hover` reaches most recipients.
|
|
50
|
+
*/
|
|
51
|
+
amp: string;
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -68,6 +82,7 @@ export const emailRenderer: Renderer<RenderedEmail> = {
|
|
|
68
82
|
subject: content.metadata.title,
|
|
69
83
|
text: textShell(lines),
|
|
70
84
|
html: htmlShell(lines),
|
|
85
|
+
amp: ampShell(lines),
|
|
71
86
|
};
|
|
72
87
|
},
|
|
73
88
|
};
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* module offered templates a component per intent (`greeting()`, `keyValue()`,
|
|
6
6
|
* `footer()`) and each template chose which to call; here the CONTENT names the
|
|
7
7
|
* intent and this module is the only thing that knows the markup. The strings
|
|
8
|
-
*
|
|
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.
|
|
9
11
|
*
|
|
10
12
|
* **Spacing is derived, because the content model does not carry it.** The old
|
|
11
13
|
* layer passed a `Spacing` per block, and `../../kinds` deliberately dropped it:
|
|
@@ -43,22 +45,17 @@ import type {
|
|
|
43
45
|
} from "../../content";
|
|
44
46
|
import type { RenderContext } from "../../context";
|
|
45
47
|
|
|
46
|
-
import {
|
|
47
|
-
COMPANY_URL,
|
|
48
|
-
FONT_SIZE,
|
|
49
|
-
MONO,
|
|
50
|
-
SPACING,
|
|
51
|
-
type Spacing,
|
|
52
|
-
} from "./constants";
|
|
48
|
+
import { COMPANY_URL, type Spacing } from "./constants";
|
|
53
49
|
import { renderChatUnit } from "./chat";
|
|
54
50
|
import { ctaButton } from "./cta";
|
|
55
51
|
import { escapeHtml } from "./escape-html";
|
|
56
52
|
import type { EmailLine } from "./shells";
|
|
53
|
+
import { styleClass } from "./styles";
|
|
57
54
|
|
|
58
55
|
/** One `<p>` — the primitive every prose-ish element is built from. */
|
|
59
56
|
function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
|
|
60
57
|
return {
|
|
61
|
-
html: `<p
|
|
58
|
+
html: `<p ${styleClass(`p-${spacing}`)}>${html}</p>`,
|
|
62
59
|
text,
|
|
63
60
|
spacing,
|
|
64
61
|
};
|
|
@@ -89,7 +86,7 @@ function keyValueLine(row: KeyValueRow): { html: string; text: string } {
|
|
|
89
86
|
|
|
90
87
|
/** The standalone `>> LABEL <<` box. */
|
|
91
88
|
function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
|
|
92
|
-
const { html, text } = ctaButton(cta,
|
|
89
|
+
const { html, text } = ctaButton(cta, trailing);
|
|
93
90
|
return { html, text, spacing: trailing };
|
|
94
91
|
}
|
|
95
92
|
|
|
@@ -97,7 +94,7 @@ function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
|
|
|
97
94
|
function signatureLine(signer: string, year: number): EmailLine {
|
|
98
95
|
const line = `ⓒ ${year} • ${signer}`;
|
|
99
96
|
return {
|
|
100
|
-
html: `<p
|
|
97
|
+
html: `<p ${styleClass("p-none")}><br><br><span ${styleClass("eom", "faint")}>/* EOM */</span><br>${escapeHtml(line)}<br><a href="${escapeHtml(COMPANY_URL)}" target="_blank" rel="noopener noreferrer" ${styleClass("signature-link", "link")}>${escapeHtml(COMPANY_URL)}</a></p>`,
|
|
101
98
|
text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
|
|
102
99
|
spacing: "none",
|
|
103
100
|
};
|
|
@@ -151,8 +148,18 @@ export function renderElement(
|
|
|
151
148
|
case "warning": {
|
|
152
149
|
// Fieldless by design — the banner IS the content, and its wording is the
|
|
153
150
|
// renderer's (see `../../content.ts`).
|
|
151
|
+
//
|
|
152
|
+
// The colour is this channel's answer to "this is a security notice", and
|
|
153
|
+
// only the HTML surface can give it: plain text has no colour, so there
|
|
154
|
+
// the banner's own glyphs carry the whole signal.
|
|
154
155
|
const text = "🆆🅰🆁🅽🅸🅽🅶";
|
|
155
|
-
return [
|
|
156
|
+
return [
|
|
157
|
+
paragraph(
|
|
158
|
+
`<span ${styleClass("warning", "destructive")}>${escapeHtml(text)}</span>`,
|
|
159
|
+
text,
|
|
160
|
+
trailing,
|
|
161
|
+
),
|
|
162
|
+
];
|
|
156
163
|
}
|
|
157
164
|
|
|
158
165
|
case "notice":
|
|
@@ -202,20 +209,29 @@ export function renderElement(
|
|
|
202
209
|
case "divider":
|
|
203
210
|
return [
|
|
204
211
|
{
|
|
205
|
-
html: `<hr
|
|
212
|
+
html: `<hr ${styleClass(`hr-${trailing}`, "faint")}>`,
|
|
206
213
|
text: "---",
|
|
207
214
|
spacing: trailing,
|
|
208
215
|
},
|
|
209
216
|
];
|
|
210
217
|
|
|
211
|
-
case "heroImage":
|
|
218
|
+
case "heroImage": {
|
|
219
|
+
// Stated as attributes, not just style: they are the image's intrinsic
|
|
220
|
+
// size, every client uses them to reserve space before it loads, and
|
|
221
|
+
// `./styles.ts`'s `ampify` reads them to decide whether the AMP surface can
|
|
222
|
+
// draw an `<amp-img>` at all.
|
|
223
|
+
const dimensions =
|
|
224
|
+
element.width !== undefined && element.height !== undefined
|
|
225
|
+
? ` width="${element.width}" height="${element.height}"`
|
|
226
|
+
: "";
|
|
212
227
|
return [
|
|
213
228
|
{
|
|
214
|
-
html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}"
|
|
229
|
+
html: `<img src="${escapeHtml(element.src)}" alt="${escapeHtml(element.alt)}"${dimensions} ${styleClass(`hero-${trailing}`)}>`,
|
|
215
230
|
text: element.alt,
|
|
216
231
|
spacing: trailing,
|
|
217
232
|
},
|
|
218
233
|
];
|
|
234
|
+
}
|
|
219
235
|
|
|
220
236
|
default: {
|
|
221
237
|
const _exhaustive: never = element;
|
|
@@ -8,11 +8,21 @@
|
|
|
8
8
|
*
|
|
9
9
|
* INVARIANTS:
|
|
10
10
|
* - Pure functions of their input lines. No clock, no environment, no I/O.
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
11
|
+
* - These strings are the email's actual markup, and
|
|
12
|
+
* `../../__tests__/render-snapshot.test.ts` asserts them
|
|
13
|
+
* character-for-character.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
|
-
import {
|
|
16
|
+
import { BASE_STYLE, DARK_STYLE, hoverStyle } from "./colors";
|
|
17
|
+
import type { Spacing } from "./constants";
|
|
18
|
+
import { MSO_CTA_STYLE } from "./cta";
|
|
19
|
+
import {
|
|
20
|
+
AMP_RECIPE_RULES,
|
|
21
|
+
ampify,
|
|
22
|
+
inlineOf,
|
|
23
|
+
inlineStyles,
|
|
24
|
+
styleClass,
|
|
25
|
+
} from "./styles";
|
|
16
26
|
|
|
17
27
|
/**
|
|
18
28
|
* One rendered line — both presentations of one thing the notification says,
|
|
@@ -29,17 +39,111 @@ export interface EmailLine {
|
|
|
29
39
|
spacing: Spacing;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell.
|
|
44
|
+
*
|
|
45
|
+
* The two metas declare that this email renders in either scheme, and `<style>`
|
|
46
|
+
* is what makes the declaration true — see `./colors.ts`'s `DARK_STYLE`. The
|
|
47
|
+
* declaration is a promise: made without the stylesheet, Apple Mail would stop
|
|
48
|
+
* protecting our colours, apply its dark background, and leave the inline LIGHT
|
|
49
|
+
* text colour sitting on top of it. The two ship together or not at all.
|
|
50
|
+
*
|
|
51
|
+
* Only Apple Mail, iOS Mail and Outlook for Mac read any of this. Clients that
|
|
52
|
+
* force-invert (Outlook.com, Windows Outlook, Gmail Android) ignore both metas
|
|
53
|
+
* and rewrite the inline styles post-delivery — nothing here can stop that, which
|
|
54
|
+
* is why the body still states an explicit background rather than trusting a
|
|
55
|
+
* default.
|
|
56
|
+
*
|
|
57
|
+
* The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
|
|
58
|
+
* Word engine, and undoes the one layout that engine cannot render — a linked
|
|
59
|
+
* CTA's full-box click target. See `./cta.ts`'s `MSO_CTA_STYLE`.
|
|
60
|
+
*/
|
|
33
61
|
export function htmlShell(lines: EmailLine[]): string {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
62
|
+
// `inlineStyles` is spent on the ELEMENT markup only. The shell states its own
|
|
63
|
+
// two slots directly: they are not `EmailLine`s, and keeping the inliner away
|
|
64
|
+
// from the `<head>` means a stylesheet can never be rewritten by it — CSS has
|
|
65
|
+
// attribute selectors (`[class="x"]`), and today's blocks happen not to use one.
|
|
66
|
+
const inner = inlineStyles(
|
|
67
|
+
lines
|
|
68
|
+
.map((line) => line.html)
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join("\n"),
|
|
71
|
+
);
|
|
38
72
|
return `<!DOCTYPE html>
|
|
39
73
|
<html lang="en">
|
|
40
|
-
<head><meta charset="UTF-8"
|
|
41
|
-
<
|
|
42
|
-
<
|
|
74
|
+
<head><meta charset="UTF-8">
|
|
75
|
+
<meta name="color-scheme" content="light dark">
|
|
76
|
+
<meta name="supported-color-schemes" content="light dark">
|
|
77
|
+
<style>${BASE_STYLE}
|
|
78
|
+
${DARK_STYLE}</style>
|
|
79
|
+
<!--[if mso]><style>${MSO_CTA_STYLE}</style><![endif]--></head>
|
|
80
|
+
<body style="${inlineOf("body")}">
|
|
81
|
+
<div style="${inlineOf("frame")}">
|
|
82
|
+
${inner}
|
|
83
|
+
</div>
|
|
84
|
+
</body>
|
|
85
|
+
</html>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Wrap rendered lines in the AMP4EMAIL shell (ADR-CONTRACTS-089).
|
|
90
|
+
*
|
|
91
|
+
* The third MIME part of the same email — `text/x-amp-html`, beside `text/html`
|
|
92
|
+
* and `text/plain`. Gmail, Yahoo and Mail.ru read it; everyone else reads the
|
|
93
|
+
* html part and never knows this existed. That is the point: `./colors.ts`'s
|
|
94
|
+
* `hoverStyle` is absent in Gmail on the html surface, and here it simply works.
|
|
95
|
+
*
|
|
96
|
+
* Everything AMP forbids is absent BY CONSTRUCTION rather than by stripping:
|
|
97
|
+
*
|
|
98
|
+
* - **No inline `style`.** The lines are class-only; only `htmlShell` inlines them.
|
|
99
|
+
* - **No `!important`.** `hoverStyle(false)` — there is no inline style to beat
|
|
100
|
+
* here, so nothing needs to win a specificity fight, and AMP forbids the keyword
|
|
101
|
+
* anyway.
|
|
102
|
+
* - **No `<!--[if mso]>`.** `MSO_CTA_STYLE` lives in `htmlShell`'s `<head>`, never
|
|
103
|
+
* in a line, so nothing has to remove it. Outlook is not an AMP client, and
|
|
104
|
+
* `display: block` needs no correction in the three that are.
|
|
105
|
+
*
|
|
106
|
+
* **No dark scheme, and that is not an omission.** AMP4EMAIL disallows the
|
|
107
|
+
* `prefers-color-scheme` media feature — verified against the AMP validator, with
|
|
108
|
+
* and without `data-css-strict` — so `DARK_STYLE` cannot appear here in any form.
|
|
109
|
+
* It costs nothing: that block is read by Apple Mail, iOS Mail and Outlook for Mac,
|
|
110
|
+
* and this surface is read by Gmail, Yahoo and Mail.ru. The two sets are disjoint,
|
|
111
|
+
* and the AMP clients force-invert rather than honour a stylesheet — so they were
|
|
112
|
+
* never receiving our dark colours on the html surface either. What this surface
|
|
113
|
+
* adds is `:hover`, which those clients DO honour and which the html surface cannot
|
|
114
|
+
* reach them with. It trades nothing away.
|
|
115
|
+
*
|
|
116
|
+
* The boilerplate hides the body until the runtime unhides it — which is why an
|
|
117
|
+
* AMP document that cannot run scripts shows nothing at all, rather than showing
|
|
118
|
+
* an unstyled version of itself.
|
|
119
|
+
*/
|
|
120
|
+
export function ampShell(lines: EmailLine[]): string {
|
|
121
|
+
const inner = ampify(
|
|
122
|
+
lines
|
|
123
|
+
.map((line) => line.html)
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join("\n"),
|
|
126
|
+
);
|
|
127
|
+
// `amp4email` rather than the `⚡4email` the spec also allows: both are valid,
|
|
128
|
+
// and one of them survives every editor, terminal and diff it will ever cross.
|
|
129
|
+
//
|
|
130
|
+
// `data-css-strict` opts into AMP's strict CSS validation. The validator warns
|
|
131
|
+
// when it is absent and says it "may become an error in the future"; our CSS
|
|
132
|
+
// passes with and without it, so taking it now costs nothing and means that
|
|
133
|
+
// future arrives already handled.
|
|
134
|
+
//
|
|
135
|
+
// The shell wears its own recipes as CLASSES, where `htmlShell` inlines them —
|
|
136
|
+
// that asymmetry is the whole surface split, and it means `body` and `frame`
|
|
137
|
+
// need no special case: `AMP_RECIPE_RULES` already states every recipe's rule.
|
|
138
|
+
return `<!DOCTYPE html>
|
|
139
|
+
<html amp4email data-css-strict lang="en">
|
|
140
|
+
<head><meta charset="utf-8">
|
|
141
|
+
<script async src="https://cdn.ampproject.org/v0.js"></script>
|
|
142
|
+
<style amp4email-boilerplate>body{visibility:hidden}</style>
|
|
143
|
+
<style amp-custom>${AMP_RECIPE_RULES}
|
|
144
|
+
${hoverStyle(false)}</style></head>
|
|
145
|
+
<body ${styleClass("body")}>
|
|
146
|
+
<div ${styleClass("frame")}>
|
|
43
147
|
${inner}
|
|
44
148
|
</div>
|
|
45
149
|
</body>
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every declaration the email channel makes, and the two surfaces built from them
|
|
3
|
+
* (ADR-CONTRACTS-089).
|
|
4
|
+
*
|
|
5
|
+
* **Why this file exists.** AMP4EMAIL forbids inline `style` attributes; the html
|
|
6
|
+
* surface depends on them, because inline is the light baseline every client gets
|
|
7
|
+
* including the ones that strip `<style>` (see `./colors.ts`'s `roleDecl`). The two
|
|
8
|
+
* surfaces are opposed on exactly the styling axis, so the markup can belong to
|
|
9
|
+
* neither: `./render`, `./cta` and `./chat` emit CLASS-ONLY markup, and each shell
|
|
10
|
+
* spends this registry its own way — `htmlShell` inlines it, `ampShell` states it
|
|
11
|
+
* as `<style amp-custom>`. One markup, two transforms, no second copy to drift.
|
|
12
|
+
*
|
|
13
|
+
* This is deliberately NOT a third field on `EmailLine`. `../../renderer.ts`
|
|
14
|
+
* warns that the old `Block` "carried `html` and `text` together and would have
|
|
15
|
+
* carried a third surface bolted on"; a surface is a way of SPENDING the lines,
|
|
16
|
+
* not a thing a line carries.
|
|
17
|
+
*
|
|
18
|
+
* **Why this is not `./colors.ts`.** The split is not "colour vs layout" — it is
|
|
19
|
+
* *what a colour is* vs *which declarations an element makes, in what order*.
|
|
20
|
+
* Nothing here states a colour; it asks `./colors.ts` for one. That keeps colour
|
|
21
|
+
* changeable in exactly one place, and it means `__tests__/colors.test.ts`'s
|
|
22
|
+
* literal guard — which reads every `.ts` in this directory except `colors.ts` —
|
|
23
|
+
* covers this file for free. Keep it here, not in a subdirectory, and that stays
|
|
24
|
+
* true.
|
|
25
|
+
*
|
|
26
|
+
* **Why recipes and not utility classes.** A utility set (`cs-m-20`, `cs-fs-13`)
|
|
27
|
+
* cannot reproduce today's bytes, because declaration ORDER differs per slot:
|
|
28
|
+
* `dots-over-cta` is `font → dots → text-align → margin` and `dots-cell` is
|
|
29
|
+
* `width → text-align → font → dots`. Same declarations, different order, two
|
|
30
|
+
* recipes. A recipe carries the order; a utility set cannot. It also keeps the
|
|
31
|
+
* emitting modules readable — one class names one intent.
|
|
32
|
+
*
|
|
33
|
+
* INVARIANTS:
|
|
34
|
+
* - Pure data and pure functions of it. No clock, no environment, no I/O.
|
|
35
|
+
* - A recipe's declarations are UNTERMINATED. This module owns every separator,
|
|
36
|
+
* so a declaration cannot decide the joiner's output.
|
|
37
|
+
* - `cs-` classes ship; `csr-` classes do not — `__tests__/styles.test.ts`.
|
|
38
|
+
* - The html surface's bytes are locked by `../../__tests__/render-snapshot.test.ts`,
|
|
39
|
+
* which is what proves this registry reconstitutes them exactly.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { classNameFor, palette, type Role, roleDecl } from "./colors";
|
|
43
|
+
import { CTA_PAD, FONT_SIZE, MONO_DECL, SPACING } from "./constants";
|
|
44
|
+
|
|
45
|
+
/** One slot's declarations, in the order they are written. Unterminated. */
|
|
46
|
+
type Recipe = readonly string[];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every styled slot in the channel, named by what it is.
|
|
50
|
+
*
|
|
51
|
+
* Spacing-parameterized families are written out per `Spacing` rather than
|
|
52
|
+
* generated: `../../constants.ts` closes `Spacing` at three, the names are what
|
|
53
|
+
* `./render` reads, and three explicit lines are worth more than a clever fold.
|
|
54
|
+
*/
|
|
55
|
+
const RECIPES = {
|
|
56
|
+
// --- ./shells.ts ---------------------------------------------------------
|
|
57
|
+
body: [
|
|
58
|
+
MONO_DECL,
|
|
59
|
+
roleDecl("text"),
|
|
60
|
+
roleDecl("background", "background-color"),
|
|
61
|
+
"margin: 0",
|
|
62
|
+
"padding: 0",
|
|
63
|
+
],
|
|
64
|
+
/** The 520px column every email is poured into. */
|
|
65
|
+
frame: ["max-width: 520px", "margin: 0 auto"],
|
|
66
|
+
|
|
67
|
+
// --- ./render.ts ---------------------------------------------------------
|
|
68
|
+
"p-normal": [
|
|
69
|
+
MONO_DECL,
|
|
70
|
+
`font-size: ${FONT_SIZE}`,
|
|
71
|
+
`margin: ${SPACING.normal}`,
|
|
72
|
+
],
|
|
73
|
+
"p-tight": [MONO_DECL, `font-size: ${FONT_SIZE}`, `margin: ${SPACING.tight}`],
|
|
74
|
+
"p-none": [MONO_DECL, `font-size: ${FONT_SIZE}`, `margin: ${SPACING.none}`],
|
|
75
|
+
/** The `/* EOM *\/` marker above the signer. */
|
|
76
|
+
eom: [roleDecl("faint")],
|
|
77
|
+
"signature-link": [roleDecl("link"), "text-decoration: none"],
|
|
78
|
+
warning: [roleDecl("destructive")],
|
|
79
|
+
"hr-normal": [
|
|
80
|
+
"border: none",
|
|
81
|
+
`border-top: 1px solid ${palette("light").faint}`,
|
|
82
|
+
`margin: ${SPACING.normal}`,
|
|
83
|
+
],
|
|
84
|
+
"hr-tight": [
|
|
85
|
+
"border: none",
|
|
86
|
+
`border-top: 1px solid ${palette("light").faint}`,
|
|
87
|
+
`margin: ${SPACING.tight}`,
|
|
88
|
+
],
|
|
89
|
+
"hr-none": [
|
|
90
|
+
"border: none",
|
|
91
|
+
`border-top: 1px solid ${palette("light").faint}`,
|
|
92
|
+
`margin: ${SPACING.none}`,
|
|
93
|
+
],
|
|
94
|
+
"hero-normal": ["max-width: 100%", `margin: ${SPACING.normal}`],
|
|
95
|
+
"hero-tight": ["max-width: 100%", `margin: ${SPACING.tight}`],
|
|
96
|
+
"hero-none": ["max-width: 100%", `margin: ${SPACING.none}`],
|
|
97
|
+
|
|
98
|
+
// --- ./cta.ts ------------------------------------------------------------
|
|
99
|
+
"cta-box-normal": [
|
|
100
|
+
"display: inline-block",
|
|
101
|
+
`border: 1px solid ${palette("light").cta}`,
|
|
102
|
+
"border-radius: 2px",
|
|
103
|
+
`margin: ${SPACING.normal}`,
|
|
104
|
+
"max-width: 220px",
|
|
105
|
+
],
|
|
106
|
+
"cta-box-tight": [
|
|
107
|
+
"display: inline-block",
|
|
108
|
+
`border: 1px solid ${palette("light").cta}`,
|
|
109
|
+
"border-radius: 2px",
|
|
110
|
+
`margin: ${SPACING.tight}`,
|
|
111
|
+
"max-width: 220px",
|
|
112
|
+
],
|
|
113
|
+
"cta-box-none": [
|
|
114
|
+
"display: inline-block",
|
|
115
|
+
`border: 1px solid ${palette("light").cta}`,
|
|
116
|
+
"border-radius: 2px",
|
|
117
|
+
`margin: ${SPACING.none}`,
|
|
118
|
+
"max-width: 220px",
|
|
119
|
+
],
|
|
120
|
+
/** A linked button's cell holds no padding — the anchor carries it. */
|
|
121
|
+
"cta-cell-linked": [
|
|
122
|
+
"text-align: center",
|
|
123
|
+
MONO_DECL,
|
|
124
|
+
`font-size: ${FONT_SIZE}`,
|
|
125
|
+
],
|
|
126
|
+
/** An unlinked code is not clickable, so its padding stays on the cell. */
|
|
127
|
+
"cta-cell-code": [
|
|
128
|
+
`padding: ${CTA_PAD}`,
|
|
129
|
+
"text-align: center",
|
|
130
|
+
MONO_DECL,
|
|
131
|
+
`font-size: ${FONT_SIZE}`,
|
|
132
|
+
],
|
|
133
|
+
"cta-label": ["text-decoration: underline"],
|
|
134
|
+
/** `display: block` + the padding is what makes the WHOLE box the click target. */
|
|
135
|
+
"cta-anchor": [
|
|
136
|
+
"display: block",
|
|
137
|
+
`padding: ${CTA_PAD}`,
|
|
138
|
+
roleDecl("link"),
|
|
139
|
+
"text-decoration: none",
|
|
140
|
+
],
|
|
141
|
+
"cta-code": [roleDecl("link")],
|
|
142
|
+
|
|
143
|
+
// --- ./chat.ts -----------------------------------------------------------
|
|
144
|
+
"chat-rule-top": [
|
|
145
|
+
"border: none",
|
|
146
|
+
`border-top: 1px solid ${palette("light").faint}`,
|
|
147
|
+
"margin: 12px 0 24px 0",
|
|
148
|
+
],
|
|
149
|
+
"chat-rule-bottom": [
|
|
150
|
+
"border: none",
|
|
151
|
+
`border-top: 1px solid ${palette("light").faint}`,
|
|
152
|
+
"margin: 24px 0 12px 0",
|
|
153
|
+
],
|
|
154
|
+
/** A row that clears the bubbles: 24px. */
|
|
155
|
+
"chat-row-24": ["margin: 0 0 24px 0"],
|
|
156
|
+
/** A row hugging what follows it (a CTA, the dots): 16px. */
|
|
157
|
+
"chat-row-16": ["margin: 0 0 16px 0"],
|
|
158
|
+
"chat-attribution": [
|
|
159
|
+
MONO_DECL,
|
|
160
|
+
`font-size: ${FONT_SIZE}`,
|
|
161
|
+
roleDecl("meta"),
|
|
162
|
+
"text-align: right",
|
|
163
|
+
"padding-top: 6px",
|
|
164
|
+
"padding-right: 1ch",
|
|
165
|
+
],
|
|
166
|
+
"chat-avatar-left": [
|
|
167
|
+
MONO_DECL,
|
|
168
|
+
`font-size: ${FONT_SIZE}`,
|
|
169
|
+
"padding-right: 8px",
|
|
170
|
+
"vertical-align: bottom",
|
|
171
|
+
"white-space: nowrap",
|
|
172
|
+
],
|
|
173
|
+
/** The mirror that reserves the column's width without drawing the avatar. */
|
|
174
|
+
"chat-avatar-left-hidden": [
|
|
175
|
+
MONO_DECL,
|
|
176
|
+
`font-size: ${FONT_SIZE}`,
|
|
177
|
+
"padding-right: 8px",
|
|
178
|
+
"vertical-align: bottom",
|
|
179
|
+
"visibility: hidden",
|
|
180
|
+
"white-space: nowrap",
|
|
181
|
+
],
|
|
182
|
+
"chat-avatar-right": [
|
|
183
|
+
MONO_DECL,
|
|
184
|
+
`font-size: ${FONT_SIZE}`,
|
|
185
|
+
"padding-left: 8px",
|
|
186
|
+
"vertical-align: bottom",
|
|
187
|
+
"white-space: nowrap",
|
|
188
|
+
],
|
|
189
|
+
"chat-avatar-right-hidden": [
|
|
190
|
+
MONO_DECL,
|
|
191
|
+
`font-size: ${FONT_SIZE}`,
|
|
192
|
+
"padding-left: 8px",
|
|
193
|
+
"vertical-align: bottom",
|
|
194
|
+
"visibility: hidden",
|
|
195
|
+
"white-space: nowrap",
|
|
196
|
+
],
|
|
197
|
+
"chat-channel-left": ["width: 100%", "text-align: left"],
|
|
198
|
+
"chat-channel-right": ["width: 100%", "text-align: right"],
|
|
199
|
+
"chat-bubble-wrap": [
|
|
200
|
+
"display: inline-block",
|
|
201
|
+
"max-width: 100%",
|
|
202
|
+
"vertical-align: bottom",
|
|
203
|
+
],
|
|
204
|
+
"bubble-user": [
|
|
205
|
+
"border-radius: 8px 8px 0 8px",
|
|
206
|
+
"padding: 10px 14px",
|
|
207
|
+
"text-align: right",
|
|
208
|
+
MONO_DECL,
|
|
209
|
+
`font-size: ${FONT_SIZE}`,
|
|
210
|
+
roleDecl("onBubble"),
|
|
211
|
+
roleDecl("bubble", "background"),
|
|
212
|
+
],
|
|
213
|
+
"bubble-assistant": [
|
|
214
|
+
"border-radius: 8px 8px 8px 0",
|
|
215
|
+
"padding: 10px 14px",
|
|
216
|
+
MONO_DECL,
|
|
217
|
+
`font-size: ${FONT_SIZE}`,
|
|
218
|
+
roleDecl("onBubble"),
|
|
219
|
+
roleDecl("bubble", "background"),
|
|
220
|
+
],
|
|
221
|
+
/** Dots sharing the CTA's inline-block, so they centre over its exact width. */
|
|
222
|
+
"dots-over-cta": [
|
|
223
|
+
MONO_DECL,
|
|
224
|
+
"font-size: 20px",
|
|
225
|
+
"font-weight: bold",
|
|
226
|
+
roleDecl("dots"),
|
|
227
|
+
"text-align: center",
|
|
228
|
+
"margin: 0 0 16px 0",
|
|
229
|
+
],
|
|
230
|
+
/** Standalone dots, centred in the message channel. */
|
|
231
|
+
"dots-cell": [
|
|
232
|
+
"width: 100%",
|
|
233
|
+
"text-align: center",
|
|
234
|
+
MONO_DECL,
|
|
235
|
+
"font-size: 20px",
|
|
236
|
+
"font-weight: bold",
|
|
237
|
+
roleDecl("dots"),
|
|
238
|
+
],
|
|
239
|
+
"cta-stack": ["display: inline-block", "text-align: left"],
|
|
240
|
+
} as const satisfies Record<string, Recipe>;
|
|
241
|
+
|
|
242
|
+
/** Every styled slot, named. */
|
|
243
|
+
export type StyleName = keyof typeof RECIPES;
|
|
244
|
+
|
|
245
|
+
/** Every recipe name, for tests to iterate. */
|
|
246
|
+
export const STYLE_NAMES = Object.keys(RECIPES) as StyleName[];
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The prefix that marks a class as the inliner's to consume.
|
|
250
|
+
*
|
|
251
|
+
* This is the whole mechanism. A `cs-` role class is a HOOK: `darkStyle` needs it
|
|
252
|
+
* in the shipped markup, so it survives. A `csr-` recipe class is a BUNDLE OF
|
|
253
|
+
* DECLARATIONS: the html surface spends it and drops it, the AMP surface keeps it
|
|
254
|
+
* and states its rule. One prefix test separates them — for `inlineStyles` below,
|
|
255
|
+
* and for `__tests__/colors.test.ts`, which asserts every SHIPPED class has a dark
|
|
256
|
+
* rule and would otherwise trip over every recipe class in this file.
|
|
257
|
+
*/
|
|
258
|
+
const RECIPE_PREFIX = "csr-";
|
|
259
|
+
|
|
260
|
+
/** `bubble-user` → `csr-bubble-user`. Derived, never hand-typed. */
|
|
261
|
+
function recipeClass(name: StyleName): string {
|
|
262
|
+
return `${RECIPE_PREFIX}${name}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The class attribute for one slot: its roles, then its recipe.
|
|
267
|
+
*
|
|
268
|
+
* Roles come FIRST so that dropping the recipe leaves exactly the class attribute
|
|
269
|
+
* the html surface shipped before this registry existed — which is what makes
|
|
270
|
+
* `inlineStyles` a byte-for-byte identity rather than a re-render.
|
|
271
|
+
*/
|
|
272
|
+
export function styleClass(name: StyleName, ...roles: Role[]): string {
|
|
273
|
+
const classes = [...roles.map(classNameFor), recipeClass(name)];
|
|
274
|
+
return `class="${classes.join(" ")}"`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** `border-radius: 8px; padding: 10px 14px;` — one recipe, inline. */
|
|
278
|
+
export function inlineOf(name: StyleName): string {
|
|
279
|
+
return `${RECIPES[name].join("; ")};`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `.csr-bubble-user { border-radius: 8px; … }` — one recipe, as a rule. */
|
|
283
|
+
export function ruleOf(name: StyleName): string {
|
|
284
|
+
return `.${recipeClass(name)} { ${inlineOf(name)} }`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Class-only markup → the html surface's markup.
|
|
289
|
+
*
|
|
290
|
+
* It never looks for elements; it rewrites CLASS ATTRIBUTES. Recipe classes become
|
|
291
|
+
* an inline `style`, role classes survive untouched, and a class attribute left
|
|
292
|
+
* with nothing in it disappears — which is what collapses `<p class="csr-p-normal">`
|
|
293
|
+
* back to `<p style="…">` rather than leaving a `class=""` behind.
|
|
294
|
+
*
|
|
295
|
+
* **Why a regex is safe here, and only here.** We control every class this can
|
|
296
|
+
* match, and `./escape-html.ts` escapes `"` — so no escaped content can close an
|
|
297
|
+
* attribute and forge one. The two values that do NOT pass through `escapeHtml`
|
|
298
|
+
* would be the hole, which is why `./cta.ts` and `./render.ts` escape `href` and
|
|
299
|
+
* `src` before interpolating: without that, a URL containing `" class="csr-body`
|
|
300
|
+
* is an HTML injection today and a style injection here.
|
|
301
|
+
*/
|
|
302
|
+
export function inlineStyles(html: string): string {
|
|
303
|
+
return html.replace(/class="([^"]*)"/g, (_match, value: string) => {
|
|
304
|
+
const names = value.split(" ").filter(Boolean);
|
|
305
|
+
const hooks = names.filter((name) => !name.startsWith(RECIPE_PREFIX));
|
|
306
|
+
const decls = names
|
|
307
|
+
.filter((name) => name.startsWith(RECIPE_PREFIX))
|
|
308
|
+
.flatMap(
|
|
309
|
+
(name) => RECIPES[name.slice(RECIPE_PREFIX.length) as StyleName],
|
|
310
|
+
);
|
|
311
|
+
return [
|
|
312
|
+
hooks.length ? `class="${hooks.join(" ")}"` : "",
|
|
313
|
+
decls.length ? `style="${decls.join("; ")};"` : "",
|
|
314
|
+
]
|
|
315
|
+
.filter(Boolean)
|
|
316
|
+
.join(" ");
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Class-only markup → the AMP surface's markup.
|
|
322
|
+
*
|
|
323
|
+
* The mirror of `inlineStyles`, and the reason both exist: that one resolves the
|
|
324
|
+
* surfaces' disagreement about STYLE, this one their disagreement about TAGS. AMP
|
|
325
|
+
* keeps the classes (its stylesheet is what needs them), so all this has to do is
|
|
326
|
+
* the one structural swap the surfaces cannot share.
|
|
327
|
+
*
|
|
328
|
+
* `<img>` → `<amp-img>`, which AMP requires and which needs explicit dimensions to
|
|
329
|
+
* reserve layout before the image loads. `../../content.ts` makes them optional,
|
|
330
|
+
* because a channel that can measure an image should not be told — so when they
|
|
331
|
+
* are absent this surface CANNOT draw the image, and says the `alt` text instead.
|
|
332
|
+
* That is a degradation, not a lie: `alt` is required exactly so a channel with
|
|
333
|
+
* nothing else has something to say, and the html surface still draws the picture.
|
|
334
|
+
*
|
|
335
|
+
* This does not contradict `supports("heroImage") === true` — `../../renderer.ts`
|
|
336
|
+
* puts `supports` on the RENDERER, and AMP is one of the email renderer's surfaces,
|
|
337
|
+
* not a renderer. Email can depict a hero image. One of its three MIME parts is
|
|
338
|
+
* plain text, which cannot, and that has never made the claim false.
|
|
339
|
+
*/
|
|
340
|
+
export function ampify(html: string): string {
|
|
341
|
+
return html.replace(/<img ([^>]*)>/g, (_match, attrs: string) => {
|
|
342
|
+
const sized = /\bwidth="\d+"/.test(attrs) && /\bheight="\d+"/.test(attrs);
|
|
343
|
+
if (sized) return `<amp-img ${attrs} layout="intrinsic"></amp-img>`;
|
|
344
|
+
const alt = /\balt="([^"]*)"/.exec(attrs)?.[1] ?? "";
|
|
345
|
+
// Carry the image's own spacing over to the paragraph replacing it, so the
|
|
346
|
+
// gap below it does not change with the fallback.
|
|
347
|
+
const spacing = /\bcsr-hero-(\w+)\b/.exec(attrs)?.[1] ?? "normal";
|
|
348
|
+
return `<p class="${RECIPE_PREFIX}p-${spacing}">${alt}</p>`;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Every recipe as a rule, for `<style amp-custom>`.
|
|
354
|
+
*
|
|
355
|
+
* ALL of them, always — not just the ones this email happens to use. Emitting the
|
|
356
|
+
* used subset would make the stylesheet a function of the content, so two emails
|
|
357
|
+
* would carry two different stylesheets and the snapshot would stop being a
|
|
358
|
+
* statement about the channel. ~40 rules is about 4KB against AMP's 75,000-byte
|
|
359
|
+
* budget, which `__tests__/amp.test.ts` holds us to.
|
|
360
|
+
*/
|
|
361
|
+
export const AMP_RECIPE_RULES = STYLE_NAMES.map(ruleOf).join("\n");
|