@company-semantics/contracts 35.1.0 → 36.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
- package/src/notifications/content.ts +13 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +122 -11
- package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
- package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +86 -0
- package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
- package/src/notifications/renderers/email/chat.ts +41 -30
- package/src/notifications/renderers/email/colors.ts +342 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +60 -10
- package/src/notifications/renderers/email/index.ts +19 -4
- package/src/notifications/renderers/email/render.ts +18 -15
- package/src/notifications/renderers/email/shells.ts +112 -9
- package/src/notifications/renderers/email/styles.ts +361 -0
- package/src/notifications/renderers/slack/README.md +66 -25
- package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
- package/src/notifications/renderers/slack/index.ts +192 -107
|
@@ -13,7 +13,16 @@
|
|
|
13
13
|
* character-for-character.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
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";
|
|
17
26
|
|
|
18
27
|
/**
|
|
19
28
|
* One rendered line — both presentations of one thing the notification says,
|
|
@@ -30,17 +39,111 @@ export interface EmailLine {
|
|
|
30
39
|
spacing: Spacing;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
|
-
/**
|
|
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
|
+
*/
|
|
34
61
|
export function htmlShell(lines: EmailLine[]): string {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
);
|
|
39
72
|
return `<!DOCTYPE html>
|
|
40
73
|
<html lang="en">
|
|
41
|
-
<head><meta charset="UTF-8"
|
|
42
|
-
<
|
|
43
|
-
<
|
|
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")}>
|
|
44
147
|
${inner}
|
|
45
148
|
</div>
|
|
46
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");
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
The Slack channel (ADR-CONTRACTS-086) —
|
|
6
|
-
|
|
5
|
+
The Slack channel (ADR-CONTRACTS-086, ADR-CONTRACTS-090) —
|
|
6
|
+
`slackRenderer: Renderer<SlackMessage>`.
|
|
7
7
|
|
|
8
8
|
Where `../sms` proves the content model survives the poorest channel, this proves
|
|
9
9
|
the other edge: a channel whose output is a tree of typed blocks — neither a
|
|
@@ -12,23 +12,52 @@ record, a string and a different record, which is `../../renderer.ts`'s "each
|
|
|
12
12
|
renderer returns its natural type" held by real modules rather than asserted about
|
|
13
13
|
the type itself.
|
|
14
14
|
|
|
15
|
-
The whole channel is one `index.ts` — the
|
|
16
|
-
renderer. `../email` splits across seven modules because it carries real markup;
|
|
17
|
-
|
|
15
|
+
The whole channel is one `index.ts` — the surfaces, the escaping, and the
|
|
16
|
+
renderer. `../email` splits across seven modules because it carries real markup;
|
|
17
|
+
this one composes blocks and has no markup to own.
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## The blocks are Slack's, not ours
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
`SlackMessage.blocks` is `KnownBlock[]` from **`@slack/types`** — the published
|
|
22
|
+
type package behind `node-slack-sdk`, so it is the same union Slack's API
|
|
23
|
+
validates against. This channel used to hand-write a five-block approximation;
|
|
24
|
+
ADR-CONTRACTS-090 retires it. A renderer whose output type is invented can drift
|
|
25
|
+
from the wire format silently and nothing in this package would notice — the
|
|
26
|
+
compiler is the only thing that can, and only if the types are theirs.
|
|
27
|
+
|
|
28
|
+
`index.ts` re-exports the block types it uses, so a consumer can name them without
|
|
29
|
+
taking its own dependency on `@slack/types`. Widen that list rather than
|
|
30
|
+
redeclaring a shape.
|
|
27
31
|
|
|
28
32
|
The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
|
|
29
33
|
wire format and those are its names. That is deliberate, not a lapse in the
|
|
30
34
|
repo's camelCase convention.
|
|
31
35
|
|
|
36
|
+
## What is still non-functional
|
|
37
|
+
|
|
38
|
+
- **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
|
|
39
|
+
renders; delivery and the decision to deliver are the backend's —
|
|
40
|
+
`SlackChannelDelivery` throws `ChannelNotImplementedError` by design.
|
|
41
|
+
- **The copy is invented**, not relocated from a shipped Slack app. There is no
|
|
42
|
+
golden here the way `../email` has one. The SHAPE is legitimate Block Kit and
|
|
43
|
+
`__tests__/index.test.ts` pins it; the WORDING is expected to change when a kind
|
|
44
|
+
is actually posted to Slack.
|
|
45
|
+
|
|
46
|
+
## Element → block
|
|
47
|
+
|
|
48
|
+
| Element | Block |
|
|
49
|
+
| --------------------------------- | ---------------------------------------------------------- |
|
|
50
|
+
| `metadata.title` | `header` (+ the `text` fallback) |
|
|
51
|
+
| `greeting`, `body`, `warning` | `section` (mrkdwn) |
|
|
52
|
+
| `keyValueTable` | `section.fields` — Slack's two-column facts, chunked at 10 |
|
|
53
|
+
| `callToAction` **with** `href` | `actions` + `button` |
|
|
54
|
+
| `callToAction` **without** `href` | `rich_text` › `rich_text_preformatted` |
|
|
55
|
+
| `list` | `rich_text` › `rich_text_list` (`bullet` / `ordered`) |
|
|
56
|
+
| `divider` | `divider` |
|
|
57
|
+
| `metadata`, `notice`, `signature` | `context` |
|
|
58
|
+
| `heroImage` | `image` |
|
|
59
|
+
| `chatUnit` | _declined_ |
|
|
60
|
+
|
|
32
61
|
## The one decline
|
|
33
62
|
|
|
34
63
|
`supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
|
|
@@ -37,9 +66,9 @@ attribution that IS the meaning — which the content model names as the reason
|
|
|
37
66
|
decline a whole element rather than approximate it.
|
|
38
67
|
|
|
39
68
|
Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
|
|
40
|
-
has no surface for**. That difference is the point of shipping
|
|
41
|
-
than one: same `NotificationContent`, no channel tags on it, three channels
|
|
42
|
-
differing capability each keeping what they can show.
|
|
69
|
+
has no surface for**. That difference is the point of shipping three channels
|
|
70
|
+
rather than one: same `NotificationContent`, no channel tags on it, three channels
|
|
71
|
+
of differing capability each keeping what they can show.
|
|
43
72
|
|
|
44
73
|
`metadata` and `notice` become `context` blocks — Slack's own device for
|
|
45
74
|
de-emphasised small print, which is exactly what `MetadataElement` exists to be.
|
|
@@ -52,20 +81,32 @@ de-emphasised small print, which is exactly what `MetadataElement` exists to be.
|
|
|
52
81
|
nowhere to hide.
|
|
53
82
|
- `supports` is total over `NotificationElementType` and AGREES with `render`:
|
|
54
83
|
`chatUnit` is omitted, never approximated and never thrown on.
|
|
55
|
-
-
|
|
56
|
-
`escapeMrkdwn`
|
|
57
|
-
|
|
58
|
-
|
|
84
|
+
- **Escaping follows the SURFACE, not the string.** Every user-controlled string
|
|
85
|
+
reaching an **mrkdwn** surface passes through `escapeMrkdwn` — the same rule
|
|
86
|
+
`../email` applies with `escapeHtml`. But `plain_text` (header, button label)
|
|
87
|
+
and `rich_text` (the OTP code, list items) are **literal**: escaping them is a
|
|
88
|
+
DEFECT, because those surfaces do not parse the entities and the reader sees
|
|
89
|
+
`Acme & Co`. The old subset had no literal surface beyond a button label, so
|
|
90
|
+
the distinction only became load-bearing with `header` and `rich_text`. The
|
|
91
|
+
compiler cannot help — both surfaces are `string` — so
|
|
92
|
+
`__tests__/index.test.ts` pins both halves.
|
|
93
|
+
- Slack's documented LIMITS are enforced here, not left to the API to reject: a
|
|
94
|
+
header truncates at 150 characters, `section.fields` chunks at 10. These are
|
|
95
|
+
400s from `chat.postMessage`, not fidelity gaps.
|
|
59
96
|
- `renderElement` switches over all TWELVE element types, not the eleven
|
|
60
97
|
`supports` admits. The declined arm is unreachable; writing it out is what makes
|
|
61
98
|
the compiler prove the two lists agree. Do not collapse it into a `default` —
|
|
62
99
|
that would silently swallow a thirteenth member of the union.
|
|
63
|
-
- A `callToAction` with no `href` becomes
|
|
64
|
-
means the label IS the payload (an OTP code); a button with no
|
|
65
|
-
lie about what the notification is asking for.
|
|
66
|
-
- `
|
|
67
|
-
|
|
68
|
-
|
|
100
|
+
- A `callToAction` with no `href` becomes a code block, never a button. `href`
|
|
101
|
+
absent means the label IS the payload (an OTP code); a button with no
|
|
102
|
+
destination is a lie about what the notification is asking for.
|
|
103
|
+
- `metadata.title` is spent TWICE and neither use replaces the other:
|
|
104
|
+
`SlackMessage.text` is the fallback Slack shows where blocks cannot render (a
|
|
105
|
+
push notification, a screen reader), and the `header` block is what the message
|
|
106
|
+
looks like. It is the same content-level fact email spends as its subject —
|
|
107
|
+
three channel uses of one fact, which is why it lives on the content rather than
|
|
108
|
+
in a channel's registry. Only the block carries the 150-char limit; the fallback
|
|
109
|
+
is untruncated.
|
|
69
110
|
- Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
|
|
70
111
|
lost the model; the fix for a missing fact is upstream in `compose`.
|
|
71
112
|
- This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
|