@emailens/engine 0.10.3 → 0.11.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/README.md +30 -27
- package/dist/{chunk-HBKZMR7V.js → chunk-UFSYNSDP.js} +1 -1
- package/dist/chunk-UFSYNSDP.js.map +1 -0
- package/dist/compile/index.cjs.map +1 -1
- package/dist/compile/index.d.cts +2 -2
- package/dist/compile/index.d.ts +2 -2
- package/dist/compile/index.js +2 -2
- package/dist/index.cjs +1063 -170
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +107 -15
- package/dist/index.d.ts +107 -15
- package/dist/index.js +1055 -170
- package/dist/index.js.map +1 -1
- package/dist/{react-email-DwBWB2kr.d.ts → react-email-BWIfwG7U.d.cts} +5 -5
- package/dist/{react-email-QKHRTEJW.js → react-email-CVZZNVZ2.js} +2 -2
- package/dist/{react-email-DTzVpGgB.d.cts → react-email-D24HkW2Z.d.ts} +5 -5
- package/dist/server.cjs +10 -10
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +10 -10
- package/dist/server.js.map +1 -1
- package/dist/{types-BLR3-Fzo.d.cts → types-BFhPXh3c.d.cts} +25 -9
- package/dist/{types-BLR3-Fzo.d.ts → types-BFhPXh3c.d.ts} +25 -9
- package/package.json +7 -3
- package/dist/chunk-HBKZMR7V.js.map +0 -1
- /package/dist/{react-email-QKHRTEJW.js.map → react-email-CVZZNVZ2.js.map} +0 -0
package/dist/index.cjs
CHANGED
|
@@ -68,6 +68,7 @@ __export(index_exports, {
|
|
|
68
68
|
CompileError: () => CompileError,
|
|
69
69
|
EMAIL_CLIENTS: () => EMAIL_CLIENTS,
|
|
70
70
|
EMPTY_DELIVERABILITY: () => EMPTY_DELIVERABILITY,
|
|
71
|
+
FEATURE_URLS: () => FEATURE_URLS,
|
|
71
72
|
GENERIC_LINK_TEXT: () => GENERIC_LINK_TEXT,
|
|
72
73
|
HTML_ELEMENT_FEATURES: () => HTML_ELEMENT_FEATURES,
|
|
73
74
|
MAX_HTML_SIZE: () => MAX_HTML_SIZE,
|
|
@@ -81,10 +82,15 @@ __export(index_exports, {
|
|
|
81
82
|
auditEmail: () => auditEmail,
|
|
82
83
|
caveatApplies: () => caveatApplies,
|
|
83
84
|
checkAccessibility: () => checkAccessibility,
|
|
85
|
+
checkDarkModeContrast: () => checkDarkModeContrast,
|
|
86
|
+
checkDarkStylesContrastFromDom: () => checkDarkStylesContrastFromDom,
|
|
87
|
+
checkDesignConsistency: () => checkDesignConsistency,
|
|
88
|
+
checkMobileContrast: () => checkMobileContrast,
|
|
84
89
|
checkOverflow: () => checkOverflow,
|
|
85
90
|
checkSize: () => checkSize,
|
|
86
91
|
checkTemplateVariables: () => checkTemplateVariables,
|
|
87
92
|
checkVisual: () => checkVisual,
|
|
93
|
+
colorDistance: () => colorDistance,
|
|
88
94
|
contrastRatio: () => contrastRatio,
|
|
89
95
|
createSession: () => createSession,
|
|
90
96
|
diffResults: () => diffResults,
|
|
@@ -92,6 +98,7 @@ __export(index_exports, {
|
|
|
92
98
|
errorWarnings: () => errorWarnings,
|
|
93
99
|
estimateAiFixTokens: () => estimateAiFixTokens,
|
|
94
100
|
extractInboxPreview: () => extractInboxPreview,
|
|
101
|
+
featureUrl: () => featureUrl,
|
|
95
102
|
formatRgb: () => formatRgb,
|
|
96
103
|
generateAiFix: () => generateAiFix,
|
|
97
104
|
generateCompatibilityScore: () => generateCompatibilityScore,
|
|
@@ -102,6 +109,7 @@ __export(index_exports, {
|
|
|
102
109
|
heuristicTokenCount: () => heuristicTokenCount,
|
|
103
110
|
parseColor: () => parseColor,
|
|
104
111
|
relativeLuminance: () => relativeLuminance,
|
|
112
|
+
rgbToOklab: () => rgbToOklab,
|
|
105
113
|
simulateDarkMode: () => simulateDarkMode,
|
|
106
114
|
structuralWarnings: () => structuralWarnings,
|
|
107
115
|
toPlainText: () => toPlainText,
|
|
@@ -7601,12 +7609,12 @@ h1 {
|
|
|
7601
7609
|
description: "Account for padding in width manually (no box-sizing)",
|
|
7602
7610
|
before: `<div style="width: 300px; padding: 20px;
|
|
7603
7611
|
box-sizing: border-box;">
|
|
7604
|
-
Content
|
|
7612
|
+
Content; total width stays 300px
|
|
7605
7613
|
</div>`,
|
|
7606
7614
|
after: `<!-- Set width to content-width (300 - 40 = 260px) -->
|
|
7607
7615
|
<div style="width: 300px;">
|
|
7608
7616
|
<div style="padding: 20px;">
|
|
7609
|
-
Content
|
|
7617
|
+
Content; padding on inner element
|
|
7610
7618
|
</div>
|
|
7611
7619
|
</div>`
|
|
7612
7620
|
},
|
|
@@ -7687,7 +7695,7 @@ h1 {
|
|
|
7687
7695
|
// ── transition (not supported) ────────────────────────────────────────
|
|
7688
7696
|
"transition": {
|
|
7689
7697
|
language: "css",
|
|
7690
|
-
description: "Transitions don't work in email
|
|
7698
|
+
description: "Transitions don't work in email; style the default state well",
|
|
7691
7699
|
before: `.button {
|
|
7692
7700
|
background-color: #6d28d9;
|
|
7693
7701
|
transition: background-color 0.2s;
|
|
@@ -7707,7 +7715,7 @@ h1 {
|
|
|
7707
7715
|
// ── background-size (Outlook) → VML ───────────────────────────────────
|
|
7708
7716
|
"background-size": {
|
|
7709
7717
|
language: "html",
|
|
7710
|
-
description: "Outlook ignores background-size
|
|
7718
|
+
description: "Outlook ignores background-size; use VML or sized images",
|
|
7711
7719
|
before: `<td style="background: url('bg.jpg') center/cover no-repeat;">
|
|
7712
7720
|
Content
|
|
7713
7721
|
</td>`,
|
|
@@ -7729,7 +7737,7 @@ h1 {
|
|
|
7729
7737
|
// ── overflow (Gmail strips it) ────────────────────────────────────────
|
|
7730
7738
|
"overflow": {
|
|
7731
7739
|
language: "html",
|
|
7732
|
-
description: "Content will always be visible
|
|
7740
|
+
description: "Content will always be visible; design for full content display",
|
|
7733
7741
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7734
7742
|
Long content that gets clipped...
|
|
7735
7743
|
</div>`,
|
|
@@ -7831,7 +7839,7 @@ h1 {
|
|
|
7831
7839
|
// ── max-height → fixed height ────────────────────────────────────────
|
|
7832
7840
|
"max-height": {
|
|
7833
7841
|
language: "html",
|
|
7834
|
-
description: "Outlook ignores max-height
|
|
7842
|
+
description: "Outlook ignores max-height; truncate content server-side",
|
|
7835
7843
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7836
7844
|
Long content...
|
|
7837
7845
|
</div>`,
|
|
@@ -8062,7 +8070,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8062
8070
|
</Row>`,
|
|
8063
8071
|
after: `import { Row, Column } from "@react-email/components";
|
|
8064
8072
|
|
|
8065
|
-
{/* Use padding on Column
|
|
8073
|
+
{/* Use padding on Column; gap is not supported in email clients */}
|
|
8066
8074
|
<Row>
|
|
8067
8075
|
<Column style={{ paddingRight: "16px" }}>Item 1</Column>
|
|
8068
8076
|
<Column style={{ paddingRight: "16px" }}>Item 2</Column>
|
|
@@ -8072,7 +8080,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8072
8080
|
// ── <style> (Gmail JSX) ─────────────────────────────────────────────
|
|
8073
8081
|
"<style>::gmail::jsx": {
|
|
8074
8082
|
language: "jsx",
|
|
8075
|
-
description: "React Email inlines styles via style props
|
|
8083
|
+
description: "React Email inlines styles via style props; manual <style> blocks won't survive Gmail",
|
|
8076
8084
|
before: `import { Head } from "@react-email/components";
|
|
8077
8085
|
|
|
8078
8086
|
<Head>
|
|
@@ -8133,7 +8141,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8133
8141
|
// ── @media (JSX) ────────────────────────────────────────────────────
|
|
8134
8142
|
"@media::jsx": {
|
|
8135
8143
|
language: "jsx",
|
|
8136
|
-
description: "Design mobile-first
|
|
8144
|
+
description: "Design mobile-first; @media queries are stripped by many clients",
|
|
8137
8145
|
before: `import { Head } from "@react-email/components";
|
|
8138
8146
|
|
|
8139
8147
|
<Head>
|
|
@@ -8275,7 +8283,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8275
8283
|
</div>`,
|
|
8276
8284
|
after: `import { Img } from "@react-email/components";
|
|
8277
8285
|
|
|
8278
|
-
{/* CSS transforms are not supported in email
|
|
8286
|
+
{/* CSS transforms are not supported in email: pre-render as an image */}
|
|
8279
8287
|
<Img
|
|
8280
8288
|
src="https://example.com/rotated-content.png"
|
|
8281
8289
|
width={200}
|
|
@@ -8291,7 +8299,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8291
8299
|
before: `<span style={{ animation: "pulse 2s infinite" }}>New!</span>`,
|
|
8292
8300
|
after: `import { Img } from "@react-email/components";
|
|
8293
8301
|
|
|
8294
|
-
{/* CSS animations are not supported
|
|
8302
|
+
{/* CSS animations are not supported: use an animated GIF */}
|
|
8295
8303
|
<Img
|
|
8296
8304
|
src="https://example.com/badge-animated.gif"
|
|
8297
8305
|
width={60}
|
|
@@ -8303,7 +8311,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8303
8311
|
// ── transition (JSX) ─────────────────────────────────────────────
|
|
8304
8312
|
"transition::jsx": {
|
|
8305
8313
|
language: "jsx",
|
|
8306
|
-
description: "Transitions don't work in email
|
|
8314
|
+
description: "Transitions don't work in email; style the default state well",
|
|
8307
8315
|
before: `<a
|
|
8308
8316
|
href="#"
|
|
8309
8317
|
style={{
|
|
@@ -8316,7 +8324,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8316
8324
|
</a>`,
|
|
8317
8325
|
after: `import { Button } from "@react-email/components";
|
|
8318
8326
|
|
|
8319
|
-
{/* Transitions are not supported
|
|
8327
|
+
{/* Transitions are not supported: style the default state: */}
|
|
8320
8328
|
<Button
|
|
8321
8329
|
href="https://example.com"
|
|
8322
8330
|
style={{
|
|
@@ -8333,13 +8341,13 @@ var JSX_FIX_DATABASE = {
|
|
|
8333
8341
|
// ── overflow (JSX) ───────────────────────────────────────────────
|
|
8334
8342
|
"overflow::jsx": {
|
|
8335
8343
|
language: "jsx",
|
|
8336
|
-
description: "Content will always be visible
|
|
8344
|
+
description: "Content will always be visible; design for full content display",
|
|
8337
8345
|
before: `<div style={{ maxHeight: "200px", overflow: "hidden" }}>
|
|
8338
8346
|
Long content that gets clipped...
|
|
8339
8347
|
</div>`,
|
|
8340
8348
|
after: `import { Link, Text } from "@react-email/components";
|
|
8341
8349
|
|
|
8342
|
-
{/* overflow:hidden is stripped
|
|
8350
|
+
{/* overflow:hidden is stripped; show full content or truncate server-side */}
|
|
8343
8351
|
<div>
|
|
8344
8352
|
<Text>Shortened content that fits...</Text>
|
|
8345
8353
|
<Link href="https://example.com/full">Read more</Link>
|
|
@@ -8352,7 +8360,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8352
8360
|
before: `<div style={{ visibility: "hidden" }}>
|
|
8353
8361
|
Hidden preheader text
|
|
8354
8362
|
</div>`,
|
|
8355
|
-
after: `{/* visibility:hidden is stripped by most clients
|
|
8363
|
+
after: `{/* visibility:hidden is stripped by most clients; use the preheader trick.
|
|
8356
8364
|
msoHide is non-standard but needed to hide content in Outlook. */}
|
|
8357
8365
|
<div
|
|
8358
8366
|
style={{
|
|
@@ -8390,7 +8398,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8390
8398
|
// ── background-size (JSX) ────────────────────────────────────────
|
|
8391
8399
|
"background-size::jsx": {
|
|
8392
8400
|
language: "jsx",
|
|
8393
|
-
description: "Outlook ignores background-size
|
|
8401
|
+
description: "Outlook ignores background-size; use sized images instead",
|
|
8394
8402
|
before: `<div style={{
|
|
8395
8403
|
background: "url('bg.jpg') center/cover no-repeat",
|
|
8396
8404
|
}}>
|
|
@@ -8416,12 +8424,12 @@ var JSX_FIX_DATABASE = {
|
|
|
8416
8424
|
padding: "20px",
|
|
8417
8425
|
boxSizing: "border-box",
|
|
8418
8426
|
}}>
|
|
8419
|
-
Content
|
|
8427
|
+
Content (total width stays 300px)
|
|
8420
8428
|
</div>`,
|
|
8421
8429
|
after: `{/* Set outer width, use inner element for padding */}
|
|
8422
8430
|
<div style={{ width: "300px" }}>
|
|
8423
8431
|
<div style={{ padding: "20px" }}>
|
|
8424
|
-
Content
|
|
8432
|
+
Content; padding on inner element
|
|
8425
8433
|
</div>
|
|
8426
8434
|
</div>`
|
|
8427
8435
|
},
|
|
@@ -8461,7 +8469,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8461
8469
|
// ── word-break (MJML) ────────────────────────────────────────────────
|
|
8462
8470
|
"word-break::mjml": {
|
|
8463
8471
|
language: "mjml",
|
|
8464
|
-
description: "MJML renders text in table cells by default
|
|
8472
|
+
description: "MJML renders text in table cells by default; word-break works via mj-text",
|
|
8465
8473
|
before: `<mj-text>
|
|
8466
8474
|
<span style="word-break: break-all;">Long URL here</span>
|
|
8467
8475
|
</mj-text>`,
|
|
@@ -8515,7 +8523,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8515
8523
|
// ── border-radius (Outlook MJML) ────────────────────────────────────
|
|
8516
8524
|
"border-radius::outlook::mjml": {
|
|
8517
8525
|
language: "mjml",
|
|
8518
|
-
description: "MJML limitation: border-radius is unsupported in Outlook
|
|
8526
|
+
description: "MJML limitation: border-radius is unsupported in Outlook; MJML does not generate VML",
|
|
8519
8527
|
before: `<mj-button border-radius="6px" background-color="#6d28d9">
|
|
8520
8528
|
Click Here
|
|
8521
8529
|
</mj-button>`,
|
|
@@ -8555,7 +8563,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8555
8563
|
</div>
|
|
8556
8564
|
</mj-raw>`,
|
|
8557
8565
|
after: `<!-- Flexbox in MJML is not Outlook-compatible.
|
|
8558
|
-
Use mj-section and mj-column
|
|
8566
|
+
Use mj-section and mj-column: MJML compiles these to table-based layouts. -->
|
|
8559
8567
|
<mj-section>
|
|
8560
8568
|
<mj-column width="50%">
|
|
8561
8569
|
<mj-text>Column 1</mj-text>
|
|
@@ -8615,7 +8623,7 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8615
8623
|
// ── @font-face (Maizzle) ────────────────────────────────────────────
|
|
8616
8624
|
"@font-face::maizzle": {
|
|
8617
8625
|
language: "maizzle",
|
|
8618
|
-
description: 'Add fonts via the googleFonts key in config.js
|
|
8626
|
+
description: 'Add fonts via the googleFonts key in config.js; Maizzle injects the Google Fonts link tag automatically. Set googleFonts: "Inter:ital,wght@0,400;0,700" in your environment config, then reference the font family in your template.',
|
|
8619
8627
|
before: `<style>
|
|
8620
8628
|
@font-face {
|
|
8621
8629
|
font-family: 'Inter';
|
|
@@ -8628,12 +8636,12 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8628
8636
|
// ── <style> (Gmail Maizzle) ─────────────────────────────────────────
|
|
8629
8637
|
"<style>::gmail::maizzle": {
|
|
8630
8638
|
language: "maizzle",
|
|
8631
|
-
description: "Maizzle automatically inlines CSS via juice during build (inlineCSS: true in config.js). Manual <style> blocks bypass juice and will be stripped by Gmail
|
|
8639
|
+
description: "Maizzle automatically inlines CSS via juice during build (inlineCSS: true in config.js). Manual <style> blocks bypass juice and will be stripped by Gmail; prefer Tailwind utility classes instead.",
|
|
8632
8640
|
before: `<style>
|
|
8633
8641
|
.custom { color: #6d28d9; }
|
|
8634
8642
|
</style>
|
|
8635
8643
|
<div class="custom">Hello</div>`,
|
|
8636
|
-
after: `<!-- Prefer Tailwind classes
|
|
8644
|
+
after: `<!-- Prefer Tailwind classes; Maizzle inlines them automatically during build -->
|
|
8637
8645
|
<div class="text-[#6d28d9]">Hello</div>`
|
|
8638
8646
|
},
|
|
8639
8647
|
// ── max-width (Outlook Maizzle) ─────────────────────────────────────
|
|
@@ -8722,7 +8730,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8722
8730
|
"opacity": "Use solid colors instead of opacity.",
|
|
8723
8731
|
// ── word-break ────────────────────────────────────────────────────────
|
|
8724
8732
|
"word-break": "Wrap long text in a <table><td> to force wrapping in clients that don't support word-break.",
|
|
8725
|
-
"word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width
|
|
8733
|
+
"word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width; tables always wrap.",
|
|
8726
8734
|
// ── overflow-wrap ─────────────────────────────────────────────────────
|
|
8727
8735
|
"overflow-wrap": "Wrap text in a <table><td> to force wrapping. overflow-wrap is ignored by Outlook and unreliable in Yahoo.",
|
|
8728
8736
|
// ── white-space ───────────────────────────────────────────────────────
|
|
@@ -8758,7 +8766,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8758
8766
|
// src/fix-snippets/jsx-suggestions.ts
|
|
8759
8767
|
var JSX_SUGGESTION_DATABASE = {
|
|
8760
8768
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8761
|
-
"<style>::jsx": "Move styles to inline style props
|
|
8769
|
+
"<style>::jsx": "Move styles to inline style props; React Email components accept style objects directly.",
|
|
8762
8770
|
"<style>:partial::jsx": "Use inline style props on React Email components. Reserve <style> in <Head> for progressive enhancement only.",
|
|
8763
8771
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8764
8772
|
"<link>::jsx": "Use the React Email <Head> component for font imports; place all other styles inline via style props.",
|
|
@@ -8820,7 +8828,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8820
8828
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8821
8829
|
"@media::mjml": "MJML generates responsive @media queries automatically. Use mj-breakpoint and mj-column widths.",
|
|
8822
8830
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
8823
|
-
"display:flex::mjml": "Use mj-section and mj-column
|
|
8831
|
+
"display:flex::mjml": "Use mj-section and mj-column; MJML compiles these to table-based layouts.",
|
|
8824
8832
|
// ── display:grid ──────────────────────────────────────────────────────
|
|
8825
8833
|
"display:grid::mjml": "Use mj-section and mj-column for grid-like layouts.",
|
|
8826
8834
|
// ── linear-gradient ───────────────────────────────────────────────────
|
|
@@ -8828,7 +8836,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8828
8836
|
// ── box-shadow ────────────────────────────────────────────────────────
|
|
8829
8837
|
"box-shadow::mjml": "Use a border attribute on mj-section or mj-column as an alternative.",
|
|
8830
8838
|
// ── border-radius ─────────────────────────────────────────────────────
|
|
8831
|
-
"border-radius::mjml": 'MJML does not generate VML
|
|
8839
|
+
"border-radius::mjml": 'MJML does not generate VML; border-radius will not render in Outlook. Set border-radius="0" or accept flat corners.',
|
|
8832
8840
|
// ── max-width ─────────────────────────────────────────────────────────
|
|
8833
8841
|
"max-width::mjml": "Set the width attribute on mj-body or mj-section for maximum compatibility.",
|
|
8834
8842
|
// ── gap ───────────────────────────────────────────────────────────────
|
|
@@ -8836,7 +8844,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8836
8844
|
// ── float ─────────────────────────────────────────────────────────────
|
|
8837
8845
|
"float::mjml": "Use mj-section with multiple mj-column elements for side-by-side layout.",
|
|
8838
8846
|
// ── background-image ──────────────────────────────────────────────────
|
|
8839
|
-
"background-image::mjml": "Use background-url attribute on mj-section
|
|
8847
|
+
"background-image::mjml": "Use background-url attribute on mj-section; MJML generates VML automatically.",
|
|
8840
8848
|
// ── position ──────────────────────────────────────────────────────────
|
|
8841
8849
|
"position::mjml": "Use mj-section and mj-column for layout positioning.",
|
|
8842
8850
|
// ── opacity ───────────────────────────────────────────────────────────
|
|
@@ -8848,10 +8856,10 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8848
8856
|
// src/fix-snippets/maizzle-suggestions.ts
|
|
8849
8857
|
var MAIZZLE_SUGGESTION_DATABASE = {
|
|
8850
8858
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8851
|
-
"<style>::maizzle": "Prefer Tailwind utility classes
|
|
8859
|
+
"<style>::maizzle": "Prefer Tailwind utility classes; Maizzle inlines CSS via juice during build (inlineCSS: true in config.js).",
|
|
8852
8860
|
"<style>:partial::maizzle": "Use Tailwind utility classes for critical styles. Maizzle automatically inlines them at build time.",
|
|
8853
8861
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8854
|
-
"<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes
|
|
8862
|
+
"<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes; Maizzle inlines them at build time.",
|
|
8855
8863
|
// ── <svg> ─────────────────────────────────────────────────────────────
|
|
8856
8864
|
"<svg>::maizzle": "Replace inline SVG with an <img> tag pointing to a hosted PNG.",
|
|
8857
8865
|
// ── <video> ───────────────────────────────────────────────────────────
|
|
@@ -8859,7 +8867,7 @@ var MAIZZLE_SUGGESTION_DATABASE = {
|
|
|
8859
8867
|
// ── <form> ────────────────────────────────────────────────────────────
|
|
8860
8868
|
"<form>::maizzle": "Replace the form with a CTA link/button pointing to a hosted form page.",
|
|
8861
8869
|
// ── @font-face ────────────────────────────────────────────────────────
|
|
8862
|
-
"@font-face::maizzle": "Use the googleFonts key in config.js
|
|
8870
|
+
"@font-face::maizzle": "Use the googleFonts key in config.js; Maizzle injects the Google Fonts link tag automatically.",
|
|
8863
8871
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8864
8872
|
"@media::maizzle": "Use Tailwind responsive utility classes and Maizzle's breakpoints config instead of hand-written @media.",
|
|
8865
8873
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
@@ -9030,6 +9038,30 @@ function serializeStyle(map) {
|
|
|
9030
9038
|
});
|
|
9031
9039
|
return parts.join("; ");
|
|
9032
9040
|
}
|
|
9041
|
+
function splitTopLevel(value) {
|
|
9042
|
+
const parts = [];
|
|
9043
|
+
let current = "";
|
|
9044
|
+
let depth = 0;
|
|
9045
|
+
let quote = null;
|
|
9046
|
+
for (const ch of value) {
|
|
9047
|
+
if (quote) {
|
|
9048
|
+
if (ch === quote) quote = null;
|
|
9049
|
+
} else if (ch === '"' || ch === "'") {
|
|
9050
|
+
quote = ch;
|
|
9051
|
+
} else if (ch === "(") {
|
|
9052
|
+
depth++;
|
|
9053
|
+
} else if (ch === ")") {
|
|
9054
|
+
depth = Math.max(0, depth - 1);
|
|
9055
|
+
} else if (ch === "," && depth === 0) {
|
|
9056
|
+
parts.push(current);
|
|
9057
|
+
current = "";
|
|
9058
|
+
continue;
|
|
9059
|
+
}
|
|
9060
|
+
current += ch;
|
|
9061
|
+
}
|
|
9062
|
+
parts.push(current);
|
|
9063
|
+
return parts;
|
|
9064
|
+
}
|
|
9033
9065
|
|
|
9034
9066
|
// src/downlevel.ts
|
|
9035
9067
|
var cheerio = __toESM(require("cheerio"), 1);
|
|
@@ -9405,6 +9437,71 @@ function alphaBlend(fg, bgR, bgG, bgB) {
|
|
|
9405
9437
|
Math.round(fg.b * a + bgB * (1 - a))
|
|
9406
9438
|
];
|
|
9407
9439
|
}
|
|
9440
|
+
function backgroundShorthandColor(value) {
|
|
9441
|
+
if (!value) return null;
|
|
9442
|
+
const trimmed = value.trim();
|
|
9443
|
+
if (parseColor(trimmed)) return trimmed;
|
|
9444
|
+
for (const token of trimmed.split(/\s+/)) {
|
|
9445
|
+
if (token.includes("(")) continue;
|
|
9446
|
+
if (parseColor(token)) return token;
|
|
9447
|
+
}
|
|
9448
|
+
return null;
|
|
9449
|
+
}
|
|
9450
|
+
function functionArgs(value, nameRe) {
|
|
9451
|
+
const out = [];
|
|
9452
|
+
const re = new RegExp(nameRe.source + "\\(", nameRe.flags.includes("i") ? "gi" : "g");
|
|
9453
|
+
let match;
|
|
9454
|
+
while ((match = re.exec(value)) !== null) {
|
|
9455
|
+
let depth = 1;
|
|
9456
|
+
let i = match.index + match[0].length;
|
|
9457
|
+
const start = i;
|
|
9458
|
+
while (i < value.length && depth > 0) {
|
|
9459
|
+
if (value[i] === "(") depth++;
|
|
9460
|
+
else if (value[i] === ")") depth--;
|
|
9461
|
+
i++;
|
|
9462
|
+
}
|
|
9463
|
+
if (depth === 0) out.push(value.slice(start, i - 1));
|
|
9464
|
+
re.lastIndex = i;
|
|
9465
|
+
}
|
|
9466
|
+
return out;
|
|
9467
|
+
}
|
|
9468
|
+
function gradientStops(value) {
|
|
9469
|
+
if (!value) return [];
|
|
9470
|
+
const stops = [];
|
|
9471
|
+
for (const args of functionArgs(value, /(?:repeating-)?(?:linear|radial|conic)-gradient/i)) {
|
|
9472
|
+
for (const part of splitTopLevel(args)) {
|
|
9473
|
+
const trimmed = part.trim();
|
|
9474
|
+
if (!trimmed || /^(?:to\b|at\b|from\b|in\b|[\d.]+(?:deg|turn|rad|grad)\b)/i.test(trimmed)) continue;
|
|
9475
|
+
const fnMatch = trimmed.match(/^(?:rgba?|hsla?|color|lab|lch|oklab|oklch)\([^)]*\)/i);
|
|
9476
|
+
const token = fnMatch ? fnMatch[0] : trimmed.split(/\s+/)[0];
|
|
9477
|
+
const parsed = parseColor(token);
|
|
9478
|
+
if (parsed && parsed.a > 0) stops.push(parsed);
|
|
9479
|
+
}
|
|
9480
|
+
}
|
|
9481
|
+
return stops;
|
|
9482
|
+
}
|
|
9483
|
+
function gammaToLinear(c) {
|
|
9484
|
+
const s = c / 255;
|
|
9485
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
9486
|
+
}
|
|
9487
|
+
function rgbToOklab(rgba) {
|
|
9488
|
+
const r = gammaToLinear(rgba.r);
|
|
9489
|
+
const g = gammaToLinear(rgba.g);
|
|
9490
|
+
const b = gammaToLinear(rgba.b);
|
|
9491
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
9492
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
9493
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
9494
|
+
return [
|
|
9495
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
9496
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
9497
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
9498
|
+
];
|
|
9499
|
+
}
|
|
9500
|
+
function colorDistance(a, b) {
|
|
9501
|
+
const [l1, a1, b1] = rgbToOklab(a);
|
|
9502
|
+
const [l2, a2, b2] = rgbToOklab(b);
|
|
9503
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
9504
|
+
}
|
|
9408
9505
|
|
|
9409
9506
|
// src/downlevel.ts
|
|
9410
9507
|
var LOGICAL_PROPERTY_MAP = {
|
|
@@ -9845,6 +9942,10 @@ var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
|
|
|
9845
9942
|
"ui-monospace"
|
|
9846
9943
|
]);
|
|
9847
9944
|
var EMPTY_DELIVERABILITY = { domain: "", checks: [], score: 0, issues: [] };
|
|
9945
|
+
var EMPTY_DESIGN = {
|
|
9946
|
+
issues: [],
|
|
9947
|
+
palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] }
|
|
9948
|
+
};
|
|
9848
9949
|
|
|
9849
9950
|
// src/transform.ts
|
|
9850
9951
|
function hasPseudoSelector(selector) {
|
|
@@ -10215,7 +10316,7 @@ var CLIENT_CONFIGS = {
|
|
|
10215
10316
|
"outlook-macos": {
|
|
10216
10317
|
id: "outlook-macos",
|
|
10217
10318
|
// caniemail: the new (WebKit) Outlook for Mac supports position/transform/
|
|
10218
|
-
// animation/box-shadow/opacity
|
|
10319
|
+
// animation/box-shadow/opacity; only transition is unsupported. It is NOT
|
|
10219
10320
|
// the Word engine, so do not mirror outlook-web's broader strip set.
|
|
10220
10321
|
strippedProperties: /* @__PURE__ */ new Set(["transition"]),
|
|
10221
10322
|
stripMode: "strip",
|
|
@@ -10307,7 +10408,7 @@ var CLIENT_CONFIGS = {
|
|
|
10307
10408
|
additionalChecks: heyAdditionalChecks
|
|
10308
10409
|
},
|
|
10309
10410
|
// ponytail: strip sets below are data-driven from caniemail (the same source
|
|
10310
|
-
// as CSS_SUPPORT), not hand-guessed
|
|
10411
|
+
// as CSS_SUPPORT), not hand-guessed; no manual re-verification debt.
|
|
10311
10412
|
"protonmail": {
|
|
10312
10413
|
id: "protonmail",
|
|
10313
10414
|
// caniemail: strips animation + external stylesheets + forms; supports
|
|
@@ -10321,7 +10422,7 @@ var CLIENT_CONFIGS = {
|
|
|
10321
10422
|
},
|
|
10322
10423
|
"aol": {
|
|
10323
10424
|
id: "aol",
|
|
10324
|
-
// caniemail: restrictive
|
|
10425
|
+
// caniemail: restrictive; drops animation, box-shadow, opacity, transform,
|
|
10325
10426
|
// external stylesheets, and SVG. Forms are kept.
|
|
10326
10427
|
strippedProperties: /* @__PURE__ */ new Set(["animation", "box-shadow", "opacity", "transform"]),
|
|
10327
10428
|
stripMode: "strip",
|
|
@@ -10875,7 +10976,7 @@ function simulateDarkMode(html, clientId) {
|
|
|
10875
10976
|
client: clientId,
|
|
10876
10977
|
property: "dark-mode",
|
|
10877
10978
|
message: "Superhuman respects @media (prefers-color-scheme: dark). Many Superhuman users run in dark mode.",
|
|
10878
|
-
suggestion: "Add @media (prefers-color-scheme: dark) styles
|
|
10979
|
+
suggestion: "Add @media (prefers-color-scheme: dark) styles; Superhuman's power-user audience often prefers dark mode."
|
|
10879
10980
|
});
|
|
10880
10981
|
}
|
|
10881
10982
|
break;
|
|
@@ -11251,16 +11352,6 @@ function positionAt(data, index, anchor) {
|
|
|
11251
11352
|
// src/dark-mode-checker.ts
|
|
11252
11353
|
var DARK_MEDIA_RE = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
|
|
11253
11354
|
var MAX_UNCOVERED_ELEMENTS = 3;
|
|
11254
|
-
function backgroundShorthandColor(value) {
|
|
11255
|
-
if (!value) return null;
|
|
11256
|
-
const trimmed = value.trim();
|
|
11257
|
-
if (parseColor(trimmed)) return trimmed;
|
|
11258
|
-
for (const token of trimmed.split(/\s+/)) {
|
|
11259
|
-
if (token.includes("(")) continue;
|
|
11260
|
-
if (parseColor(token)) return token;
|
|
11261
|
-
}
|
|
11262
|
-
return null;
|
|
11263
|
-
}
|
|
11264
11355
|
function isLight(value) {
|
|
11265
11356
|
const c = parseColor(value);
|
|
11266
11357
|
if (!c || c.a < 0.5) return false;
|
|
@@ -11378,7 +11469,7 @@ function checkDarkModeFromDom($, source) {
|
|
|
11378
11469
|
severity: "warning",
|
|
11379
11470
|
client: clientId,
|
|
11380
11471
|
property: "dark-mode-coverage",
|
|
11381
|
-
message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode
|
|
11472
|
+
message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode; the dark block never overrides it. The rest of the email inverts around it, producing a half-inverted render (e.g. light text left on a still-white background).`,
|
|
11382
11473
|
suggestion: inline ? `Override it inside @media (prefers-color-scheme: dark) with a dark background-color and !important (an inline style beats a plain rule), or move the colour onto a class the dark block already targets.` : `Override it inside @media (prefers-color-scheme: dark) with a dark background-color, or give the element a class the dark block already targets.`,
|
|
11383
11474
|
fixType: "css",
|
|
11384
11475
|
selector
|
|
@@ -11451,7 +11542,7 @@ var COMPOUND_DETECTORS = [
|
|
|
11451
11542
|
var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
|
|
11452
11543
|
key: fn,
|
|
11453
11544
|
pattern: `${fn}(`
|
|
11454
|
-
// require opening paren
|
|
11545
|
+
// require opening paren, matches "min(" but not "Minion"
|
|
11455
11546
|
}));
|
|
11456
11547
|
function analyzeEmailFromDom($, framework, source) {
|
|
11457
11548
|
const warnings = [];
|
|
@@ -11949,14 +12040,14 @@ ${originalHtml}
|
|
|
11949
12040
|
- **${w.property}** (${clientName})${fixTypeLabel}: ${w.message}`;
|
|
11950
12041
|
if (w.fixType === "structural") {
|
|
11951
12042
|
issueSection += `
|
|
11952
|
-
- **Fix type: structural
|
|
12043
|
+
- **Fix type: structural**. CSS-only changes will NOT work. HTML restructuring required.`;
|
|
11953
12044
|
}
|
|
11954
12045
|
if (w.suggestion) {
|
|
11955
12046
|
issueSection += `
|
|
11956
12047
|
- Suggestion: ${w.suggestion}`;
|
|
11957
12048
|
}
|
|
11958
12049
|
if (w.fix) {
|
|
11959
|
-
const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML
|
|
12050
|
+
const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML, adapt to ${format.toUpperCase()} syntax)` : "Fix";
|
|
11960
12051
|
issueSection += `
|
|
11961
12052
|
- ${fixLabel}:`;
|
|
11962
12053
|
issueSection += `
|
|
@@ -11988,10 +12079,10 @@ Client-agnostic rendering bugs. Apply each fix directly to the code.
|
|
|
11988
12079
|
sections.push(s);
|
|
11989
12080
|
}
|
|
11990
12081
|
const formatInstructions = {
|
|
11991
|
-
jsx: `Apply all the fixes listed above to the original email code. Return complete fixed JSX code using @react-email/components. Use Row, Column, Container, Font, Img, Head, and Link components from @react-email/components wherever the fix suggests them. Keep all style values as camelCase JavaScript object properties (e.g. { backgroundColor: "#fff" }). Ensure the result is compatible with ${clientLabel}. Do not remove any content
|
|
11992
|
-
mjml: `Apply all the fixes listed above to the original email code. Return complete fixed MJML markup. Use MJML-native elements (mj-section, mj-column, mj-text, mj-button, mj-font, mj-style, mj-raw) as indicated in the fixes. Ensure the result is valid MJML that compiles without errors. Ensure the result is compatible with ${clientLabel}. Do not remove any content
|
|
11993
|
-
maizzle: `Apply all the fixes listed above to the original email code. Return the complete fixed Maizzle template. Use Tailwind CSS utility classes and Maizzle config settings as indicated in the fixes. Add MSO conditional comment table wrappers where needed for Outlook compatibility. Ensure the result is compatible with ${clientLabel}. Do not remove any content
|
|
11994
|
-
html: `Apply all the fixes listed above to the original email code. Return the complete fixed HTML code. Ensure the result is compatible with ${clientLabel}. Do not remove any content
|
|
12082
|
+
jsx: `Apply all the fixes listed above to the original email code. Return complete fixed JSX code using @react-email/components. Use Row, Column, Container, Font, Img, Head, and Link components from @react-email/components wherever the fix suggests them. Keep all style values as camelCase JavaScript object properties (e.g. { backgroundColor: "#fff" }). Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the JSX structure and style props needed to fix the issues.`,
|
|
12083
|
+
mjml: `Apply all the fixes listed above to the original email code. Return complete fixed MJML markup. Use MJML-native elements (mj-section, mj-column, mj-text, mj-button, mj-font, mj-style, mj-raw) as indicated in the fixes. Ensure the result is valid MJML that compiles without errors. Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the MJML structure and attributes needed to fix the issues.`,
|
|
12084
|
+
maizzle: `Apply all the fixes listed above to the original email code. Return the complete fixed Maizzle template. Use Tailwind CSS utility classes and Maizzle config settings as indicated in the fixes. Add MSO conditional comment table wrappers where needed for Outlook compatibility. Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the Tailwind classes and HTML structure needed to fix the issues.`,
|
|
12085
|
+
html: `Apply all the fixes listed above to the original email code. Return the complete fixed HTML code. Ensure the result is compatible with ${clientLabel}. Do not remove any content; only modify the CSS and HTML attributes needed to fix the issues.`
|
|
11995
12086
|
};
|
|
11996
12087
|
sections.push(
|
|
11997
12088
|
`## Instructions
|
|
@@ -12123,7 +12214,7 @@ var AI_FIX_SYSTEM_PROMPT = `You are an expert email developer specializing in cr
|
|
|
12123
12214
|
Rules:
|
|
12124
12215
|
- Return ONLY the fixed code inside a single code fence. No explanations before or after.
|
|
12125
12216
|
- Preserve all existing content, text, links, and visual design.
|
|
12126
|
-
- For structural issues (fixType: "structural"), you MUST restructure the HTML
|
|
12217
|
+
- For structural issues (fixType: "structural"), you MUST restructure the HTML; CSS-only changes will not work.
|
|
12127
12218
|
- Common structural patterns:
|
|
12128
12219
|
- word-break/overflow-wrap unsupported \u2192 wrap text in <table><tr><td> with constrained width
|
|
12129
12220
|
- display:flex/grid \u2192 convert to <table> layout (match the original column count and proportions)
|
|
@@ -12134,7 +12225,7 @@ Rules:
|
|
|
12134
12225
|
- <svg> \u2192 replace with <img> pointing to a hosted PNG
|
|
12135
12226
|
- For CSS-only issues (fixType: "css"), swap properties or add fallbacks.
|
|
12136
12227
|
- For "Layout & Visual Issues", apply the provided Fix directly: add the background-color fallback beneath a gradient/image, append a web-safe font to the stack, and constrain fixed widths wider than the frame to width:100% with max-width.
|
|
12137
|
-
- Apply ALL fixes from the issues list
|
|
12228
|
+
- Apply ALL fixes from the issues list: do not skip any.
|
|
12138
12229
|
- Use the framework syntax specified (JSX/MJML/Maizzle/HTML).
|
|
12139
12230
|
- For JSX: use camelCase style props, React Email components, and proper TypeScript types.
|
|
12140
12231
|
- For MJML: use mj-* elements and attributes.
|
|
@@ -12159,6 +12250,263 @@ function extractCode(response) {
|
|
|
12159
12250
|
return response.trim();
|
|
12160
12251
|
}
|
|
12161
12252
|
|
|
12253
|
+
// src/rules/feature-urls.ts
|
|
12254
|
+
var FEATURE_URLS = {
|
|
12255
|
+
"!important": "https://www.caniemail.com/features/css-important/",
|
|
12256
|
+
"::after": "https://www.caniemail.com/features/css-pseudo-element-after/",
|
|
12257
|
+
"::before": "https://www.caniemail.com/features/css-pseudo-element-before/",
|
|
12258
|
+
"::first-letter": "https://www.caniemail.com/features/css-pseudo-element-first-letter/",
|
|
12259
|
+
"::first-line": "https://www.caniemail.com/features/css-pseudo-element-first-line/",
|
|
12260
|
+
"::marker": "https://www.caniemail.com/features/css-pseudo-element-marker/",
|
|
12261
|
+
"::placeholder": "https://www.caniemail.com/features/css-pseudo-element-placeholder/",
|
|
12262
|
+
":adjacent-sibling": "https://www.caniemail.com/features/css-selector-adjacent-sibling/",
|
|
12263
|
+
":attribute-selector": "https://www.caniemail.com/features/css-selector-attribute/",
|
|
12264
|
+
":chaining": "https://www.caniemail.com/features/css-selector-chaining/",
|
|
12265
|
+
":checked": "https://www.caniemail.com/features/css-pseudo-class-checked/",
|
|
12266
|
+
":child-combinator": "https://www.caniemail.com/features/css-selector-child/",
|
|
12267
|
+
":class-selector": "https://www.caniemail.com/features/css-selector-class/",
|
|
12268
|
+
":descendant-combinator": "https://www.caniemail.com/features/css-selector-descendant/",
|
|
12269
|
+
":first-child": "https://www.caniemail.com/features/css-pseudo-class-first-child/",
|
|
12270
|
+
":first-of-type": "https://www.caniemail.com/features/css-pseudo-class-first-of-type/",
|
|
12271
|
+
":focus": "https://www.caniemail.com/features/css-pseudo-class-focus/",
|
|
12272
|
+
":focus-visible": "https://www.caniemail.com/features/css-pseudo-class-focus-visible/",
|
|
12273
|
+
":focus-within": "https://www.caniemail.com/features/css-pseudo-class-focus-within/",
|
|
12274
|
+
":general-sibling": "https://www.caniemail.com/features/css-selector-general-sibling/",
|
|
12275
|
+
":grouping": "https://www.caniemail.com/features/css-selector-grouping/",
|
|
12276
|
+
":has": "https://www.caniemail.com/features/css-pseudo-class-has/",
|
|
12277
|
+
":hover": "https://www.caniemail.com/features/css-pseudo-class-hover/",
|
|
12278
|
+
":id-selector": "https://www.caniemail.com/features/css-selector-id/",
|
|
12279
|
+
":lang": "https://www.caniemail.com/features/css-pseudo-class-lang/",
|
|
12280
|
+
":last-child": "https://www.caniemail.com/features/css-pseudo-class-last-child/",
|
|
12281
|
+
":last-of-type": "https://www.caniemail.com/features/css-pseudo-class-last-of-type/",
|
|
12282
|
+
":link": "https://www.caniemail.com/features/css-pseudo-class-link/",
|
|
12283
|
+
":not": "https://www.caniemail.com/features/css-pseudo-class-not/",
|
|
12284
|
+
":nth-child": "https://www.caniemail.com/features/css-pseudo-class-nth-child/",
|
|
12285
|
+
":nth-last-child": "https://www.caniemail.com/features/css-pseudo-class-nth-last-child/",
|
|
12286
|
+
":nth-of-type": "https://www.caniemail.com/features/css-pseudo-class-nth-of-type/",
|
|
12287
|
+
":only-child": "https://www.caniemail.com/features/css-pseudo-class-only-child/",
|
|
12288
|
+
":only-of-type": "https://www.caniemail.com/features/css-pseudo-class-only-of-type/",
|
|
12289
|
+
":target": "https://www.caniemail.com/features/css-pseudo-class-target/",
|
|
12290
|
+
":type-selector": "https://www.caniemail.com/features/css-selector-type/",
|
|
12291
|
+
":universal-selector": "https://www.caniemail.com/features/css-selector-universal/",
|
|
12292
|
+
":visited": "https://www.caniemail.com/features/css-pseudo-class-visited/",
|
|
12293
|
+
"<abbr>": "https://www.caniemail.com/features/html-abbr/",
|
|
12294
|
+
"<acronym>": "https://www.caniemail.com/features/html-acronym/",
|
|
12295
|
+
"<address>": "https://www.caniemail.com/features/html-address/",
|
|
12296
|
+
"<audio>": "https://www.caniemail.com/features/html-audio/",
|
|
12297
|
+
"<base>": "https://www.caniemail.com/features/html-base/",
|
|
12298
|
+
"<bdi>": "https://www.caniemail.com/features/html-bdi/",
|
|
12299
|
+
"<blockquote>": "https://www.caniemail.com/features/html-blockquote/",
|
|
12300
|
+
"<body>": "https://www.caniemail.com/features/html-body/",
|
|
12301
|
+
"<code>": "https://www.caniemail.com/features/html-code/",
|
|
12302
|
+
"<del>": "https://www.caniemail.com/features/html-del/",
|
|
12303
|
+
"<dfn>": "https://www.caniemail.com/features/html-dfn/",
|
|
12304
|
+
"<dialog>": "https://www.caniemail.com/features/html-dialog/",
|
|
12305
|
+
"<div>": "https://www.caniemail.com/features/html-div/",
|
|
12306
|
+
"<form>": "https://www.caniemail.com/features/html-form/",
|
|
12307
|
+
"<h1>": "https://www.caniemail.com/features/html-h1-h6/",
|
|
12308
|
+
"<hr>": "https://www.caniemail.com/features/html-hr/",
|
|
12309
|
+
"<img>": "https://www.caniemail.com/features/html-img/",
|
|
12310
|
+
"<link>": "https://www.caniemail.com/features/html-link/",
|
|
12311
|
+
"<marquee>": "https://www.caniemail.com/features/html-marquee/",
|
|
12312
|
+
"<meter>": "https://www.caniemail.com/features/html-meter/",
|
|
12313
|
+
"<object>": "https://www.caniemail.com/features/html-object/",
|
|
12314
|
+
"<p>": "https://www.caniemail.com/features/html-p/",
|
|
12315
|
+
"<picture>": "https://www.caniemail.com/features/html-picture/",
|
|
12316
|
+
"<pre>": "https://www.caniemail.com/features/html-pre/",
|
|
12317
|
+
"<progress>": "https://www.caniemail.com/features/html-progress/",
|
|
12318
|
+
"<rp>": "https://www.caniemail.com/features/html-rp/",
|
|
12319
|
+
"<rt>": "https://www.caniemail.com/features/html-rt/",
|
|
12320
|
+
"<ruby>": "https://www.caniemail.com/features/html-ruby/",
|
|
12321
|
+
"<select>": "https://www.caniemail.com/features/html-select/",
|
|
12322
|
+
"<small>": "https://www.caniemail.com/features/html-small/",
|
|
12323
|
+
"<span>": "https://www.caniemail.com/features/html-span/",
|
|
12324
|
+
"<strike>": "https://www.caniemail.com/features/html-strike/",
|
|
12325
|
+
"<strong>": "https://www.caniemail.com/features/html-strong/",
|
|
12326
|
+
"<style>": "https://www.caniemail.com/features/html-style/",
|
|
12327
|
+
"<svg>": "https://www.caniemail.com/features/html-svg/",
|
|
12328
|
+
"<table>": "https://www.caniemail.com/features/html-table/",
|
|
12329
|
+
"<textarea>": "https://www.caniemail.com/features/html-textarea/",
|
|
12330
|
+
"<ul>": "https://www.caniemail.com/features/html-lists/",
|
|
12331
|
+
"<video>": "https://www.caniemail.com/features/html-video/",
|
|
12332
|
+
"<wbr>": "https://www.caniemail.com/features/html-wbr/",
|
|
12333
|
+
"@font-face": "https://www.caniemail.com/features/css-at-font-face/",
|
|
12334
|
+
"@import": "https://www.caniemail.com/features/css-at-import/",
|
|
12335
|
+
"@keyframes": "https://www.caniemail.com/features/css-at-keyframes/",
|
|
12336
|
+
"@media": "https://www.caniemail.com/features/css-at-media/",
|
|
12337
|
+
"@supports": "https://www.caniemail.com/features/css-at-supports/",
|
|
12338
|
+
"accent-color": "https://www.caniemail.com/features/css-accent-color/",
|
|
12339
|
+
"align-items": "https://www.caniemail.com/features/css-align-items/",
|
|
12340
|
+
"animation": "https://www.caniemail.com/features/css-animation/",
|
|
12341
|
+
"aspect-ratio": "https://www.caniemail.com/features/css-aspect-ratio/",
|
|
12342
|
+
"backdrop-filter": "https://www.caniemail.com/features/css-backdrop-filter/",
|
|
12343
|
+
"background": "https://www.caniemail.com/features/css-background/",
|
|
12344
|
+
"background-blend-mode": "https://www.caniemail.com/features/css-background-blend-mode/",
|
|
12345
|
+
"background-clip": "https://www.caniemail.com/features/css-background-clip/",
|
|
12346
|
+
"background-color": "https://www.caniemail.com/features/css-background-color/",
|
|
12347
|
+
"background-image": "https://www.caniemail.com/features/css-background-image/",
|
|
12348
|
+
"background-origin": "https://www.caniemail.com/features/css-background-origin/",
|
|
12349
|
+
"background-position": "https://www.caniemail.com/features/css-background-position/",
|
|
12350
|
+
"background-repeat": "https://www.caniemail.com/features/css-background-repeat/",
|
|
12351
|
+
"background-size": "https://www.caniemail.com/features/css-background-size/",
|
|
12352
|
+
"bimi": "https://www.caniemail.com/features/bimi/",
|
|
12353
|
+
"block-size": "https://www.caniemail.com/features/css-block-inline-size/",
|
|
12354
|
+
"border": "https://www.caniemail.com/features/css-border/",
|
|
12355
|
+
"border-collapse": "https://www.caniemail.com/features/css-border-collapse/",
|
|
12356
|
+
"border-image": "https://www.caniemail.com/features/css-border-image/",
|
|
12357
|
+
"border-inline": "https://www.caniemail.com/features/css-border-inline-block/",
|
|
12358
|
+
"border-inline-individual": "https://www.caniemail.com/features/css-border-inline-block-individual/",
|
|
12359
|
+
"border-inline-longhand": "https://www.caniemail.com/features/css-border-inline-block-longhand/",
|
|
12360
|
+
"border-radius": "https://www.caniemail.com/features/css-border-radius/",
|
|
12361
|
+
"border-radius-logical": "https://www.caniemail.com/features/css-border-radius-logical/",
|
|
12362
|
+
"border-spacing": "https://www.caniemail.com/features/css-border-spacing/",
|
|
12363
|
+
"box-shadow": "https://www.caniemail.com/features/css-box-shadow/",
|
|
12364
|
+
"box-sizing": "https://www.caniemail.com/features/css-box-sizing/",
|
|
12365
|
+
"calc": "https://www.caniemail.com/features/css-unit-calc/",
|
|
12366
|
+
"caption-side": "https://www.caniemail.com/features/css-caption-side/",
|
|
12367
|
+
"clamp": "https://www.caniemail.com/features/css-function-clamp/",
|
|
12368
|
+
"clear": "https://www.caniemail.com/features/css-clear/",
|
|
12369
|
+
"clip-path": "https://www.caniemail.com/features/css-clip-path/",
|
|
12370
|
+
"color-scheme": "https://www.caniemail.com/features/css-color-scheme/",
|
|
12371
|
+
"column-count": "https://www.caniemail.com/features/css-column-count/",
|
|
12372
|
+
"columns": "https://www.caniemail.com/features/css-column-layout-properties/",
|
|
12373
|
+
"conic-gradient": "https://www.caniemail.com/features/css-conic-gradient/",
|
|
12374
|
+
"css-comments": "https://www.caniemail.com/features/css-comments/",
|
|
12375
|
+
"cursor": "https://www.caniemail.com/features/css-cursor/",
|
|
12376
|
+
"custom-properties": "https://www.caniemail.com/features/css-variables/",
|
|
12377
|
+
"direction": "https://www.caniemail.com/features/css-direction/",
|
|
12378
|
+
"display": "https://www.caniemail.com/features/css-display/",
|
|
12379
|
+
"display:flex": "https://www.caniemail.com/features/css-display-flex/",
|
|
12380
|
+
"display:grid": "https://www.caniemail.com/features/css-display-grid/",
|
|
12381
|
+
"display:none": "https://www.caniemail.com/features/css-display-none/",
|
|
12382
|
+
"empty-cells": "https://www.caniemail.com/features/css-empty-cells/",
|
|
12383
|
+
"filter": "https://www.caniemail.com/features/css-filter/",
|
|
12384
|
+
"fit-content": "https://www.caniemail.com/features/css-intrinsic-size/",
|
|
12385
|
+
"flex-direction": "https://www.caniemail.com/features/css-flex-direction/",
|
|
12386
|
+
"flex-wrap": "https://www.caniemail.com/features/css-flex-wrap/",
|
|
12387
|
+
"float": "https://www.caniemail.com/features/css-float/",
|
|
12388
|
+
"font": "https://www.caniemail.com/features/css-font/",
|
|
12389
|
+
"font-kerning": "https://www.caniemail.com/features/css-font-kerning/",
|
|
12390
|
+
"font-size": "https://www.caniemail.com/features/css-font-size/",
|
|
12391
|
+
"font-size-adjust": "https://www.caniemail.com/features/css-font-size-adjust/",
|
|
12392
|
+
"font-stretch": "https://www.caniemail.com/features/css-font-stretch/",
|
|
12393
|
+
"font-weight": "https://www.caniemail.com/features/css-font-weight/",
|
|
12394
|
+
"gap": "https://www.caniemail.com/features/css-gap/",
|
|
12395
|
+
"grid-template-columns": "https://www.caniemail.com/features/css-grid-template/",
|
|
12396
|
+
"height": "https://www.caniemail.com/features/css-height/",
|
|
12397
|
+
"hyphenate-character": "https://www.caniemail.com/features/css-hyphenate-character/",
|
|
12398
|
+
"hyphenate-limit-chars": "https://www.caniemail.com/features/css-hyphenate-limit-chars/",
|
|
12399
|
+
"hyphens": "https://www.caniemail.com/features/css-hyphens/",
|
|
12400
|
+
"inert": "https://www.caniemail.com/features/css-inert-attribute/",
|
|
12401
|
+
"inline-size": "https://www.caniemail.com/features/css-inline-size/",
|
|
12402
|
+
"inset": "https://www.caniemail.com/features/css-inset/",
|
|
12403
|
+
"justify-content": "https://www.caniemail.com/features/css-justify-content/",
|
|
12404
|
+
"left": "https://www.caniemail.com/features/css-left-right-top-bottom/",
|
|
12405
|
+
"letter-spacing": "https://www.caniemail.com/features/css-letter-spacing/",
|
|
12406
|
+
"light-dark": "https://www.caniemail.com/features/css-function-light-dark/",
|
|
12407
|
+
"line-height": "https://www.caniemail.com/features/css-line-height/",
|
|
12408
|
+
"linear-gradient": "https://www.caniemail.com/features/css-linear-gradient/",
|
|
12409
|
+
"list-style": "https://www.caniemail.com/features/css-list-style/",
|
|
12410
|
+
"list-style-image": "https://www.caniemail.com/features/css-list-style-image/",
|
|
12411
|
+
"list-style-position": "https://www.caniemail.com/features/css-list-style-position/",
|
|
12412
|
+
"list-style-type": "https://www.caniemail.com/features/css-list-style-type/",
|
|
12413
|
+
"margin": "https://www.caniemail.com/features/css-margin/",
|
|
12414
|
+
"margin-block-start-end": "https://www.caniemail.com/features/css-margin-block-start-end/",
|
|
12415
|
+
"margin-inline-block": "https://www.caniemail.com/features/css-margin-inline-block/",
|
|
12416
|
+
"margin-inline-start-end": "https://www.caniemail.com/features/css-margin-inline-start-end/",
|
|
12417
|
+
"mask-image": "https://www.caniemail.com/features/css-mask-image/",
|
|
12418
|
+
"max": "https://www.caniemail.com/features/css-function-max/",
|
|
12419
|
+
"max-block-size": "https://www.caniemail.com/features/css-max-block-size/",
|
|
12420
|
+
"max-height": "https://www.caniemail.com/features/css-max-height/",
|
|
12421
|
+
"max-inline-size": "https://www.caniemail.com/features/css-max-inline-size/",
|
|
12422
|
+
"max-width": "https://www.caniemail.com/features/css-max-width/",
|
|
12423
|
+
"min": "https://www.caniemail.com/features/css-function-min/",
|
|
12424
|
+
"min-block-size": "https://www.caniemail.com/features/css-min-block-size/",
|
|
12425
|
+
"min-height": "https://www.caniemail.com/features/css-min-height/",
|
|
12426
|
+
"min-inline-size": "https://www.caniemail.com/features/css-min-inline-size/",
|
|
12427
|
+
"min-width": "https://www.caniemail.com/features/css-min-width/",
|
|
12428
|
+
"mix-blend-mode": "https://www.caniemail.com/features/css-mix-blend-mode/",
|
|
12429
|
+
"modern-color": "https://www.caniemail.com/features/css-modern-color/",
|
|
12430
|
+
"nesting": "https://www.caniemail.com/features/css-nesting/",
|
|
12431
|
+
"object-fit": "https://www.caniemail.com/features/css-object-fit/",
|
|
12432
|
+
"object-position": "https://www.caniemail.com/features/css-object-position/",
|
|
12433
|
+
"opacity": "https://www.caniemail.com/features/css-opacity/",
|
|
12434
|
+
"orphans": "https://www.caniemail.com/features/css-orphans/",
|
|
12435
|
+
"outline": "https://www.caniemail.com/features/css-outline/",
|
|
12436
|
+
"outline-offset": "https://www.caniemail.com/features/css-outline-offset/",
|
|
12437
|
+
"overflow": "https://www.caniemail.com/features/css-overflow/",
|
|
12438
|
+
"overflow-wrap": "https://www.caniemail.com/features/css-word-wrap/",
|
|
12439
|
+
"padding": "https://www.caniemail.com/features/css-padding/",
|
|
12440
|
+
"padding-block-start-end": "https://www.caniemail.com/features/css-padding-block-start-end/",
|
|
12441
|
+
"padding-inline-block": "https://www.caniemail.com/features/css-padding-inline-block/",
|
|
12442
|
+
"padding-inline-start-end": "https://www.caniemail.com/features/css-padding-inline-start-end/",
|
|
12443
|
+
"position": "https://www.caniemail.com/features/css-position/",
|
|
12444
|
+
"pseudo-class-active": "https://www.caniemail.com/features/css-pseudo-class-active/",
|
|
12445
|
+
"pseudo-class-default": "https://www.caniemail.com/features/css-pseudo-class-default/",
|
|
12446
|
+
"pseudo-class-nth-last-of-type": "https://www.caniemail.com/features/css-pseudo-class-nth-last-of-type/",
|
|
12447
|
+
"radial-gradient": "https://www.caniemail.com/features/css-radial-gradient/",
|
|
12448
|
+
"resize": "https://www.caniemail.com/features/css-resize/",
|
|
12449
|
+
"rgb": "https://www.caniemail.com/features/css-rgb/",
|
|
12450
|
+
"rgba": "https://www.caniemail.com/features/css-rgba/",
|
|
12451
|
+
"scroll-snap": "https://www.caniemail.com/features/css-scroll-snap/",
|
|
12452
|
+
"shape-margin": "https://www.caniemail.com/features/css-shape-margin/",
|
|
12453
|
+
"shape-outside": "https://www.caniemail.com/features/css-shape-outside/",
|
|
12454
|
+
"system-ui": "https://www.caniemail.com/features/css-sytem-ui/",
|
|
12455
|
+
"tab-size": "https://www.caniemail.com/features/css-tab-size/",
|
|
12456
|
+
"table-layout": "https://www.caniemail.com/features/css-table-layout/",
|
|
12457
|
+
"text-align": "https://www.caniemail.com/features/css-text-align/",
|
|
12458
|
+
"text-align-last": "https://www.caniemail.com/features/css-text-align-last/",
|
|
12459
|
+
"text-decoration": "https://www.caniemail.com/features/css-text-decoration/",
|
|
12460
|
+
"text-decoration-color": "https://www.caniemail.com/features/css-text-decoration-color/",
|
|
12461
|
+
"text-decoration-line": "https://www.caniemail.com/features/css-text-decoration-line/",
|
|
12462
|
+
"text-decoration-skip-ink": "https://www.caniemail.com/features/css-text-decoration-skip-ink/",
|
|
12463
|
+
"text-decoration-style": "https://www.caniemail.com/features/css-text-decoration-style/",
|
|
12464
|
+
"text-decoration-thickness": "https://www.caniemail.com/features/css-text-decoration-thickness/",
|
|
12465
|
+
"text-emphasis": "https://www.caniemail.com/features/css-text-emphasis/",
|
|
12466
|
+
"text-emphasis-position": "https://www.caniemail.com/features/css-text-emphasis-position/",
|
|
12467
|
+
"text-indent": "https://www.caniemail.com/features/css-text-indent/",
|
|
12468
|
+
"text-justify": "https://www.caniemail.com/features/css-text-justify/",
|
|
12469
|
+
"text-orientation": "https://www.caniemail.com/features/css-text-orientation/",
|
|
12470
|
+
"text-overflow": "https://www.caniemail.com/features/css-text-overflow/",
|
|
12471
|
+
"text-shadow": "https://www.caniemail.com/features/css-text-shadow/",
|
|
12472
|
+
"text-transform": "https://www.caniemail.com/features/css-text-transform/",
|
|
12473
|
+
"text-underline-offset": "https://www.caniemail.com/features/css-text-underline-offset/",
|
|
12474
|
+
"text-underline-position": "https://www.caniemail.com/features/css-text-underline-position/",
|
|
12475
|
+
"text-wrap": "https://www.caniemail.com/features/css-text-wrap/",
|
|
12476
|
+
"transform": "https://www.caniemail.com/features/css-transform/",
|
|
12477
|
+
"transition": "https://www.caniemail.com/features/css-transition/",
|
|
12478
|
+
"unit-ch": "https://www.caniemail.com/features/css-unit-ch/",
|
|
12479
|
+
"unit-cm": "https://www.caniemail.com/features/css-unit-cm/",
|
|
12480
|
+
"unit-em": "https://www.caniemail.com/features/css-unit-em/",
|
|
12481
|
+
"unit-ex": "https://www.caniemail.com/features/css-unit-ex/",
|
|
12482
|
+
"unit-in": "https://www.caniemail.com/features/css-unit-in/",
|
|
12483
|
+
"unit-initial": "https://www.caniemail.com/features/css-unit-initial/",
|
|
12484
|
+
"unit-mm": "https://www.caniemail.com/features/css-unit-mm/",
|
|
12485
|
+
"unit-pc": "https://www.caniemail.com/features/css-unit-pc/",
|
|
12486
|
+
"unit-percent": "https://www.caniemail.com/features/css-unit-percent/",
|
|
12487
|
+
"unit-pt": "https://www.caniemail.com/features/css-unit-pt/",
|
|
12488
|
+
"unit-px": "https://www.caniemail.com/features/css-unit-px/",
|
|
12489
|
+
"unit-rem": "https://www.caniemail.com/features/css-unit-rem/",
|
|
12490
|
+
"unit-vh": "https://www.caniemail.com/features/css-unit-vh/",
|
|
12491
|
+
"unit-vmax": "https://www.caniemail.com/features/css-unit-vmax/",
|
|
12492
|
+
"unit-vmin": "https://www.caniemail.com/features/css-unit-vmin/",
|
|
12493
|
+
"unit-vw": "https://www.caniemail.com/features/css-unit-vw/",
|
|
12494
|
+
"user-select": "https://www.caniemail.com/features/css-user-select/",
|
|
12495
|
+
"vertical-align": "https://www.caniemail.com/features/css-vertical-align/",
|
|
12496
|
+
"visibility": "https://www.caniemail.com/features/css-visibility/",
|
|
12497
|
+
"white-space": "https://www.caniemail.com/features/css-white-space/",
|
|
12498
|
+
"white-space-collapse": "https://www.caniemail.com/features/css-white-space-collapse/",
|
|
12499
|
+
"widows": "https://www.caniemail.com/features/css-widows/",
|
|
12500
|
+
"width": "https://www.caniemail.com/features/css-width/",
|
|
12501
|
+
"word-break": "https://www.caniemail.com/features/css-word-break/",
|
|
12502
|
+
"word-spacing": "https://www.caniemail.com/features/css-word-spacing/",
|
|
12503
|
+
"writing-mode": "https://www.caniemail.com/features/css-writing-mode/",
|
|
12504
|
+
"z-index": "https://www.caniemail.com/features/css-z-index/"
|
|
12505
|
+
};
|
|
12506
|
+
function featureUrl(code) {
|
|
12507
|
+
return FEATURE_URLS[code];
|
|
12508
|
+
}
|
|
12509
|
+
|
|
12162
12510
|
// src/spam-scorer.ts
|
|
12163
12511
|
var SPAM_TRIGGER_PHRASES = [
|
|
12164
12512
|
"act now",
|
|
@@ -12288,7 +12636,7 @@ function checkCapsRatio(text) {
|
|
|
12288
12636
|
return {
|
|
12289
12637
|
rule: "caps-ratio",
|
|
12290
12638
|
severity: "warning",
|
|
12291
|
-
message: `${Math.round(ratio * 100)}% of words are ALL CAPS
|
|
12639
|
+
message: `${Math.round(ratio * 100)}% of words are ALL CAPS; spam filters flag excessive capitalization.`,
|
|
12292
12640
|
detail: `Found ${capsWords.length} of ${words.length} words in all caps.`
|
|
12293
12641
|
};
|
|
12294
12642
|
}
|
|
@@ -12303,7 +12651,7 @@ function checkExcessivePunctuation(text) {
|
|
|
12303
12651
|
return {
|
|
12304
12652
|
rule: "excessive-punctuation",
|
|
12305
12653
|
severity: "warning",
|
|
12306
|
-
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$")
|
|
12654
|
+
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$"), a common spam trigger.`
|
|
12307
12655
|
};
|
|
12308
12656
|
}
|
|
12309
12657
|
return null;
|
|
@@ -12345,14 +12693,14 @@ function checkUnsubscribe($, text, options) {
|
|
|
12345
12693
|
return {
|
|
12346
12694
|
rule: "missing-unsubscribe",
|
|
12347
12695
|
severity: "info",
|
|
12348
|
-
message: "No unsubscribe link found, but email appears transactional
|
|
12696
|
+
message: "No unsubscribe link found, but email appears transactional, so it may not be required.",
|
|
12349
12697
|
detail: `Detected transactional signals: ${signalMatches.join(", ")}${hasOtp ? ", OTP code" : ""}`
|
|
12350
12698
|
};
|
|
12351
12699
|
}
|
|
12352
12700
|
return {
|
|
12353
12701
|
rule: "missing-unsubscribe",
|
|
12354
12702
|
severity: "error",
|
|
12355
|
-
message: "No unsubscribe link found
|
|
12703
|
+
message: "No unsubscribe link found; required by CAN-SPAM and GDPR. Most spam filters penalize this.",
|
|
12356
12704
|
detail: 'Add an <a> link with "unsubscribe" text or href.'
|
|
12357
12705
|
};
|
|
12358
12706
|
}
|
|
@@ -12402,7 +12750,7 @@ function checkHiddenText($) {
|
|
|
12402
12750
|
return {
|
|
12403
12751
|
rule: "hidden-text",
|
|
12404
12752
|
severity: "error",
|
|
12405
|
-
message: "Hidden text detected
|
|
12753
|
+
message: "Hidden text detected: a major spam filter red flag.",
|
|
12406
12754
|
detail
|
|
12407
12755
|
};
|
|
12408
12756
|
}
|
|
@@ -12425,7 +12773,7 @@ function checkUrlShorteners($) {
|
|
|
12425
12773
|
issues.push({
|
|
12426
12774
|
rule: "url-shortener",
|
|
12427
12775
|
severity: "warning",
|
|
12428
|
-
message: `URL shortener detected (${shortener})
|
|
12776
|
+
message: `URL shortener detected (${shortener}); spam filters distrust shortened links.`,
|
|
12429
12777
|
detail: href
|
|
12430
12778
|
});
|
|
12431
12779
|
}
|
|
@@ -12440,7 +12788,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12440
12788
|
return {
|
|
12441
12789
|
rule: "image-only",
|
|
12442
12790
|
severity: "error",
|
|
12443
|
-
message: `Image-heavy email with almost no text (${text.length} chars, ${images} images)
|
|
12791
|
+
message: `Image-heavy email with almost no text (${text.length} chars, ${images} images), likely to be flagged as spam or clipped.`
|
|
12444
12792
|
};
|
|
12445
12793
|
}
|
|
12446
12794
|
const ratio = images / (text.length / 100);
|
|
@@ -12448,7 +12796,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12448
12796
|
return {
|
|
12449
12797
|
rule: "high-image-ratio",
|
|
12450
12798
|
severity: "warning",
|
|
12451
|
-
message: `High image-to-text ratio (${images} images for ${text.length} chars of text)
|
|
12799
|
+
message: `High image-to-text ratio (${images} images for ${text.length} chars of text); consider adding more text content.`
|
|
12452
12800
|
};
|
|
12453
12801
|
}
|
|
12454
12802
|
return null;
|
|
@@ -12479,7 +12827,7 @@ function checkDeceptiveLinks($) {
|
|
|
12479
12827
|
issues.push({
|
|
12480
12828
|
rule: "deceptive-link",
|
|
12481
12829
|
severity: "error",
|
|
12482
|
-
message: `Link text shows "${textDomain}" but links to "${hrefDomain}"
|
|
12830
|
+
message: `Link text shows "${textDomain}" but links to "${hrefDomain}": a phishing red flag.`,
|
|
12483
12831
|
detail: `Text: ${text}
|
|
12484
12832
|
Href: ${href}`
|
|
12485
12833
|
});
|
|
@@ -12518,7 +12866,7 @@ function checkPhysicalAddress($, text, options) {
|
|
|
12518
12866
|
return {
|
|
12519
12867
|
rule: "missing-physical-address",
|
|
12520
12868
|
severity: "warning",
|
|
12521
|
-
message: "No physical mailing address detected
|
|
12869
|
+
message: "No physical mailing address detected; required by CAN-SPAM for marketing emails.",
|
|
12522
12870
|
detail: "Include a street address or P.O. Box in the email footer."
|
|
12523
12871
|
};
|
|
12524
12872
|
}
|
|
@@ -12541,7 +12889,7 @@ function checkAllCapsTitle($) {
|
|
|
12541
12889
|
return {
|
|
12542
12890
|
rule: "all-caps-subject",
|
|
12543
12891
|
severity: "warning",
|
|
12544
|
-
message: "Email title/subject is ALL CAPS
|
|
12892
|
+
message: "Email title/subject is ALL CAPS: a common spam indicator."
|
|
12545
12893
|
};
|
|
12546
12894
|
}
|
|
12547
12895
|
return null;
|
|
@@ -12700,7 +13048,7 @@ function validateLinksFromDom($) {
|
|
|
12700
13048
|
issues.push(__spreadValues({
|
|
12701
13049
|
severity: "warning",
|
|
12702
13050
|
rule: "protocol-relative",
|
|
12703
|
-
message: "Protocol-relative URL may break in email clients
|
|
13051
|
+
message: "Protocol-relative URL may break in email clients; use https:// explicitly",
|
|
12704
13052
|
href: href.slice(0, 120),
|
|
12705
13053
|
text: text.slice(0, 80) || "(no text)"
|
|
12706
13054
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -12709,7 +13057,7 @@ function validateLinksFromDom($) {
|
|
|
12709
13057
|
issues.push(__spreadValues({
|
|
12710
13058
|
severity: "warning",
|
|
12711
13059
|
rule: "generic-link-text",
|
|
12712
|
-
message: `Link text "${text}" is vague
|
|
13060
|
+
message: `Link text "${text}" is vague; use descriptive text for accessibility and engagement`,
|
|
12713
13061
|
href: href.slice(0, 120),
|
|
12714
13062
|
text
|
|
12715
13063
|
}, elLoc ? { loc: elLoc } : {}));
|
|
@@ -12744,7 +13092,7 @@ function validateLinksFromDom($) {
|
|
|
12744
13092
|
issues.push(__spreadValues({
|
|
12745
13093
|
severity: "info",
|
|
12746
13094
|
rule: "long-url",
|
|
12747
|
-
message: "URL exceeds 2000 characters
|
|
13095
|
+
message: "URL exceeds 2000 characters, may be truncated by some email clients",
|
|
12748
13096
|
href: href.slice(0, 120) + "...",
|
|
12749
13097
|
text: text.slice(0, 80) || "(no text)"
|
|
12750
13098
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -12773,7 +13121,7 @@ function validateLinksFromDom($) {
|
|
|
12773
13121
|
issues.push({
|
|
12774
13122
|
severity: "info",
|
|
12775
13123
|
rule: "duplicate-links",
|
|
12776
|
-
message: `URL appears ${count} times
|
|
13124
|
+
message: `URL appears ${count} times; consider consolidating`,
|
|
12777
13125
|
href: href.slice(0, 120)
|
|
12778
13126
|
});
|
|
12779
13127
|
}
|
|
@@ -12785,6 +13133,8 @@ function validateLinks(html, options) {
|
|
|
12785
13133
|
}
|
|
12786
13134
|
|
|
12787
13135
|
// src/accessibility-checker.ts
|
|
13136
|
+
var cheerio5 = __toESM(require("cheerio"), 1);
|
|
13137
|
+
var csstree6 = __toESM(require("css-tree"), 1);
|
|
12788
13138
|
var RULE_PENALTY_CAPS = {
|
|
12789
13139
|
"img-missing-alt": 3,
|
|
12790
13140
|
"link-generic-text": 3,
|
|
@@ -12857,7 +13207,7 @@ function checkImageAlt($) {
|
|
|
12857
13207
|
issues.push(__spreadProps(__spreadValues({
|
|
12858
13208
|
severity: "info",
|
|
12859
13209
|
rule: "img-empty-alt",
|
|
12860
|
-
message: "Image has empty alt text
|
|
13210
|
+
message: "Image has empty alt text; verify it is decorative",
|
|
12861
13211
|
element: describeElement($, el)
|
|
12862
13212
|
}, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
|
|
12863
13213
|
details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
|
|
@@ -12932,7 +13282,352 @@ function checkTableAccessibility($) {
|
|
|
12932
13282
|
});
|
|
12933
13283
|
return issues;
|
|
12934
13284
|
}
|
|
12935
|
-
|
|
13285
|
+
var CLIENT_DEFAULT_BACKGROUND = { r: 255, g: 255, b: 255 };
|
|
13286
|
+
var CLIENT_DEFAULT_TEXT = { r: 0, g: 0, b: 0, a: 1 };
|
|
13287
|
+
var UNRESOLVABLE_PAINT_RE = /url\(|(?:linear|radial|conic)-gradient\(/i;
|
|
13288
|
+
var NON_RENDERING_TAGS = /* @__PURE__ */ new Set(["head", "style", "script", "title", "meta", "link"]);
|
|
13289
|
+
var CASCADE_PROPS = /* @__PURE__ */ new Set([
|
|
13290
|
+
"color",
|
|
13291
|
+
"background",
|
|
13292
|
+
"background-color",
|
|
13293
|
+
"background-image",
|
|
13294
|
+
"font-size",
|
|
13295
|
+
"font-weight",
|
|
13296
|
+
// Not for contrast itself, for deciding whether the text renders at all.
|
|
13297
|
+
"display",
|
|
13298
|
+
"visibility",
|
|
13299
|
+
"opacity"
|
|
13300
|
+
]);
|
|
13301
|
+
var DESKTOP_RENDER = { width: 640, dark: false };
|
|
13302
|
+
var MOBILE_RENDER = { width: 375, dark: false };
|
|
13303
|
+
var DARK_RENDER = { width: 640, dark: true };
|
|
13304
|
+
function mediaPx(raw) {
|
|
13305
|
+
const m = raw.match(/([\d.]+)\s*(px|em|rem)?/i);
|
|
13306
|
+
if (!m) return null;
|
|
13307
|
+
const n = parseFloat(m[1]);
|
|
13308
|
+
if (!Number.isFinite(n)) return null;
|
|
13309
|
+
return (m[2] || "px").toLowerCase() === "px" ? n : n * 16;
|
|
13310
|
+
}
|
|
13311
|
+
function mediaApplies(prelude, ctx) {
|
|
13312
|
+
const text = prelude.toLowerCase();
|
|
13313
|
+
if (/\bprint\b/.test(text) && !/\bscreen\b/.test(text)) return false;
|
|
13314
|
+
const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
|
|
13315
|
+
if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
|
|
13316
|
+
for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
|
|
13317
|
+
const px = mediaPx(m[2]);
|
|
13318
|
+
if (px === null) continue;
|
|
13319
|
+
if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
|
|
13320
|
+
}
|
|
13321
|
+
return true;
|
|
13322
|
+
}
|
|
13323
|
+
var FORWARDING_PSEUDOS = /* @__PURE__ */ new Set(["not", "is", "has", "matches", "any"]);
|
|
13324
|
+
function maxSpecificity(list) {
|
|
13325
|
+
let best = [0, 0, 0];
|
|
13326
|
+
list.children.forEach((sel) => {
|
|
13327
|
+
const s = specificityOfSelector(sel);
|
|
13328
|
+
if (moreSpecific(s, best)) best = s;
|
|
13329
|
+
});
|
|
13330
|
+
return best;
|
|
13331
|
+
}
|
|
13332
|
+
function specificityOfSelector(node) {
|
|
13333
|
+
const acc = [0, 0, 0];
|
|
13334
|
+
if (node.type !== "Selector") return acc;
|
|
13335
|
+
node.children.forEach((child) => {
|
|
13336
|
+
var _a;
|
|
13337
|
+
switch (child.type) {
|
|
13338
|
+
case "IdSelector":
|
|
13339
|
+
acc[0]++;
|
|
13340
|
+
break;
|
|
13341
|
+
case "ClassSelector":
|
|
13342
|
+
case "AttributeSelector":
|
|
13343
|
+
acc[1]++;
|
|
13344
|
+
break;
|
|
13345
|
+
case "TypeSelector":
|
|
13346
|
+
if (child.name !== "*") acc[2]++;
|
|
13347
|
+
break;
|
|
13348
|
+
case "PseudoElementSelector":
|
|
13349
|
+
acc[2]++;
|
|
13350
|
+
break;
|
|
13351
|
+
case "PseudoClassSelector": {
|
|
13352
|
+
const name = child.name.toLowerCase();
|
|
13353
|
+
if (name === "where") break;
|
|
13354
|
+
const inner = (_a = child.children) == null ? void 0 : _a.first;
|
|
13355
|
+
if (inner && inner.type === "SelectorList" && FORWARDING_PSEUDOS.has(name)) {
|
|
13356
|
+
const nested = maxSpecificity(inner);
|
|
13357
|
+
acc[0] += nested[0];
|
|
13358
|
+
acc[1] += nested[1];
|
|
13359
|
+
acc[2] += nested[2];
|
|
13360
|
+
break;
|
|
13361
|
+
}
|
|
13362
|
+
acc[1]++;
|
|
13363
|
+
break;
|
|
13364
|
+
}
|
|
13365
|
+
default:
|
|
13366
|
+
break;
|
|
13367
|
+
}
|
|
13368
|
+
});
|
|
13369
|
+
return acc;
|
|
13370
|
+
}
|
|
13371
|
+
function specificityFromTokens(selector) {
|
|
13372
|
+
const bare = selector.replace(/\([^)]*\)/g, "").replace(/["'][^"']*["']/g, "");
|
|
13373
|
+
return [
|
|
13374
|
+
(bare.match(/#[\w-]+/g) || []).length,
|
|
13375
|
+
(bare.match(/\.[\w-]+|\[[^\]]*\]|:[\w-]+/g) || []).length,
|
|
13376
|
+
(bare.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length
|
|
13377
|
+
];
|
|
13378
|
+
}
|
|
13379
|
+
function specificityOf(selector) {
|
|
13380
|
+
try {
|
|
13381
|
+
const ast = csstree6.parse(selector, { context: "selector" });
|
|
13382
|
+
return specificityOfSelector(ast);
|
|
13383
|
+
} catch (e) {
|
|
13384
|
+
return specificityFromTokens(selector);
|
|
13385
|
+
}
|
|
13386
|
+
}
|
|
13387
|
+
function moreSpecific(a, b) {
|
|
13388
|
+
for (let i = 0; i < 3; i++) {
|
|
13389
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
13390
|
+
}
|
|
13391
|
+
return false;
|
|
13392
|
+
}
|
|
13393
|
+
function wins(next, current) {
|
|
13394
|
+
if (!current) return true;
|
|
13395
|
+
if (next.important !== current.important) return next.important;
|
|
13396
|
+
if (next.presentational !== current.presentational) return current.presentational;
|
|
13397
|
+
if (next.inline !== current.inline) return next.inline;
|
|
13398
|
+
if (moreSpecific(next.specificity, current.specificity)) return true;
|
|
13399
|
+
if (moreSpecific(current.specificity, next.specificity)) return false;
|
|
13400
|
+
return next.order >= current.order;
|
|
13401
|
+
}
|
|
13402
|
+
function computeCascade($, ctx) {
|
|
13403
|
+
const cascade = /* @__PURE__ */ new Map();
|
|
13404
|
+
let order = 0;
|
|
13405
|
+
const record = (el, prop, decl) => {
|
|
13406
|
+
let props = cascade.get(el);
|
|
13407
|
+
if (!props) {
|
|
13408
|
+
props = /* @__PURE__ */ new Map();
|
|
13409
|
+
cascade.set(el, props);
|
|
13410
|
+
}
|
|
13411
|
+
if (wins(decl, props.get(prop))) props.set(prop, decl);
|
|
13412
|
+
};
|
|
13413
|
+
$("[bgcolor]").each((_, el) => {
|
|
13414
|
+
const value = $(el).attr("bgcolor");
|
|
13415
|
+
if (!value) return;
|
|
13416
|
+
record(el, "background-color", {
|
|
13417
|
+
value,
|
|
13418
|
+
important: false,
|
|
13419
|
+
inline: false,
|
|
13420
|
+
presentational: true,
|
|
13421
|
+
specificity: [0, 0, 0],
|
|
13422
|
+
order: order++
|
|
13423
|
+
});
|
|
13424
|
+
});
|
|
13425
|
+
const handleRule = (node) => {
|
|
13426
|
+
const selectorList = csstree6.generate(node.prelude).trim();
|
|
13427
|
+
if (!selectorList) return;
|
|
13428
|
+
const decls = [];
|
|
13429
|
+
node.block.children.forEach((child) => {
|
|
13430
|
+
if (child.type !== "Declaration") return;
|
|
13431
|
+
const prop = child.property.toLowerCase();
|
|
13432
|
+
if (!CASCADE_PROPS.has(prop)) return;
|
|
13433
|
+
decls.push({ prop, value: csstree6.generate(child.value), important: !!child.important });
|
|
13434
|
+
});
|
|
13435
|
+
if (!decls.length) return;
|
|
13436
|
+
for (const selector of splitTopLevel(selectorList).map((x) => x.trim()).filter(Boolean)) {
|
|
13437
|
+
const specificity = specificityOf(selector);
|
|
13438
|
+
let matched;
|
|
13439
|
+
try {
|
|
13440
|
+
matched = $(selector).toArray();
|
|
13441
|
+
} catch (e) {
|
|
13442
|
+
continue;
|
|
13443
|
+
}
|
|
13444
|
+
for (const el of matched) {
|
|
13445
|
+
for (const d of decls) {
|
|
13446
|
+
record(el, d.prop, __spreadProps(__spreadValues({}, d), {
|
|
13447
|
+
inline: false,
|
|
13448
|
+
presentational: false,
|
|
13449
|
+
specificity,
|
|
13450
|
+
order: order++
|
|
13451
|
+
}));
|
|
13452
|
+
}
|
|
13453
|
+
}
|
|
13454
|
+
}
|
|
13455
|
+
};
|
|
13456
|
+
const visitBlock = (children) => {
|
|
13457
|
+
children.forEach((node) => {
|
|
13458
|
+
if (node.type === "Rule") {
|
|
13459
|
+
handleRule(node);
|
|
13460
|
+
return;
|
|
13461
|
+
}
|
|
13462
|
+
if (node.type !== "Atrule" || node.name.toLowerCase() !== "media" || !node.block) return;
|
|
13463
|
+
const prelude = node.prelude ? csstree6.generate(node.prelude) : "";
|
|
13464
|
+
if (!mediaApplies(prelude, ctx)) return;
|
|
13465
|
+
visitBlock(node.block.children);
|
|
13466
|
+
});
|
|
13467
|
+
};
|
|
13468
|
+
$("style").each((_, styleEl) => {
|
|
13469
|
+
let ast;
|
|
13470
|
+
try {
|
|
13471
|
+
ast = csstree6.parse($(styleEl).text());
|
|
13472
|
+
} catch (e) {
|
|
13473
|
+
return;
|
|
13474
|
+
}
|
|
13475
|
+
if (ast.type !== "StyleSheet") return;
|
|
13476
|
+
visitBlock(ast.children);
|
|
13477
|
+
});
|
|
13478
|
+
$("[style]").each((_, el) => {
|
|
13479
|
+
for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
|
|
13480
|
+
const colon = part.indexOf(":");
|
|
13481
|
+
if (colon === -1) continue;
|
|
13482
|
+
const prop = part.slice(0, colon).trim().toLowerCase();
|
|
13483
|
+
if (!CASCADE_PROPS.has(prop)) continue;
|
|
13484
|
+
let value = part.slice(colon + 1).trim();
|
|
13485
|
+
const important = /!\s*important$/i.test(value);
|
|
13486
|
+
if (important) value = value.replace(/!\s*important$/i, "").trim();
|
|
13487
|
+
record(el, prop, {
|
|
13488
|
+
value,
|
|
13489
|
+
important,
|
|
13490
|
+
inline: true,
|
|
13491
|
+
presentational: false,
|
|
13492
|
+
specificity: [0, 0, 0],
|
|
13493
|
+
order: order++
|
|
13494
|
+
});
|
|
13495
|
+
}
|
|
13496
|
+
});
|
|
13497
|
+
return cascade;
|
|
13498
|
+
}
|
|
13499
|
+
function paintedColor(value) {
|
|
13500
|
+
if (!value) return null;
|
|
13501
|
+
const solid = backgroundShorthandColor(value);
|
|
13502
|
+
if (solid) {
|
|
13503
|
+
const c = parseColor(solid);
|
|
13504
|
+
if (c && c.a > 0) return [c, ...gradientStops(value)];
|
|
13505
|
+
}
|
|
13506
|
+
const stops = gradientStops(value);
|
|
13507
|
+
if (stops.length) return stops;
|
|
13508
|
+
return UNRESOLVABLE_PAINT_RE.test(value) ? "unknown" : null;
|
|
13509
|
+
}
|
|
13510
|
+
function asColors(painted) {
|
|
13511
|
+
return painted && painted !== "unknown" ? painted : [];
|
|
13512
|
+
}
|
|
13513
|
+
function elementBackground(props) {
|
|
13514
|
+
const shorthand = props.get("background");
|
|
13515
|
+
const longhand = props.get("background-color");
|
|
13516
|
+
const image = props.get("background-image");
|
|
13517
|
+
const colorDecl = shorthand && wins(shorthand, longhand) ? shorthand : longhand;
|
|
13518
|
+
const candidates = [
|
|
13519
|
+
...colorDecl ? asColors(paintedColor(colorDecl.value)) : [],
|
|
13520
|
+
...image ? asColors(paintedColor(image.value)) : []
|
|
13521
|
+
];
|
|
13522
|
+
if (candidates.length) return candidates;
|
|
13523
|
+
for (const decl of [colorDecl, image]) {
|
|
13524
|
+
if (decl && UNRESOLVABLE_PAINT_RE.test(decl.value)) return "unknown";
|
|
13525
|
+
}
|
|
13526
|
+
return null;
|
|
13527
|
+
}
|
|
13528
|
+
function resolveBackground($, el, cascade) {
|
|
13529
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13530
|
+
const props = cascade.get(node);
|
|
13531
|
+
if (!props) continue;
|
|
13532
|
+
const painted = elementBackground(props);
|
|
13533
|
+
if (painted !== null) return painted;
|
|
13534
|
+
}
|
|
13535
|
+
return null;
|
|
13536
|
+
}
|
|
13537
|
+
function isLargeText(size, weight) {
|
|
13538
|
+
const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
|
|
13539
|
+
if (!match) return false;
|
|
13540
|
+
const px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
|
|
13541
|
+
const w = weight == null ? void 0 : weight.trim().toLowerCase();
|
|
13542
|
+
const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
|
|
13543
|
+
return px >= 18 || px >= 14 && bold;
|
|
13544
|
+
}
|
|
13545
|
+
function inherited($, el, cascade, prop) {
|
|
13546
|
+
var _a, _b;
|
|
13547
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13548
|
+
const value = (_b = (_a = cascade.get(node)) == null ? void 0 : _a.get(prop)) == null ? void 0 : _b.value;
|
|
13549
|
+
if (value) return { value, source: node };
|
|
13550
|
+
}
|
|
13551
|
+
return null;
|
|
13552
|
+
}
|
|
13553
|
+
function isHiddenText($, el, cascade) {
|
|
13554
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
13555
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13556
|
+
const props = cascade.get(node);
|
|
13557
|
+
if (!props) continue;
|
|
13558
|
+
const display = (_b = (_a = props.get("display")) == null ? void 0 : _a.value) == null ? void 0 : _b.trim().toLowerCase();
|
|
13559
|
+
if (display === "none") return true;
|
|
13560
|
+
const visibility = (_d = (_c = props.get("visibility")) == null ? void 0 : _c.value) == null ? void 0 : _d.trim().toLowerCase();
|
|
13561
|
+
if (visibility === "hidden" || visibility === "collapse") return true;
|
|
13562
|
+
const opacity = (_f = (_e = props.get("opacity")) == null ? void 0 : _e.value) == null ? void 0 : _f.trim();
|
|
13563
|
+
if (opacity !== void 0 && parseFloat(opacity) === 0) return true;
|
|
13564
|
+
const size = (_h = (_g = props.get("font-size")) == null ? void 0 : _g.value) == null ? void 0 : _h.trim();
|
|
13565
|
+
if (size !== void 0 && /^0(?:px|pt|em|rem|%)?$/i.test(size)) return true;
|
|
13566
|
+
}
|
|
13567
|
+
return $(el).closest("[style*='mso-hide']").length > 0;
|
|
13568
|
+
}
|
|
13569
|
+
function hasOwnText($, el) {
|
|
13570
|
+
return $(el).contents().toArray().some((n) => n.type === "text" && typeof n.data === "string" && n.data.trim().length > 0);
|
|
13571
|
+
}
|
|
13572
|
+
function checkContrast($, cascade) {
|
|
13573
|
+
const issues = [];
|
|
13574
|
+
$("*").each((_, el) => {
|
|
13575
|
+
var _a, _b, _c, _d, _e;
|
|
13576
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
13577
|
+
if (NON_RENDERING_TAGS.has(tag)) return;
|
|
13578
|
+
if (!hasOwnText($, el)) return;
|
|
13579
|
+
if (isHiddenText($, el, cascade)) return;
|
|
13580
|
+
const declared = inherited($, el, cascade, "color");
|
|
13581
|
+
const fg = declared ? parseColor(declared.value) : CLIENT_DEFAULT_TEXT;
|
|
13582
|
+
if (!fg) return;
|
|
13583
|
+
const colorValue = (_a = declared == null ? void 0 : declared.value) != null ? _a : "the client default";
|
|
13584
|
+
const background = resolveBackground($, el, cascade);
|
|
13585
|
+
if (background === "unknown") return;
|
|
13586
|
+
const surfaces = background != null ? background : [null];
|
|
13587
|
+
let ratio = Infinity;
|
|
13588
|
+
let worst = "";
|
|
13589
|
+
for (const surface of surfaces) {
|
|
13590
|
+
let { r: bgR, g: bgG, b: bgB } = surface != null ? surface : CLIENT_DEFAULT_BACKGROUND;
|
|
13591
|
+
if (surface && surface.a < 1) {
|
|
13592
|
+
[bgR, bgG, bgB] = alphaBlend(surface, 255, 255, 255);
|
|
13593
|
+
}
|
|
13594
|
+
const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
|
|
13595
|
+
const candidate = contrastRatio(relativeLuminance(fR, fG, fB), relativeLuminance(bgR, bgG, bgB));
|
|
13596
|
+
if (candidate < ratio) {
|
|
13597
|
+
ratio = candidate;
|
|
13598
|
+
worst = formatRgb(surface != null ? surface : __spreadProps(__spreadValues({}, CLIENT_DEFAULT_BACKGROUND), { a: 1 }));
|
|
13599
|
+
}
|
|
13600
|
+
}
|
|
13601
|
+
const against = surfaces.length > 1 ? ` (worst gradient stop ${worst})` : "";
|
|
13602
|
+
const large = isLargeText(
|
|
13603
|
+
(_b = inherited($, el, cascade, "font-size")) == null ? void 0 : _b.value,
|
|
13604
|
+
(_c = inherited($, el, cascade, "font-weight")) == null ? void 0 : _c.value
|
|
13605
|
+
);
|
|
13606
|
+
const grade = wcagGrade(ratio);
|
|
13607
|
+
const styleLoc = (_e = locOfAttr((_d = declared == null ? void 0 : declared.source) != null ? _d : el, "style")) != null ? _e : locOfAttr(el, "style");
|
|
13608
|
+
if (grade === "Fail") {
|
|
13609
|
+
issues.push(__spreadProps(__spreadValues({
|
|
13610
|
+
severity: "error",
|
|
13611
|
+
rule: "low-contrast",
|
|
13612
|
+
message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG minimum`,
|
|
13613
|
+
element: describeElement($, el)
|
|
13614
|
+
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13615
|
+
details: `Foreground ${colorValue} on background${against} needs at least ${large ? "3:1" : "4.5:1"} contrast ratio.`
|
|
13616
|
+
}));
|
|
13617
|
+
} else if (!large && grade === "AA Large") {
|
|
13618
|
+
issues.push(__spreadProps(__spreadValues({
|
|
13619
|
+
severity: "warning",
|
|
13620
|
+
rule: "low-contrast",
|
|
13621
|
+
message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG AA for normal text`,
|
|
13622
|
+
element: describeElement($, el)
|
|
13623
|
+
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13624
|
+
details: `Foreground ${colorValue} on background${against} needs at least 4.5:1 for normal-sized text.`
|
|
13625
|
+
}));
|
|
13626
|
+
}
|
|
13627
|
+
});
|
|
13628
|
+
return issues;
|
|
13629
|
+
}
|
|
13630
|
+
function checkTextSize($) {
|
|
12936
13631
|
const issues = [];
|
|
12937
13632
|
let smallTextCount = 0;
|
|
12938
13633
|
$("[style]").each((_, el) => {
|
|
@@ -12957,67 +13652,6 @@ function checkTextSizeAndContrast($) {
|
|
|
12957
13652
|
}
|
|
12958
13653
|
}
|
|
12959
13654
|
}
|
|
12960
|
-
const colorValue = getStyleValue(style, "color");
|
|
12961
|
-
if (colorValue) {
|
|
12962
|
-
const fg = parseColor(colorValue);
|
|
12963
|
-
if (fg) {
|
|
12964
|
-
let bgR = 255, bgG = 255, bgB = 255;
|
|
12965
|
-
let current = $(el);
|
|
12966
|
-
let foundBg = false;
|
|
12967
|
-
const elements = [current, ...$(el).parents("[style]").toArray().map((p) => $(p))];
|
|
12968
|
-
for (const ancestor of elements) {
|
|
12969
|
-
const ancestorStyle = (typeof ancestor === "function" ? ancestor : $(ancestor)).attr("style") || "";
|
|
12970
|
-
const bgValue = getStyleValue(ancestorStyle, "background-color");
|
|
12971
|
-
if (bgValue) {
|
|
12972
|
-
const bg = parseColor(bgValue);
|
|
12973
|
-
if (bg && bg.a > 0) {
|
|
12974
|
-
if (bg.a < 1) {
|
|
12975
|
-
[bgR, bgG, bgB] = alphaBlend(bg, 255, 255, 255);
|
|
12976
|
-
} else {
|
|
12977
|
-
bgR = bg.r;
|
|
12978
|
-
bgG = bg.g;
|
|
12979
|
-
bgB = bg.b;
|
|
12980
|
-
}
|
|
12981
|
-
foundBg = true;
|
|
12982
|
-
break;
|
|
12983
|
-
}
|
|
12984
|
-
}
|
|
12985
|
-
}
|
|
12986
|
-
const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
|
|
12987
|
-
const fgLum = relativeLuminance(fR, fG, fB);
|
|
12988
|
-
const bgLum = relativeLuminance(bgR, bgG, bgB);
|
|
12989
|
-
const ratio = contrastRatio(fgLum, bgLum);
|
|
12990
|
-
let isLargeText = false;
|
|
12991
|
-
if (fontSizeMatch) {
|
|
12992
|
-
const size = parseFloat(fontSizeMatch[1]);
|
|
12993
|
-
const unit = fontSizeMatch[2].toLowerCase();
|
|
12994
|
-
const pxSize = unit === "pt" ? size * 1.333 : size;
|
|
12995
|
-
const fontWeight = getStyleValue(style, "font-weight");
|
|
12996
|
-
const isBold = fontWeight === "bold" || fontWeight === "bolder" || fontWeight && parseInt(fontWeight, 10) >= 700;
|
|
12997
|
-
isLargeText = pxSize >= 18 || pxSize >= 14 && !!isBold;
|
|
12998
|
-
}
|
|
12999
|
-
const grade = wcagGrade(ratio);
|
|
13000
|
-
if (grade === "Fail") {
|
|
13001
|
-
issues.push(__spreadProps(__spreadValues({
|
|
13002
|
-
severity: "error",
|
|
13003
|
-
rule: "low-contrast",
|
|
13004
|
-
message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG minimum`,
|
|
13005
|
-
element: describeElement($, el)
|
|
13006
|
-
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13007
|
-
details: `Foreground ${colorValue} on background needs at least ${isLargeText ? "3:1" : "4.5:1"} contrast ratio.`
|
|
13008
|
-
}));
|
|
13009
|
-
} else if (!isLargeText && grade === "AA Large") {
|
|
13010
|
-
issues.push(__spreadProps(__spreadValues({
|
|
13011
|
-
severity: "warning",
|
|
13012
|
-
rule: "low-contrast",
|
|
13013
|
-
message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG AA for normal text`,
|
|
13014
|
-
element: describeElement($, el)
|
|
13015
|
-
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13016
|
-
details: `Foreground ${colorValue} on background needs at least 4.5:1 for normal-sized text.`
|
|
13017
|
-
}));
|
|
13018
|
-
}
|
|
13019
|
-
}
|
|
13020
|
-
}
|
|
13021
13655
|
});
|
|
13022
13656
|
if (smallTextCount > 3) {
|
|
13023
13657
|
issues.push({
|
|
@@ -13077,7 +13711,8 @@ function checkAccessibilityFromDom($) {
|
|
|
13077
13711
|
issues.push(...checkImageAlt($));
|
|
13078
13712
|
issues.push(...checkLinkAccessibility($));
|
|
13079
13713
|
issues.push(...checkTableAccessibility($));
|
|
13080
|
-
issues.push(...
|
|
13714
|
+
issues.push(...checkTextSize($));
|
|
13715
|
+
issues.push(...checkContrast($, computeCascade($, DESKTOP_RENDER)));
|
|
13081
13716
|
issues.push(...checkSemanticStructure($));
|
|
13082
13717
|
const charsetIssue = checkCharsetDeclaration($);
|
|
13083
13718
|
if (charsetIssue) issues.push(charsetIssue);
|
|
@@ -13106,6 +13741,76 @@ function checkAccessibilityFromDom($) {
|
|
|
13106
13741
|
function checkAccessibility(html, options) {
|
|
13107
13742
|
return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom, options);
|
|
13108
13743
|
}
|
|
13744
|
+
function checkDarkStylesContrastFromDom($, lightIssues) {
|
|
13745
|
+
const baseline = lightIssues != null ? lightIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
|
|
13746
|
+
const dark = checkContrast($, computeCascade($, DARK_RENDER));
|
|
13747
|
+
return contrastDelta(
|
|
13748
|
+
dark,
|
|
13749
|
+
baseline,
|
|
13750
|
+
"low-contrast-dark",
|
|
13751
|
+
"Dark mode",
|
|
13752
|
+
"This pairing comes from the email's own @media (prefers-color-scheme: dark) block. Clients that honour it (Apple Mail, Superhuman, Thunderbird) render exactly this, so re-colour the text alongside the background."
|
|
13753
|
+
);
|
|
13754
|
+
}
|
|
13755
|
+
var DARK_CONTRAST_CLIENTS = ["gmail-android", "gmail-ios"];
|
|
13756
|
+
function lowContrastOnly(issues) {
|
|
13757
|
+
return issues.filter((i) => i.rule === "low-contrast");
|
|
13758
|
+
}
|
|
13759
|
+
function contrastDelta(candidates, baseline, rule, prefix, details) {
|
|
13760
|
+
const seen = new Set(lowContrastOnly(baseline).map((i) => i.element));
|
|
13761
|
+
return lowContrastOnly(candidates).filter((issue) => {
|
|
13762
|
+
if (seen.has(issue.element)) return false;
|
|
13763
|
+
seen.add(issue.element);
|
|
13764
|
+
return true;
|
|
13765
|
+
}).map((issue) => __spreadProps(__spreadValues(__spreadValues({
|
|
13766
|
+
severity: issue.severity,
|
|
13767
|
+
rule,
|
|
13768
|
+
message: `${prefix}: ${issue.message}`
|
|
13769
|
+
}, issue.element ? { element: issue.element } : {}), issue.loc ? { loc: issue.loc } : {}), {
|
|
13770
|
+
details
|
|
13771
|
+
}));
|
|
13772
|
+
}
|
|
13773
|
+
function checkMobileContrastFromDom($, desktopIssues) {
|
|
13774
|
+
const baseline = desktopIssues != null ? desktopIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
|
|
13775
|
+
const mobile = checkContrast($, computeCascade($, MOBILE_RENDER));
|
|
13776
|
+
return contrastDelta(
|
|
13777
|
+
mobile,
|
|
13778
|
+
baseline,
|
|
13779
|
+
"low-contrast-mobile",
|
|
13780
|
+
"At mobile width",
|
|
13781
|
+
"This colour pairing only appears below the email's breakpoint. Check the contrast of what the media query restyles, not just the desktop palette."
|
|
13782
|
+
);
|
|
13783
|
+
}
|
|
13784
|
+
function checkMobileContrast(html, options) {
|
|
13785
|
+
return fromHtml(html, [], ($) => checkMobileContrastFromDom($), options);
|
|
13786
|
+
}
|
|
13787
|
+
function checkDarkModeContrast(html, lightIssues) {
|
|
13788
|
+
if (!html || !html.trim()) return [];
|
|
13789
|
+
const inverted = [];
|
|
13790
|
+
for (const client of DARK_CONTRAST_CLIENTS) {
|
|
13791
|
+
let darkHtml;
|
|
13792
|
+
try {
|
|
13793
|
+
darkHtml = simulateDarkMode(html, client).html;
|
|
13794
|
+
} catch (e) {
|
|
13795
|
+
continue;
|
|
13796
|
+
}
|
|
13797
|
+
const $dark = cheerio5.load(darkHtml);
|
|
13798
|
+
for (const issue of checkContrast($dark, computeCascade($dark, DESKTOP_RENDER))) {
|
|
13799
|
+
const _a = issue, { loc, locs, locsTruncated } = _a, rest = __objRest(_a, ["loc", "locs", "locsTruncated"]);
|
|
13800
|
+
void loc;
|
|
13801
|
+
void locs;
|
|
13802
|
+
void locsTruncated;
|
|
13803
|
+
inverted.push(rest);
|
|
13804
|
+
}
|
|
13805
|
+
}
|
|
13806
|
+
return contrastDelta(
|
|
13807
|
+
inverted,
|
|
13808
|
+
lightIssues != null ? lightIssues : checkAccessibility(html).issues,
|
|
13809
|
+
"low-contrast-dark",
|
|
13810
|
+
"Dark mode",
|
|
13811
|
+
"Clients that force dark mode repaint backgrounds without re-colouring every text layer. Set explicit colours inside @media (prefers-color-scheme: dark), or pick text that holds up on both."
|
|
13812
|
+
);
|
|
13813
|
+
}
|
|
13109
13814
|
|
|
13110
13815
|
// src/image-analyzer.ts
|
|
13111
13816
|
var DATA_URI_WARN_BYTES = 100 * 1024;
|
|
@@ -13179,7 +13884,7 @@ function analyzeImagesFromDom($) {
|
|
|
13179
13884
|
issues.push(__spreadValues({
|
|
13180
13885
|
rule: "missing-dimensions",
|
|
13181
13886
|
severity: "warning",
|
|
13182
|
-
message: "Image missing width/height attributes
|
|
13887
|
+
message: "Image missing width/height attributes, causes layout shifts and Outlook rendering issues.",
|
|
13183
13888
|
src: truncateSrc(src)
|
|
13184
13889
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13185
13890
|
}
|
|
@@ -13190,7 +13895,7 @@ function analyzeImagesFromDom($) {
|
|
|
13190
13895
|
issues.push(__spreadValues({
|
|
13191
13896
|
rule: "large-data-uri",
|
|
13192
13897
|
severity: "warning",
|
|
13193
|
-
message: `Data URI is ${kb}KB
|
|
13898
|
+
message: `Data URI is ${kb}KB; consider hosting the image externally to reduce email size.`,
|
|
13194
13899
|
src: truncateSrc(src)
|
|
13195
13900
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13196
13901
|
}
|
|
@@ -13199,7 +13904,7 @@ function analyzeImagesFromDom($) {
|
|
|
13199
13904
|
issues.push(__spreadValues({
|
|
13200
13905
|
rule: "missing-alt",
|
|
13201
13906
|
severity: "warning",
|
|
13202
|
-
message: "Image missing alt attribute
|
|
13907
|
+
message: "Image missing alt attribute, hurts deliverability and accessibility.",
|
|
13203
13908
|
src: truncateSrc(src)
|
|
13204
13909
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13205
13910
|
}
|
|
@@ -13208,7 +13913,7 @@ function analyzeImagesFromDom($) {
|
|
|
13208
13913
|
issues.push(__spreadValues({
|
|
13209
13914
|
rule: "webp-format",
|
|
13210
13915
|
severity: "info",
|
|
13211
|
-
message: "WebP format detected
|
|
13916
|
+
message: "WebP format detected, not supported by all email clients. Consider PNG or JPEG.",
|
|
13212
13917
|
src: truncateSrc(src)
|
|
13213
13918
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13214
13919
|
}
|
|
@@ -13217,7 +13922,7 @@ function analyzeImagesFromDom($) {
|
|
|
13217
13922
|
issues.push(__spreadValues({
|
|
13218
13923
|
rule: "svg-format",
|
|
13219
13924
|
severity: "info",
|
|
13220
|
-
message: "SVG format detected
|
|
13925
|
+
message: "SVG format detected, not supported by most email clients. Use PNG instead.",
|
|
13221
13926
|
src: truncateSrc(src)
|
|
13222
13927
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13223
13928
|
}
|
|
@@ -13226,7 +13931,7 @@ function analyzeImagesFromDom($) {
|
|
|
13226
13931
|
issues.push(__spreadValues({
|
|
13227
13932
|
rule: "missing-display-block",
|
|
13228
13933
|
severity: "info",
|
|
13229
|
-
message: "Image without display:block
|
|
13934
|
+
message: "Image without display:block, may cause unwanted gaps in Outlook.",
|
|
13230
13935
|
src: truncateSrc(src)
|
|
13231
13936
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13232
13937
|
}
|
|
@@ -13245,7 +13950,7 @@ function analyzeImagesFromDom($) {
|
|
|
13245
13950
|
issues.push({
|
|
13246
13951
|
rule: "high-image-count",
|
|
13247
13952
|
severity: "info",
|
|
13248
|
-
message: `Email contains ${nonTrackingImages.length} images
|
|
13953
|
+
message: `Email contains ${nonTrackingImages.length} images; heavy emails may be clipped or load slowly.`
|
|
13249
13954
|
});
|
|
13250
13955
|
}
|
|
13251
13956
|
const trackingPixels = images.filter((i) => i.isTrackingPixel);
|
|
@@ -13261,7 +13966,7 @@ function analyzeImagesFromDom($) {
|
|
|
13261
13966
|
issues.push({
|
|
13262
13967
|
rule: "total-data-uri-size",
|
|
13263
13968
|
severity: "warning",
|
|
13264
|
-
message: `Total data URI size is ${kb}KB
|
|
13969
|
+
message: `Total data URI size is ${kb}KB; consider hosting images externally to reduce email size.`
|
|
13265
13970
|
});
|
|
13266
13971
|
}
|
|
13267
13972
|
return { total: images.length, totalDataUriBytes, issues, images };
|
|
@@ -13302,7 +14007,7 @@ function extractInboxPreviewFromDom($) {
|
|
|
13302
14007
|
issues.push({
|
|
13303
14008
|
rule: "subject-too-long",
|
|
13304
14009
|
severity: "warning",
|
|
13305
|
-
message: `Subject is ${subjectLength} characters
|
|
14010
|
+
message: `Subject is ${subjectLength} characters, and may be truncated in inboxes (recommended: ${MAX_SUBJECT_LENGTH} or fewer).`
|
|
13306
14011
|
});
|
|
13307
14012
|
}
|
|
13308
14013
|
const preheader = extractPreheaderText($);
|
|
@@ -13317,27 +14022,27 @@ function extractInboxPreviewFromDom($) {
|
|
|
13317
14022
|
issues.push({
|
|
13318
14023
|
rule: "preheader-too-short",
|
|
13319
14024
|
severity: "warning",
|
|
13320
|
-
message: `Preheader is only ${preheaderLength} characters
|
|
14025
|
+
message: `Preheader is only ${preheaderLength} characters, so email clients may backfill with body text (recommended: ${MIN_PREHEADER_LENGTH}+).`
|
|
13321
14026
|
});
|
|
13322
14027
|
} else if (preheaderLength > MAX_PREHEADER_LENGTH) {
|
|
13323
14028
|
issues.push({
|
|
13324
14029
|
rule: "preheader-too-long",
|
|
13325
14030
|
severity: "info",
|
|
13326
|
-
message: `Preheader is ${preheaderLength} characters
|
|
14031
|
+
message: `Preheader is ${preheaderLength} characters; most clients show 40\u2013100 characters. Text beyond that is hidden.`
|
|
13327
14032
|
});
|
|
13328
14033
|
}
|
|
13329
14034
|
if (hasZwnjPadding($)) {
|
|
13330
14035
|
issues.push({
|
|
13331
14036
|
rule: "zwnj-padding",
|
|
13332
14037
|
severity: "info",
|
|
13333
|
-
message: "Preheader uses ‌ padding hack
|
|
14038
|
+
message: "Preheader uses ‌ padding hack, works in most clients but may show garbled text in some."
|
|
13334
14039
|
});
|
|
13335
14040
|
}
|
|
13336
14041
|
if (subject && EMOJI_PATTERN.test(subject)) {
|
|
13337
14042
|
issues.push({
|
|
13338
14043
|
rule: "emoji-in-subject",
|
|
13339
14044
|
severity: "info",
|
|
13340
|
-
message: "Subject line contains emoji
|
|
14045
|
+
message: "Subject line contains emoji, renders inconsistently across email clients and may trigger spam filters."
|
|
13341
14046
|
});
|
|
13342
14047
|
}
|
|
13343
14048
|
const truncation = computeTruncation(subject, preheader);
|
|
@@ -13468,14 +14173,14 @@ function checkSizeFromDom(_$, html) {
|
|
|
13468
14173
|
issues.push({
|
|
13469
14174
|
rule: "gmail-clipped",
|
|
13470
14175
|
severity: "error",
|
|
13471
|
-
message: `Email is ${humanSize}
|
|
14176
|
+
message: `Email is ${humanSize}; Gmail will clip it at ~102 KB. Recipients see a "View entire message" link instead of your content.`,
|
|
13472
14177
|
detail: `${htmlBytes} bytes exceeds the ${GMAIL_CLIP_THRESHOLD} byte threshold.`
|
|
13473
14178
|
});
|
|
13474
14179
|
} else if (htmlBytes > GMAIL_CLIP_WARNING_THRESHOLD) {
|
|
13475
14180
|
issues.push({
|
|
13476
14181
|
rule: "gmail-clip-warning",
|
|
13477
14182
|
severity: "warning",
|
|
13478
|
-
message: `Email is ${humanSize}
|
|
14183
|
+
message: `Email is ${humanSize}, approaching Gmail's ~102 KB clip threshold. Consider trimming.`,
|
|
13479
14184
|
detail: `${htmlBytes} bytes is within ${GMAIL_CLIP_THRESHOLD - htmlBytes} bytes of the clip threshold.`
|
|
13480
14185
|
});
|
|
13481
14186
|
}
|
|
@@ -13593,7 +14298,7 @@ function checkTemplateVariables(html, options) {
|
|
|
13593
14298
|
}
|
|
13594
14299
|
|
|
13595
14300
|
// src/overflow-checker.ts
|
|
13596
|
-
var
|
|
14301
|
+
var csstree7 = __toESM(require("css-tree"), 1);
|
|
13597
14302
|
function fixedPxWidth($el) {
|
|
13598
14303
|
const style = $el.attr("style") || "";
|
|
13599
14304
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
@@ -13615,7 +14320,7 @@ function addWidthIssue(width, label, issues, seen, loc) {
|
|
|
13615
14320
|
const issue = __spreadValues({
|
|
13616
14321
|
rule: "fixed-width-overflow",
|
|
13617
14322
|
severity: "warning",
|
|
13618
|
-
message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame
|
|
14323
|
+
message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame; it will force horizontal scrolling, especially on mobile.`,
|
|
13619
14324
|
detail: `Use width:100% with max-width:${EMAIL_MAX_WIDTH}px instead of a fixed width beyond the frame.`
|
|
13620
14325
|
}, loc ? { loc, locs: [loc] } : {});
|
|
13621
14326
|
seen.set(key, issue);
|
|
@@ -13669,11 +14374,11 @@ function checkOverflowFromDom($, source) {
|
|
|
13669
14374
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
13670
14375
|
let ast;
|
|
13671
14376
|
try {
|
|
13672
|
-
ast =
|
|
14377
|
+
ast = csstree7.parse(cssText, { positions: true });
|
|
13673
14378
|
} catch (e) {
|
|
13674
14379
|
return;
|
|
13675
14380
|
}
|
|
13676
|
-
|
|
14381
|
+
csstree7.walk(ast, {
|
|
13677
14382
|
visit: "Rule",
|
|
13678
14383
|
enter(node) {
|
|
13679
14384
|
if (node.type !== "Rule") return;
|
|
@@ -13683,7 +14388,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13683
14388
|
node.block.children.forEach((child) => {
|
|
13684
14389
|
if (child.type !== "Declaration") return;
|
|
13685
14390
|
const prop = child.property.toLowerCase();
|
|
13686
|
-
const val =
|
|
14391
|
+
const val = csstree7.generate(child.value);
|
|
13687
14392
|
if (prop === "width") {
|
|
13688
14393
|
const m = val.match(/^(\d+)px$/);
|
|
13689
14394
|
if (m) {
|
|
@@ -13696,7 +14401,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13696
14401
|
}
|
|
13697
14402
|
});
|
|
13698
14403
|
if (widthPx !== null && widthPx > EMAIL_MAX_WIDTH && !fluid) {
|
|
13699
|
-
const selector =
|
|
14404
|
+
const selector = csstree7.generate(node.prelude).trim().slice(0, 40);
|
|
13700
14405
|
addWidthIssue(widthPx, selector || "rule", issues, seen, widthLoc);
|
|
13701
14406
|
}
|
|
13702
14407
|
}
|
|
@@ -13740,7 +14445,7 @@ function checkOverflow(html, options) {
|
|
|
13740
14445
|
}
|
|
13741
14446
|
|
|
13742
14447
|
// src/visual-checker.ts
|
|
13743
|
-
var
|
|
14448
|
+
var csstree8 = __toESM(require("css-tree"), 1);
|
|
13744
14449
|
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
13745
14450
|
var GRADIENT_RE = /(?:linear|radial|conic)-gradient\(/i;
|
|
13746
14451
|
function isSolidColor(value) {
|
|
@@ -13813,7 +14518,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
13813
14518
|
const issue = __spreadValues({
|
|
13814
14519
|
rule: "missing-background-fallback",
|
|
13815
14520
|
severity: "warning",
|
|
13816
|
-
message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback
|
|
14521
|
+
message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback; it renders as a blank area (and can hide overlaid text) in Outlook and other clients that drop image backgrounds.`,
|
|
13817
14522
|
detail: isGradient ? `Add a solid fallback beneath the gradient using its first colour stop.` : `Add a solid background-color so the area still has colour when the image is dropped.`,
|
|
13818
14523
|
fix
|
|
13819
14524
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -13833,7 +14538,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
13833
14538
|
const issue = __spreadValues({
|
|
13834
14539
|
rule: "missing-font-fallback",
|
|
13835
14540
|
severity: "warning",
|
|
13836
|
-
message: `font-family "${font.trim()}" has no web-safe fallback
|
|
14541
|
+
message: `font-family "${font.trim()}" has no web-safe fallback; clients that strip web fonts (Gmail, Outlook) fall back to Times New Roman.`,
|
|
13837
14542
|
detail: `End the stack with a web-safe font and a generic family.`,
|
|
13838
14543
|
fix
|
|
13839
14544
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -13846,7 +14551,7 @@ function ruleToMap(node) {
|
|
|
13846
14551
|
const map = /* @__PURE__ */ new Map();
|
|
13847
14552
|
node.block.children.forEach((child) => {
|
|
13848
14553
|
if (child.type === "Declaration") {
|
|
13849
|
-
map.set(child.property.toLowerCase(),
|
|
14554
|
+
map.set(child.property.toLowerCase(), csstree8.generate(child.value));
|
|
13850
14555
|
}
|
|
13851
14556
|
});
|
|
13852
14557
|
return map;
|
|
@@ -13865,11 +14570,11 @@ function checkVisualFromDom($, source) {
|
|
|
13865
14570
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
13866
14571
|
let ast;
|
|
13867
14572
|
try {
|
|
13868
|
-
ast =
|
|
14573
|
+
ast = csstree8.parse(cssText, { positions: true });
|
|
13869
14574
|
} catch (e) {
|
|
13870
14575
|
return;
|
|
13871
14576
|
}
|
|
13872
|
-
|
|
14577
|
+
csstree8.walk(ast, {
|
|
13873
14578
|
visit: "Rule",
|
|
13874
14579
|
enter(node) {
|
|
13875
14580
|
if (node.type !== "Rule") return;
|
|
@@ -13894,6 +14599,169 @@ function checkVisual(html, options) {
|
|
|
13894
14599
|
);
|
|
13895
14600
|
}
|
|
13896
14601
|
|
|
14602
|
+
// src/design-consistency.ts
|
|
14603
|
+
var csstree9 = __toESM(require("css-tree"), 1);
|
|
14604
|
+
var SAME_COLOUR_DISTANCE = 0.02;
|
|
14605
|
+
var LIMITS = {
|
|
14606
|
+
fontSize: 8,
|
|
14607
|
+
fontFamily: 2,
|
|
14608
|
+
radius: 3
|
|
14609
|
+
};
|
|
14610
|
+
var NON_COLOURS = /* @__PURE__ */ new Set(["transparent", "inherit", "initial", "unset", "currentcolor", "none"]);
|
|
14611
|
+
var TRACKED = {
|
|
14612
|
+
"color": "colors",
|
|
14613
|
+
"background-color": "backgrounds",
|
|
14614
|
+
"background": "backgrounds",
|
|
14615
|
+
"font-size": "fontSizes",
|
|
14616
|
+
"font-family": "fontFamilies",
|
|
14617
|
+
"border-radius": "radii"
|
|
14618
|
+
};
|
|
14619
|
+
function primaryTypeface(value) {
|
|
14620
|
+
var _a;
|
|
14621
|
+
return ((_a = value.split(",")[0]) != null ? _a : "").replace(/['"]/g, "").trim().toLowerCase();
|
|
14622
|
+
}
|
|
14623
|
+
function radiusLengths(value) {
|
|
14624
|
+
const lengths = value.trim().toLowerCase().split(/[\s/]+/).filter(Boolean);
|
|
14625
|
+
return lengths.filter((v) => {
|
|
14626
|
+
if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
|
|
14627
|
+
if (v === "50%") return false;
|
|
14628
|
+
const px = parseFloat(v);
|
|
14629
|
+
return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
|
|
14630
|
+
});
|
|
14631
|
+
}
|
|
14632
|
+
function collect($) {
|
|
14633
|
+
const found = {
|
|
14634
|
+
colors: [],
|
|
14635
|
+
backgrounds: [],
|
|
14636
|
+
fontSizes: [],
|
|
14637
|
+
fontFamilies: [],
|
|
14638
|
+
radii: []
|
|
14639
|
+
};
|
|
14640
|
+
const record = (prop, rawValue) => {
|
|
14641
|
+
const bucket = TRACKED[prop.toLowerCase()];
|
|
14642
|
+
if (!bucket) return;
|
|
14643
|
+
const value = rawValue.trim();
|
|
14644
|
+
if (!value) return;
|
|
14645
|
+
found[bucket].push(value);
|
|
14646
|
+
};
|
|
14647
|
+
$("[style]").each((_, el) => {
|
|
14648
|
+
for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
|
|
14649
|
+
const colon = part.indexOf(":");
|
|
14650
|
+
if (colon === -1) continue;
|
|
14651
|
+
record(part.slice(0, colon), part.slice(colon + 1));
|
|
14652
|
+
}
|
|
14653
|
+
});
|
|
14654
|
+
$("style").each((_, el) => {
|
|
14655
|
+
let ast;
|
|
14656
|
+
try {
|
|
14657
|
+
ast = csstree9.parse($(el).text());
|
|
14658
|
+
} catch (e) {
|
|
14659
|
+
return;
|
|
14660
|
+
}
|
|
14661
|
+
csstree9.walk(ast, {
|
|
14662
|
+
visit: "Declaration",
|
|
14663
|
+
enter(node) {
|
|
14664
|
+
if (node.type !== "Declaration") return;
|
|
14665
|
+
record(node.property, csstree9.generate(node.value));
|
|
14666
|
+
}
|
|
14667
|
+
});
|
|
14668
|
+
});
|
|
14669
|
+
return found;
|
|
14670
|
+
}
|
|
14671
|
+
function driftClusters(values) {
|
|
14672
|
+
const byNormalised = /* @__PURE__ */ new Map();
|
|
14673
|
+
for (const raw of values) {
|
|
14674
|
+
const value = raw.trim();
|
|
14675
|
+
if (NON_COLOURS.has(value.toLowerCase())) continue;
|
|
14676
|
+
const rgb = parseColor(value);
|
|
14677
|
+
if (!rgb || rgb.a === 0) continue;
|
|
14678
|
+
const key = formatRgb(__spreadProps(__spreadValues({}, rgb), { a: 1 }));
|
|
14679
|
+
const entry = byNormalised.get(key);
|
|
14680
|
+
if (entry) entry.spellings.add(value);
|
|
14681
|
+
else byNormalised.set(key, { spellings: /* @__PURE__ */ new Set([value]), rgb });
|
|
14682
|
+
}
|
|
14683
|
+
const distinct = [...byNormalised.entries()];
|
|
14684
|
+
const clusters = [];
|
|
14685
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
14686
|
+
for (let i = 0; i < distinct.length; i++) {
|
|
14687
|
+
const [keyA, a] = distinct[i];
|
|
14688
|
+
if (claimed.has(keyA)) continue;
|
|
14689
|
+
const cluster = [keyA];
|
|
14690
|
+
for (let j = i + 1; j < distinct.length; j++) {
|
|
14691
|
+
const [keyB, b] = distinct[j];
|
|
14692
|
+
if (claimed.has(keyB)) continue;
|
|
14693
|
+
if (!a.rgb || !b.rgb) continue;
|
|
14694
|
+
if (colorDistance(a.rgb, b.rgb) < SAME_COLOUR_DISTANCE) {
|
|
14695
|
+
cluster.push(keyB);
|
|
14696
|
+
claimed.add(keyB);
|
|
14697
|
+
}
|
|
14698
|
+
}
|
|
14699
|
+
claimed.add(keyA);
|
|
14700
|
+
if (cluster.length > 1) clusters.push(cluster);
|
|
14701
|
+
}
|
|
14702
|
+
return clusters;
|
|
14703
|
+
}
|
|
14704
|
+
function distinctValues(values, normalise) {
|
|
14705
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14706
|
+
for (const raw of values) {
|
|
14707
|
+
const key = normalise(raw);
|
|
14708
|
+
if (!key) continue;
|
|
14709
|
+
if (!seen.has(key)) seen.set(key, raw.trim());
|
|
14710
|
+
}
|
|
14711
|
+
return [...seen.values()];
|
|
14712
|
+
}
|
|
14713
|
+
function checkDesignConsistencyFromDom($) {
|
|
14714
|
+
const found = collect($);
|
|
14715
|
+
const issues = [];
|
|
14716
|
+
const colors = distinctValues(found.colors, (v) => {
|
|
14717
|
+
const c = parseColor(v.trim());
|
|
14718
|
+
return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
|
|
14719
|
+
});
|
|
14720
|
+
const backgrounds = distinctValues(found.backgrounds, (v) => {
|
|
14721
|
+
const c = parseColor(v.trim());
|
|
14722
|
+
return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
|
|
14723
|
+
});
|
|
14724
|
+
const fontSizes = distinctValues(found.fontSizes, (v) => v.trim().toLowerCase());
|
|
14725
|
+
const fontFamilies = distinctValues(found.fontFamilies, primaryTypeface);
|
|
14726
|
+
const radii = distinctValues(found.radii.flatMap(radiusLengths), (v) => v);
|
|
14727
|
+
for (const cluster of driftClusters([...found.colors, ...found.backgrounds])) {
|
|
14728
|
+
issues.push({
|
|
14729
|
+
rule: "colour-drift",
|
|
14730
|
+
severity: "info",
|
|
14731
|
+
message: `${cluster.length} near-identical colours are used where one was probably meant: ${cluster.join(", ")}.`,
|
|
14732
|
+
detail: "These render as the same colour to a reader, so the difference is drift rather than a choice. Pick one and use it everywhere.",
|
|
14733
|
+
values: cluster
|
|
14734
|
+
});
|
|
14735
|
+
}
|
|
14736
|
+
const overflowing = [
|
|
14737
|
+
["font sizes", fontSizes, LIMITS.fontSize, "Settle on a type scale and reuse it; every extra size is one more thing to keep in step."],
|
|
14738
|
+
["typefaces", fontFamilies, LIMITS.fontFamily, "Email clients strip most web fonts anyway, so each extra typeface mostly adds fallback risk."],
|
|
14739
|
+
["corner radii", radii, LIMITS.radius, "Corner rounding reads as a single decision; several close values look like an accident."]
|
|
14740
|
+
];
|
|
14741
|
+
for (const [label, values, limit, detail] of overflowing) {
|
|
14742
|
+
if (values.length <= limit) continue;
|
|
14743
|
+
issues.push({
|
|
14744
|
+
rule: "too-many-values",
|
|
14745
|
+
severity: "info",
|
|
14746
|
+
message: `${values.length} distinct ${label} in one email: ${values.slice(0, 10).join(", ")}${values.length > 10 ? ", \u2026" : ""}.`,
|
|
14747
|
+
detail,
|
|
14748
|
+
values
|
|
14749
|
+
});
|
|
14750
|
+
}
|
|
14751
|
+
return {
|
|
14752
|
+
issues,
|
|
14753
|
+
palette: { colors, backgrounds, fontSizes, fontFamilies, radii }
|
|
14754
|
+
};
|
|
14755
|
+
}
|
|
14756
|
+
function checkDesignConsistency(html, options) {
|
|
14757
|
+
return fromHtml(
|
|
14758
|
+
html,
|
|
14759
|
+
{ issues: [], palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] } },
|
|
14760
|
+
checkDesignConsistencyFromDom,
|
|
14761
|
+
options
|
|
14762
|
+
);
|
|
14763
|
+
}
|
|
14764
|
+
|
|
13897
14765
|
// src/audit.ts
|
|
13898
14766
|
var EMPTY_AUDIT = {
|
|
13899
14767
|
compatibility: { warnings: [], scores: {} },
|
|
@@ -13905,8 +14773,19 @@ var EMPTY_AUDIT = {
|
|
|
13905
14773
|
size: EMPTY_SIZE,
|
|
13906
14774
|
templateVariables: EMPTY_TEMPLATE,
|
|
13907
14775
|
overflow: EMPTY_OVERFLOW,
|
|
13908
|
-
visual: EMPTY_VISUAL
|
|
14776
|
+
visual: EMPTY_VISUAL,
|
|
14777
|
+
darkContrast: [],
|
|
14778
|
+
mobileContrast: [],
|
|
14779
|
+
design: EMPTY_DESIGN
|
|
13909
14780
|
};
|
|
14781
|
+
function dedupeByElement(issues) {
|
|
14782
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14783
|
+
return issues.filter((issue) => {
|
|
14784
|
+
if (seen.has(issue.element)) return false;
|
|
14785
|
+
seen.add(issue.element);
|
|
14786
|
+
return true;
|
|
14787
|
+
});
|
|
14788
|
+
}
|
|
13910
14789
|
function runAudit($, html, framework, options) {
|
|
13911
14790
|
var _a;
|
|
13912
14791
|
const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
|
|
@@ -13922,16 +14801,22 @@ function runAudit($, html, framework, options) {
|
|
|
13922
14801
|
const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
|
|
13923
14802
|
const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
|
|
13924
14803
|
const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
|
|
13925
|
-
|
|
14804
|
+
const darkContrast = skip.has("darkContrast") || skip.has("accessibility") ? [] : dedupeByElement([
|
|
14805
|
+
...checkDarkModeContrast(html, accessibility.issues),
|
|
14806
|
+
...checkDarkStylesContrastFromDom($, accessibility.issues)
|
|
14807
|
+
]);
|
|
14808
|
+
const design = skip.has("design") ? EMPTY_DESIGN : checkDesignConsistencyFromDom($);
|
|
14809
|
+
const mobileContrast = skip.has("mobileContrast") || skip.has("accessibility") ? [] : checkMobileContrastFromDom($, accessibility.issues);
|
|
14810
|
+
return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual, darkContrast, mobileContrast, design };
|
|
13926
14811
|
}
|
|
13927
14812
|
function auditEmail(html, options) {
|
|
13928
14813
|
return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
|
|
13929
14814
|
}
|
|
13930
14815
|
|
|
13931
14816
|
// src/plain-text.ts
|
|
13932
|
-
var
|
|
14817
|
+
var cheerio6 = __toESM(require("cheerio"), 1);
|
|
13933
14818
|
function toPlainText(html) {
|
|
13934
|
-
const $ =
|
|
14819
|
+
const $ = cheerio6.load(html);
|
|
13935
14820
|
$("style, script, head").remove();
|
|
13936
14821
|
$("[data-skip-in-text='true']").remove();
|
|
13937
14822
|
const lines = [];
|
|
@@ -13966,7 +14851,7 @@ function toPlainText(html) {
|
|
|
13966
14851
|
if (trimmed) lines.push(trimmed);
|
|
13967
14852
|
currentLine = "";
|
|
13968
14853
|
}
|
|
13969
|
-
function
|
|
14854
|
+
function walk9(node) {
|
|
13970
14855
|
var _a;
|
|
13971
14856
|
if (node.type === "text") {
|
|
13972
14857
|
const text = node.data.replace(/\s+/g, " ");
|
|
@@ -14015,7 +14900,7 @@ function toPlainText(html) {
|
|
|
14015
14900
|
currentLine = "- ";
|
|
14016
14901
|
}
|
|
14017
14902
|
for (const child of el.children) {
|
|
14018
|
-
|
|
14903
|
+
walk9(child);
|
|
14019
14904
|
}
|
|
14020
14905
|
if (isBlock) flushLine();
|
|
14021
14906
|
}
|
|
@@ -14023,7 +14908,7 @@ function toPlainText(html) {
|
|
|
14023
14908
|
const root = body.length ? body[0] : $.root()[0];
|
|
14024
14909
|
if (root && "children" in root) {
|
|
14025
14910
|
for (const child of root.children) {
|
|
14026
|
-
|
|
14911
|
+
walk9(child);
|
|
14027
14912
|
}
|
|
14028
14913
|
}
|
|
14029
14914
|
flushLine();
|
|
@@ -14134,6 +15019,7 @@ var CompileError = class extends Error {
|
|
|
14134
15019
|
CompileError,
|
|
14135
15020
|
EMAIL_CLIENTS,
|
|
14136
15021
|
EMPTY_DELIVERABILITY,
|
|
15022
|
+
FEATURE_URLS,
|
|
14137
15023
|
GENERIC_LINK_TEXT,
|
|
14138
15024
|
HTML_ELEMENT_FEATURES,
|
|
14139
15025
|
MAX_HTML_SIZE,
|
|
@@ -14147,10 +15033,15 @@ var CompileError = class extends Error {
|
|
|
14147
15033
|
auditEmail,
|
|
14148
15034
|
caveatApplies,
|
|
14149
15035
|
checkAccessibility,
|
|
15036
|
+
checkDarkModeContrast,
|
|
15037
|
+
checkDarkStylesContrastFromDom,
|
|
15038
|
+
checkDesignConsistency,
|
|
15039
|
+
checkMobileContrast,
|
|
14150
15040
|
checkOverflow,
|
|
14151
15041
|
checkSize,
|
|
14152
15042
|
checkTemplateVariables,
|
|
14153
15043
|
checkVisual,
|
|
15044
|
+
colorDistance,
|
|
14154
15045
|
contrastRatio,
|
|
14155
15046
|
createSession,
|
|
14156
15047
|
diffResults,
|
|
@@ -14158,6 +15049,7 @@ var CompileError = class extends Error {
|
|
|
14158
15049
|
errorWarnings,
|
|
14159
15050
|
estimateAiFixTokens,
|
|
14160
15051
|
extractInboxPreview,
|
|
15052
|
+
featureUrl,
|
|
14161
15053
|
formatRgb,
|
|
14162
15054
|
generateAiFix,
|
|
14163
15055
|
generateCompatibilityScore,
|
|
@@ -14168,6 +15060,7 @@ var CompileError = class extends Error {
|
|
|
14168
15060
|
heuristicTokenCount,
|
|
14169
15061
|
parseColor,
|
|
14170
15062
|
relativeLuminance,
|
|
15063
|
+
rgbToOklab,
|
|
14171
15064
|
simulateDarkMode,
|
|
14172
15065
|
structuralWarnings,
|
|
14173
15066
|
toPlainText,
|