@company-semantics/contracts 35.1.0 → 37.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +615 -215
- package/src/notifications/content.ts +14 -1
- package/src/notifications/index.ts +15 -9
- package/src/notifications/renderers/email/README.md +120 -11
- 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 +42 -30
- package/src/notifications/renderers/email/colors.ts +345 -0
- package/src/notifications/renderers/email/constants.ts +27 -20
- package/src/notifications/renderers/email/cta.ts +58 -10
- package/src/notifications/renderers/email/index.ts +15 -3
- package/src/notifications/renderers/email/render.ts +16 -15
- package/src/notifications/renderers/email/shells.ts +49 -9
- package/src/notifications/renderers/email/styles.ts +319 -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
|
@@ -138,11 +138,24 @@ export interface Signature {
|
|
|
138
138
|
signer?: string;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* A leading image. `alt` is required — a text-only channel has nothing else.
|
|
143
|
+
*
|
|
144
|
+
* `width`/`height` are the image's intrinsic pixel dimensions. They are optional
|
|
145
|
+
* because a channel that can measure an image itself does not need to be told, and
|
|
146
|
+
* requiring them would put a rendering constraint into channel-agnostic content.
|
|
147
|
+
* But a channel MAY be unable to draw one without them, and the constraint is real
|
|
148
|
+
* even though the surface that first raised it is gone: AMP's `<amp-img>` required
|
|
149
|
+
* explicit dimensions to reserve layout, and the email channel's AMP surface fell
|
|
150
|
+
* back to the `alt` text without them (retired in ADR-CONTRACTS-091). Supplying
|
|
151
|
+
* them is what makes the image drawable everywhere.
|
|
152
|
+
*/
|
|
142
153
|
export interface HeroImage {
|
|
143
154
|
type: "heroImage";
|
|
144
155
|
src: string;
|
|
145
156
|
alt: string;
|
|
157
|
+
width?: number;
|
|
158
|
+
height?: number;
|
|
146
159
|
}
|
|
147
160
|
|
|
148
161
|
// =============================================================================
|
|
@@ -58,17 +58,23 @@ export type { RenderedEmail } from "./renderers/email";
|
|
|
58
58
|
|
|
59
59
|
export { emailRenderer } from "./renderers/email";
|
|
60
60
|
|
|
61
|
+
// The `Slack*` block types this barrel used to export were a hand-written subset
|
|
62
|
+
// of Block Kit and are GONE (ADR-CONTRACTS-090). Slack's own `@slack/types` is
|
|
63
|
+
// the vocabulary now, re-exported here so a consumer need not depend on it
|
|
64
|
+
// directly: `SlackBlock` -> `KnownBlock`, `SlackSectionBlock` -> `SectionBlock`,
|
|
65
|
+
// `SlackMrkdwnText` -> `MrkdwnElement`, `SlackPlainText` -> `PlainTextElement`.
|
|
61
66
|
export type {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
ActionsBlock,
|
|
68
|
+
ContextBlock,
|
|
69
|
+
DividerBlock,
|
|
70
|
+
HeaderBlock,
|
|
71
|
+
ImageBlock,
|
|
72
|
+
KnownBlock,
|
|
73
|
+
MrkdwnElement,
|
|
74
|
+
PlainTextElement,
|
|
75
|
+
RichTextBlock,
|
|
76
|
+
SectionBlock,
|
|
68
77
|
SlackMessage,
|
|
69
|
-
SlackMrkdwnText,
|
|
70
|
-
SlackPlainText,
|
|
71
|
-
SlackSectionBlock,
|
|
72
78
|
} from "./renderers/slack";
|
|
73
79
|
|
|
74
80
|
export { slackRenderer } from "./renderers/slack";
|
|
@@ -10,21 +10,81 @@ 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` / `textShell` — what makes one email two surfaces |
|
|
20
|
+
| `styles.ts` | every declaration, as recipes — plus `inlineStyles` |
|
|
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
|
+
## Two surfaces, one markup (ADR-CONTRACTS-089, -091)
|
|
31
|
+
|
|
32
|
+
An email is a `multipart/alternative`: `text/plain` and `text/html` are two
|
|
33
|
+
presentations of ONE message, and `RenderedEmail` carries both.
|
|
34
|
+
|
|
35
|
+
`render.ts`, `cta.ts` and `chat.ts` emit CLASS-ONLY markup, and `htmlShell` runs
|
|
36
|
+
`inlineStyles`, which turns recipe classes back into the exact inline styles this
|
|
37
|
+
channel has always shipped.
|
|
38
|
+
|
|
39
|
+
`EmailLine` gains no third field: `../../renderer.ts` warns against exactly that,
|
|
40
|
+
and a surface is a way of SPENDING the lines rather than a thing a line carries.
|
|
41
|
+
The cost is that `EmailLine.html` is an intermediate representation — `render.ts`
|
|
42
|
+
does not read as the bytes it emits, and the snapshot is where the real markup is
|
|
43
|
+
legible.
|
|
44
|
+
|
|
45
|
+
### There was a third surface, and removing it is the point
|
|
46
|
+
|
|
47
|
+
`text/x-amp-html` shipped in 36.0.0 and was retired in 37.0.0. The class-only split
|
|
48
|
+
above exists BECAUSE of it — AMP forbids inline `style`, the html surface is built
|
|
49
|
+
from it — and the split stays: the registry is now the single source for a recipe,
|
|
50
|
+
and the markup is byte-identical either way (removing AMP changed the snapshot by
|
|
51
|
+
1965 deletions and **zero** insertions).
|
|
52
|
+
|
|
53
|
+
AMP bought `:hover` in Gmail. It cost Gmail iOS its dark mode, and we did not know
|
|
54
|
+
that when we shipped it: **Gmail iOS inverts the html part and does NOT touch an AMP
|
|
55
|
+
part**, so having one opts those readers out of the only dark scheme they get. That
|
|
56
|
+
is measured, not argued — `company-semantics-backend/docs/dark-mode-probe.md`, and
|
|
57
|
+
it is a fact the public record does not contain. A hover state is worth less than a
|
|
58
|
+
legible email (ADR-CONTRACTS-091).
|
|
59
|
+
|
|
60
|
+
Two things we learned the expensive way, so nobody re-derives them:
|
|
61
|
+
|
|
62
|
+
- `prefers-color-scheme` in `<style amp-custom>` is a hard AMP4EMAIL validator
|
|
63
|
+
ERROR. Real, permanent, frozen since 2023.
|
|
64
|
+
- The `media` ATTRIBUTE on an AMP element takes the same feature and PASSES the
|
|
65
|
+
validator — and Gmail's runtime ignores it entirely, rendering both a dark-gated
|
|
66
|
+
and a light-gated block. Validator-legal, runtime-inert. That door is closed.
|
|
67
|
+
|
|
68
|
+
## Colour — the thing this channel has and the others do not
|
|
69
|
+
|
|
70
|
+
`colors.ts` is the only place a colour lives, and it is the channel's own rather
|
|
71
|
+
than the layer's. Email hand-authors colour because it is the only channel with
|
|
72
|
+
no client-side semantic vocabulary to lean on: `../slack` says `:warning:` and
|
|
73
|
+
`context` and lets Slack's client theme them (Block Kit exposes no author-set
|
|
74
|
+
colour at all), and `../sms` has none to have. A shared `notifications/colors.ts`
|
|
75
|
+
would look DRY and would put a channel's presentation back inside the
|
|
76
|
+
channel-agnostic layer — the coupling `../../renderer.ts` exists to prevent. A
|
|
77
|
+
channel that needs colour gets its own file next to its own renderer.
|
|
78
|
+
|
|
79
|
+
Colour is named by ROLE, and a scheme owes an answer to every role. Roles are
|
|
80
|
+
meaning, not coincidence: `border`, `bubble`, `meta` and `dots` all answer `#666`
|
|
81
|
+
in light and are still four roles, because a CTA's border and a chat bubble's
|
|
82
|
+
fill have no reason to move together.
|
|
83
|
+
|
|
84
|
+
The channel renders in **one or more schemes** — two today (`light`, `dark`), and
|
|
85
|
+
callers ask `palette(scheme)` rather than importing a concrete palette, so a
|
|
86
|
+
third is an entry in a table rather than an edit to every call site.
|
|
87
|
+
|
|
28
88
|
## Spacing — the thing the content model does not carry
|
|
29
89
|
|
|
30
90
|
The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
|
|
@@ -71,8 +131,57 @@ expect to style it properly rather than trust the placeholder.
|
|
|
71
131
|
- Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
|
|
72
132
|
work. Nothing here re-formats a value.
|
|
73
133
|
- Every user-controlled field in the HTML surface passes through `escapeHtml`
|
|
74
|
-
before interpolation
|
|
75
|
-
|
|
134
|
+
before interpolation — INCLUDING `href` and `src`, which reach the markup as
|
|
135
|
+
URLs rather than as text and were interpolated raw until ADR-CONTRACTS-089. A
|
|
136
|
+
URL that can close its attribute can forge another; under `inlineStyles` it
|
|
137
|
+
could also forge a `csr-` class and write any declaration in the registry into
|
|
138
|
+
someone else's element. That is also what makes the regex in `inlineStyles` safe:
|
|
139
|
+
nothing that reaches it can contain an unescaped `"`.
|
|
140
|
+
- The markup is CLASS-ONLY. `cs-` role classes SHIP (the dark stylesheet needs the
|
|
141
|
+
hook); `csr-` recipe classes DO NOT — `inlineStyles` spends and drops them. That
|
|
142
|
+
prefix split is a mechanism, not a convention: it is what lets one markup serve
|
|
143
|
+
two surfaces, and it is why `__tests__/colors.test.ts` needs no filter — it scans
|
|
144
|
+
rendered output, where recipe classes no longer exist.
|
|
145
|
+
- `MONO_DECL` / `FONT_SIZE` / `SPACING` / `CTA_PAD` are this channel's own and are
|
|
146
|
+
NOT exported
|
|
76
147
|
from the domain barrel: a margin is not vocabulary. They were duplicated from
|
|
77
148
|
the old `email/render/blocks`'s private copies while both layers existed; that
|
|
78
149
|
duplication ended with the directory, and these are now the only copies.
|
|
150
|
+
`colors.ts` is unexported for the same reason — a colour is not vocabulary
|
|
151
|
+
either.
|
|
152
|
+
- NO module here states a colour. Every colour comes from `colors.ts` via a role;
|
|
153
|
+
`__tests__/colors.test.ts` fails on a raw hex anywhere else in the directory,
|
|
154
|
+
including one in a comment, because a comment naming a literal goes stale the
|
|
155
|
+
first time the palette is tuned.
|
|
156
|
+
- Light is inline and dark is the `<style>` block, and that is the mechanism rather
|
|
157
|
+
than duplication: one source each for two schemes, both from `colors.ts`. Every
|
|
158
|
+
dark declaration carries `!important` because the inline light colour would
|
|
159
|
+
otherwise win on specificity and dark would silently do nothing.
|
|
160
|
+
- `:hover` is the one exception, because it has no inline form: BOTH schemes state
|
|
161
|
+
it in the stylesheet (`BASE_STYLE` light, `DARK_STYLE` dark, resolved by source
|
|
162
|
+
order). It is opportunistic — Apple Mail honours it, Gmail and classic Outlook do
|
|
163
|
+
not — and needs no fallback, because without it the button simply rests.
|
|
164
|
+
- **`DARK_STYLE` reaches Apple Mail, iOS Mail and Outlook for Mac, and nobody else.**
|
|
165
|
+
No Gmail client honours `prefers-color-scheme` on any surface; Yahoo rewrites the
|
|
166
|
+
query. A Gmail reader's dark mode is whatever their client does to our light
|
|
167
|
+
colours unaided: Gmail iOS inverts the html part, Gmail web does nothing at all.
|
|
168
|
+
Closing the Gmail-web gap means inversion-indifferent authoring HERE — mid-tones,
|
|
169
|
+
no pure `#ffffff`/`#000000` in large fields, solid-fill CTAs — not another surface.
|
|
170
|
+
- Role hooks (`cs-faint`, `cs-bubble`, `cs-cta`, `cs-link`) carry their colours via
|
|
171
|
+
each recipe's `roleDecl`; their only stylesheet rules live in `DARK_STYLE`.
|
|
172
|
+
- A hover hook goes on a CTA ONLY when it has an `href`. An unlinked label is a
|
|
173
|
+
payload (an OTP code), and a hover state on it would promise a click that does
|
|
174
|
+
not exist — the same invariant that keeps it a `<span>` rather than an `<a>`.
|
|
175
|
+
- A linked CTA's whole box is the click target: the padding sits on the `<a>`
|
|
176
|
+
(`display: block`), not the cell. Outlook ignores `display`, so an
|
|
177
|
+
`<!--[if mso]>` block restores the cell padding there and the box degrades to
|
|
178
|
+
today's text click. The padding is one constant shared by the anchor, the cell,
|
|
179
|
+
and the MSO block — they must agree.
|
|
180
|
+
- The `color-scheme` metas and `DARK_STYLE` ship together. The declaration is a
|
|
181
|
+
promise: made alone it stops Apple Mail protecting our colours while leaving
|
|
182
|
+
light text on a dark background — worse than declaring light-only. This channel
|
|
183
|
+
WAS light-only by construction until ADR-CONTRACTS-088; it is not any more.
|
|
184
|
+
- Dark reaches Apple Mail, iOS Mail and Outlook for Mac. Gmail and Outlook
|
|
185
|
+
force-invert and are unreachable — the dark path is additive, and a client that
|
|
186
|
+
strips `<style>` keeps the inline light colours, which is the status quo rather
|
|
187
|
+
than a degradation.
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that keep `../colors.ts` the only place colour lives
|
|
3
|
+
* (ADR-CONTRACTS-088).
|
|
4
|
+
*
|
|
5
|
+
* The markup is not asserted here — `../../../__tests__/render-snapshot.test.ts`
|
|
6
|
+
* locks that. These are the claims a snapshot cannot make, because a snapshot
|
|
7
|
+
* records what the output IS and every one of these is about what it must never
|
|
8
|
+
* become:
|
|
9
|
+
*
|
|
10
|
+
* - A dark stylesheet is invisible to the snapshot's reviewer in the sense that
|
|
11
|
+
* matters: you can read the `<style>` block in a diff and still not notice that
|
|
12
|
+
* a role is missing from it, because the light bytes look perfect either way.
|
|
13
|
+
* Dark mode fails silently. These tests are the alarm.
|
|
14
|
+
* - `DARK_STYLE` is a hand-written template, deliberately (it reads as the CSS it
|
|
15
|
+
* is). The cost of a template over generated output is that it can forget a
|
|
16
|
+
* role; this is where that cost is paid back.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
import { describe, expect, it } from "vitest";
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
NotificationContent,
|
|
27
|
+
NotificationElement,
|
|
28
|
+
} from "../../../content";
|
|
29
|
+
import type { RenderContext } from "../../../context";
|
|
30
|
+
import {
|
|
31
|
+
BASE_STYLE,
|
|
32
|
+
CLASS_ROLES,
|
|
33
|
+
DARK_STYLE,
|
|
34
|
+
HOVER_ROLES,
|
|
35
|
+
palette,
|
|
36
|
+
ROLES,
|
|
37
|
+
roleClass,
|
|
38
|
+
} from "../colors";
|
|
39
|
+
import { emailRenderer } from "../index";
|
|
40
|
+
|
|
41
|
+
const CONTEXT: RenderContext = {
|
|
42
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** The class name inside a role's attribute — `cs-meta` from `class="cs-meta"`. */
|
|
46
|
+
function classNameOf(role: (typeof ROLES)[number]): string {
|
|
47
|
+
const match = roleClass(role).match(/class="([^"]+)"/);
|
|
48
|
+
if (!match) throw new Error(`roleClass(${role}) is not a class attribute`);
|
|
49
|
+
return match[1];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One notification exercising every colour-bearing element. The chat unit carries
|
|
54
|
+
* both continuation shapes on purpose: dots followed by a CTA fold into it, and
|
|
55
|
+
* dots followed by nothing render standalone — two different emitters, two
|
|
56
|
+
* chances to forget a class.
|
|
57
|
+
*/
|
|
58
|
+
const EVERY_COLOURED_ELEMENT: NotificationContent = {
|
|
59
|
+
metadata: { kind: "chat.shared", title: "Colour coverage" },
|
|
60
|
+
sections: [
|
|
61
|
+
{
|
|
62
|
+
elements: [
|
|
63
|
+
{ type: "warning" },
|
|
64
|
+
{ type: "divider" },
|
|
65
|
+
{ type: "callToAction", label: "Open", href: "https://example.com/a" },
|
|
66
|
+
{
|
|
67
|
+
type: "chatUnit",
|
|
68
|
+
items: [
|
|
69
|
+
{ type: "message", role: "user", text: "Hello", from: "Sam Chen" },
|
|
70
|
+
{ type: "continuation" },
|
|
71
|
+
{
|
|
72
|
+
type: "callToAction",
|
|
73
|
+
label: "Reply",
|
|
74
|
+
href: "https://example.com/b",
|
|
75
|
+
},
|
|
76
|
+
{ type: "message", role: "assistant", text: "Hi back" },
|
|
77
|
+
{ type: "continuation" },
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{ type: "signature" },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe("email colours", () => {
|
|
87
|
+
it("gives every role a dark answer that reaches the stylesheet", () => {
|
|
88
|
+
const dark = palette("dark");
|
|
89
|
+
for (const role of ROLES) {
|
|
90
|
+
expect(DARK_STYLE, `role "${role}" is absent from DARK_STYLE`).toContain(
|
|
91
|
+
dark[role],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("writes a rule for every class-bearing role", () => {
|
|
97
|
+
for (const role of CLASS_ROLES) {
|
|
98
|
+
// `:hover` roles are selected as `.cs-x:hover {`, resting ones as `.cs-x {`.
|
|
99
|
+
const selector = new RegExp(`\\.${classNameOf(role)}(:[a-z-]+)? \\{`);
|
|
100
|
+
expect(DARK_STYLE, `role "${role}" has a class but no dark rule`).toMatch(
|
|
101
|
+
selector,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("states every hover role in BOTH schemes, since :hover cannot be inline", () => {
|
|
107
|
+
// Every other role is inline-light + dark override. A hover role has no inline
|
|
108
|
+
// form, so a light answer that never reaches BASE_STYLE is simply lost — and
|
|
109
|
+
// nothing else in this file would notice.
|
|
110
|
+
for (const role of HOVER_ROLES) {
|
|
111
|
+
const selector = new RegExp(`\\.${classNameOf(role)}:hover \\{`);
|
|
112
|
+
expect(BASE_STYLE, `hover role "${role}" has no light rule`).toMatch(
|
|
113
|
+
selector,
|
|
114
|
+
);
|
|
115
|
+
expect(BASE_STYLE, `hover role "${role}" light value`).toContain(
|
|
116
|
+
palette("light")[role],
|
|
117
|
+
);
|
|
118
|
+
expect(DARK_STYLE, `hover role "${role}" has no dark rule`).toMatch(
|
|
119
|
+
selector,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("marks every declaration !important, in both blocks", () => {
|
|
125
|
+
// Load-bearing, not defensive: the light colour is inline, inline beats a
|
|
126
|
+
// stylesheet on specificity, and a rule that loses that fight does nothing
|
|
127
|
+
// at all while looking entirely correct.
|
|
128
|
+
const rules = `${BASE_STYLE}\n${DARK_STYLE}`.split("\n").filter((line) => {
|
|
129
|
+
const start = line.trimStart();
|
|
130
|
+
return start.startsWith(".") || start.startsWith("body");
|
|
131
|
+
});
|
|
132
|
+
// Guards the filter above: a reformat that matches no lines would turn the
|
|
133
|
+
// loop below into a vacuous pass. `body` is the +1; BASE_STYLE contributes
|
|
134
|
+
// one line per hover role.
|
|
135
|
+
expect(rules).toHaveLength(CLASS_ROLES.length + 1 + HOVER_ROLES.length);
|
|
136
|
+
|
|
137
|
+
for (const rule of rules) {
|
|
138
|
+
const body = rule.slice(rule.indexOf("{") + 1, rule.lastIndexOf("}"));
|
|
139
|
+
const declarations = body
|
|
140
|
+
.split(";")
|
|
141
|
+
.map((d) => d.trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
expect(declarations.length).toBeGreaterThan(0);
|
|
144
|
+
for (const declaration of declarations) {
|
|
145
|
+
expect(
|
|
146
|
+
declaration,
|
|
147
|
+
`"${declaration}" would lose to the inline style`,
|
|
148
|
+
).toContain("!important");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("styles every class the renderer actually emits", () => {
|
|
154
|
+
// The end-to-end direction: the tests above start from the palette, this one
|
|
155
|
+
// starts from the output, so a class hook added to a tag without a matching
|
|
156
|
+
// rule is caught from the side the palette cannot see.
|
|
157
|
+
const { html } = emailRenderer.render(EVERY_COLOURED_ELEMENT, CONTEXT);
|
|
158
|
+
// One attribute can carry several classes (`class="cs-cta cs-cta-hover"`).
|
|
159
|
+
const emitted = new Set(
|
|
160
|
+
[...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
|
|
161
|
+
match[1].split(/\s+/),
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
expect(emitted.size).toBeGreaterThan(0);
|
|
165
|
+
const stylesheet = `${BASE_STYLE}\n${DARK_STYLE}`;
|
|
166
|
+
for (const className of emitted) {
|
|
167
|
+
expect(stylesheet, `${className} is emitted but never styled`).toMatch(
|
|
168
|
+
new RegExp(`\\.${className}(:[a-z-]+)? \\{`),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("puts the hover hook on a CTA that links, and never on one that does not", () => {
|
|
174
|
+
// The invariant `./cta.ts` already carried — an unlinked label is a payload,
|
|
175
|
+
// not a button — extended to hover. Lighting up an OTP code under the pointer
|
|
176
|
+
// promises a click that does not exist.
|
|
177
|
+
const hoverClass = classNameOf("ctaHover");
|
|
178
|
+
// The classes the markup APPLIES, not every mention in the document — the
|
|
179
|
+
// stylesheet names `.cs-cta-hover` too, and matching that would pass whether
|
|
180
|
+
// or not any element wears it.
|
|
181
|
+
const applied = (cta: NotificationElement) => {
|
|
182
|
+
const { html } = emailRenderer.render(
|
|
183
|
+
{
|
|
184
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
185
|
+
sections: [{ elements: [cta] }],
|
|
186
|
+
},
|
|
187
|
+
CONTEXT,
|
|
188
|
+
);
|
|
189
|
+
return [...html.matchAll(/class="([^"]+)"/g)].flatMap((match) =>
|
|
190
|
+
match[1].split(/\s+/),
|
|
191
|
+
);
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const linked = applied({
|
|
195
|
+
type: "callToAction",
|
|
196
|
+
label: "Open",
|
|
197
|
+
href: "https://example.com/a",
|
|
198
|
+
});
|
|
199
|
+
const payload = applied({ type: "callToAction", label: "123456" });
|
|
200
|
+
|
|
201
|
+
expect(linked).toContain(hoverClass);
|
|
202
|
+
expect(payload).not.toContain(hoverClass);
|
|
203
|
+
// Both still wear the resting edge.
|
|
204
|
+
expect(payload).toContain(classNameOf("cta"));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("leaves no raw colour anywhere else in the channel", () => {
|
|
208
|
+
// What stops `style="color:#888"` reappearing in chat.ts in six months. A
|
|
209
|
+
// test rather than a CI guard: guards live in company-semantics-ci and adding
|
|
210
|
+
// one is cross-repo plus its own ADR — revisit if this earns it.
|
|
211
|
+
const dir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
212
|
+
const hex = /#(?:[0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/;
|
|
213
|
+
|
|
214
|
+
const sources = readdirSync(dir).filter(
|
|
215
|
+
(file) => file.endsWith(".ts") && file !== "colors.ts",
|
|
216
|
+
);
|
|
217
|
+
expect(sources.length).toBeGreaterThan(0);
|
|
218
|
+
|
|
219
|
+
for (const file of sources) {
|
|
220
|
+
const found = readFileSync(join(dir, file), "utf8").match(hex);
|
|
221
|
+
expect(
|
|
222
|
+
found?.[0],
|
|
223
|
+
`${file} states ${found?.[0]} directly — give it a role in colors.ts`,
|
|
224
|
+
).toBeUndefined();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -20,6 +20,7 @@ import { describe, expect, it } from "vitest";
|
|
|
20
20
|
|
|
21
21
|
import type {
|
|
22
22
|
NotificationContent,
|
|
23
|
+
NotificationElement,
|
|
23
24
|
NotificationElementType,
|
|
24
25
|
} from "../../../content";
|
|
25
26
|
import type { RenderContext } from "../../../context";
|
|
@@ -143,6 +144,91 @@ describe("emailRenderer", () => {
|
|
|
143
144
|
expect(html).not.toContain("<script>");
|
|
144
145
|
});
|
|
145
146
|
|
|
147
|
+
it("escapes a URL, so it cannot close its attribute and forge another", () => {
|
|
148
|
+
// `href` and `src` are the two values that reach the markup as a URL rather
|
|
149
|
+
// than as text, and they used to be interpolated raw — an HTML injection on
|
|
150
|
+
// its own, and since ADR-CONTRACTS-089 a style injection too: `inlineStyles`
|
|
151
|
+
// rewrites class attributes, so a URL that can write `class="csr-body"` can
|
|
152
|
+
// write any declaration in the registry into someone else's element.
|
|
153
|
+
const html = (element: NotificationElement) =>
|
|
154
|
+
emailRenderer.render(
|
|
155
|
+
{
|
|
156
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
157
|
+
sections: [{ elements: [element] }],
|
|
158
|
+
},
|
|
159
|
+
FIXTURE_CONTEXT,
|
|
160
|
+
).html;
|
|
161
|
+
|
|
162
|
+
const cta = html({
|
|
163
|
+
type: "callToAction",
|
|
164
|
+
label: "Open",
|
|
165
|
+
href: 'https://x.test/" data-forged="1',
|
|
166
|
+
});
|
|
167
|
+
// The quote survives as text, so the forged attribute never becomes one.
|
|
168
|
+
// Asserted against `data-forged="` — the ATTRIBUTE form — because the escaped
|
|
169
|
+
// payload still contains the string `data-forged=`, and a looser match would
|
|
170
|
+
// pass whether or not the fix is present.
|
|
171
|
+
expect(cta).toContain(""");
|
|
172
|
+
expect(cta).not.toMatch(/data-forged="/);
|
|
173
|
+
|
|
174
|
+
const hero = html({
|
|
175
|
+
type: "heroImage",
|
|
176
|
+
src: '/x.png" onerror="alert(1)',
|
|
177
|
+
alt: "x",
|
|
178
|
+
});
|
|
179
|
+
expect(hero).not.toMatch(/onerror="/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("makes a linked CTA's whole box the click target, an unlinked one not clickable", () => {
|
|
183
|
+
// The click target is the anchor, so it must fill the box — `display: block` +
|
|
184
|
+
// the padding on the `<a>` itself, not the cell. An OTP code has no href: it
|
|
185
|
+
// must not become an anchor at all, which is what keeps it uncopiable-as-a-link
|
|
186
|
+
// and unclickable.
|
|
187
|
+
const box = (cta: NotificationElement) =>
|
|
188
|
+
emailRenderer.render(
|
|
189
|
+
{
|
|
190
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
191
|
+
sections: [{ elements: [cta] }],
|
|
192
|
+
},
|
|
193
|
+
FIXTURE_CONTEXT,
|
|
194
|
+
).html;
|
|
195
|
+
|
|
196
|
+
const linked = box({
|
|
197
|
+
type: "callToAction",
|
|
198
|
+
label: "Open",
|
|
199
|
+
href: "https://example.com/x",
|
|
200
|
+
});
|
|
201
|
+
expect(linked).toMatch(
|
|
202
|
+
/<a [^>]*href="https:\/\/example\.com\/x"[^>]*style="display: block; padding:/,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
const payload = box({ type: "callToAction", label: "123456" });
|
|
206
|
+
expect(payload).not.toContain("<a ");
|
|
207
|
+
// The cell carries the padding instead, so the code is still a padded box.
|
|
208
|
+
expect(payload).toMatch(/<td style="padding: /);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("restores the CTA's cell padding for Outlook, which cannot fill the anchor", () => {
|
|
212
|
+
// Outlook's Word engine ignores `display: block`, so the full-box target
|
|
213
|
+
// degrades to today's text click there. The MSO block must put the padding
|
|
214
|
+
// back on the cell, or Outlook renders the button cramped.
|
|
215
|
+
const { html } = emailRenderer.render(
|
|
216
|
+
{
|
|
217
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
218
|
+
sections: [
|
|
219
|
+
{
|
|
220
|
+
elements: [
|
|
221
|
+
{ type: "callToAction", label: "Join", href: "https://x.test" },
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
},
|
|
226
|
+
FIXTURE_CONTEXT,
|
|
227
|
+
);
|
|
228
|
+
expect(html).toContain("<!--[if mso]>");
|
|
229
|
+
expect(html).toMatch(/\[if mso\]>.*\.cs-cta td \{ padding: .* \}/s);
|
|
230
|
+
});
|
|
231
|
+
|
|
146
232
|
it("is pure — same inputs, same bytes", () => {
|
|
147
233
|
const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
|
|
148
234
|
{
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The style registry's contract (ADR-CONTRACTS-089).
|
|
3
|
+
*
|
|
4
|
+
* `../../__tests__/render-snapshot.test.ts` already proves the html surface's
|
|
5
|
+
* bytes, and it is the authority on what the email LOOKS like. What it cannot say
|
|
6
|
+
* is WHY those bytes are safe to derive, because a snapshot passes just as happily
|
|
7
|
+
* over markup that leaks a recipe class or drops a role hook. This file asserts the
|
|
8
|
+
* two properties the derivation rests on:
|
|
9
|
+
*
|
|
10
|
+
* - a `cs-` role hook SHIPS, because `darkStyle` needs it in the markup;
|
|
11
|
+
* - a `csr-` recipe class DOES NOT, because it is spent and dropped.
|
|
12
|
+
*
|
|
13
|
+
* Get either backwards and the snapshot still passes while dark mode silently
|
|
14
|
+
* stops working, or the shipped email carries dead classes nothing styles.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import type { NotificationContent } from "../../../content";
|
|
20
|
+
import type { RenderContext } from "../../../context";
|
|
21
|
+
import { emailRenderer } from "../index";
|
|
22
|
+
import { inlineOf, inlineStyles, STYLE_NAMES } from "../styles";
|
|
23
|
+
|
|
24
|
+
const CONTEXT: RenderContext = {
|
|
25
|
+
brand: { name: "Company Semantics", copyrightYear: 2026 },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One of everything that carries a class, so the scan below has something to see. */
|
|
29
|
+
const CONTENT: NotificationContent = {
|
|
30
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
31
|
+
sections: [
|
|
32
|
+
{
|
|
33
|
+
elements: [
|
|
34
|
+
{ type: "greeting", recipientName: "Sam" },
|
|
35
|
+
{ type: "warning" },
|
|
36
|
+
{ type: "divider" },
|
|
37
|
+
{ type: "callToAction", label: "Open", href: "https://x.test/a" },
|
|
38
|
+
{ type: "callToAction", label: "123456" },
|
|
39
|
+
{
|
|
40
|
+
type: "chatUnit",
|
|
41
|
+
items: [
|
|
42
|
+
{ type: "message", role: "user", text: "hi", from: "Sam" },
|
|
43
|
+
{ type: "message", role: "assistant", text: "hello" },
|
|
44
|
+
{ type: "continuation" },
|
|
45
|
+
{ type: "callToAction", label: "Read", href: "https://x.test/b" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ type: "signature" },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Every class the shipped markup actually applies. */
|
|
55
|
+
function shippedClasses(html: string): string[] {
|
|
56
|
+
return [...html.matchAll(/class="([^"]+)"/g)].flatMap((m) =>
|
|
57
|
+
m[1].split(/\s+/),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("the style registry", () => {
|
|
62
|
+
it("ships no recipe class — they are spent by the inliner, not delivered", () => {
|
|
63
|
+
const { html } = emailRenderer.render(CONTENT, CONTEXT);
|
|
64
|
+
// The whole document, not just the class attributes: a recipe class must not
|
|
65
|
+
// survive anywhere, including in a stylesheet the html surface never wants.
|
|
66
|
+
expect(html).not.toContain("csr-");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ships every role hook, because the dark stylesheet needs it there", () => {
|
|
70
|
+
const { html } = emailRenderer.render(CONTENT, CONTEXT);
|
|
71
|
+
const shipped = shippedClasses(html);
|
|
72
|
+
expect(shipped.length).toBeGreaterThan(0);
|
|
73
|
+
// A sample across the roles this content exercises. `cs-cta-hover` is the
|
|
74
|
+
// interesting one: it is the only reason `.cs-cta` and the hover state are
|
|
75
|
+
// separate roles, and it rides on the linked CTA above.
|
|
76
|
+
for (const hook of ["cs-link", "cs-cta", "cs-cta-hover", "cs-bubble"]) {
|
|
77
|
+
expect(shipped, `${hook} never reached the markup`).toContain(hook);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("turns a recipe-only class attribute into a style, leaving no class behind", () => {
|
|
82
|
+
// The collapse that makes `<p class="csr-p-none">` render as the `<p style="…">`
|
|
83
|
+
// the channel shipped before this registry existed. A leftover `class=""` would
|
|
84
|
+
// be invisible in a client and a permanent diff in the snapshot.
|
|
85
|
+
expect(inlineStyles(`<p class="csr-p-none">x</p>`)).toBe(
|
|
86
|
+
`<p style="${inlineOf("p-none")}">x</p>`,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps role hooks and appends the style, in that order", () => {
|
|
91
|
+
// Order is not cosmetic: it is what makes the inliner reproduce the previous
|
|
92
|
+
// markup byte-for-byte rather than merely equivalently.
|
|
93
|
+
expect(inlineStyles(`<td class="cs-bubble csr-bubble-user">x</td>`)).toBe(
|
|
94
|
+
`<td class="cs-bubble" style="${inlineOf("bubble-user")}">x</td>`,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("leaves markup with no classes untouched", () => {
|
|
99
|
+
const plain = `<tr><td></td></tr>`;
|
|
100
|
+
expect(inlineStyles(plain)).toBe(plain);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("states every recipe as an inline style, from one source", () => {
|
|
104
|
+
// `ruleOf` — the same recipe as a CSS rule — went with the AMP surface
|
|
105
|
+
// (ADR-CONTRACTS-091). It had no other caller, and a rule nobody states is not
|
|
106
|
+
// a shape this registry needs to keep proving it can take.
|
|
107
|
+
for (const name of STYLE_NAMES) {
|
|
108
|
+
expect(inlineOf(name).endsWith(";")).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|