@broberg/mail-core 0.1.0 → 0.2.1
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/dist/index.cjs +57 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +79 -2
- package/dist/index.d.ts +79 -2
- package/dist/index.js +54 -4
- package/dist/index.js.map +1 -1
- package/package.json +10 -4
package/dist/index.cjs
CHANGED
|
@@ -24,13 +24,24 @@ function resolveColors(b) {
|
|
|
24
24
|
const fontSerif = b.fontSerif ?? "Georgia,'Times New Roman',serif";
|
|
25
25
|
return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };
|
|
26
26
|
}
|
|
27
|
+
function resolveLogoSrc(logo, fallbackUrl) {
|
|
28
|
+
const cid = logo?.cid?.trim();
|
|
29
|
+
if (cid) return `cid:${cid}`;
|
|
30
|
+
const url = logo?.url?.trim() || fallbackUrl?.trim();
|
|
31
|
+
if (!url) return null;
|
|
32
|
+
if (/^data:/i.test(url)) return null;
|
|
33
|
+
return url;
|
|
34
|
+
}
|
|
35
|
+
var SHELL_VERSION = "1";
|
|
27
36
|
function renderShell(opts) {
|
|
28
37
|
const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
|
|
29
38
|
const lang = opts.lang ?? "en";
|
|
30
39
|
const showFooter = opts.showFooter ?? true;
|
|
31
|
-
const
|
|
40
|
+
const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);
|
|
41
|
+
const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? "";
|
|
42
|
+
const logoBlock = logoSrc ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
|
|
32
43
|
<tr><td>
|
|
33
|
-
<img src="${escapeAttr(
|
|
44
|
+
<img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
|
|
34
45
|
</td></tr>
|
|
35
46
|
</table>` : "";
|
|
36
47
|
const footerBlock = showFooter ? `<tr>
|
|
@@ -40,6 +51,7 @@ function renderShell(opts) {
|
|
|
40
51
|
</td>
|
|
41
52
|
</tr>` : "";
|
|
42
53
|
return `<!doctype html>
|
|
54
|
+
<!-- @broberg/mail-core shell v${SHELL_VERSION} -->
|
|
43
55
|
<html lang="${escapeAttr(lang)}">
|
|
44
56
|
<head>
|
|
45
57
|
<meta charset="utf-8">
|
|
@@ -48,6 +60,26 @@ function renderShell(opts) {
|
|
|
48
60
|
<meta name="supported-color-schemes" content="light only">
|
|
49
61
|
<title>${escapeHtml(opts.subject)}</title>
|
|
50
62
|
<style>
|
|
63
|
+
/* \u26A0\uFE0F THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.
|
|
64
|
+
Not partial \u2014 zero. Measured by fd-sundhed on a real iPhone, 2026-08-19
|
|
65
|
+
18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with
|
|
66
|
+
card AND footer landing on the same colour so the footer stopped being a
|
|
67
|
+
zone at all. The three are: these color-scheme metas + rule, the
|
|
68
|
+
[data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.
|
|
69
|
+
|
|
70
|
+
THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer
|
|
71
|
+
expecting it to fix Outlook \u2014 three have been measured at nothing.
|
|
72
|
+
|
|
73
|
+
\u26A0\uFE0F AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK
|
|
74
|
+
source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->
|
|
75
|
+
#a7a9bf). So to make a too-faint line MORE readable at the recipient, make
|
|
76
|
+
the source colour DARKER. Someone seeing a washed-out line will reach for
|
|
77
|
+
"lighten it" and make it worse \u2014 that is the whole reason this comment sits
|
|
78
|
+
here rather than in a plan-doc.
|
|
79
|
+
|
|
80
|
+
What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no
|
|
81
|
+
mid-tones, structure from rule-and-space rather than fills, no gradient,
|
|
82
|
+
and a button with fill AND border. */
|
|
51
83
|
:root { color-scheme: light only; supported-color-schemes: light only; }
|
|
52
84
|
@media (prefers-color-scheme: dark) {
|
|
53
85
|
.mc-bg-outer { background:${backdropColor} !important; }
|
|
@@ -87,7 +119,25 @@ ${opts.preheader ? `<div style="display:none;font-size:1px;max-height:0;overflow
|
|
|
87
119
|
function heading(text, opts) {
|
|
88
120
|
const fontSerif = opts?.fontSerif ?? "Georgia,'Times New Roman',serif";
|
|
89
121
|
const textColor = opts?.textColor ?? "#1a1a1a";
|
|
90
|
-
|
|
122
|
+
let inner = escapeHtml(text);
|
|
123
|
+
const em = opts?.emphasis;
|
|
124
|
+
if (em) {
|
|
125
|
+
const needle = escapeHtml(em);
|
|
126
|
+
const at = inner.indexOf(needle);
|
|
127
|
+
if (at !== -1) {
|
|
128
|
+
const colour = opts?.accentColor ?? textColor;
|
|
129
|
+
inner = inner.slice(0, at) + `<i style="color:${colour};font-style:italic;">${needle}</i>` + inner.slice(at + needle.length);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return `<h1 style="margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;">${inner}</h1>`;
|
|
133
|
+
}
|
|
134
|
+
function eyebrow(text, opts) {
|
|
135
|
+
return `<p style="margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;">${escapeHtml(text)}</p>`;
|
|
136
|
+
}
|
|
137
|
+
function noteBox(html, opts) {
|
|
138
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;">
|
|
139
|
+
<tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;">${html}</td></tr>
|
|
140
|
+
</table>`;
|
|
91
141
|
}
|
|
92
142
|
function paragraph(text) {
|
|
93
143
|
return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${escapeHtml(text)}</p>`;
|
|
@@ -143,16 +193,20 @@ function makeLogoAttachment(filePath, opts) {
|
|
|
143
193
|
}
|
|
144
194
|
}
|
|
145
195
|
|
|
196
|
+
exports.SHELL_VERSION = SHELL_VERSION;
|
|
146
197
|
exports.cta = cta;
|
|
147
198
|
exports.escapeAttr = escapeAttr;
|
|
148
199
|
exports.escapeHtml = escapeHtml;
|
|
200
|
+
exports.eyebrow = eyebrow;
|
|
149
201
|
exports.factBox = factBox;
|
|
150
202
|
exports.fill = fill;
|
|
151
203
|
exports.heading = heading;
|
|
152
204
|
exports.makeLogoAttachment = makeLogoAttachment;
|
|
205
|
+
exports.noteBox = noteBox;
|
|
153
206
|
exports.paragraph = paragraph;
|
|
154
207
|
exports.paragraphHtml = paragraphHtml;
|
|
155
208
|
exports.renderShell = renderShell;
|
|
209
|
+
exports.resolveLogoSrc = resolveLogoSrc;
|
|
156
210
|
exports.signOff = signOff;
|
|
157
211
|
//# sourceMappingURL=index.cjs.map
|
|
158
212
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":";;;;;AAcO,SAAS,WAAW,CAAA,EAAmB;AAC5C,EAAA,OAAO,EAAE,OAAA,CAAQ,UAAA,EAAY,CAAC,CAAA,KAAA,CAAO,EAAE,KAAK,OAAA,EAAS,GAAA,EAAK,QAAQ,GAAA,EAAK,MAAA,EAAQ,KAAK,QAAA,EAAU,GAAA,EAAK,SAAQ,EAAG,CAAC,KAAK,CAAC,CAAA;AACvH;AAEO,SAAS,WAAW,CAAA,EAAmB;AAC5C,EAAA,OAAO,WAAW,CAAC,CAAA;AACrB;AAkBA,SAAS,OAAO,GAAA,EAAsB;AACpC,EAAA,MAAM,CAAA,GAAI,oBAAA,CAAqB,IAAA,CAAK,GAAA,CAAI,MAAM,CAAA;AAC9C,EAAA,IAAI,CAAC,GAAG,OAAO,KAAA;AACf,EAAA,MAAM,CAAA,GAAI,QAAA,CAAS,CAAA,CAAE,CAAC,GAAG,EAAE,CAAA;AAC3B,EAAA,MAAM,CAAA,GAAK,KAAK,EAAA,GAAM,GAAA,EAAK,IAAK,CAAA,IAAK,CAAA,GAAK,GAAA,EAAK,CAAA,GAAI,CAAA,GAAI,GAAA;AAEvD,EAAA,OAAA,CAAQ,IAAI,GAAA,GAAM,CAAA,GAAI,GAAA,GAAM,CAAA,GAAI,OAAO,GAAA,GAAO,GAAA;AAChD;AAEA,SAAS,cAAc,CAAA,EAAgB;AACrC,EAAA,MAAM,MAAA,GAAS,EAAE,MAAA,IAAU,SAAA;AAC3B,EAAA,MAAM,YAAY,CAAA,CAAE,SAAA,KAAc,MAAA,CAAO,MAAM,IAAI,SAAA,GAAY,SAAA,CAAA;AAC/D,EAAA,MAAM,aAAA,GAAgB,EAAE,aAAA,IAAiB,SAAA;AACzC,EAAA,MAAM,QAAA,GAAW,EAAE,QAAA,IAAY,+DAAA;AAC/B,EAAA,MAAM,SAAA,GAAY,EAAE,SAAA,IAAa,iCAAA;AACjC,EAAA,OAAO,EAAE,aAAa,CAAA,CAAE,WAAA,EAAa,QAAQ,SAAA,EAAW,aAAA,EAAe,UAAU,SAAA,EAAU;AAC7F;AAsBO,SAAS,YAAY,IAAA,EAAyB;AACnD,EAAA,MAAM,EAAE,aAAa,MAAA,EAAQ,SAAA,EAAW,eAAe,QAAA,EAAS,GAAI,cAAc,IAAI,CAAA;AACtF,EAAA,MAAM,IAAA,GAAO,KAAK,IAAA,IAAQ,IAAA;AAC1B,EAAA,MAAM,UAAA,GAAa,KAAK,UAAA,IAAc,IAAA;AAEtC,EAAA,MAAM,SAAA,GAAY,KAAK,OAAA,GACnB,CAAA;AAAA;AAAA,gBAAA,EAEY,UAAA,CAAW,KAAK,OAAO,CAAC,UAAU,UAAA,CAAW,IAAA,CAAK,OAAA,IAAW,EAAE,CAAC,CAAA;AAAA;AAAA,UAAA,CAAA,GAG5E,EAAA;AAEJ,EAAA,MAAM,cAAc,UAAA,GAChB,CAAA;AAAA,mBAAA,EACe,aAAa,uBAAuB,aAAa,CAAA;AAAA,QAAA,EAAA,CAC3D,IAAA,CAAK,WAAA,IAAe,EAAC,EAAG,IAAI,CAAC,CAAA,KAAM,CAAA,uDAAA,EAA0D,UAAA,CAAW,CAAC,CAAC,CAAA,IAAA,CAAM,CAAA,CAAE,IAAA,CAAK,EAAE,CAAC;AAAA,QAAA,EAC3H,KAAK,UAAA,GAAa,CAAA,6CAAA,EAAgD,UAAA,CAAW,IAAA,CAAK,UAAU,CAAC,CAAA,eAAA,EAAkB,WAAW,CAAA,wCAAA,EAA2C,WAAW,IAAA,CAAK,WAAA,IAAe,KAAK,UAAU,CAAC,aAAa,EAAE;AAAA;AAAA,SAAA,CAAA,GAGvO,EAAA;AAEJ,EAAA,OAAO,CAAA;AAAA,YAAA,EACK,UAAA,CAAW,IAAI,CAAC,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,OAAA,EAMrB,UAAA,CAAW,IAAA,CAAK,OAAO,CAAC,CAAA;AAAA;AAAA;AAAA;AAAA,8BAAA,EAID,aAAa,CAAA;AAAA,8BAAA,EACb,MAAM,CAAA;AAAA,yBAAA,EACX,SAAS,CAAA;AAAA;AAAA,wCAAA,EAEM,aAAa,CAAA;AAAA,wCAAA,EACb,MAAM,CAAA;AAAA,mCAAA,EACX,SAAS,CAAA;AAAA;AAAA;AAAA,2CAAA,EAGD,aAAa,CAAA,uCAAA,EAA0C,aAAa,CAAA,aAAA,EAAgB,QAAQ,UAAU,SAAS,CAAA;AAAA,EAC1J,IAAA,CAAK,YAAY,CAAA,mFAAA,EAAsF,UAAA,CAAW,KAAK,SAAS,CAAC,WAAW,EAAE;AAAA,4FAAA,EAClD,aAAa,2CAA2C,aAAa,CAAA;AAAA;AAAA;AAAA,iGAAA,EAGhE,MAAM,qEAAqE,MAAM,CAAA;AAAA,yBAAA,EACzJ,WAAW,uBAAuB,WAAW,CAAA;AAAA;AAAA,uBAAA,EAE/C,MAAM,0CAA0C,MAAM,CAAA;AAAA,YAAA,EACjE,SAAS;AAAA;AAAA;AAAA;AAAA,uBAAA,EAIE,MAAM,kDAAkD,MAAM,CAAA;AAAA,YAAA,EACzE,KAAK,QAAQ;AAAA;AAAA;AAAA,QAAA,EAGjB,WAAW;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,OAAA,CAAA;AAOrB;AAEO,SAAS,OAAA,CAAQ,MAAc,IAAA,EAA2D;AAC/F,EAAA,MAAM,SAAA,GAAY,MAAM,SAAA,IAAa,iCAAA;AACrC,EAAA,MAAM,SAAA,GAAY,MAAM,SAAA,IAAa,SAAA;AACrC,EAAA,OAAO,0CAA0C,SAAS,CAAA,sCAAA,EAAyC,SAAS,CAAA,qBAAA,EAAwB,UAAA,CAAW,IAAI,CAAC,CAAA,KAAA,CAAA;AACtJ;AAEO,SAAS,UAAU,IAAA,EAAsB;AAC9C,EAAA,OAAO,CAAA,2DAAA,EAA8D,UAAA,CAAW,IAAI,CAAC,CAAA,IAAA,CAAA;AACvF;AAIO,SAAS,cAAc,IAAA,EAAsB;AAClD,EAAA,OAAO,8DAA8D,IAAI,CAAA,IAAA,CAAA;AAC3E;AAEO,SAAS,OAAA,CAAQ,KAAA,EAAe,KAAA,EAAe,IAAA,EAAsB;AAC1E,EAAA,OAAO,CAAA;AAAA;AAAA,MAAA,EAED,UAAA,CAAW,KAAK,CAAC,CAAA;AAAA,MAAA,EACjB,UAAA,CAAW,KAAK,CAAC,CAAA;AAAA,oCAAA,EACa,UAAA,CAAW,IAAI,CAAC,CAAA;AAAA;AAAA,QAAA,CAAA;AAGtD;AAIO,SAAS,GAAA,CAAI,IAAA,EAAc,KAAA,EAAe,IAAA,EAAuC;AACtF,EAAA,OAAO,CAAA;AAAA;AAAA,mBAAA,EAEY,IAAA,CAAK,WAAW,CAAA,oBAAA,EAAuB,IAAA,CAAK,WAAW,CAAA;AAAA,iBAAA,EACzD,WAAW,IAAI,CAAC,CAAA,oHAAA,EAAuH,UAAA,CAAW,KAAK,CAAC,CAAA;AAAA;AAAA;AAAA,UAAA,CAAA;AAI3K;AASO,SAAS,OAAA,CAAQ,MAAiB,IAAA,EAAyC;AAChF,EAAA,IAAI,IAAA,CAAK,MAAA,KAAW,CAAA,EAAG,OAAO,EAAA;AAC9B,EAAA,MAAM,SAAS,IAAA,EAAM,WAAA,GAAc,CAAA,sBAAA,EAAyB,IAAA,CAAK,WAAW,CAAA,CAAA,CAAA,GAAM,mCAAA;AAClF,EAAA,MAAM,QAAQ,IAAA,CACX,GAAA;AAAA,IACC,CAAC,CAAA,KAAM,CAAA;AAAA,8GAAA,EACmG,UAAA,CAAW,CAAA,CAAE,KAAK,CAAC,CAAA;AAAA,kEAAA,EAC/D,UAAA,CAAW,CAAA,CAAE,KAAK,CAAC,CAAA;AAAA,WAAA;AAAA,GAEnF,CACC,KAAK,EAAE,CAAA;AACV,EAAA,OAAO,2GAA2G,MAAM,CAAA;AAAA;AAAA,yFAAA,EAE/B,KAAK,CAAA;AAAA;AAAA,UAAA,CAAA;AAGhG;AAGO,SAAS,IAAA,CAAK,UAAkB,IAAA,EAA+C;AACpF,EAAA,OAAO,QAAA,CAAS,OAAA,CAAQ,YAAA,EAAc,CAAC,GAAG,GAAA,KAAS,GAAA,IAAO,IAAA,GAAO,MAAA,CAAO,KAAK,GAAG,CAAC,CAAA,GAAI,CAAA,CAAA,EAAI,GAAG,CAAA,CAAA,CAAI,CAAA;AAClG;AAYO,SAAS,kBAAA,CAAmB,UAAkB,IAAA,EAA4E;AAC/H,EAAA,IAAI,CAACA,aAAA,CAAW,QAAQ,CAAA,EAAG,OAAO,IAAA;AAClC,EAAA,IAAI;AACF,IAAA,MAAM,OAAA,GAAUC,gBAAa,QAAQ,CAAA;AACrC,IAAA,MAAM,WAAW,QAAA,CAAS,KAAA,CAAM,GAAG,CAAA,CAAE,KAAI,IAAK,MAAA;AAC9C,IAAA,MAAM,cAAc,IAAA,EAAM,WAAA,KAAgB,SAAS,QAAA,CAAS,MAAM,IAAI,eAAA,GAAkB,WAAA,CAAA;AACxF,IAAA,OAAO,EAAE,QAAA,EAAU,OAAA,EAAS,WAAW,IAAA,EAAM,SAAA,IAAa,QAAQ,WAAA,EAAY;AAAA,EAChF,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,IAAA;AAAA,EACT;AACF","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a hosted URL. */\n logoUrl?: string;\n logoAlt?: string;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoBlock = opts.logoUrl\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(opts.logoUrl)}\" alt=\"${escapeAttr(opts.logoAlt ?? \"\")}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\nexport function heading(text: string, opts?: { fontSerif?: string; textColor?: string }): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${escapeHtml(text)}</h1>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\nexport function signOff(line1: string, line2: string, sign: string): string {\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["existsSync","readFileSync"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"1\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\nexport function signOff(line1: string, line2: string, sign: string): string {\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -37,18 +37,95 @@ interface ShellOpts extends BrandColors {
|
|
|
37
37
|
footerLines?: string[];
|
|
38
38
|
footerHref?: string;
|
|
39
39
|
footerLabel?: string;
|
|
40
|
-
/** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a
|
|
40
|
+
/** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a
|
|
41
|
+
* hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */
|
|
41
42
|
logoUrl?: string;
|
|
42
43
|
logoAlt?: string;
|
|
44
|
+
/** The logo, expressed as EVERY form you have, in preference order (F023.7).
|
|
45
|
+
*
|
|
46
|
+
* WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends
|
|
47
|
+
* on a project's behalf, so a template that can only say `cid:` is unusable
|
|
48
|
+
* there. And sanne measured the opposite failure: their `data:` URI logo was
|
|
49
|
+
* stripped by Gmail's image proxy, and ONE template missed in the migration
|
|
50
|
+
* to `cid:` broke ALONE, half a year later. A field that holds one form makes
|
|
51
|
+
* that a migration; a field that holds both makes it a fallback.
|
|
52
|
+
*
|
|
53
|
+
* Preference is CID first, and it is not a style choice: a hosted logo is
|
|
54
|
+
* re-fetched every time the mail is opened, for years, so moving the file
|
|
55
|
+
* breaks every mail ever sent — retroactively. An attachment cannot rot. */
|
|
56
|
+
logo?: LogoSource;
|
|
43
57
|
}
|
|
58
|
+
interface LogoSource {
|
|
59
|
+
/** contentId of an attached image — rendered as `cid:<id>`. Preferred. */
|
|
60
|
+
cid?: string;
|
|
61
|
+
/** Hosted URL. Used when no cid is given. */
|
|
62
|
+
url?: string;
|
|
63
|
+
alt?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Pick the logo src from every form the caller supplied, in preference order.
|
|
66
|
+
*
|
|
67
|
+
* Exported so a caller can ask what WOULD be used without rendering a shell —
|
|
68
|
+
* and so the preference itself is testable rather than buried in a template
|
|
69
|
+
* literal.
|
|
70
|
+
*
|
|
71
|
+
* Returns `null` when there is nothing usable, which is a real outcome: no
|
|
72
|
+
* logo block is rendered, rather than an <img> with an empty src that shows a
|
|
73
|
+
* broken-image icon in every client. */
|
|
74
|
+
declare function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null;
|
|
44
75
|
/** Renders a complete, email-client-safe HTML document: table layout (not
|
|
45
76
|
* flex/grid — Outlook doesn't support it), dark-mode-inversion guards via
|
|
46
77
|
* both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded
|
|
47
78
|
* card with an accent-colored top strip, and an optional footer. */
|
|
79
|
+
/** The shell's own identity, emitted into every rendered mail (F023.7).
|
|
80
|
+
*
|
|
81
|
+
* WHY IT EXISTS, in cardmem's words: a project must be able to tell "MY
|
|
82
|
+
* template changed" from "the SHARED shell changed". Without it those are one
|
|
83
|
+
* observation, and fd-sundhed's condition for adopting a shared shell is
|
|
84
|
+
* exact — «ellers er delingen en risiko-flytning, ikke en forbedring».
|
|
85
|
+
*
|
|
86
|
+
* Bumped by hand when the rendered OUTPUT changes, which is deliberately not
|
|
87
|
+
* the package version: a docs-only or types-only release must not make every
|
|
88
|
+
* consumer's stored render look different. Same output, same number.
|
|
89
|
+
*
|
|
90
|
+
* An HTML COMMENT rather than an attribute: comments survive every client we
|
|
91
|
+
* have measured, and an attribute on <html> is one of the first things a
|
|
92
|
+
* sanitising webmail rewrites. */
|
|
93
|
+
declare const SHELL_VERSION = "1";
|
|
48
94
|
declare function renderShell(opts: ShellOpts): string;
|
|
95
|
+
/** `emphasis` italicises the FIRST occurrence of that substring in the accent
|
|
96
|
+
* colour — the "one word picked out of the headline" brand signature three
|
|
97
|
+
* consumers hand-rolled (reported by vn-leker, F023.7).
|
|
98
|
+
*
|
|
99
|
+
* A substring that does not occur leaves the heading UNCHANGED rather than
|
|
100
|
+
* appending anything: a caller passing a word that is not there has made a
|
|
101
|
+
* mistake, and silently adding it to the end would render that mistake as
|
|
102
|
+
* design. Omitting `emphasis` renders byte-identically to 0.1.0.
|
|
103
|
+
*
|
|
104
|
+
* `fontSerif` SHOULD be a full fallback STACK, never a single family name.
|
|
105
|
+
* vn-leker dropped their serif entirely because Outlook does not guarantee
|
|
106
|
+
* webfonts — which removed the design instead of letting Apple Mail show it.
|
|
107
|
+
* Layer it; do not choose. */
|
|
49
108
|
declare function heading(text: string, opts?: {
|
|
50
109
|
fontSerif?: string;
|
|
51
110
|
textColor?: string;
|
|
111
|
+
emphasis?: string;
|
|
112
|
+
accentColor?: string;
|
|
113
|
+
}): string;
|
|
114
|
+
/** The small uppercase label above a heading ("PROJECT UPDATE"). Letter-spaced
|
|
115
|
+
* and in the accent colour; a recurring component in every surveyed template. */
|
|
116
|
+
declare function eyebrow(text: string, opts: {
|
|
117
|
+
accentColor: string;
|
|
118
|
+
}): string;
|
|
119
|
+
/** Free prose with a coloured left rule — a NOTE, not a table.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately not an option on factBox(): that renders label/value ROWS, and
|
|
122
|
+
* this takes a paragraph. Same visual family, different datatype — folding
|
|
123
|
+
* them together would be one function doing two jobs, and the caller would
|
|
124
|
+
* have to pass prose disguised as a row to reach it.
|
|
125
|
+
*
|
|
126
|
+
* Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */
|
|
127
|
+
declare function noteBox(html: string, opts: {
|
|
128
|
+
accentColor: string;
|
|
52
129
|
}): string;
|
|
53
130
|
declare function paragraph(text: string): string;
|
|
54
131
|
/** Like paragraph(), but the string is injected as raw HTML (not escaped) —
|
|
@@ -85,4 +162,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
|
|
|
85
162
|
contentType?: string;
|
|
86
163
|
}): MailAttachment | null;
|
|
87
164
|
|
|
88
|
-
export { type BrandColors, type FactRow, type MailAttachment, type ShellOpts, cta, escapeAttr, escapeHtml, factBox, fill, heading, makeLogoAttachment, paragraph, paragraphHtml, renderShell, signOff };
|
|
165
|
+
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
package/dist/index.d.ts
CHANGED
|
@@ -37,18 +37,95 @@ interface ShellOpts extends BrandColors {
|
|
|
37
37
|
footerLines?: string[];
|
|
38
38
|
footerHref?: string;
|
|
39
39
|
footerLabel?: string;
|
|
40
|
-
/** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a
|
|
40
|
+
/** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a
|
|
41
|
+
* hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */
|
|
41
42
|
logoUrl?: string;
|
|
42
43
|
logoAlt?: string;
|
|
44
|
+
/** The logo, expressed as EVERY form you have, in preference order (F023.7).
|
|
45
|
+
*
|
|
46
|
+
* WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends
|
|
47
|
+
* on a project's behalf, so a template that can only say `cid:` is unusable
|
|
48
|
+
* there. And sanne measured the opposite failure: their `data:` URI logo was
|
|
49
|
+
* stripped by Gmail's image proxy, and ONE template missed in the migration
|
|
50
|
+
* to `cid:` broke ALONE, half a year later. A field that holds one form makes
|
|
51
|
+
* that a migration; a field that holds both makes it a fallback.
|
|
52
|
+
*
|
|
53
|
+
* Preference is CID first, and it is not a style choice: a hosted logo is
|
|
54
|
+
* re-fetched every time the mail is opened, for years, so moving the file
|
|
55
|
+
* breaks every mail ever sent — retroactively. An attachment cannot rot. */
|
|
56
|
+
logo?: LogoSource;
|
|
43
57
|
}
|
|
58
|
+
interface LogoSource {
|
|
59
|
+
/** contentId of an attached image — rendered as `cid:<id>`. Preferred. */
|
|
60
|
+
cid?: string;
|
|
61
|
+
/** Hosted URL. Used when no cid is given. */
|
|
62
|
+
url?: string;
|
|
63
|
+
alt?: string;
|
|
64
|
+
}
|
|
65
|
+
/** Pick the logo src from every form the caller supplied, in preference order.
|
|
66
|
+
*
|
|
67
|
+
* Exported so a caller can ask what WOULD be used without rendering a shell —
|
|
68
|
+
* and so the preference itself is testable rather than buried in a template
|
|
69
|
+
* literal.
|
|
70
|
+
*
|
|
71
|
+
* Returns `null` when there is nothing usable, which is a real outcome: no
|
|
72
|
+
* logo block is rendered, rather than an <img> with an empty src that shows a
|
|
73
|
+
* broken-image icon in every client. */
|
|
74
|
+
declare function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null;
|
|
44
75
|
/** Renders a complete, email-client-safe HTML document: table layout (not
|
|
45
76
|
* flex/grid — Outlook doesn't support it), dark-mode-inversion guards via
|
|
46
77
|
* both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded
|
|
47
78
|
* card with an accent-colored top strip, and an optional footer. */
|
|
79
|
+
/** The shell's own identity, emitted into every rendered mail (F023.7).
|
|
80
|
+
*
|
|
81
|
+
* WHY IT EXISTS, in cardmem's words: a project must be able to tell "MY
|
|
82
|
+
* template changed" from "the SHARED shell changed". Without it those are one
|
|
83
|
+
* observation, and fd-sundhed's condition for adopting a shared shell is
|
|
84
|
+
* exact — «ellers er delingen en risiko-flytning, ikke en forbedring».
|
|
85
|
+
*
|
|
86
|
+
* Bumped by hand when the rendered OUTPUT changes, which is deliberately not
|
|
87
|
+
* the package version: a docs-only or types-only release must not make every
|
|
88
|
+
* consumer's stored render look different. Same output, same number.
|
|
89
|
+
*
|
|
90
|
+
* An HTML COMMENT rather than an attribute: comments survive every client we
|
|
91
|
+
* have measured, and an attribute on <html> is one of the first things a
|
|
92
|
+
* sanitising webmail rewrites. */
|
|
93
|
+
declare const SHELL_VERSION = "1";
|
|
48
94
|
declare function renderShell(opts: ShellOpts): string;
|
|
95
|
+
/** `emphasis` italicises the FIRST occurrence of that substring in the accent
|
|
96
|
+
* colour — the "one word picked out of the headline" brand signature three
|
|
97
|
+
* consumers hand-rolled (reported by vn-leker, F023.7).
|
|
98
|
+
*
|
|
99
|
+
* A substring that does not occur leaves the heading UNCHANGED rather than
|
|
100
|
+
* appending anything: a caller passing a word that is not there has made a
|
|
101
|
+
* mistake, and silently adding it to the end would render that mistake as
|
|
102
|
+
* design. Omitting `emphasis` renders byte-identically to 0.1.0.
|
|
103
|
+
*
|
|
104
|
+
* `fontSerif` SHOULD be a full fallback STACK, never a single family name.
|
|
105
|
+
* vn-leker dropped their serif entirely because Outlook does not guarantee
|
|
106
|
+
* webfonts — which removed the design instead of letting Apple Mail show it.
|
|
107
|
+
* Layer it; do not choose. */
|
|
49
108
|
declare function heading(text: string, opts?: {
|
|
50
109
|
fontSerif?: string;
|
|
51
110
|
textColor?: string;
|
|
111
|
+
emphasis?: string;
|
|
112
|
+
accentColor?: string;
|
|
113
|
+
}): string;
|
|
114
|
+
/** The small uppercase label above a heading ("PROJECT UPDATE"). Letter-spaced
|
|
115
|
+
* and in the accent colour; a recurring component in every surveyed template. */
|
|
116
|
+
declare function eyebrow(text: string, opts: {
|
|
117
|
+
accentColor: string;
|
|
118
|
+
}): string;
|
|
119
|
+
/** Free prose with a coloured left rule — a NOTE, not a table.
|
|
120
|
+
*
|
|
121
|
+
* Deliberately not an option on factBox(): that renders label/value ROWS, and
|
|
122
|
+
* this takes a paragraph. Same visual family, different datatype — folding
|
|
123
|
+
* them together would be one function doing two jobs, and the caller would
|
|
124
|
+
* have to pass prose disguised as a row to reach it.
|
|
125
|
+
*
|
|
126
|
+
* Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */
|
|
127
|
+
declare function noteBox(html: string, opts: {
|
|
128
|
+
accentColor: string;
|
|
52
129
|
}): string;
|
|
53
130
|
declare function paragraph(text: string): string;
|
|
54
131
|
/** Like paragraph(), but the string is injected as raw HTML (not escaped) —
|
|
@@ -85,4 +162,4 @@ declare function makeLogoAttachment(filePath: string, opts?: {
|
|
|
85
162
|
contentType?: string;
|
|
86
163
|
}): MailAttachment | null;
|
|
87
164
|
|
|
88
|
-
export { type BrandColors, type FactRow, type MailAttachment, type ShellOpts, cta, escapeAttr, escapeHtml, factBox, fill, heading, makeLogoAttachment, paragraph, paragraphHtml, renderShell, signOff };
|
|
165
|
+
export { type BrandColors, type FactRow, type LogoSource, type MailAttachment, SHELL_VERSION, type ShellOpts, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
package/dist/index.js
CHANGED
|
@@ -22,13 +22,24 @@ function resolveColors(b) {
|
|
|
22
22
|
const fontSerif = b.fontSerif ?? "Georgia,'Times New Roman',serif";
|
|
23
23
|
return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };
|
|
24
24
|
}
|
|
25
|
+
function resolveLogoSrc(logo, fallbackUrl) {
|
|
26
|
+
const cid = logo?.cid?.trim();
|
|
27
|
+
if (cid) return `cid:${cid}`;
|
|
28
|
+
const url = logo?.url?.trim() || fallbackUrl?.trim();
|
|
29
|
+
if (!url) return null;
|
|
30
|
+
if (/^data:/i.test(url)) return null;
|
|
31
|
+
return url;
|
|
32
|
+
}
|
|
33
|
+
var SHELL_VERSION = "1";
|
|
25
34
|
function renderShell(opts) {
|
|
26
35
|
const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);
|
|
27
36
|
const lang = opts.lang ?? "en";
|
|
28
37
|
const showFooter = opts.showFooter ?? true;
|
|
29
|
-
const
|
|
38
|
+
const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);
|
|
39
|
+
const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? "";
|
|
40
|
+
const logoBlock = logoSrc ? `<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto 16px;">
|
|
30
41
|
<tr><td>
|
|
31
|
-
<img src="${escapeAttr(
|
|
42
|
+
<img src="${escapeAttr(logoSrc)}" alt="${escapeAttr(logoAlt)}" style="display:block;margin:0 auto;max-width:180px;height:auto;border:0;">
|
|
32
43
|
</td></tr>
|
|
33
44
|
</table>` : "";
|
|
34
45
|
const footerBlock = showFooter ? `<tr>
|
|
@@ -38,6 +49,7 @@ function renderShell(opts) {
|
|
|
38
49
|
</td>
|
|
39
50
|
</tr>` : "";
|
|
40
51
|
return `<!doctype html>
|
|
52
|
+
<!-- @broberg/mail-core shell v${SHELL_VERSION} -->
|
|
41
53
|
<html lang="${escapeAttr(lang)}">
|
|
42
54
|
<head>
|
|
43
55
|
<meta charset="utf-8">
|
|
@@ -46,6 +58,26 @@ function renderShell(opts) {
|
|
|
46
58
|
<meta name="supported-color-schemes" content="light only">
|
|
47
59
|
<title>${escapeHtml(opts.subject)}</title>
|
|
48
60
|
<style>
|
|
61
|
+
/* \u26A0\uFE0F THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.
|
|
62
|
+
Not partial \u2014 zero. Measured by fd-sundhed on a real iPhone, 2026-08-19
|
|
63
|
+
18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with
|
|
64
|
+
card AND footer landing on the same colour so the footer stopped being a
|
|
65
|
+
zone at all. The three are: these color-scheme metas + rule, the
|
|
66
|
+
[data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.
|
|
67
|
+
|
|
68
|
+
THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer
|
|
69
|
+
expecting it to fix Outlook \u2014 three have been measured at nothing.
|
|
70
|
+
|
|
71
|
+
\u26A0\uFE0F AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK
|
|
72
|
+
source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->
|
|
73
|
+
#a7a9bf). So to make a too-faint line MORE readable at the recipient, make
|
|
74
|
+
the source colour DARKER. Someone seeing a washed-out line will reach for
|
|
75
|
+
"lighten it" and make it worse \u2014 that is the whole reason this comment sits
|
|
76
|
+
here rather than in a plan-doc.
|
|
77
|
+
|
|
78
|
+
What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no
|
|
79
|
+
mid-tones, structure from rule-and-space rather than fills, no gradient,
|
|
80
|
+
and a button with fill AND border. */
|
|
49
81
|
:root { color-scheme: light only; supported-color-schemes: light only; }
|
|
50
82
|
@media (prefers-color-scheme: dark) {
|
|
51
83
|
.mc-bg-outer { background:${backdropColor} !important; }
|
|
@@ -85,7 +117,25 @@ ${opts.preheader ? `<div style="display:none;font-size:1px;max-height:0;overflow
|
|
|
85
117
|
function heading(text, opts) {
|
|
86
118
|
const fontSerif = opts?.fontSerif ?? "Georgia,'Times New Roman',serif";
|
|
87
119
|
const textColor = opts?.textColor ?? "#1a1a1a";
|
|
88
|
-
|
|
120
|
+
let inner = escapeHtml(text);
|
|
121
|
+
const em = opts?.emphasis;
|
|
122
|
+
if (em) {
|
|
123
|
+
const needle = escapeHtml(em);
|
|
124
|
+
const at = inner.indexOf(needle);
|
|
125
|
+
if (at !== -1) {
|
|
126
|
+
const colour = opts?.accentColor ?? textColor;
|
|
127
|
+
inner = inner.slice(0, at) + `<i style="color:${colour};font-style:italic;">${needle}</i>` + inner.slice(at + needle.length);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return `<h1 style="margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;">${inner}</h1>`;
|
|
131
|
+
}
|
|
132
|
+
function eyebrow(text, opts) {
|
|
133
|
+
return `<p style="margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;">${escapeHtml(text)}</p>`;
|
|
134
|
+
}
|
|
135
|
+
function noteBox(html, opts) {
|
|
136
|
+
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;">
|
|
137
|
+
<tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;">${html}</td></tr>
|
|
138
|
+
</table>`;
|
|
89
139
|
}
|
|
90
140
|
function paragraph(text) {
|
|
91
141
|
return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;">${escapeHtml(text)}</p>`;
|
|
@@ -141,6 +191,6 @@ function makeLogoAttachment(filePath, opts) {
|
|
|
141
191
|
}
|
|
142
192
|
}
|
|
143
193
|
|
|
144
|
-
export { cta, escapeAttr, escapeHtml, factBox, fill, heading, makeLogoAttachment, paragraph, paragraphHtml, renderShell, signOff };
|
|
194
|
+
export { SHELL_VERSION, cta, escapeAttr, escapeHtml, eyebrow, factBox, fill, heading, makeLogoAttachment, noteBox, paragraph, paragraphHtml, renderShell, resolveLogoSrc, signOff };
|
|
145
195
|
//# sourceMappingURL=index.js.map
|
|
146
196
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a hosted URL. */\n logoUrl?: string;\n logoAlt?: string;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoBlock = opts.logoUrl\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(opts.logoUrl)}\" alt=\"${escapeAttr(opts.logoAlt ?? \"\")}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\nexport function heading(text: string, opts?: { fontSerif?: string; textColor?: string }): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${escapeHtml(text)}</h1>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\nexport function signOff(line1: string, line2: string, sign: string): string {\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Branded HTML email shell + primitives — layer 1 (visual structure) of the\n * fleet's mail stack. No sending (that's @broberg/mail) and no template\n * content/override-resolution (that's @broberg/mail-templates, F040) — this\n * package only turns brand params + body HTML into a complete, email-client-\n * safe HTML document, plus the small block builders every template needs.\n *\n * Generalizes sanneandersen's site/src/lib/mail-templates/shell.ts (table\n * layout, dark-mode [data-ogsc] Outlook guards, CID logo) — every color/font/\n * copy value that file hardcoded is now a caller-supplied option.\n */\n\nimport { readFileSync, existsSync } from \"node:fs\";\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, (c) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[c] ?? c);\n}\n\nexport function escapeAttr(s: string): string {\n return escapeHtml(s);\n}\n\nexport interface BrandColors {\n /** Top-of-card accent + CTA button color. Required — no fleet-wide default,\n * so nothing is silently branded as some other product's identity. */\n accentColor: string;\n /** Card background. Default '#ffffff' — pass a dark value (e.g. '#1a1a1a')\n * for a dark-card brand; textColor's default adapts automatically. */\n cardBg?: string;\n /** Body text color. Default derived from cardBg (light card → dark text,\n * dark card → light text) so a dark-card brand isn't illegible by default. */\n textColor?: string;\n /** Page background behind the card. Default '#f4f4f5'. */\n backdropColor?: string;\n fontSans?: string;\n fontSerif?: string;\n}\n\nfunction isDark(hex: string): boolean {\n const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());\n if (!m) return false;\n const n = parseInt(m[1], 16);\n const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;\n // Perceived luminance (ITU-R BT.601).\n return (r * 299 + g * 587 + b * 114) / 1000 < 128;\n}\n\nfunction resolveColors(b: BrandColors) {\n const cardBg = b.cardBg ?? \"#ffffff\";\n const textColor = b.textColor ?? (isDark(cardBg) ? \"#f5f5f5\" : \"#1a1a1a\");\n const backdropColor = b.backdropColor ?? \"#f4f4f5\";\n const fontSans = b.fontSans ?? \"-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif\";\n const fontSerif = b.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n return { accentColor: b.accentColor, cardBg, textColor, backdropColor, fontSans, fontSerif };\n}\n\nexport interface ShellOpts extends BrandColors {\n subject: string;\n /** Hidden preview text shown in the mail-client inbox list. */\n preheader?: string;\n lang?: string;\n /** Pre-rendered body HTML — compose with heading/paragraph/cta/factBox/signOff. */\n bodyHtml: string;\n showFooter?: boolean;\n footerLines?: string[];\n footerHref?: string;\n footerLabel?: string;\n /** Resolved logo <img> src — a cid: reference (see makeLogoAttachment) or a\n * hosted URL. Still honoured; prefer `logo` below, which can carry BOTH. */\n logoUrl?: string;\n logoAlt?: string;\n /** The logo, expressed as EVERY form you have, in preference order (F023.7).\n *\n * WHY BOTH RATHER THAN A CHOICE. cardmem cannot always attach when it sends\n * on a project's behalf, so a template that can only say `cid:` is unusable\n * there. And sanne measured the opposite failure: their `data:` URI logo was\n * stripped by Gmail's image proxy, and ONE template missed in the migration\n * to `cid:` broke ALONE, half a year later. A field that holds one form makes\n * that a migration; a field that holds both makes it a fallback.\n *\n * Preference is CID first, and it is not a style choice: a hosted logo is\n * re-fetched every time the mail is opened, for years, so moving the file\n * breaks every mail ever sent — retroactively. An attachment cannot rot. */\n logo?: LogoSource;\n}\n\nexport interface LogoSource {\n /** contentId of an attached image — rendered as `cid:<id>`. Preferred. */\n cid?: string;\n /** Hosted URL. Used when no cid is given. */\n url?: string;\n alt?: string;\n}\n\n/** Pick the logo src from every form the caller supplied, in preference order.\n *\n * Exported so a caller can ask what WOULD be used without rendering a shell —\n * and so the preference itself is testable rather than buried in a template\n * literal.\n *\n * Returns `null` when there is nothing usable, which is a real outcome: no\n * logo block is rendered, rather than an <img> with an empty src that shows a\n * broken-image icon in every client. */\nexport function resolveLogoSrc(logo: LogoSource | undefined, fallbackUrl?: string): string | null {\n const cid = logo?.cid?.trim();\n if (cid) return `cid:${cid}`;\n const url = logo?.url?.trim() || fallbackUrl?.trim();\n if (!url) return null;\n // A data: URI is NOT a third option — Gmail's image proxy strips it, measured\n // by sanne on a live send. Refused rather than rendered, because a logo that\n // silently vanishes at one provider is the failure this field exists to stop.\n if (/^data:/i.test(url)) return null;\n return url;\n}\n\n/** Renders a complete, email-client-safe HTML document: table layout (not\n * flex/grid — Outlook doesn't support it), dark-mode-inversion guards via\n * both `prefers-color-scheme` and Outlook.com's `[data-ogsc]`, a rounded\n * card with an accent-colored top strip, and an optional footer. */\n/** The shell's own identity, emitted into every rendered mail (F023.7).\n *\n * WHY IT EXISTS, in cardmem's words: a project must be able to tell \"MY\n * template changed\" from \"the SHARED shell changed\". Without it those are one\n * observation, and fd-sundhed's condition for adopting a shared shell is\n * exact — «ellers er delingen en risiko-flytning, ikke en forbedring».\n *\n * Bumped by hand when the rendered OUTPUT changes, which is deliberately not\n * the package version: a docs-only or types-only release must not make every\n * consumer's stored render look different. Same output, same number.\n *\n * An HTML COMMENT rather than an attribute: comments survive every client we\n * have measured, and an attribute on <html> is one of the first things a\n * sanitising webmail rewrites. */\nexport const SHELL_VERSION = \"1\";\n\nexport function renderShell(opts: ShellOpts): string {\n const { accentColor, cardBg, textColor, backdropColor, fontSans } = resolveColors(opts);\n const lang = opts.lang ?? \"en\";\n const showFooter = opts.showFooter ?? true;\n\n const logoSrc = resolveLogoSrc(opts.logo, opts.logoUrl);\n const logoAlt = opts.logo?.alt ?? opts.logoAlt ?? \"\";\n const logoBlock = logoSrc\n ? `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:0 auto 16px;\">\n <tr><td>\n <img src=\"${escapeAttr(logoSrc)}\" alt=\"${escapeAttr(logoAlt)}\" style=\"display:block;margin:0 auto;max-width:180px;height:auto;border:0;\">\n </td></tr>\n </table>`\n : \"\";\n\n const footerBlock = showFooter\n ? `<tr>\n <td bgcolor=\"${backdropColor}\" style=\"background:${backdropColor};padding:16px 40px 32px;text-align:center;border-top:1px solid rgba(0,0,0,0.08);\">\n ${(opts.footerLines ?? []).map((l) => `<p style=\"margin:0 0 4px;font-size:11px;opacity:0.65;\">${escapeHtml(l)}</p>`).join(\"\")}\n ${opts.footerHref ? `<p style=\"margin:0;font-size:11px;\"><a href=\"${escapeAttr(opts.footerHref)}\" style=\"color:${accentColor};text-decoration:none;font-weight:600;\">${escapeHtml(opts.footerLabel ?? opts.footerHref)}</a></p>` : \"\"}\n </td>\n </tr>`\n : \"\";\n\n return `<!doctype html>\n<!-- @broberg/mail-core shell v${SHELL_VERSION} -->\n<html lang=\"${escapeAttr(lang)}\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light only\">\n<meta name=\"supported-color-schemes\" content=\"light only\">\n<title>${escapeHtml(opts.subject)}</title>\n<style>\n /* ⚠️ THE THREE FORCE-LIGHT LAYERS BELOW HAVE ZERO EFFECT IN OUTLOOK iOS.\n Not partial — zero. Measured by fd-sundhed on a real iPhone, 2026-08-19\n 18:28: asked #141969 and got #484090; asked #fffffe and got #484848, with\n card AND footer landing on the same colour so the footer stopped being a\n zone at all. The three are: these color-scheme metas + rule, the\n [data-ogsc]/[data-ogsb] rules, and #fffffe-instead-of-#ffffff.\n\n THEY STAY, because Apple Mail honours them. Do not add a FOURTH layer\n expecting it to fix Outlook — three have been measured at nothing.\n\n ⚠️ AND THE DIRECTION IS INVERTED, which is the trap: Outlook maps a DARK\n source colour to a LIGHT rendered one (#1a1c2b -> #c1c2d1, #4a4d63 ->\n #a7a9bf). So to make a too-faint line MORE readable at the recipient, make\n the source colour DARKER. Someone seeing a washed-out line will reach for\n \"lighten it\" and make it worse — that is the whole reason this comment sits\n here rather than in a plan-doc.\n\n What actually doubled legibility (2.0:1 -> 4.9:1) was structural: no\n mid-tones, structure from rule-and-space rather than fills, no gradient,\n and a button with fill AND border. */\n :root { color-scheme: light only; supported-color-schemes: light only; }\n @media (prefers-color-scheme: dark) {\n .mc-bg-outer { background:${backdropColor} !important; }\n .mc-bg-card { background:${cardBg} !important; }\n .mc-text { color:${textColor} !important; }\n }\n [data-ogsc] .mc-bg-outer { background:${backdropColor} !important; }\n [data-ogsc] .mc-bg-card { background:${cardBg} !important; }\n [data-ogsc] .mc-text { color:${textColor} !important; }\n</style>\n</head>\n<body class=\"mc-bg-outer mc-text\" bgcolor=\"${backdropColor}\" style=\"margin:0;padding:0;background:${backdropColor};font-family:${fontSans};color:${textColor};-webkit-font-smoothing:antialiased;\">\n${opts.preheader ? `<div style=\"display:none;font-size:1px;max-height:0;overflow:hidden;mso-hide:all;\">${escapeHtml(opts.preheader)}</div>` : \"\"}\n<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${backdropColor}\" class=\"mc-bg-outer\" style=\"background:${backdropColor};padding:32px 16px;\">\n <tr>\n <td align=\"center\">\n <table role=\"presentation\" width=\"520\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"max-width:520px;width:100%;background:${cardBg};border-radius:18px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);\">\n <tr><td bgcolor=\"${accentColor}\" style=\"background:${accentColor};height:4px;line-height:4px;font-size:0;\"> </td></tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card\" style=\"background:${cardBg};padding:40px 40px 0;text-align:center;\">\n ${logoBlock}\n </td>\n </tr>\n <tr>\n <td bgcolor=\"${cardBg}\" class=\"mc-bg-card mc-text\" style=\"background:${cardBg};padding:32px 40px;\">\n ${opts.bodyHtml}\n </td>\n </tr>\n ${footerBlock}\n </table>\n </td>\n </tr>\n</table>\n</body>\n</html>`;\n}\n\n/** `emphasis` italicises the FIRST occurrence of that substring in the accent\n * colour — the \"one word picked out of the headline\" brand signature three\n * consumers hand-rolled (reported by vn-leker, F023.7).\n *\n * A substring that does not occur leaves the heading UNCHANGED rather than\n * appending anything: a caller passing a word that is not there has made a\n * mistake, and silently adding it to the end would render that mistake as\n * design. Omitting `emphasis` renders byte-identically to 0.1.0.\n *\n * `fontSerif` SHOULD be a full fallback STACK, never a single family name.\n * vn-leker dropped their serif entirely because Outlook does not guarantee\n * webfonts — which removed the design instead of letting Apple Mail show it.\n * Layer it; do not choose. */\nexport function heading(\n text: string,\n opts?: { fontSerif?: string; textColor?: string; emphasis?: string; accentColor?: string },\n): string {\n const fontSerif = opts?.fontSerif ?? \"Georgia,'Times New Roman',serif\";\n const textColor = opts?.textColor ?? \"#1a1a1a\";\n let inner = escapeHtml(text);\n const em = opts?.emphasis;\n if (em) {\n // Match on the ESCAPED needle inside the ESCAPED haystack, so a word\n // containing & or < still finds itself.\n const needle = escapeHtml(em);\n const at = inner.indexOf(needle);\n if (at !== -1) {\n const colour = opts?.accentColor ?? textColor;\n inner =\n inner.slice(0, at) +\n `<i style=\"color:${colour};font-style:italic;\">${needle}</i>` +\n inner.slice(at + needle.length);\n }\n }\n return `<h1 style=\"margin:0 0 12px;font-family:${fontSerif};font-size:28px;font-weight:400;color:${textColor};text-align:center;\">${inner}</h1>`;\n}\n\n/** The small uppercase label above a heading (\"PROJECT UPDATE\"). Letter-spaced\n * and in the accent colour; a recurring component in every surveyed template. */\nexport function eyebrow(text: string, opts: { accentColor: string }): string {\n return `<p style=\"margin:0 0 6px;font-size:11px;font-weight:700;letter-spacing:0.12em;text-transform:uppercase;color:${opts.accentColor};text-align:center;\">${escapeHtml(text)}</p>`;\n}\n\n/** Free prose with a coloured left rule — a NOTE, not a table.\n *\n * Deliberately not an option on factBox(): that renders label/value ROWS, and\n * this takes a paragraph. Same visual family, different datatype — folding\n * them together would be one function doing two jobs, and the caller would\n * have to pass prose disguised as a row to reach it.\n *\n * Takes RAW HTML like paragraphHtml(): the caller escapes dynamic values. */\nexport function noteBox(html: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;border-left:3px solid ${opts.accentColor};border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;font-size:14px;line-height:1.6;\">${html}</td></tr>\n </table>`;\n}\n\nexport function paragraph(text: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${escapeHtml(text)}</p>`;\n}\n\n/** Like paragraph(), but the string is injected as raw HTML (not escaped) —\n * the caller must escapeHtml() any dynamic values themselves. */\nexport function paragraphHtml(html: string): string {\n return `<p style=\"margin:0 0 16px;font-size:15px;line-height:1.6;\">${html}</p>`;\n}\n\nexport function signOff(line1: string, line2: string, sign: string): string {\n return `<div style=\"margin-top:24px;padding-top:24px;border-top:1px solid rgba(0,0,0,0.1);text-align:center;\">\n <p style=\"margin:0;font-size:15px;line-height:1.8;\">\n ${escapeHtml(line1)}<br>\n ${escapeHtml(line2)}<br>\n <span style=\"font-size:20px;\">${escapeHtml(sign)}</span>\n </p>\n </div>`;\n}\n\n/** A bulletproof (table-cell-based, not a bare <a>/<button>) call-to-action\n * button — the pattern every surveyed template hand-rolled per-brand. */\nexport function cta(href: string, label: string, opts: { accentColor: string }): string {\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" align=\"center\" style=\"margin:28px auto 8px;\">\n <tr>\n <td bgcolor=\"${opts.accentColor}\" style=\"background:${opts.accentColor};border-radius:999px;\">\n <a href=\"${escapeAttr(href)}\" style=\"display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;\">${escapeHtml(label)}</a>\n </td>\n </tr>\n </table>`;\n}\n\nexport interface FactRow {\n label: string;\n value: string;\n}\n\n/** A structured label/value block (table rows, not flex/grid — email-client\n * safe) for rendering e.g. booking details or submitted form fields. */\nexport function factBox(rows: FactRow[], opts?: { accentColor?: string }): string {\n if (rows.length === 0) return \"\";\n const border = opts?.accentColor ? `border-left:3px solid ${opts.accentColor};` : \"border:1px solid rgba(0,0,0,0.1);\";\n const cells = rows\n .map(\n (r) => `<tr>\n <td style=\"padding:6px 12px 6px 0;font-size:13px;opacity:0.65;white-space:nowrap;vertical-align:top;\">${escapeHtml(r.label)}</td>\n <td style=\"padding:6px 0;font-size:13px;font-weight:600;\">${escapeHtml(r.value)}</td>\n </tr>`,\n )\n .join(\"\");\n return `<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\" style=\"margin:16px 0;${border}border-radius:8px;\">\n <tr><td style=\"padding:12px 16px;\">\n <table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" width=\"100%\">${cells}</table>\n </td></tr>\n </table>`;\n}\n\n/** Replace {token} placeholders with values. Unknown tokens are left as-is. */\nexport function fill(template: string, vars: Record<string, string | number>): string {\n return template.replace(/\\{(\\w+)\\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));\n}\n\nexport interface MailAttachment {\n filename: string;\n content: Buffer;\n contentId: string;\n contentType: string;\n}\n\n/** Reads a logo file from a caller-supplied full path and returns a\n * Resend-shaped inline (CID) attachment, or null if the file doesn't exist —\n * never throws, so a missing logo degrades to no-logo, not a broken send. */\nexport function makeLogoAttachment(filePath: string, opts?: { contentId?: string; contentType?: string }): MailAttachment | null {\n if (!existsSync(filePath)) return null;\n try {\n const content = readFileSync(filePath);\n const filename = filePath.split(\"/\").pop() ?? \"logo\";\n const contentType = opts?.contentType ?? (filename.endsWith(\".svg\") ? \"image/svg+xml\" : \"image/png\");\n return { filename, content, contentId: opts?.contentId ?? \"logo\", contentType };\n } catch {\n return null;\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/mail-core",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Branded HTML email shell + primitives for the broberg.ai fleet — renderShell, heading/paragraph/cta/factBox/signOff, and a CID logo-attachment helper. No sending (@broberg/mail) and no template-content/override-resolution (@broberg/mail-templates) — layer 1 (visual structure) only. Every brand value is a caller-supplied param.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": false,
|
|
8
|
-
"files": [
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
9
12
|
"main": "./dist/index.cjs",
|
|
10
13
|
"module": "./dist/index.js",
|
|
11
14
|
"types": "./dist/index.d.ts",
|
|
@@ -14,7 +17,8 @@
|
|
|
14
17
|
"types": "./dist/index.d.ts",
|
|
15
18
|
"import": "./dist/index.js",
|
|
16
19
|
"require": "./dist/index.cjs"
|
|
17
|
-
}
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"build": "tsup",
|
|
@@ -41,5 +45,7 @@
|
|
|
41
45
|
"url": "https://github.com/broberg-ai/components",
|
|
42
46
|
"directory": "packages/mail-core"
|
|
43
47
|
},
|
|
44
|
-
"publishConfig": {
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
}
|
|
45
51
|
}
|