@emailens/engine 0.10.4 → 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 +25 -25
- 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 +802 -170
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +96 -17
- package/dist/index.d.ts +96 -17
- package/dist/index.js +796 -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 +1 -1
- 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
|
@@ -82,10 +82,15 @@ __export(index_exports, {
|
|
|
82
82
|
auditEmail: () => auditEmail,
|
|
83
83
|
caveatApplies: () => caveatApplies,
|
|
84
84
|
checkAccessibility: () => checkAccessibility,
|
|
85
|
+
checkDarkModeContrast: () => checkDarkModeContrast,
|
|
86
|
+
checkDarkStylesContrastFromDom: () => checkDarkStylesContrastFromDom,
|
|
87
|
+
checkDesignConsistency: () => checkDesignConsistency,
|
|
88
|
+
checkMobileContrast: () => checkMobileContrast,
|
|
85
89
|
checkOverflow: () => checkOverflow,
|
|
86
90
|
checkSize: () => checkSize,
|
|
87
91
|
checkTemplateVariables: () => checkTemplateVariables,
|
|
88
92
|
checkVisual: () => checkVisual,
|
|
93
|
+
colorDistance: () => colorDistance,
|
|
89
94
|
contrastRatio: () => contrastRatio,
|
|
90
95
|
createSession: () => createSession,
|
|
91
96
|
diffResults: () => diffResults,
|
|
@@ -104,6 +109,7 @@ __export(index_exports, {
|
|
|
104
109
|
heuristicTokenCount: () => heuristicTokenCount,
|
|
105
110
|
parseColor: () => parseColor,
|
|
106
111
|
relativeLuminance: () => relativeLuminance,
|
|
112
|
+
rgbToOklab: () => rgbToOklab,
|
|
107
113
|
simulateDarkMode: () => simulateDarkMode,
|
|
108
114
|
structuralWarnings: () => structuralWarnings,
|
|
109
115
|
toPlainText: () => toPlainText,
|
|
@@ -7603,12 +7609,12 @@ h1 {
|
|
|
7603
7609
|
description: "Account for padding in width manually (no box-sizing)",
|
|
7604
7610
|
before: `<div style="width: 300px; padding: 20px;
|
|
7605
7611
|
box-sizing: border-box;">
|
|
7606
|
-
Content
|
|
7612
|
+
Content; total width stays 300px
|
|
7607
7613
|
</div>`,
|
|
7608
7614
|
after: `<!-- Set width to content-width (300 - 40 = 260px) -->
|
|
7609
7615
|
<div style="width: 300px;">
|
|
7610
7616
|
<div style="padding: 20px;">
|
|
7611
|
-
Content
|
|
7617
|
+
Content; padding on inner element
|
|
7612
7618
|
</div>
|
|
7613
7619
|
</div>`
|
|
7614
7620
|
},
|
|
@@ -7689,7 +7695,7 @@ h1 {
|
|
|
7689
7695
|
// ── transition (not supported) ────────────────────────────────────────
|
|
7690
7696
|
"transition": {
|
|
7691
7697
|
language: "css",
|
|
7692
|
-
description: "Transitions don't work in email
|
|
7698
|
+
description: "Transitions don't work in email; style the default state well",
|
|
7693
7699
|
before: `.button {
|
|
7694
7700
|
background-color: #6d28d9;
|
|
7695
7701
|
transition: background-color 0.2s;
|
|
@@ -7709,7 +7715,7 @@ h1 {
|
|
|
7709
7715
|
// ── background-size (Outlook) → VML ───────────────────────────────────
|
|
7710
7716
|
"background-size": {
|
|
7711
7717
|
language: "html",
|
|
7712
|
-
description: "Outlook ignores background-size
|
|
7718
|
+
description: "Outlook ignores background-size; use VML or sized images",
|
|
7713
7719
|
before: `<td style="background: url('bg.jpg') center/cover no-repeat;">
|
|
7714
7720
|
Content
|
|
7715
7721
|
</td>`,
|
|
@@ -7731,7 +7737,7 @@ h1 {
|
|
|
7731
7737
|
// ── overflow (Gmail strips it) ────────────────────────────────────────
|
|
7732
7738
|
"overflow": {
|
|
7733
7739
|
language: "html",
|
|
7734
|
-
description: "Content will always be visible
|
|
7740
|
+
description: "Content will always be visible; design for full content display",
|
|
7735
7741
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7736
7742
|
Long content that gets clipped...
|
|
7737
7743
|
</div>`,
|
|
@@ -7833,7 +7839,7 @@ h1 {
|
|
|
7833
7839
|
// ── max-height → fixed height ────────────────────────────────────────
|
|
7834
7840
|
"max-height": {
|
|
7835
7841
|
language: "html",
|
|
7836
|
-
description: "Outlook ignores max-height
|
|
7842
|
+
description: "Outlook ignores max-height; truncate content server-side",
|
|
7837
7843
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7838
7844
|
Long content...
|
|
7839
7845
|
</div>`,
|
|
@@ -8064,7 +8070,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8064
8070
|
</Row>`,
|
|
8065
8071
|
after: `import { Row, Column } from "@react-email/components";
|
|
8066
8072
|
|
|
8067
|
-
{/* Use padding on Column
|
|
8073
|
+
{/* Use padding on Column; gap is not supported in email clients */}
|
|
8068
8074
|
<Row>
|
|
8069
8075
|
<Column style={{ paddingRight: "16px" }}>Item 1</Column>
|
|
8070
8076
|
<Column style={{ paddingRight: "16px" }}>Item 2</Column>
|
|
@@ -8074,7 +8080,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8074
8080
|
// ── <style> (Gmail JSX) ─────────────────────────────────────────────
|
|
8075
8081
|
"<style>::gmail::jsx": {
|
|
8076
8082
|
language: "jsx",
|
|
8077
|
-
description: "React Email inlines styles via style props
|
|
8083
|
+
description: "React Email inlines styles via style props; manual <style> blocks won't survive Gmail",
|
|
8078
8084
|
before: `import { Head } from "@react-email/components";
|
|
8079
8085
|
|
|
8080
8086
|
<Head>
|
|
@@ -8135,7 +8141,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8135
8141
|
// ── @media (JSX) ────────────────────────────────────────────────────
|
|
8136
8142
|
"@media::jsx": {
|
|
8137
8143
|
language: "jsx",
|
|
8138
|
-
description: "Design mobile-first
|
|
8144
|
+
description: "Design mobile-first; @media queries are stripped by many clients",
|
|
8139
8145
|
before: `import { Head } from "@react-email/components";
|
|
8140
8146
|
|
|
8141
8147
|
<Head>
|
|
@@ -8277,7 +8283,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8277
8283
|
</div>`,
|
|
8278
8284
|
after: `import { Img } from "@react-email/components";
|
|
8279
8285
|
|
|
8280
|
-
{/* CSS transforms are not supported in email
|
|
8286
|
+
{/* CSS transforms are not supported in email: pre-render as an image */}
|
|
8281
8287
|
<Img
|
|
8282
8288
|
src="https://example.com/rotated-content.png"
|
|
8283
8289
|
width={200}
|
|
@@ -8293,7 +8299,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8293
8299
|
before: `<span style={{ animation: "pulse 2s infinite" }}>New!</span>`,
|
|
8294
8300
|
after: `import { Img } from "@react-email/components";
|
|
8295
8301
|
|
|
8296
|
-
{/* CSS animations are not supported
|
|
8302
|
+
{/* CSS animations are not supported: use an animated GIF */}
|
|
8297
8303
|
<Img
|
|
8298
8304
|
src="https://example.com/badge-animated.gif"
|
|
8299
8305
|
width={60}
|
|
@@ -8305,7 +8311,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8305
8311
|
// ── transition (JSX) ─────────────────────────────────────────────
|
|
8306
8312
|
"transition::jsx": {
|
|
8307
8313
|
language: "jsx",
|
|
8308
|
-
description: "Transitions don't work in email
|
|
8314
|
+
description: "Transitions don't work in email; style the default state well",
|
|
8309
8315
|
before: `<a
|
|
8310
8316
|
href="#"
|
|
8311
8317
|
style={{
|
|
@@ -8318,7 +8324,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8318
8324
|
</a>`,
|
|
8319
8325
|
after: `import { Button } from "@react-email/components";
|
|
8320
8326
|
|
|
8321
|
-
{/* Transitions are not supported
|
|
8327
|
+
{/* Transitions are not supported: style the default state: */}
|
|
8322
8328
|
<Button
|
|
8323
8329
|
href="https://example.com"
|
|
8324
8330
|
style={{
|
|
@@ -8335,13 +8341,13 @@ var JSX_FIX_DATABASE = {
|
|
|
8335
8341
|
// ── overflow (JSX) ───────────────────────────────────────────────
|
|
8336
8342
|
"overflow::jsx": {
|
|
8337
8343
|
language: "jsx",
|
|
8338
|
-
description: "Content will always be visible
|
|
8344
|
+
description: "Content will always be visible; design for full content display",
|
|
8339
8345
|
before: `<div style={{ maxHeight: "200px", overflow: "hidden" }}>
|
|
8340
8346
|
Long content that gets clipped...
|
|
8341
8347
|
</div>`,
|
|
8342
8348
|
after: `import { Link, Text } from "@react-email/components";
|
|
8343
8349
|
|
|
8344
|
-
{/* overflow:hidden is stripped
|
|
8350
|
+
{/* overflow:hidden is stripped; show full content or truncate server-side */}
|
|
8345
8351
|
<div>
|
|
8346
8352
|
<Text>Shortened content that fits...</Text>
|
|
8347
8353
|
<Link href="https://example.com/full">Read more</Link>
|
|
@@ -8354,7 +8360,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8354
8360
|
before: `<div style={{ visibility: "hidden" }}>
|
|
8355
8361
|
Hidden preheader text
|
|
8356
8362
|
</div>`,
|
|
8357
|
-
after: `{/* visibility:hidden is stripped by most clients
|
|
8363
|
+
after: `{/* visibility:hidden is stripped by most clients; use the preheader trick.
|
|
8358
8364
|
msoHide is non-standard but needed to hide content in Outlook. */}
|
|
8359
8365
|
<div
|
|
8360
8366
|
style={{
|
|
@@ -8392,7 +8398,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8392
8398
|
// ── background-size (JSX) ────────────────────────────────────────
|
|
8393
8399
|
"background-size::jsx": {
|
|
8394
8400
|
language: "jsx",
|
|
8395
|
-
description: "Outlook ignores background-size
|
|
8401
|
+
description: "Outlook ignores background-size; use sized images instead",
|
|
8396
8402
|
before: `<div style={{
|
|
8397
8403
|
background: "url('bg.jpg') center/cover no-repeat",
|
|
8398
8404
|
}}>
|
|
@@ -8418,12 +8424,12 @@ var JSX_FIX_DATABASE = {
|
|
|
8418
8424
|
padding: "20px",
|
|
8419
8425
|
boxSizing: "border-box",
|
|
8420
8426
|
}}>
|
|
8421
|
-
Content
|
|
8427
|
+
Content (total width stays 300px)
|
|
8422
8428
|
</div>`,
|
|
8423
8429
|
after: `{/* Set outer width, use inner element for padding */}
|
|
8424
8430
|
<div style={{ width: "300px" }}>
|
|
8425
8431
|
<div style={{ padding: "20px" }}>
|
|
8426
|
-
Content
|
|
8432
|
+
Content; padding on inner element
|
|
8427
8433
|
</div>
|
|
8428
8434
|
</div>`
|
|
8429
8435
|
},
|
|
@@ -8463,7 +8469,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8463
8469
|
// ── word-break (MJML) ────────────────────────────────────────────────
|
|
8464
8470
|
"word-break::mjml": {
|
|
8465
8471
|
language: "mjml",
|
|
8466
|
-
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",
|
|
8467
8473
|
before: `<mj-text>
|
|
8468
8474
|
<span style="word-break: break-all;">Long URL here</span>
|
|
8469
8475
|
</mj-text>`,
|
|
@@ -8517,7 +8523,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8517
8523
|
// ── border-radius (Outlook MJML) ────────────────────────────────────
|
|
8518
8524
|
"border-radius::outlook::mjml": {
|
|
8519
8525
|
language: "mjml",
|
|
8520
|
-
description: "MJML limitation: border-radius is unsupported in Outlook
|
|
8526
|
+
description: "MJML limitation: border-radius is unsupported in Outlook; MJML does not generate VML",
|
|
8521
8527
|
before: `<mj-button border-radius="6px" background-color="#6d28d9">
|
|
8522
8528
|
Click Here
|
|
8523
8529
|
</mj-button>`,
|
|
@@ -8557,7 +8563,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8557
8563
|
</div>
|
|
8558
8564
|
</mj-raw>`,
|
|
8559
8565
|
after: `<!-- Flexbox in MJML is not Outlook-compatible.
|
|
8560
|
-
Use mj-section and mj-column
|
|
8566
|
+
Use mj-section and mj-column: MJML compiles these to table-based layouts. -->
|
|
8561
8567
|
<mj-section>
|
|
8562
8568
|
<mj-column width="50%">
|
|
8563
8569
|
<mj-text>Column 1</mj-text>
|
|
@@ -8617,7 +8623,7 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8617
8623
|
// ── @font-face (Maizzle) ────────────────────────────────────────────
|
|
8618
8624
|
"@font-face::maizzle": {
|
|
8619
8625
|
language: "maizzle",
|
|
8620
|
-
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.',
|
|
8621
8627
|
before: `<style>
|
|
8622
8628
|
@font-face {
|
|
8623
8629
|
font-family: 'Inter';
|
|
@@ -8630,12 +8636,12 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8630
8636
|
// ── <style> (Gmail Maizzle) ─────────────────────────────────────────
|
|
8631
8637
|
"<style>::gmail::maizzle": {
|
|
8632
8638
|
language: "maizzle",
|
|
8633
|
-
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.",
|
|
8634
8640
|
before: `<style>
|
|
8635
8641
|
.custom { color: #6d28d9; }
|
|
8636
8642
|
</style>
|
|
8637
8643
|
<div class="custom">Hello</div>`,
|
|
8638
|
-
after: `<!-- Prefer Tailwind classes
|
|
8644
|
+
after: `<!-- Prefer Tailwind classes; Maizzle inlines them automatically during build -->
|
|
8639
8645
|
<div class="text-[#6d28d9]">Hello</div>`
|
|
8640
8646
|
},
|
|
8641
8647
|
// ── max-width (Outlook Maizzle) ─────────────────────────────────────
|
|
@@ -8724,7 +8730,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8724
8730
|
"opacity": "Use solid colors instead of opacity.",
|
|
8725
8731
|
// ── word-break ────────────────────────────────────────────────────────
|
|
8726
8732
|
"word-break": "Wrap long text in a <table><td> to force wrapping in clients that don't support word-break.",
|
|
8727
|
-
"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.",
|
|
8728
8734
|
// ── overflow-wrap ─────────────────────────────────────────────────────
|
|
8729
8735
|
"overflow-wrap": "Wrap text in a <table><td> to force wrapping. overflow-wrap is ignored by Outlook and unreliable in Yahoo.",
|
|
8730
8736
|
// ── white-space ───────────────────────────────────────────────────────
|
|
@@ -8760,7 +8766,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8760
8766
|
// src/fix-snippets/jsx-suggestions.ts
|
|
8761
8767
|
var JSX_SUGGESTION_DATABASE = {
|
|
8762
8768
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8763
|
-
"<style>::jsx": "Move styles to inline style props
|
|
8769
|
+
"<style>::jsx": "Move styles to inline style props; React Email components accept style objects directly.",
|
|
8764
8770
|
"<style>:partial::jsx": "Use inline style props on React Email components. Reserve <style> in <Head> for progressive enhancement only.",
|
|
8765
8771
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8766
8772
|
"<link>::jsx": "Use the React Email <Head> component for font imports; place all other styles inline via style props.",
|
|
@@ -8822,7 +8828,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8822
8828
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8823
8829
|
"@media::mjml": "MJML generates responsive @media queries automatically. Use mj-breakpoint and mj-column widths.",
|
|
8824
8830
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
8825
|
-
"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.",
|
|
8826
8832
|
// ── display:grid ──────────────────────────────────────────────────────
|
|
8827
8833
|
"display:grid::mjml": "Use mj-section and mj-column for grid-like layouts.",
|
|
8828
8834
|
// ── linear-gradient ───────────────────────────────────────────────────
|
|
@@ -8830,7 +8836,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8830
8836
|
// ── box-shadow ────────────────────────────────────────────────────────
|
|
8831
8837
|
"box-shadow::mjml": "Use a border attribute on mj-section or mj-column as an alternative.",
|
|
8832
8838
|
// ── border-radius ─────────────────────────────────────────────────────
|
|
8833
|
-
"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.',
|
|
8834
8840
|
// ── max-width ─────────────────────────────────────────────────────────
|
|
8835
8841
|
"max-width::mjml": "Set the width attribute on mj-body or mj-section for maximum compatibility.",
|
|
8836
8842
|
// ── gap ───────────────────────────────────────────────────────────────
|
|
@@ -8838,7 +8844,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8838
8844
|
// ── float ─────────────────────────────────────────────────────────────
|
|
8839
8845
|
"float::mjml": "Use mj-section with multiple mj-column elements for side-by-side layout.",
|
|
8840
8846
|
// ── background-image ──────────────────────────────────────────────────
|
|
8841
|
-
"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.",
|
|
8842
8848
|
// ── position ──────────────────────────────────────────────────────────
|
|
8843
8849
|
"position::mjml": "Use mj-section and mj-column for layout positioning.",
|
|
8844
8850
|
// ── opacity ───────────────────────────────────────────────────────────
|
|
@@ -8850,10 +8856,10 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8850
8856
|
// src/fix-snippets/maizzle-suggestions.ts
|
|
8851
8857
|
var MAIZZLE_SUGGESTION_DATABASE = {
|
|
8852
8858
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8853
|
-
"<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).",
|
|
8854
8860
|
"<style>:partial::maizzle": "Use Tailwind utility classes for critical styles. Maizzle automatically inlines them at build time.",
|
|
8855
8861
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8856
|
-
"<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.",
|
|
8857
8863
|
// ── <svg> ─────────────────────────────────────────────────────────────
|
|
8858
8864
|
"<svg>::maizzle": "Replace inline SVG with an <img> tag pointing to a hosted PNG.",
|
|
8859
8865
|
// ── <video> ───────────────────────────────────────────────────────────
|
|
@@ -8861,7 +8867,7 @@ var MAIZZLE_SUGGESTION_DATABASE = {
|
|
|
8861
8867
|
// ── <form> ────────────────────────────────────────────────────────────
|
|
8862
8868
|
"<form>::maizzle": "Replace the form with a CTA link/button pointing to a hosted form page.",
|
|
8863
8869
|
// ── @font-face ────────────────────────────────────────────────────────
|
|
8864
|
-
"@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.",
|
|
8865
8871
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8866
8872
|
"@media::maizzle": "Use Tailwind responsive utility classes and Maizzle's breakpoints config instead of hand-written @media.",
|
|
8867
8873
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
@@ -9032,6 +9038,30 @@ function serializeStyle(map) {
|
|
|
9032
9038
|
});
|
|
9033
9039
|
return parts.join("; ");
|
|
9034
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
|
+
}
|
|
9035
9065
|
|
|
9036
9066
|
// src/downlevel.ts
|
|
9037
9067
|
var cheerio = __toESM(require("cheerio"), 1);
|
|
@@ -9407,6 +9437,71 @@ function alphaBlend(fg, bgR, bgG, bgB) {
|
|
|
9407
9437
|
Math.round(fg.b * a + bgB * (1 - a))
|
|
9408
9438
|
];
|
|
9409
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
|
+
}
|
|
9410
9505
|
|
|
9411
9506
|
// src/downlevel.ts
|
|
9412
9507
|
var LOGICAL_PROPERTY_MAP = {
|
|
@@ -9847,6 +9942,10 @@ var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
|
|
|
9847
9942
|
"ui-monospace"
|
|
9848
9943
|
]);
|
|
9849
9944
|
var EMPTY_DELIVERABILITY = { domain: "", checks: [], score: 0, issues: [] };
|
|
9945
|
+
var EMPTY_DESIGN = {
|
|
9946
|
+
issues: [],
|
|
9947
|
+
palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] }
|
|
9948
|
+
};
|
|
9850
9949
|
|
|
9851
9950
|
// src/transform.ts
|
|
9852
9951
|
function hasPseudoSelector(selector) {
|
|
@@ -10217,7 +10316,7 @@ var CLIENT_CONFIGS = {
|
|
|
10217
10316
|
"outlook-macos": {
|
|
10218
10317
|
id: "outlook-macos",
|
|
10219
10318
|
// caniemail: the new (WebKit) Outlook for Mac supports position/transform/
|
|
10220
|
-
// animation/box-shadow/opacity
|
|
10319
|
+
// animation/box-shadow/opacity; only transition is unsupported. It is NOT
|
|
10221
10320
|
// the Word engine, so do not mirror outlook-web's broader strip set.
|
|
10222
10321
|
strippedProperties: /* @__PURE__ */ new Set(["transition"]),
|
|
10223
10322
|
stripMode: "strip",
|
|
@@ -10309,7 +10408,7 @@ var CLIENT_CONFIGS = {
|
|
|
10309
10408
|
additionalChecks: heyAdditionalChecks
|
|
10310
10409
|
},
|
|
10311
10410
|
// ponytail: strip sets below are data-driven from caniemail (the same source
|
|
10312
|
-
// as CSS_SUPPORT), not hand-guessed
|
|
10411
|
+
// as CSS_SUPPORT), not hand-guessed; no manual re-verification debt.
|
|
10313
10412
|
"protonmail": {
|
|
10314
10413
|
id: "protonmail",
|
|
10315
10414
|
// caniemail: strips animation + external stylesheets + forms; supports
|
|
@@ -10323,7 +10422,7 @@ var CLIENT_CONFIGS = {
|
|
|
10323
10422
|
},
|
|
10324
10423
|
"aol": {
|
|
10325
10424
|
id: "aol",
|
|
10326
|
-
// caniemail: restrictive
|
|
10425
|
+
// caniemail: restrictive; drops animation, box-shadow, opacity, transform,
|
|
10327
10426
|
// external stylesheets, and SVG. Forms are kept.
|
|
10328
10427
|
strippedProperties: /* @__PURE__ */ new Set(["animation", "box-shadow", "opacity", "transform"]),
|
|
10329
10428
|
stripMode: "strip",
|
|
@@ -10877,7 +10976,7 @@ function simulateDarkMode(html, clientId) {
|
|
|
10877
10976
|
client: clientId,
|
|
10878
10977
|
property: "dark-mode",
|
|
10879
10978
|
message: "Superhuman respects @media (prefers-color-scheme: dark). Many Superhuman users run in dark mode.",
|
|
10880
|
-
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."
|
|
10881
10980
|
});
|
|
10882
10981
|
}
|
|
10883
10982
|
break;
|
|
@@ -11253,16 +11352,6 @@ function positionAt(data, index, anchor) {
|
|
|
11253
11352
|
// src/dark-mode-checker.ts
|
|
11254
11353
|
var DARK_MEDIA_RE = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
|
|
11255
11354
|
var MAX_UNCOVERED_ELEMENTS = 3;
|
|
11256
|
-
function backgroundShorthandColor(value) {
|
|
11257
|
-
if (!value) return null;
|
|
11258
|
-
const trimmed = value.trim();
|
|
11259
|
-
if (parseColor(trimmed)) return trimmed;
|
|
11260
|
-
for (const token of trimmed.split(/\s+/)) {
|
|
11261
|
-
if (token.includes("(")) continue;
|
|
11262
|
-
if (parseColor(token)) return token;
|
|
11263
|
-
}
|
|
11264
|
-
return null;
|
|
11265
|
-
}
|
|
11266
11355
|
function isLight(value) {
|
|
11267
11356
|
const c = parseColor(value);
|
|
11268
11357
|
if (!c || c.a < 0.5) return false;
|
|
@@ -11380,7 +11469,7 @@ function checkDarkModeFromDom($, source) {
|
|
|
11380
11469
|
severity: "warning",
|
|
11381
11470
|
client: clientId,
|
|
11382
11471
|
property: "dark-mode-coverage",
|
|
11383
|
-
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).`,
|
|
11384
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.`,
|
|
11385
11474
|
fixType: "css",
|
|
11386
11475
|
selector
|
|
@@ -11453,7 +11542,7 @@ var COMPOUND_DETECTORS = [
|
|
|
11453
11542
|
var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
|
|
11454
11543
|
key: fn,
|
|
11455
11544
|
pattern: `${fn}(`
|
|
11456
|
-
// require opening paren
|
|
11545
|
+
// require opening paren, matches "min(" but not "Minion"
|
|
11457
11546
|
}));
|
|
11458
11547
|
function analyzeEmailFromDom($, framework, source) {
|
|
11459
11548
|
const warnings = [];
|
|
@@ -11951,14 +12040,14 @@ ${originalHtml}
|
|
|
11951
12040
|
- **${w.property}** (${clientName})${fixTypeLabel}: ${w.message}`;
|
|
11952
12041
|
if (w.fixType === "structural") {
|
|
11953
12042
|
issueSection += `
|
|
11954
|
-
- **Fix type: structural
|
|
12043
|
+
- **Fix type: structural**. CSS-only changes will NOT work. HTML restructuring required.`;
|
|
11955
12044
|
}
|
|
11956
12045
|
if (w.suggestion) {
|
|
11957
12046
|
issueSection += `
|
|
11958
12047
|
- Suggestion: ${w.suggestion}`;
|
|
11959
12048
|
}
|
|
11960
12049
|
if (w.fix) {
|
|
11961
|
-
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";
|
|
11962
12051
|
issueSection += `
|
|
11963
12052
|
- ${fixLabel}:`;
|
|
11964
12053
|
issueSection += `
|
|
@@ -11990,10 +12079,10 @@ Client-agnostic rendering bugs. Apply each fix directly to the code.
|
|
|
11990
12079
|
sections.push(s);
|
|
11991
12080
|
}
|
|
11992
12081
|
const formatInstructions = {
|
|
11993
|
-
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
|
|
11994
|
-
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
|
|
11995
|
-
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
|
|
11996
|
-
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.`
|
|
11997
12086
|
};
|
|
11998
12087
|
sections.push(
|
|
11999
12088
|
`## Instructions
|
|
@@ -12125,7 +12214,7 @@ var AI_FIX_SYSTEM_PROMPT = `You are an expert email developer specializing in cr
|
|
|
12125
12214
|
Rules:
|
|
12126
12215
|
- Return ONLY the fixed code inside a single code fence. No explanations before or after.
|
|
12127
12216
|
- Preserve all existing content, text, links, and visual design.
|
|
12128
|
-
- 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.
|
|
12129
12218
|
- Common structural patterns:
|
|
12130
12219
|
- word-break/overflow-wrap unsupported \u2192 wrap text in <table><tr><td> with constrained width
|
|
12131
12220
|
- display:flex/grid \u2192 convert to <table> layout (match the original column count and proportions)
|
|
@@ -12136,7 +12225,7 @@ Rules:
|
|
|
12136
12225
|
- <svg> \u2192 replace with <img> pointing to a hosted PNG
|
|
12137
12226
|
- For CSS-only issues (fixType: "css"), swap properties or add fallbacks.
|
|
12138
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.
|
|
12139
|
-
- Apply ALL fixes from the issues list
|
|
12228
|
+
- Apply ALL fixes from the issues list: do not skip any.
|
|
12140
12229
|
- Use the framework syntax specified (JSX/MJML/Maizzle/HTML).
|
|
12141
12230
|
- For JSX: use camelCase style props, React Email components, and proper TypeScript types.
|
|
12142
12231
|
- For MJML: use mj-* elements and attributes.
|
|
@@ -12547,7 +12636,7 @@ function checkCapsRatio(text) {
|
|
|
12547
12636
|
return {
|
|
12548
12637
|
rule: "caps-ratio",
|
|
12549
12638
|
severity: "warning",
|
|
12550
|
-
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.`,
|
|
12551
12640
|
detail: `Found ${capsWords.length} of ${words.length} words in all caps.`
|
|
12552
12641
|
};
|
|
12553
12642
|
}
|
|
@@ -12562,7 +12651,7 @@ function checkExcessivePunctuation(text) {
|
|
|
12562
12651
|
return {
|
|
12563
12652
|
rule: "excessive-punctuation",
|
|
12564
12653
|
severity: "warning",
|
|
12565
|
-
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$")
|
|
12654
|
+
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$"), a common spam trigger.`
|
|
12566
12655
|
};
|
|
12567
12656
|
}
|
|
12568
12657
|
return null;
|
|
@@ -12604,14 +12693,14 @@ function checkUnsubscribe($, text, options) {
|
|
|
12604
12693
|
return {
|
|
12605
12694
|
rule: "missing-unsubscribe",
|
|
12606
12695
|
severity: "info",
|
|
12607
|
-
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.",
|
|
12608
12697
|
detail: `Detected transactional signals: ${signalMatches.join(", ")}${hasOtp ? ", OTP code" : ""}`
|
|
12609
12698
|
};
|
|
12610
12699
|
}
|
|
12611
12700
|
return {
|
|
12612
12701
|
rule: "missing-unsubscribe",
|
|
12613
12702
|
severity: "error",
|
|
12614
|
-
message: "No unsubscribe link found
|
|
12703
|
+
message: "No unsubscribe link found; required by CAN-SPAM and GDPR. Most spam filters penalize this.",
|
|
12615
12704
|
detail: 'Add an <a> link with "unsubscribe" text or href.'
|
|
12616
12705
|
};
|
|
12617
12706
|
}
|
|
@@ -12661,7 +12750,7 @@ function checkHiddenText($) {
|
|
|
12661
12750
|
return {
|
|
12662
12751
|
rule: "hidden-text",
|
|
12663
12752
|
severity: "error",
|
|
12664
|
-
message: "Hidden text detected
|
|
12753
|
+
message: "Hidden text detected: a major spam filter red flag.",
|
|
12665
12754
|
detail
|
|
12666
12755
|
};
|
|
12667
12756
|
}
|
|
@@ -12684,7 +12773,7 @@ function checkUrlShorteners($) {
|
|
|
12684
12773
|
issues.push({
|
|
12685
12774
|
rule: "url-shortener",
|
|
12686
12775
|
severity: "warning",
|
|
12687
|
-
message: `URL shortener detected (${shortener})
|
|
12776
|
+
message: `URL shortener detected (${shortener}); spam filters distrust shortened links.`,
|
|
12688
12777
|
detail: href
|
|
12689
12778
|
});
|
|
12690
12779
|
}
|
|
@@ -12699,7 +12788,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12699
12788
|
return {
|
|
12700
12789
|
rule: "image-only",
|
|
12701
12790
|
severity: "error",
|
|
12702
|
-
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.`
|
|
12703
12792
|
};
|
|
12704
12793
|
}
|
|
12705
12794
|
const ratio = images / (text.length / 100);
|
|
@@ -12707,7 +12796,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12707
12796
|
return {
|
|
12708
12797
|
rule: "high-image-ratio",
|
|
12709
12798
|
severity: "warning",
|
|
12710
|
-
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.`
|
|
12711
12800
|
};
|
|
12712
12801
|
}
|
|
12713
12802
|
return null;
|
|
@@ -12738,7 +12827,7 @@ function checkDeceptiveLinks($) {
|
|
|
12738
12827
|
issues.push({
|
|
12739
12828
|
rule: "deceptive-link",
|
|
12740
12829
|
severity: "error",
|
|
12741
|
-
message: `Link text shows "${textDomain}" but links to "${hrefDomain}"
|
|
12830
|
+
message: `Link text shows "${textDomain}" but links to "${hrefDomain}": a phishing red flag.`,
|
|
12742
12831
|
detail: `Text: ${text}
|
|
12743
12832
|
Href: ${href}`
|
|
12744
12833
|
});
|
|
@@ -12777,7 +12866,7 @@ function checkPhysicalAddress($, text, options) {
|
|
|
12777
12866
|
return {
|
|
12778
12867
|
rule: "missing-physical-address",
|
|
12779
12868
|
severity: "warning",
|
|
12780
|
-
message: "No physical mailing address detected
|
|
12869
|
+
message: "No physical mailing address detected; required by CAN-SPAM for marketing emails.",
|
|
12781
12870
|
detail: "Include a street address or P.O. Box in the email footer."
|
|
12782
12871
|
};
|
|
12783
12872
|
}
|
|
@@ -12800,7 +12889,7 @@ function checkAllCapsTitle($) {
|
|
|
12800
12889
|
return {
|
|
12801
12890
|
rule: "all-caps-subject",
|
|
12802
12891
|
severity: "warning",
|
|
12803
|
-
message: "Email title/subject is ALL CAPS
|
|
12892
|
+
message: "Email title/subject is ALL CAPS: a common spam indicator."
|
|
12804
12893
|
};
|
|
12805
12894
|
}
|
|
12806
12895
|
return null;
|
|
@@ -12959,7 +13048,7 @@ function validateLinksFromDom($) {
|
|
|
12959
13048
|
issues.push(__spreadValues({
|
|
12960
13049
|
severity: "warning",
|
|
12961
13050
|
rule: "protocol-relative",
|
|
12962
|
-
message: "Protocol-relative URL may break in email clients
|
|
13051
|
+
message: "Protocol-relative URL may break in email clients; use https:// explicitly",
|
|
12963
13052
|
href: href.slice(0, 120),
|
|
12964
13053
|
text: text.slice(0, 80) || "(no text)"
|
|
12965
13054
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -12968,7 +13057,7 @@ function validateLinksFromDom($) {
|
|
|
12968
13057
|
issues.push(__spreadValues({
|
|
12969
13058
|
severity: "warning",
|
|
12970
13059
|
rule: "generic-link-text",
|
|
12971
|
-
message: `Link text "${text}" is vague
|
|
13060
|
+
message: `Link text "${text}" is vague; use descriptive text for accessibility and engagement`,
|
|
12972
13061
|
href: href.slice(0, 120),
|
|
12973
13062
|
text
|
|
12974
13063
|
}, elLoc ? { loc: elLoc } : {}));
|
|
@@ -13003,7 +13092,7 @@ function validateLinksFromDom($) {
|
|
|
13003
13092
|
issues.push(__spreadValues({
|
|
13004
13093
|
severity: "info",
|
|
13005
13094
|
rule: "long-url",
|
|
13006
|
-
message: "URL exceeds 2000 characters
|
|
13095
|
+
message: "URL exceeds 2000 characters, may be truncated by some email clients",
|
|
13007
13096
|
href: href.slice(0, 120) + "...",
|
|
13008
13097
|
text: text.slice(0, 80) || "(no text)"
|
|
13009
13098
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -13032,7 +13121,7 @@ function validateLinksFromDom($) {
|
|
|
13032
13121
|
issues.push({
|
|
13033
13122
|
severity: "info",
|
|
13034
13123
|
rule: "duplicate-links",
|
|
13035
|
-
message: `URL appears ${count} times
|
|
13124
|
+
message: `URL appears ${count} times; consider consolidating`,
|
|
13036
13125
|
href: href.slice(0, 120)
|
|
13037
13126
|
});
|
|
13038
13127
|
}
|
|
@@ -13044,6 +13133,8 @@ function validateLinks(html, options) {
|
|
|
13044
13133
|
}
|
|
13045
13134
|
|
|
13046
13135
|
// src/accessibility-checker.ts
|
|
13136
|
+
var cheerio5 = __toESM(require("cheerio"), 1);
|
|
13137
|
+
var csstree6 = __toESM(require("css-tree"), 1);
|
|
13047
13138
|
var RULE_PENALTY_CAPS = {
|
|
13048
13139
|
"img-missing-alt": 3,
|
|
13049
13140
|
"link-generic-text": 3,
|
|
@@ -13116,7 +13207,7 @@ function checkImageAlt($) {
|
|
|
13116
13207
|
issues.push(__spreadProps(__spreadValues({
|
|
13117
13208
|
severity: "info",
|
|
13118
13209
|
rule: "img-empty-alt",
|
|
13119
|
-
message: "Image has empty alt text
|
|
13210
|
+
message: "Image has empty alt text; verify it is decorative",
|
|
13120
13211
|
element: describeElement($, el)
|
|
13121
13212
|
}, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
|
|
13122
13213
|
details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
|
|
@@ -13191,7 +13282,352 @@ function checkTableAccessibility($) {
|
|
|
13191
13282
|
});
|
|
13192
13283
|
return issues;
|
|
13193
13284
|
}
|
|
13194
|
-
|
|
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($) {
|
|
13195
13631
|
const issues = [];
|
|
13196
13632
|
let smallTextCount = 0;
|
|
13197
13633
|
$("[style]").each((_, el) => {
|
|
@@ -13216,67 +13652,6 @@ function checkTextSizeAndContrast($) {
|
|
|
13216
13652
|
}
|
|
13217
13653
|
}
|
|
13218
13654
|
}
|
|
13219
|
-
const colorValue = getStyleValue(style, "color");
|
|
13220
|
-
if (colorValue) {
|
|
13221
|
-
const fg = parseColor(colorValue);
|
|
13222
|
-
if (fg) {
|
|
13223
|
-
let bgR = 255, bgG = 255, bgB = 255;
|
|
13224
|
-
let current = $(el);
|
|
13225
|
-
let foundBg = false;
|
|
13226
|
-
const elements = [current, ...$(el).parents("[style]").toArray().map((p) => $(p))];
|
|
13227
|
-
for (const ancestor of elements) {
|
|
13228
|
-
const ancestorStyle = (typeof ancestor === "function" ? ancestor : $(ancestor)).attr("style") || "";
|
|
13229
|
-
const bgValue = getStyleValue(ancestorStyle, "background-color");
|
|
13230
|
-
if (bgValue) {
|
|
13231
|
-
const bg = parseColor(bgValue);
|
|
13232
|
-
if (bg && bg.a > 0) {
|
|
13233
|
-
if (bg.a < 1) {
|
|
13234
|
-
[bgR, bgG, bgB] = alphaBlend(bg, 255, 255, 255);
|
|
13235
|
-
} else {
|
|
13236
|
-
bgR = bg.r;
|
|
13237
|
-
bgG = bg.g;
|
|
13238
|
-
bgB = bg.b;
|
|
13239
|
-
}
|
|
13240
|
-
foundBg = true;
|
|
13241
|
-
break;
|
|
13242
|
-
}
|
|
13243
|
-
}
|
|
13244
|
-
}
|
|
13245
|
-
const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
|
|
13246
|
-
const fgLum = relativeLuminance(fR, fG, fB);
|
|
13247
|
-
const bgLum = relativeLuminance(bgR, bgG, bgB);
|
|
13248
|
-
const ratio = contrastRatio(fgLum, bgLum);
|
|
13249
|
-
let isLargeText = false;
|
|
13250
|
-
if (fontSizeMatch) {
|
|
13251
|
-
const size = parseFloat(fontSizeMatch[1]);
|
|
13252
|
-
const unit = fontSizeMatch[2].toLowerCase();
|
|
13253
|
-
const pxSize = unit === "pt" ? size * 1.333 : size;
|
|
13254
|
-
const fontWeight = getStyleValue(style, "font-weight");
|
|
13255
|
-
const isBold = fontWeight === "bold" || fontWeight === "bolder" || fontWeight && parseInt(fontWeight, 10) >= 700;
|
|
13256
|
-
isLargeText = pxSize >= 18 || pxSize >= 14 && !!isBold;
|
|
13257
|
-
}
|
|
13258
|
-
const grade = wcagGrade(ratio);
|
|
13259
|
-
if (grade === "Fail") {
|
|
13260
|
-
issues.push(__spreadProps(__spreadValues({
|
|
13261
|
-
severity: "error",
|
|
13262
|
-
rule: "low-contrast",
|
|
13263
|
-
message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG minimum`,
|
|
13264
|
-
element: describeElement($, el)
|
|
13265
|
-
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13266
|
-
details: `Foreground ${colorValue} on background needs at least ${isLargeText ? "3:1" : "4.5:1"} contrast ratio.`
|
|
13267
|
-
}));
|
|
13268
|
-
} else if (!isLargeText && grade === "AA Large") {
|
|
13269
|
-
issues.push(__spreadProps(__spreadValues({
|
|
13270
|
-
severity: "warning",
|
|
13271
|
-
rule: "low-contrast",
|
|
13272
|
-
message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG AA for normal text`,
|
|
13273
|
-
element: describeElement($, el)
|
|
13274
|
-
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13275
|
-
details: `Foreground ${colorValue} on background needs at least 4.5:1 for normal-sized text.`
|
|
13276
|
-
}));
|
|
13277
|
-
}
|
|
13278
|
-
}
|
|
13279
|
-
}
|
|
13280
13655
|
});
|
|
13281
13656
|
if (smallTextCount > 3) {
|
|
13282
13657
|
issues.push({
|
|
@@ -13336,7 +13711,8 @@ function checkAccessibilityFromDom($) {
|
|
|
13336
13711
|
issues.push(...checkImageAlt($));
|
|
13337
13712
|
issues.push(...checkLinkAccessibility($));
|
|
13338
13713
|
issues.push(...checkTableAccessibility($));
|
|
13339
|
-
issues.push(...
|
|
13714
|
+
issues.push(...checkTextSize($));
|
|
13715
|
+
issues.push(...checkContrast($, computeCascade($, DESKTOP_RENDER)));
|
|
13340
13716
|
issues.push(...checkSemanticStructure($));
|
|
13341
13717
|
const charsetIssue = checkCharsetDeclaration($);
|
|
13342
13718
|
if (charsetIssue) issues.push(charsetIssue);
|
|
@@ -13365,6 +13741,76 @@ function checkAccessibilityFromDom($) {
|
|
|
13365
13741
|
function checkAccessibility(html, options) {
|
|
13366
13742
|
return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom, options);
|
|
13367
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
|
+
}
|
|
13368
13814
|
|
|
13369
13815
|
// src/image-analyzer.ts
|
|
13370
13816
|
var DATA_URI_WARN_BYTES = 100 * 1024;
|
|
@@ -13438,7 +13884,7 @@ function analyzeImagesFromDom($) {
|
|
|
13438
13884
|
issues.push(__spreadValues({
|
|
13439
13885
|
rule: "missing-dimensions",
|
|
13440
13886
|
severity: "warning",
|
|
13441
|
-
message: "Image missing width/height attributes
|
|
13887
|
+
message: "Image missing width/height attributes, causes layout shifts and Outlook rendering issues.",
|
|
13442
13888
|
src: truncateSrc(src)
|
|
13443
13889
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13444
13890
|
}
|
|
@@ -13449,7 +13895,7 @@ function analyzeImagesFromDom($) {
|
|
|
13449
13895
|
issues.push(__spreadValues({
|
|
13450
13896
|
rule: "large-data-uri",
|
|
13451
13897
|
severity: "warning",
|
|
13452
|
-
message: `Data URI is ${kb}KB
|
|
13898
|
+
message: `Data URI is ${kb}KB; consider hosting the image externally to reduce email size.`,
|
|
13453
13899
|
src: truncateSrc(src)
|
|
13454
13900
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13455
13901
|
}
|
|
@@ -13458,7 +13904,7 @@ function analyzeImagesFromDom($) {
|
|
|
13458
13904
|
issues.push(__spreadValues({
|
|
13459
13905
|
rule: "missing-alt",
|
|
13460
13906
|
severity: "warning",
|
|
13461
|
-
message: "Image missing alt attribute
|
|
13907
|
+
message: "Image missing alt attribute, hurts deliverability and accessibility.",
|
|
13462
13908
|
src: truncateSrc(src)
|
|
13463
13909
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13464
13910
|
}
|
|
@@ -13467,7 +13913,7 @@ function analyzeImagesFromDom($) {
|
|
|
13467
13913
|
issues.push(__spreadValues({
|
|
13468
13914
|
rule: "webp-format",
|
|
13469
13915
|
severity: "info",
|
|
13470
|
-
message: "WebP format detected
|
|
13916
|
+
message: "WebP format detected, not supported by all email clients. Consider PNG or JPEG.",
|
|
13471
13917
|
src: truncateSrc(src)
|
|
13472
13918
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13473
13919
|
}
|
|
@@ -13476,7 +13922,7 @@ function analyzeImagesFromDom($) {
|
|
|
13476
13922
|
issues.push(__spreadValues({
|
|
13477
13923
|
rule: "svg-format",
|
|
13478
13924
|
severity: "info",
|
|
13479
|
-
message: "SVG format detected
|
|
13925
|
+
message: "SVG format detected, not supported by most email clients. Use PNG instead.",
|
|
13480
13926
|
src: truncateSrc(src)
|
|
13481
13927
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13482
13928
|
}
|
|
@@ -13485,7 +13931,7 @@ function analyzeImagesFromDom($) {
|
|
|
13485
13931
|
issues.push(__spreadValues({
|
|
13486
13932
|
rule: "missing-display-block",
|
|
13487
13933
|
severity: "info",
|
|
13488
|
-
message: "Image without display:block
|
|
13934
|
+
message: "Image without display:block, may cause unwanted gaps in Outlook.",
|
|
13489
13935
|
src: truncateSrc(src)
|
|
13490
13936
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13491
13937
|
}
|
|
@@ -13504,7 +13950,7 @@ function analyzeImagesFromDom($) {
|
|
|
13504
13950
|
issues.push({
|
|
13505
13951
|
rule: "high-image-count",
|
|
13506
13952
|
severity: "info",
|
|
13507
|
-
message: `Email contains ${nonTrackingImages.length} images
|
|
13953
|
+
message: `Email contains ${nonTrackingImages.length} images; heavy emails may be clipped or load slowly.`
|
|
13508
13954
|
});
|
|
13509
13955
|
}
|
|
13510
13956
|
const trackingPixels = images.filter((i) => i.isTrackingPixel);
|
|
@@ -13520,7 +13966,7 @@ function analyzeImagesFromDom($) {
|
|
|
13520
13966
|
issues.push({
|
|
13521
13967
|
rule: "total-data-uri-size",
|
|
13522
13968
|
severity: "warning",
|
|
13523
|
-
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.`
|
|
13524
13970
|
});
|
|
13525
13971
|
}
|
|
13526
13972
|
return { total: images.length, totalDataUriBytes, issues, images };
|
|
@@ -13561,7 +14007,7 @@ function extractInboxPreviewFromDom($) {
|
|
|
13561
14007
|
issues.push({
|
|
13562
14008
|
rule: "subject-too-long",
|
|
13563
14009
|
severity: "warning",
|
|
13564
|
-
message: `Subject is ${subjectLength} characters
|
|
14010
|
+
message: `Subject is ${subjectLength} characters, and may be truncated in inboxes (recommended: ${MAX_SUBJECT_LENGTH} or fewer).`
|
|
13565
14011
|
});
|
|
13566
14012
|
}
|
|
13567
14013
|
const preheader = extractPreheaderText($);
|
|
@@ -13576,27 +14022,27 @@ function extractInboxPreviewFromDom($) {
|
|
|
13576
14022
|
issues.push({
|
|
13577
14023
|
rule: "preheader-too-short",
|
|
13578
14024
|
severity: "warning",
|
|
13579
|
-
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}+).`
|
|
13580
14026
|
});
|
|
13581
14027
|
} else if (preheaderLength > MAX_PREHEADER_LENGTH) {
|
|
13582
14028
|
issues.push({
|
|
13583
14029
|
rule: "preheader-too-long",
|
|
13584
14030
|
severity: "info",
|
|
13585
|
-
message: `Preheader is ${preheaderLength} characters
|
|
14031
|
+
message: `Preheader is ${preheaderLength} characters; most clients show 40\u2013100 characters. Text beyond that is hidden.`
|
|
13586
14032
|
});
|
|
13587
14033
|
}
|
|
13588
14034
|
if (hasZwnjPadding($)) {
|
|
13589
14035
|
issues.push({
|
|
13590
14036
|
rule: "zwnj-padding",
|
|
13591
14037
|
severity: "info",
|
|
13592
|
-
message: "Preheader uses ‌ padding hack
|
|
14038
|
+
message: "Preheader uses ‌ padding hack, works in most clients but may show garbled text in some."
|
|
13593
14039
|
});
|
|
13594
14040
|
}
|
|
13595
14041
|
if (subject && EMOJI_PATTERN.test(subject)) {
|
|
13596
14042
|
issues.push({
|
|
13597
14043
|
rule: "emoji-in-subject",
|
|
13598
14044
|
severity: "info",
|
|
13599
|
-
message: "Subject line contains emoji
|
|
14045
|
+
message: "Subject line contains emoji, renders inconsistently across email clients and may trigger spam filters."
|
|
13600
14046
|
});
|
|
13601
14047
|
}
|
|
13602
14048
|
const truncation = computeTruncation(subject, preheader);
|
|
@@ -13727,14 +14173,14 @@ function checkSizeFromDom(_$, html) {
|
|
|
13727
14173
|
issues.push({
|
|
13728
14174
|
rule: "gmail-clipped",
|
|
13729
14175
|
severity: "error",
|
|
13730
|
-
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.`,
|
|
13731
14177
|
detail: `${htmlBytes} bytes exceeds the ${GMAIL_CLIP_THRESHOLD} byte threshold.`
|
|
13732
14178
|
});
|
|
13733
14179
|
} else if (htmlBytes > GMAIL_CLIP_WARNING_THRESHOLD) {
|
|
13734
14180
|
issues.push({
|
|
13735
14181
|
rule: "gmail-clip-warning",
|
|
13736
14182
|
severity: "warning",
|
|
13737
|
-
message: `Email is ${humanSize}
|
|
14183
|
+
message: `Email is ${humanSize}, approaching Gmail's ~102 KB clip threshold. Consider trimming.`,
|
|
13738
14184
|
detail: `${htmlBytes} bytes is within ${GMAIL_CLIP_THRESHOLD - htmlBytes} bytes of the clip threshold.`
|
|
13739
14185
|
});
|
|
13740
14186
|
}
|
|
@@ -13852,7 +14298,7 @@ function checkTemplateVariables(html, options) {
|
|
|
13852
14298
|
}
|
|
13853
14299
|
|
|
13854
14300
|
// src/overflow-checker.ts
|
|
13855
|
-
var
|
|
14301
|
+
var csstree7 = __toESM(require("css-tree"), 1);
|
|
13856
14302
|
function fixedPxWidth($el) {
|
|
13857
14303
|
const style = $el.attr("style") || "";
|
|
13858
14304
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
@@ -13874,7 +14320,7 @@ function addWidthIssue(width, label, issues, seen, loc) {
|
|
|
13874
14320
|
const issue = __spreadValues({
|
|
13875
14321
|
rule: "fixed-width-overflow",
|
|
13876
14322
|
severity: "warning",
|
|
13877
|
-
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.`,
|
|
13878
14324
|
detail: `Use width:100% with max-width:${EMAIL_MAX_WIDTH}px instead of a fixed width beyond the frame.`
|
|
13879
14325
|
}, loc ? { loc, locs: [loc] } : {});
|
|
13880
14326
|
seen.set(key, issue);
|
|
@@ -13928,11 +14374,11 @@ function checkOverflowFromDom($, source) {
|
|
|
13928
14374
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
13929
14375
|
let ast;
|
|
13930
14376
|
try {
|
|
13931
|
-
ast =
|
|
14377
|
+
ast = csstree7.parse(cssText, { positions: true });
|
|
13932
14378
|
} catch (e) {
|
|
13933
14379
|
return;
|
|
13934
14380
|
}
|
|
13935
|
-
|
|
14381
|
+
csstree7.walk(ast, {
|
|
13936
14382
|
visit: "Rule",
|
|
13937
14383
|
enter(node) {
|
|
13938
14384
|
if (node.type !== "Rule") return;
|
|
@@ -13942,7 +14388,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13942
14388
|
node.block.children.forEach((child) => {
|
|
13943
14389
|
if (child.type !== "Declaration") return;
|
|
13944
14390
|
const prop = child.property.toLowerCase();
|
|
13945
|
-
const val =
|
|
14391
|
+
const val = csstree7.generate(child.value);
|
|
13946
14392
|
if (prop === "width") {
|
|
13947
14393
|
const m = val.match(/^(\d+)px$/);
|
|
13948
14394
|
if (m) {
|
|
@@ -13955,7 +14401,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13955
14401
|
}
|
|
13956
14402
|
});
|
|
13957
14403
|
if (widthPx !== null && widthPx > EMAIL_MAX_WIDTH && !fluid) {
|
|
13958
|
-
const selector =
|
|
14404
|
+
const selector = csstree7.generate(node.prelude).trim().slice(0, 40);
|
|
13959
14405
|
addWidthIssue(widthPx, selector || "rule", issues, seen, widthLoc);
|
|
13960
14406
|
}
|
|
13961
14407
|
}
|
|
@@ -13999,7 +14445,7 @@ function checkOverflow(html, options) {
|
|
|
13999
14445
|
}
|
|
14000
14446
|
|
|
14001
14447
|
// src/visual-checker.ts
|
|
14002
|
-
var
|
|
14448
|
+
var csstree8 = __toESM(require("css-tree"), 1);
|
|
14003
14449
|
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
14004
14450
|
var GRADIENT_RE = /(?:linear|radial|conic)-gradient\(/i;
|
|
14005
14451
|
function isSolidColor(value) {
|
|
@@ -14072,7 +14518,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
14072
14518
|
const issue = __spreadValues({
|
|
14073
14519
|
rule: "missing-background-fallback",
|
|
14074
14520
|
severity: "warning",
|
|
14075
|
-
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.`,
|
|
14076
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.`,
|
|
14077
14523
|
fix
|
|
14078
14524
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -14092,7 +14538,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
14092
14538
|
const issue = __spreadValues({
|
|
14093
14539
|
rule: "missing-font-fallback",
|
|
14094
14540
|
severity: "warning",
|
|
14095
|
-
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.`,
|
|
14096
14542
|
detail: `End the stack with a web-safe font and a generic family.`,
|
|
14097
14543
|
fix
|
|
14098
14544
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -14105,7 +14551,7 @@ function ruleToMap(node) {
|
|
|
14105
14551
|
const map = /* @__PURE__ */ new Map();
|
|
14106
14552
|
node.block.children.forEach((child) => {
|
|
14107
14553
|
if (child.type === "Declaration") {
|
|
14108
|
-
map.set(child.property.toLowerCase(),
|
|
14554
|
+
map.set(child.property.toLowerCase(), csstree8.generate(child.value));
|
|
14109
14555
|
}
|
|
14110
14556
|
});
|
|
14111
14557
|
return map;
|
|
@@ -14124,11 +14570,11 @@ function checkVisualFromDom($, source) {
|
|
|
14124
14570
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
14125
14571
|
let ast;
|
|
14126
14572
|
try {
|
|
14127
|
-
ast =
|
|
14573
|
+
ast = csstree8.parse(cssText, { positions: true });
|
|
14128
14574
|
} catch (e) {
|
|
14129
14575
|
return;
|
|
14130
14576
|
}
|
|
14131
|
-
|
|
14577
|
+
csstree8.walk(ast, {
|
|
14132
14578
|
visit: "Rule",
|
|
14133
14579
|
enter(node) {
|
|
14134
14580
|
if (node.type !== "Rule") return;
|
|
@@ -14153,6 +14599,169 @@ function checkVisual(html, options) {
|
|
|
14153
14599
|
);
|
|
14154
14600
|
}
|
|
14155
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
|
+
|
|
14156
14765
|
// src/audit.ts
|
|
14157
14766
|
var EMPTY_AUDIT = {
|
|
14158
14767
|
compatibility: { warnings: [], scores: {} },
|
|
@@ -14164,8 +14773,19 @@ var EMPTY_AUDIT = {
|
|
|
14164
14773
|
size: EMPTY_SIZE,
|
|
14165
14774
|
templateVariables: EMPTY_TEMPLATE,
|
|
14166
14775
|
overflow: EMPTY_OVERFLOW,
|
|
14167
|
-
visual: EMPTY_VISUAL
|
|
14776
|
+
visual: EMPTY_VISUAL,
|
|
14777
|
+
darkContrast: [],
|
|
14778
|
+
mobileContrast: [],
|
|
14779
|
+
design: EMPTY_DESIGN
|
|
14168
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
|
+
}
|
|
14169
14789
|
function runAudit($, html, framework, options) {
|
|
14170
14790
|
var _a;
|
|
14171
14791
|
const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
|
|
@@ -14181,16 +14801,22 @@ function runAudit($, html, framework, options) {
|
|
|
14181
14801
|
const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
|
|
14182
14802
|
const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
|
|
14183
14803
|
const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
|
|
14184
|
-
|
|
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 };
|
|
14185
14811
|
}
|
|
14186
14812
|
function auditEmail(html, options) {
|
|
14187
14813
|
return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
|
|
14188
14814
|
}
|
|
14189
14815
|
|
|
14190
14816
|
// src/plain-text.ts
|
|
14191
|
-
var
|
|
14817
|
+
var cheerio6 = __toESM(require("cheerio"), 1);
|
|
14192
14818
|
function toPlainText(html) {
|
|
14193
|
-
const $ =
|
|
14819
|
+
const $ = cheerio6.load(html);
|
|
14194
14820
|
$("style, script, head").remove();
|
|
14195
14821
|
$("[data-skip-in-text='true']").remove();
|
|
14196
14822
|
const lines = [];
|
|
@@ -14225,7 +14851,7 @@ function toPlainText(html) {
|
|
|
14225
14851
|
if (trimmed) lines.push(trimmed);
|
|
14226
14852
|
currentLine = "";
|
|
14227
14853
|
}
|
|
14228
|
-
function
|
|
14854
|
+
function walk9(node) {
|
|
14229
14855
|
var _a;
|
|
14230
14856
|
if (node.type === "text") {
|
|
14231
14857
|
const text = node.data.replace(/\s+/g, " ");
|
|
@@ -14274,7 +14900,7 @@ function toPlainText(html) {
|
|
|
14274
14900
|
currentLine = "- ";
|
|
14275
14901
|
}
|
|
14276
14902
|
for (const child of el.children) {
|
|
14277
|
-
|
|
14903
|
+
walk9(child);
|
|
14278
14904
|
}
|
|
14279
14905
|
if (isBlock) flushLine();
|
|
14280
14906
|
}
|
|
@@ -14282,7 +14908,7 @@ function toPlainText(html) {
|
|
|
14282
14908
|
const root = body.length ? body[0] : $.root()[0];
|
|
14283
14909
|
if (root && "children" in root) {
|
|
14284
14910
|
for (const child of root.children) {
|
|
14285
|
-
|
|
14911
|
+
walk9(child);
|
|
14286
14912
|
}
|
|
14287
14913
|
}
|
|
14288
14914
|
flushLine();
|
|
@@ -14407,10 +15033,15 @@ var CompileError = class extends Error {
|
|
|
14407
15033
|
auditEmail,
|
|
14408
15034
|
caveatApplies,
|
|
14409
15035
|
checkAccessibility,
|
|
15036
|
+
checkDarkModeContrast,
|
|
15037
|
+
checkDarkStylesContrastFromDom,
|
|
15038
|
+
checkDesignConsistency,
|
|
15039
|
+
checkMobileContrast,
|
|
14410
15040
|
checkOverflow,
|
|
14411
15041
|
checkSize,
|
|
14412
15042
|
checkTemplateVariables,
|
|
14413
15043
|
checkVisual,
|
|
15044
|
+
colorDistance,
|
|
14414
15045
|
contrastRatio,
|
|
14415
15046
|
createSession,
|
|
14416
15047
|
diffResults,
|
|
@@ -14429,6 +15060,7 @@ var CompileError = class extends Error {
|
|
|
14429
15060
|
heuristicTokenCount,
|
|
14430
15061
|
parseColor,
|
|
14431
15062
|
relativeLuminance,
|
|
15063
|
+
rgbToOklab,
|
|
14432
15064
|
simulateDarkMode,
|
|
14433
15065
|
structuralWarnings,
|
|
14434
15066
|
toPlainText,
|