@emailens/engine 0.10.4 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +1003 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +116 -17
- package/dist/index.d.ts +116 -17
- package/dist/index.js +996 -179
- package/dist/index.js.map +1 -1
- package/dist/{react-email-DwBWB2kr.d.ts → react-email-BROBcC8u.d.ts} +5 -5
- package/dist/{react-email-QKHRTEJW.js → react-email-CVZZNVZ2.js} +2 -2
- package/dist/{react-email-DTzVpGgB.d.cts → react-email-DuZAgJtw.d.cts} +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-bvGDId6f.d.cts} +33 -9
- package/dist/{types-BLR3-Fzo.d.ts → types-bvGDId6f.d.ts} +33 -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,16 @@ __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
|
+
checkVml: () => checkVml,
|
|
94
|
+
colorDistance: () => colorDistance,
|
|
89
95
|
contrastRatio: () => contrastRatio,
|
|
90
96
|
createSession: () => createSession,
|
|
91
97
|
diffResults: () => diffResults,
|
|
@@ -104,6 +110,7 @@ __export(index_exports, {
|
|
|
104
110
|
heuristicTokenCount: () => heuristicTokenCount,
|
|
105
111
|
parseColor: () => parseColor,
|
|
106
112
|
relativeLuminance: () => relativeLuminance,
|
|
113
|
+
rgbToOklab: () => rgbToOklab,
|
|
107
114
|
simulateDarkMode: () => simulateDarkMode,
|
|
108
115
|
structuralWarnings: () => structuralWarnings,
|
|
109
116
|
toPlainText: () => toPlainText,
|
|
@@ -7603,12 +7610,12 @@ h1 {
|
|
|
7603
7610
|
description: "Account for padding in width manually (no box-sizing)",
|
|
7604
7611
|
before: `<div style="width: 300px; padding: 20px;
|
|
7605
7612
|
box-sizing: border-box;">
|
|
7606
|
-
Content
|
|
7613
|
+
Content; total width stays 300px
|
|
7607
7614
|
</div>`,
|
|
7608
7615
|
after: `<!-- Set width to content-width (300 - 40 = 260px) -->
|
|
7609
7616
|
<div style="width: 300px;">
|
|
7610
7617
|
<div style="padding: 20px;">
|
|
7611
|
-
Content
|
|
7618
|
+
Content; padding on inner element
|
|
7612
7619
|
</div>
|
|
7613
7620
|
</div>`
|
|
7614
7621
|
},
|
|
@@ -7689,7 +7696,7 @@ h1 {
|
|
|
7689
7696
|
// ── transition (not supported) ────────────────────────────────────────
|
|
7690
7697
|
"transition": {
|
|
7691
7698
|
language: "css",
|
|
7692
|
-
description: "Transitions don't work in email
|
|
7699
|
+
description: "Transitions don't work in email; style the default state well",
|
|
7693
7700
|
before: `.button {
|
|
7694
7701
|
background-color: #6d28d9;
|
|
7695
7702
|
transition: background-color 0.2s;
|
|
@@ -7709,7 +7716,7 @@ h1 {
|
|
|
7709
7716
|
// ── background-size (Outlook) → VML ───────────────────────────────────
|
|
7710
7717
|
"background-size": {
|
|
7711
7718
|
language: "html",
|
|
7712
|
-
description: "Outlook ignores background-size
|
|
7719
|
+
description: "Outlook ignores background-size; use VML or sized images",
|
|
7713
7720
|
before: `<td style="background: url('bg.jpg') center/cover no-repeat;">
|
|
7714
7721
|
Content
|
|
7715
7722
|
</td>`,
|
|
@@ -7731,7 +7738,7 @@ h1 {
|
|
|
7731
7738
|
// ── overflow (Gmail strips it) ────────────────────────────────────────
|
|
7732
7739
|
"overflow": {
|
|
7733
7740
|
language: "html",
|
|
7734
|
-
description: "Content will always be visible
|
|
7741
|
+
description: "Content will always be visible; design for full content display",
|
|
7735
7742
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7736
7743
|
Long content that gets clipped...
|
|
7737
7744
|
</div>`,
|
|
@@ -7833,7 +7840,7 @@ h1 {
|
|
|
7833
7840
|
// ── max-height → fixed height ────────────────────────────────────────
|
|
7834
7841
|
"max-height": {
|
|
7835
7842
|
language: "html",
|
|
7836
|
-
description: "Outlook ignores max-height
|
|
7843
|
+
description: "Outlook ignores max-height; truncate content server-side",
|
|
7837
7844
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7838
7845
|
Long content...
|
|
7839
7846
|
</div>`,
|
|
@@ -8064,7 +8071,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8064
8071
|
</Row>`,
|
|
8065
8072
|
after: `import { Row, Column } from "@react-email/components";
|
|
8066
8073
|
|
|
8067
|
-
{/* Use padding on Column
|
|
8074
|
+
{/* Use padding on Column; gap is not supported in email clients */}
|
|
8068
8075
|
<Row>
|
|
8069
8076
|
<Column style={{ paddingRight: "16px" }}>Item 1</Column>
|
|
8070
8077
|
<Column style={{ paddingRight: "16px" }}>Item 2</Column>
|
|
@@ -8074,7 +8081,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8074
8081
|
// ── <style> (Gmail JSX) ─────────────────────────────────────────────
|
|
8075
8082
|
"<style>::gmail::jsx": {
|
|
8076
8083
|
language: "jsx",
|
|
8077
|
-
description: "React Email inlines styles via style props
|
|
8084
|
+
description: "React Email inlines styles via style props; manual <style> blocks won't survive Gmail",
|
|
8078
8085
|
before: `import { Head } from "@react-email/components";
|
|
8079
8086
|
|
|
8080
8087
|
<Head>
|
|
@@ -8135,7 +8142,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8135
8142
|
// ── @media (JSX) ────────────────────────────────────────────────────
|
|
8136
8143
|
"@media::jsx": {
|
|
8137
8144
|
language: "jsx",
|
|
8138
|
-
description: "Design mobile-first
|
|
8145
|
+
description: "Design mobile-first; @media queries are stripped by many clients",
|
|
8139
8146
|
before: `import { Head } from "@react-email/components";
|
|
8140
8147
|
|
|
8141
8148
|
<Head>
|
|
@@ -8277,7 +8284,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8277
8284
|
</div>`,
|
|
8278
8285
|
after: `import { Img } from "@react-email/components";
|
|
8279
8286
|
|
|
8280
|
-
{/* CSS transforms are not supported in email
|
|
8287
|
+
{/* CSS transforms are not supported in email: pre-render as an image */}
|
|
8281
8288
|
<Img
|
|
8282
8289
|
src="https://example.com/rotated-content.png"
|
|
8283
8290
|
width={200}
|
|
@@ -8293,7 +8300,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8293
8300
|
before: `<span style={{ animation: "pulse 2s infinite" }}>New!</span>`,
|
|
8294
8301
|
after: `import { Img } from "@react-email/components";
|
|
8295
8302
|
|
|
8296
|
-
{/* CSS animations are not supported
|
|
8303
|
+
{/* CSS animations are not supported: use an animated GIF */}
|
|
8297
8304
|
<Img
|
|
8298
8305
|
src="https://example.com/badge-animated.gif"
|
|
8299
8306
|
width={60}
|
|
@@ -8305,7 +8312,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8305
8312
|
// ── transition (JSX) ─────────────────────────────────────────────
|
|
8306
8313
|
"transition::jsx": {
|
|
8307
8314
|
language: "jsx",
|
|
8308
|
-
description: "Transitions don't work in email
|
|
8315
|
+
description: "Transitions don't work in email; style the default state well",
|
|
8309
8316
|
before: `<a
|
|
8310
8317
|
href="#"
|
|
8311
8318
|
style={{
|
|
@@ -8318,7 +8325,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8318
8325
|
</a>`,
|
|
8319
8326
|
after: `import { Button } from "@react-email/components";
|
|
8320
8327
|
|
|
8321
|
-
{/* Transitions are not supported
|
|
8328
|
+
{/* Transitions are not supported: style the default state: */}
|
|
8322
8329
|
<Button
|
|
8323
8330
|
href="https://example.com"
|
|
8324
8331
|
style={{
|
|
@@ -8335,13 +8342,13 @@ var JSX_FIX_DATABASE = {
|
|
|
8335
8342
|
// ── overflow (JSX) ───────────────────────────────────────────────
|
|
8336
8343
|
"overflow::jsx": {
|
|
8337
8344
|
language: "jsx",
|
|
8338
|
-
description: "Content will always be visible
|
|
8345
|
+
description: "Content will always be visible; design for full content display",
|
|
8339
8346
|
before: `<div style={{ maxHeight: "200px", overflow: "hidden" }}>
|
|
8340
8347
|
Long content that gets clipped...
|
|
8341
8348
|
</div>`,
|
|
8342
8349
|
after: `import { Link, Text } from "@react-email/components";
|
|
8343
8350
|
|
|
8344
|
-
{/* overflow:hidden is stripped
|
|
8351
|
+
{/* overflow:hidden is stripped; show full content or truncate server-side */}
|
|
8345
8352
|
<div>
|
|
8346
8353
|
<Text>Shortened content that fits...</Text>
|
|
8347
8354
|
<Link href="https://example.com/full">Read more</Link>
|
|
@@ -8354,7 +8361,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8354
8361
|
before: `<div style={{ visibility: "hidden" }}>
|
|
8355
8362
|
Hidden preheader text
|
|
8356
8363
|
</div>`,
|
|
8357
|
-
after: `{/* visibility:hidden is stripped by most clients
|
|
8364
|
+
after: `{/* visibility:hidden is stripped by most clients; use the preheader trick.
|
|
8358
8365
|
msoHide is non-standard but needed to hide content in Outlook. */}
|
|
8359
8366
|
<div
|
|
8360
8367
|
style={{
|
|
@@ -8392,7 +8399,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8392
8399
|
// ── background-size (JSX) ────────────────────────────────────────
|
|
8393
8400
|
"background-size::jsx": {
|
|
8394
8401
|
language: "jsx",
|
|
8395
|
-
description: "Outlook ignores background-size
|
|
8402
|
+
description: "Outlook ignores background-size; use sized images instead",
|
|
8396
8403
|
before: `<div style={{
|
|
8397
8404
|
background: "url('bg.jpg') center/cover no-repeat",
|
|
8398
8405
|
}}>
|
|
@@ -8418,12 +8425,12 @@ var JSX_FIX_DATABASE = {
|
|
|
8418
8425
|
padding: "20px",
|
|
8419
8426
|
boxSizing: "border-box",
|
|
8420
8427
|
}}>
|
|
8421
|
-
Content
|
|
8428
|
+
Content (total width stays 300px)
|
|
8422
8429
|
</div>`,
|
|
8423
8430
|
after: `{/* Set outer width, use inner element for padding */}
|
|
8424
8431
|
<div style={{ width: "300px" }}>
|
|
8425
8432
|
<div style={{ padding: "20px" }}>
|
|
8426
|
-
Content
|
|
8433
|
+
Content; padding on inner element
|
|
8427
8434
|
</div>
|
|
8428
8435
|
</div>`
|
|
8429
8436
|
},
|
|
@@ -8463,7 +8470,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8463
8470
|
// ── word-break (MJML) ────────────────────────────────────────────────
|
|
8464
8471
|
"word-break::mjml": {
|
|
8465
8472
|
language: "mjml",
|
|
8466
|
-
description: "MJML renders text in table cells by default
|
|
8473
|
+
description: "MJML renders text in table cells by default; word-break works via mj-text",
|
|
8467
8474
|
before: `<mj-text>
|
|
8468
8475
|
<span style="word-break: break-all;">Long URL here</span>
|
|
8469
8476
|
</mj-text>`,
|
|
@@ -8517,7 +8524,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8517
8524
|
// ── border-radius (Outlook MJML) ────────────────────────────────────
|
|
8518
8525
|
"border-radius::outlook::mjml": {
|
|
8519
8526
|
language: "mjml",
|
|
8520
|
-
description: "MJML limitation: border-radius is unsupported in Outlook
|
|
8527
|
+
description: "MJML limitation: border-radius is unsupported in Outlook; MJML does not generate VML",
|
|
8521
8528
|
before: `<mj-button border-radius="6px" background-color="#6d28d9">
|
|
8522
8529
|
Click Here
|
|
8523
8530
|
</mj-button>`,
|
|
@@ -8557,7 +8564,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8557
8564
|
</div>
|
|
8558
8565
|
</mj-raw>`,
|
|
8559
8566
|
after: `<!-- Flexbox in MJML is not Outlook-compatible.
|
|
8560
|
-
Use mj-section and mj-column
|
|
8567
|
+
Use mj-section and mj-column: MJML compiles these to table-based layouts. -->
|
|
8561
8568
|
<mj-section>
|
|
8562
8569
|
<mj-column width="50%">
|
|
8563
8570
|
<mj-text>Column 1</mj-text>
|
|
@@ -8617,7 +8624,7 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8617
8624
|
// ── @font-face (Maizzle) ────────────────────────────────────────────
|
|
8618
8625
|
"@font-face::maizzle": {
|
|
8619
8626
|
language: "maizzle",
|
|
8620
|
-
description: 'Add fonts via the googleFonts key in config.js
|
|
8627
|
+
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
8628
|
before: `<style>
|
|
8622
8629
|
@font-face {
|
|
8623
8630
|
font-family: 'Inter';
|
|
@@ -8630,12 +8637,12 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8630
8637
|
// ── <style> (Gmail Maizzle) ─────────────────────────────────────────
|
|
8631
8638
|
"<style>::gmail::maizzle": {
|
|
8632
8639
|
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
|
|
8640
|
+
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
8641
|
before: `<style>
|
|
8635
8642
|
.custom { color: #6d28d9; }
|
|
8636
8643
|
</style>
|
|
8637
8644
|
<div class="custom">Hello</div>`,
|
|
8638
|
-
after: `<!-- Prefer Tailwind classes
|
|
8645
|
+
after: `<!-- Prefer Tailwind classes; Maizzle inlines them automatically during build -->
|
|
8639
8646
|
<div class="text-[#6d28d9]">Hello</div>`
|
|
8640
8647
|
},
|
|
8641
8648
|
// ── max-width (Outlook Maizzle) ─────────────────────────────────────
|
|
@@ -8724,7 +8731,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8724
8731
|
"opacity": "Use solid colors instead of opacity.",
|
|
8725
8732
|
// ── word-break ────────────────────────────────────────────────────────
|
|
8726
8733
|
"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
|
|
8734
|
+
"word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width; tables always wrap.",
|
|
8728
8735
|
// ── overflow-wrap ─────────────────────────────────────────────────────
|
|
8729
8736
|
"overflow-wrap": "Wrap text in a <table><td> to force wrapping. overflow-wrap is ignored by Outlook and unreliable in Yahoo.",
|
|
8730
8737
|
// ── white-space ───────────────────────────────────────────────────────
|
|
@@ -8760,7 +8767,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8760
8767
|
// src/fix-snippets/jsx-suggestions.ts
|
|
8761
8768
|
var JSX_SUGGESTION_DATABASE = {
|
|
8762
8769
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8763
|
-
"<style>::jsx": "Move styles to inline style props
|
|
8770
|
+
"<style>::jsx": "Move styles to inline style props; React Email components accept style objects directly.",
|
|
8764
8771
|
"<style>:partial::jsx": "Use inline style props on React Email components. Reserve <style> in <Head> for progressive enhancement only.",
|
|
8765
8772
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8766
8773
|
"<link>::jsx": "Use the React Email <Head> component for font imports; place all other styles inline via style props.",
|
|
@@ -8822,7 +8829,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8822
8829
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8823
8830
|
"@media::mjml": "MJML generates responsive @media queries automatically. Use mj-breakpoint and mj-column widths.",
|
|
8824
8831
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
8825
|
-
"display:flex::mjml": "Use mj-section and mj-column
|
|
8832
|
+
"display:flex::mjml": "Use mj-section and mj-column; MJML compiles these to table-based layouts.",
|
|
8826
8833
|
// ── display:grid ──────────────────────────────────────────────────────
|
|
8827
8834
|
"display:grid::mjml": "Use mj-section and mj-column for grid-like layouts.",
|
|
8828
8835
|
// ── linear-gradient ───────────────────────────────────────────────────
|
|
@@ -8830,7 +8837,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8830
8837
|
// ── box-shadow ────────────────────────────────────────────────────────
|
|
8831
8838
|
"box-shadow::mjml": "Use a border attribute on mj-section or mj-column as an alternative.",
|
|
8832
8839
|
// ── border-radius ─────────────────────────────────────────────────────
|
|
8833
|
-
"border-radius::mjml": 'MJML does not generate VML
|
|
8840
|
+
"border-radius::mjml": 'MJML does not generate VML; border-radius will not render in Outlook. Set border-radius="0" or accept flat corners.',
|
|
8834
8841
|
// ── max-width ─────────────────────────────────────────────────────────
|
|
8835
8842
|
"max-width::mjml": "Set the width attribute on mj-body or mj-section for maximum compatibility.",
|
|
8836
8843
|
// ── gap ───────────────────────────────────────────────────────────────
|
|
@@ -8838,7 +8845,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8838
8845
|
// ── float ─────────────────────────────────────────────────────────────
|
|
8839
8846
|
"float::mjml": "Use mj-section with multiple mj-column elements for side-by-side layout.",
|
|
8840
8847
|
// ── background-image ──────────────────────────────────────────────────
|
|
8841
|
-
"background-image::mjml": "Use background-url attribute on mj-section
|
|
8848
|
+
"background-image::mjml": "Use background-url attribute on mj-section; MJML generates VML automatically.",
|
|
8842
8849
|
// ── position ──────────────────────────────────────────────────────────
|
|
8843
8850
|
"position::mjml": "Use mj-section and mj-column for layout positioning.",
|
|
8844
8851
|
// ── opacity ───────────────────────────────────────────────────────────
|
|
@@ -8850,10 +8857,10 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8850
8857
|
// src/fix-snippets/maizzle-suggestions.ts
|
|
8851
8858
|
var MAIZZLE_SUGGESTION_DATABASE = {
|
|
8852
8859
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8853
|
-
"<style>::maizzle": "Prefer Tailwind utility classes
|
|
8860
|
+
"<style>::maizzle": "Prefer Tailwind utility classes; Maizzle inlines CSS via juice during build (inlineCSS: true in config.js).",
|
|
8854
8861
|
"<style>:partial::maizzle": "Use Tailwind utility classes for critical styles. Maizzle automatically inlines them at build time.",
|
|
8855
8862
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8856
|
-
"<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes
|
|
8863
|
+
"<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes; Maizzle inlines them at build time.",
|
|
8857
8864
|
// ── <svg> ─────────────────────────────────────────────────────────────
|
|
8858
8865
|
"<svg>::maizzle": "Replace inline SVG with an <img> tag pointing to a hosted PNG.",
|
|
8859
8866
|
// ── <video> ───────────────────────────────────────────────────────────
|
|
@@ -8861,7 +8868,7 @@ var MAIZZLE_SUGGESTION_DATABASE = {
|
|
|
8861
8868
|
// ── <form> ────────────────────────────────────────────────────────────
|
|
8862
8869
|
"<form>::maizzle": "Replace the form with a CTA link/button pointing to a hosted form page.",
|
|
8863
8870
|
// ── @font-face ────────────────────────────────────────────────────────
|
|
8864
|
-
"@font-face::maizzle": "Use the googleFonts key in config.js
|
|
8871
|
+
"@font-face::maizzle": "Use the googleFonts key in config.js; Maizzle injects the Google Fonts link tag automatically.",
|
|
8865
8872
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8866
8873
|
"@media::maizzle": "Use Tailwind responsive utility classes and Maizzle's breakpoints config instead of hand-written @media.",
|
|
8867
8874
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
@@ -9032,6 +9039,30 @@ function serializeStyle(map) {
|
|
|
9032
9039
|
});
|
|
9033
9040
|
return parts.join("; ");
|
|
9034
9041
|
}
|
|
9042
|
+
function splitTopLevel(value) {
|
|
9043
|
+
const parts = [];
|
|
9044
|
+
let current = "";
|
|
9045
|
+
let depth = 0;
|
|
9046
|
+
let quote = null;
|
|
9047
|
+
for (const ch of value) {
|
|
9048
|
+
if (quote) {
|
|
9049
|
+
if (ch === quote) quote = null;
|
|
9050
|
+
} else if (ch === '"' || ch === "'") {
|
|
9051
|
+
quote = ch;
|
|
9052
|
+
} else if (ch === "(") {
|
|
9053
|
+
depth++;
|
|
9054
|
+
} else if (ch === ")") {
|
|
9055
|
+
depth = Math.max(0, depth - 1);
|
|
9056
|
+
} else if (ch === "," && depth === 0) {
|
|
9057
|
+
parts.push(current);
|
|
9058
|
+
current = "";
|
|
9059
|
+
continue;
|
|
9060
|
+
}
|
|
9061
|
+
current += ch;
|
|
9062
|
+
}
|
|
9063
|
+
parts.push(current);
|
|
9064
|
+
return parts;
|
|
9065
|
+
}
|
|
9035
9066
|
|
|
9036
9067
|
// src/downlevel.ts
|
|
9037
9068
|
var cheerio = __toESM(require("cheerio"), 1);
|
|
@@ -9407,6 +9438,71 @@ function alphaBlend(fg, bgR, bgG, bgB) {
|
|
|
9407
9438
|
Math.round(fg.b * a + bgB * (1 - a))
|
|
9408
9439
|
];
|
|
9409
9440
|
}
|
|
9441
|
+
function backgroundShorthandColor(value) {
|
|
9442
|
+
if (!value) return null;
|
|
9443
|
+
const trimmed = value.trim();
|
|
9444
|
+
if (parseColor(trimmed)) return trimmed;
|
|
9445
|
+
for (const token of trimmed.split(/\s+/)) {
|
|
9446
|
+
if (token.includes("(")) continue;
|
|
9447
|
+
if (parseColor(token)) return token;
|
|
9448
|
+
}
|
|
9449
|
+
return null;
|
|
9450
|
+
}
|
|
9451
|
+
function functionArgs(value, nameRe) {
|
|
9452
|
+
const out = [];
|
|
9453
|
+
const re = new RegExp(nameRe.source + "\\(", nameRe.flags.includes("i") ? "gi" : "g");
|
|
9454
|
+
let match;
|
|
9455
|
+
while ((match = re.exec(value)) !== null) {
|
|
9456
|
+
let depth = 1;
|
|
9457
|
+
let i = match.index + match[0].length;
|
|
9458
|
+
const start = i;
|
|
9459
|
+
while (i < value.length && depth > 0) {
|
|
9460
|
+
if (value[i] === "(") depth++;
|
|
9461
|
+
else if (value[i] === ")") depth--;
|
|
9462
|
+
i++;
|
|
9463
|
+
}
|
|
9464
|
+
if (depth === 0) out.push(value.slice(start, i - 1));
|
|
9465
|
+
re.lastIndex = i;
|
|
9466
|
+
}
|
|
9467
|
+
return out;
|
|
9468
|
+
}
|
|
9469
|
+
function gradientStops(value) {
|
|
9470
|
+
if (!value) return [];
|
|
9471
|
+
const stops = [];
|
|
9472
|
+
for (const args of functionArgs(value, /(?:repeating-)?(?:linear|radial|conic)-gradient/i)) {
|
|
9473
|
+
for (const part of splitTopLevel(args)) {
|
|
9474
|
+
const trimmed = part.trim();
|
|
9475
|
+
if (!trimmed || /^(?:to\b|at\b|from\b|in\b|[\d.]+(?:deg|turn|rad|grad)\b)/i.test(trimmed)) continue;
|
|
9476
|
+
const fnMatch = trimmed.match(/^(?:rgba?|hsla?|color|lab|lch|oklab|oklch)\([^)]*\)/i);
|
|
9477
|
+
const token = fnMatch ? fnMatch[0] : trimmed.split(/\s+/)[0];
|
|
9478
|
+
const parsed = parseColor(token);
|
|
9479
|
+
if (parsed && parsed.a > 0) stops.push(parsed);
|
|
9480
|
+
}
|
|
9481
|
+
}
|
|
9482
|
+
return stops;
|
|
9483
|
+
}
|
|
9484
|
+
function gammaToLinear(c) {
|
|
9485
|
+
const s = c / 255;
|
|
9486
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
9487
|
+
}
|
|
9488
|
+
function rgbToOklab(rgba) {
|
|
9489
|
+
const r = gammaToLinear(rgba.r);
|
|
9490
|
+
const g = gammaToLinear(rgba.g);
|
|
9491
|
+
const b = gammaToLinear(rgba.b);
|
|
9492
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
9493
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
9494
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
9495
|
+
return [
|
|
9496
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
9497
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
9498
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
9499
|
+
];
|
|
9500
|
+
}
|
|
9501
|
+
function colorDistance(a, b) {
|
|
9502
|
+
const [l1, a1, b1] = rgbToOklab(a);
|
|
9503
|
+
const [l2, a2, b2] = rgbToOklab(b);
|
|
9504
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
9505
|
+
}
|
|
9410
9506
|
|
|
9411
9507
|
// src/downlevel.ts
|
|
9412
9508
|
var LOGICAL_PROPERTY_MAP = {
|
|
@@ -9812,6 +9908,7 @@ var EMPTY_INBOX_PREVIEW = { subject: null, preheader: null, subjectLength: 0, pr
|
|
|
9812
9908
|
var EMPTY_SIZE = { htmlBytes: 0, humanSize: "0 B", clipped: false, issues: [] };
|
|
9813
9909
|
var EMPTY_TEMPLATE = { unresolvedCount: 0, issues: [] };
|
|
9814
9910
|
var EMPTY_OVERFLOW = { hasOverflow: false, issues: [] };
|
|
9911
|
+
var EMPTY_VML = { hasVml: false, issues: [] };
|
|
9815
9912
|
var EMPTY_VISUAL = { issues: [] };
|
|
9816
9913
|
var EMAIL_MAX_WIDTH = 600;
|
|
9817
9914
|
var UNBREAKABLE_STRING_LENGTH = 30;
|
|
@@ -9847,6 +9944,10 @@ var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
|
|
|
9847
9944
|
"ui-monospace"
|
|
9848
9945
|
]);
|
|
9849
9946
|
var EMPTY_DELIVERABILITY = { domain: "", checks: [], score: 0, issues: [] };
|
|
9947
|
+
var EMPTY_DESIGN = {
|
|
9948
|
+
issues: [],
|
|
9949
|
+
palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] }
|
|
9950
|
+
};
|
|
9850
9951
|
|
|
9851
9952
|
// src/transform.ts
|
|
9852
9953
|
function hasPseudoSelector(selector) {
|
|
@@ -10217,7 +10318,7 @@ var CLIENT_CONFIGS = {
|
|
|
10217
10318
|
"outlook-macos": {
|
|
10218
10319
|
id: "outlook-macos",
|
|
10219
10320
|
// caniemail: the new (WebKit) Outlook for Mac supports position/transform/
|
|
10220
|
-
// animation/box-shadow/opacity
|
|
10321
|
+
// animation/box-shadow/opacity; only transition is unsupported. It is NOT
|
|
10221
10322
|
// the Word engine, so do not mirror outlook-web's broader strip set.
|
|
10222
10323
|
strippedProperties: /* @__PURE__ */ new Set(["transition"]),
|
|
10223
10324
|
stripMode: "strip",
|
|
@@ -10309,7 +10410,7 @@ var CLIENT_CONFIGS = {
|
|
|
10309
10410
|
additionalChecks: heyAdditionalChecks
|
|
10310
10411
|
},
|
|
10311
10412
|
// ponytail: strip sets below are data-driven from caniemail (the same source
|
|
10312
|
-
// as CSS_SUPPORT), not hand-guessed
|
|
10413
|
+
// as CSS_SUPPORT), not hand-guessed; no manual re-verification debt.
|
|
10313
10414
|
"protonmail": {
|
|
10314
10415
|
id: "protonmail",
|
|
10315
10416
|
// caniemail: strips animation + external stylesheets + forms; supports
|
|
@@ -10323,7 +10424,7 @@ var CLIENT_CONFIGS = {
|
|
|
10323
10424
|
},
|
|
10324
10425
|
"aol": {
|
|
10325
10426
|
id: "aol",
|
|
10326
|
-
// caniemail: restrictive
|
|
10427
|
+
// caniemail: restrictive; drops animation, box-shadow, opacity, transform,
|
|
10327
10428
|
// external stylesheets, and SVG. Forms are kept.
|
|
10328
10429
|
strippedProperties: /* @__PURE__ */ new Set(["animation", "box-shadow", "opacity", "transform"]),
|
|
10329
10430
|
stripMode: "strip",
|
|
@@ -10877,7 +10978,7 @@ function simulateDarkMode(html, clientId) {
|
|
|
10877
10978
|
client: clientId,
|
|
10878
10979
|
property: "dark-mode",
|
|
10879
10980
|
message: "Superhuman respects @media (prefers-color-scheme: dark). Many Superhuman users run in dark mode.",
|
|
10880
|
-
suggestion: "Add @media (prefers-color-scheme: dark) styles
|
|
10981
|
+
suggestion: "Add @media (prefers-color-scheme: dark) styles; Superhuman's power-user audience often prefers dark mode."
|
|
10881
10982
|
});
|
|
10882
10983
|
}
|
|
10883
10984
|
break;
|
|
@@ -11016,11 +11117,11 @@ function locOfElement(el) {
|
|
|
11016
11117
|
if (!raw) return void 0;
|
|
11017
11118
|
return toLoc((_a = raw.startTag) != null ? _a : raw);
|
|
11018
11119
|
}
|
|
11019
|
-
function locOfAttr(el,
|
|
11120
|
+
function locOfAttr(el, attr2) {
|
|
11020
11121
|
var _a, _b, _c, _d;
|
|
11021
11122
|
const raw = el == null ? void 0 : el.sourceCodeLocation;
|
|
11022
11123
|
if (!raw) return void 0;
|
|
11023
|
-
const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[
|
|
11124
|
+
const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[attr2]) != null ? _d : (_c = (_b = raw.startTag) == null ? void 0 : _b.attrs) == null ? void 0 : _c[attr2];
|
|
11024
11125
|
return attrLoc ? toLoc(attrLoc) : locOfElement(el);
|
|
11025
11126
|
}
|
|
11026
11127
|
function locInAttr(attrLoc, source, property, occurrence = 0) {
|
|
@@ -11253,16 +11354,6 @@ function positionAt(data, index, anchor) {
|
|
|
11253
11354
|
// src/dark-mode-checker.ts
|
|
11254
11355
|
var DARK_MEDIA_RE = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
|
|
11255
11356
|
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
11357
|
function isLight(value) {
|
|
11267
11358
|
const c = parseColor(value);
|
|
11268
11359
|
if (!c || c.a < 0.5) return false;
|
|
@@ -11380,7 +11471,7 @@ function checkDarkModeFromDom($, source) {
|
|
|
11380
11471
|
severity: "warning",
|
|
11381
11472
|
client: clientId,
|
|
11382
11473
|
property: "dark-mode-coverage",
|
|
11383
|
-
message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode
|
|
11474
|
+
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
11475
|
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
11476
|
fixType: "css",
|
|
11386
11477
|
selector
|
|
@@ -11453,7 +11544,7 @@ var COMPOUND_DETECTORS = [
|
|
|
11453
11544
|
var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
|
|
11454
11545
|
key: fn,
|
|
11455
11546
|
pattern: `${fn}(`
|
|
11456
|
-
// require opening paren
|
|
11547
|
+
// require opening paren, matches "min(" but not "Minion"
|
|
11457
11548
|
}));
|
|
11458
11549
|
function analyzeEmailFromDom($, framework, source) {
|
|
11459
11550
|
const warnings = [];
|
|
@@ -11951,14 +12042,14 @@ ${originalHtml}
|
|
|
11951
12042
|
- **${w.property}** (${clientName})${fixTypeLabel}: ${w.message}`;
|
|
11952
12043
|
if (w.fixType === "structural") {
|
|
11953
12044
|
issueSection += `
|
|
11954
|
-
- **Fix type: structural
|
|
12045
|
+
- **Fix type: structural**. CSS-only changes will NOT work. HTML restructuring required.`;
|
|
11955
12046
|
}
|
|
11956
12047
|
if (w.suggestion) {
|
|
11957
12048
|
issueSection += `
|
|
11958
12049
|
- Suggestion: ${w.suggestion}`;
|
|
11959
12050
|
}
|
|
11960
12051
|
if (w.fix) {
|
|
11961
|
-
const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML
|
|
12052
|
+
const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML, adapt to ${format.toUpperCase()} syntax)` : "Fix";
|
|
11962
12053
|
issueSection += `
|
|
11963
12054
|
- ${fixLabel}:`;
|
|
11964
12055
|
issueSection += `
|
|
@@ -11990,10 +12081,10 @@ Client-agnostic rendering bugs. Apply each fix directly to the code.
|
|
|
11990
12081
|
sections.push(s);
|
|
11991
12082
|
}
|
|
11992
12083
|
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
|
|
12084
|
+
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.`,
|
|
12085
|
+
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.`,
|
|
12086
|
+
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.`,
|
|
12087
|
+
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
12088
|
};
|
|
11998
12089
|
sections.push(
|
|
11999
12090
|
`## Instructions
|
|
@@ -12125,7 +12216,7 @@ var AI_FIX_SYSTEM_PROMPT = `You are an expert email developer specializing in cr
|
|
|
12125
12216
|
Rules:
|
|
12126
12217
|
- Return ONLY the fixed code inside a single code fence. No explanations before or after.
|
|
12127
12218
|
- Preserve all existing content, text, links, and visual design.
|
|
12128
|
-
- For structural issues (fixType: "structural"), you MUST restructure the HTML
|
|
12219
|
+
- For structural issues (fixType: "structural"), you MUST restructure the HTML; CSS-only changes will not work.
|
|
12129
12220
|
- Common structural patterns:
|
|
12130
12221
|
- word-break/overflow-wrap unsupported \u2192 wrap text in <table><tr><td> with constrained width
|
|
12131
12222
|
- display:flex/grid \u2192 convert to <table> layout (match the original column count and proportions)
|
|
@@ -12136,7 +12227,7 @@ Rules:
|
|
|
12136
12227
|
- <svg> \u2192 replace with <img> pointing to a hosted PNG
|
|
12137
12228
|
- For CSS-only issues (fixType: "css"), swap properties or add fallbacks.
|
|
12138
12229
|
- 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
|
|
12230
|
+
- Apply ALL fixes from the issues list: do not skip any.
|
|
12140
12231
|
- Use the framework syntax specified (JSX/MJML/Maizzle/HTML).
|
|
12141
12232
|
- For JSX: use camelCase style props, React Email components, and proper TypeScript types.
|
|
12142
12233
|
- For MJML: use mj-* elements and attributes.
|
|
@@ -12547,7 +12638,7 @@ function checkCapsRatio(text) {
|
|
|
12547
12638
|
return {
|
|
12548
12639
|
rule: "caps-ratio",
|
|
12549
12640
|
severity: "warning",
|
|
12550
|
-
message: `${Math.round(ratio * 100)}% of words are ALL CAPS
|
|
12641
|
+
message: `${Math.round(ratio * 100)}% of words are ALL CAPS; spam filters flag excessive capitalization.`,
|
|
12551
12642
|
detail: `Found ${capsWords.length} of ${words.length} words in all caps.`
|
|
12552
12643
|
};
|
|
12553
12644
|
}
|
|
@@ -12562,7 +12653,7 @@ function checkExcessivePunctuation(text) {
|
|
|
12562
12653
|
return {
|
|
12563
12654
|
rule: "excessive-punctuation",
|
|
12564
12655
|
severity: "warning",
|
|
12565
|
-
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$")
|
|
12656
|
+
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$"), a common spam trigger.`
|
|
12566
12657
|
};
|
|
12567
12658
|
}
|
|
12568
12659
|
return null;
|
|
@@ -12604,14 +12695,14 @@ function checkUnsubscribe($, text, options) {
|
|
|
12604
12695
|
return {
|
|
12605
12696
|
rule: "missing-unsubscribe",
|
|
12606
12697
|
severity: "info",
|
|
12607
|
-
message: "No unsubscribe link found, but email appears transactional
|
|
12698
|
+
message: "No unsubscribe link found, but email appears transactional, so it may not be required.",
|
|
12608
12699
|
detail: `Detected transactional signals: ${signalMatches.join(", ")}${hasOtp ? ", OTP code" : ""}`
|
|
12609
12700
|
};
|
|
12610
12701
|
}
|
|
12611
12702
|
return {
|
|
12612
12703
|
rule: "missing-unsubscribe",
|
|
12613
12704
|
severity: "error",
|
|
12614
|
-
message: "No unsubscribe link found
|
|
12705
|
+
message: "No unsubscribe link found; required by CAN-SPAM and GDPR. Most spam filters penalize this.",
|
|
12615
12706
|
detail: 'Add an <a> link with "unsubscribe" text or href.'
|
|
12616
12707
|
};
|
|
12617
12708
|
}
|
|
@@ -12661,7 +12752,7 @@ function checkHiddenText($) {
|
|
|
12661
12752
|
return {
|
|
12662
12753
|
rule: "hidden-text",
|
|
12663
12754
|
severity: "error",
|
|
12664
|
-
message: "Hidden text detected
|
|
12755
|
+
message: "Hidden text detected: a major spam filter red flag.",
|
|
12665
12756
|
detail
|
|
12666
12757
|
};
|
|
12667
12758
|
}
|
|
@@ -12684,7 +12775,7 @@ function checkUrlShorteners($) {
|
|
|
12684
12775
|
issues.push({
|
|
12685
12776
|
rule: "url-shortener",
|
|
12686
12777
|
severity: "warning",
|
|
12687
|
-
message: `URL shortener detected (${shortener})
|
|
12778
|
+
message: `URL shortener detected (${shortener}); spam filters distrust shortened links.`,
|
|
12688
12779
|
detail: href
|
|
12689
12780
|
});
|
|
12690
12781
|
}
|
|
@@ -12699,7 +12790,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12699
12790
|
return {
|
|
12700
12791
|
rule: "image-only",
|
|
12701
12792
|
severity: "error",
|
|
12702
|
-
message: `Image-heavy email with almost no text (${text.length} chars, ${images} images)
|
|
12793
|
+
message: `Image-heavy email with almost no text (${text.length} chars, ${images} images), likely to be flagged as spam or clipped.`
|
|
12703
12794
|
};
|
|
12704
12795
|
}
|
|
12705
12796
|
const ratio = images / (text.length / 100);
|
|
@@ -12707,7 +12798,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12707
12798
|
return {
|
|
12708
12799
|
rule: "high-image-ratio",
|
|
12709
12800
|
severity: "warning",
|
|
12710
|
-
message: `High image-to-text ratio (${images} images for ${text.length} chars of text)
|
|
12801
|
+
message: `High image-to-text ratio (${images} images for ${text.length} chars of text); consider adding more text content.`
|
|
12711
12802
|
};
|
|
12712
12803
|
}
|
|
12713
12804
|
return null;
|
|
@@ -12738,7 +12829,7 @@ function checkDeceptiveLinks($) {
|
|
|
12738
12829
|
issues.push({
|
|
12739
12830
|
rule: "deceptive-link",
|
|
12740
12831
|
severity: "error",
|
|
12741
|
-
message: `Link text shows "${textDomain}" but links to "${hrefDomain}"
|
|
12832
|
+
message: `Link text shows "${textDomain}" but links to "${hrefDomain}": a phishing red flag.`,
|
|
12742
12833
|
detail: `Text: ${text}
|
|
12743
12834
|
Href: ${href}`
|
|
12744
12835
|
});
|
|
@@ -12777,7 +12868,7 @@ function checkPhysicalAddress($, text, options) {
|
|
|
12777
12868
|
return {
|
|
12778
12869
|
rule: "missing-physical-address",
|
|
12779
12870
|
severity: "warning",
|
|
12780
|
-
message: "No physical mailing address detected
|
|
12871
|
+
message: "No physical mailing address detected; required by CAN-SPAM for marketing emails.",
|
|
12781
12872
|
detail: "Include a street address or P.O. Box in the email footer."
|
|
12782
12873
|
};
|
|
12783
12874
|
}
|
|
@@ -12800,7 +12891,7 @@ function checkAllCapsTitle($) {
|
|
|
12800
12891
|
return {
|
|
12801
12892
|
rule: "all-caps-subject",
|
|
12802
12893
|
severity: "warning",
|
|
12803
|
-
message: "Email title/subject is ALL CAPS
|
|
12894
|
+
message: "Email title/subject is ALL CAPS: a common spam indicator."
|
|
12804
12895
|
};
|
|
12805
12896
|
}
|
|
12806
12897
|
return null;
|
|
@@ -12959,7 +13050,7 @@ function validateLinksFromDom($) {
|
|
|
12959
13050
|
issues.push(__spreadValues({
|
|
12960
13051
|
severity: "warning",
|
|
12961
13052
|
rule: "protocol-relative",
|
|
12962
|
-
message: "Protocol-relative URL may break in email clients
|
|
13053
|
+
message: "Protocol-relative URL may break in email clients; use https:// explicitly",
|
|
12963
13054
|
href: href.slice(0, 120),
|
|
12964
13055
|
text: text.slice(0, 80) || "(no text)"
|
|
12965
13056
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -12968,7 +13059,7 @@ function validateLinksFromDom($) {
|
|
|
12968
13059
|
issues.push(__spreadValues({
|
|
12969
13060
|
severity: "warning",
|
|
12970
13061
|
rule: "generic-link-text",
|
|
12971
|
-
message: `Link text "${text}" is vague
|
|
13062
|
+
message: `Link text "${text}" is vague; use descriptive text for accessibility and engagement`,
|
|
12972
13063
|
href: href.slice(0, 120),
|
|
12973
13064
|
text
|
|
12974
13065
|
}, elLoc ? { loc: elLoc } : {}));
|
|
@@ -13003,7 +13094,7 @@ function validateLinksFromDom($) {
|
|
|
13003
13094
|
issues.push(__spreadValues({
|
|
13004
13095
|
severity: "info",
|
|
13005
13096
|
rule: "long-url",
|
|
13006
|
-
message: "URL exceeds 2000 characters
|
|
13097
|
+
message: "URL exceeds 2000 characters, may be truncated by some email clients",
|
|
13007
13098
|
href: href.slice(0, 120) + "...",
|
|
13008
13099
|
text: text.slice(0, 80) || "(no text)"
|
|
13009
13100
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -13032,7 +13123,7 @@ function validateLinksFromDom($) {
|
|
|
13032
13123
|
issues.push({
|
|
13033
13124
|
severity: "info",
|
|
13034
13125
|
rule: "duplicate-links",
|
|
13035
|
-
message: `URL appears ${count} times
|
|
13126
|
+
message: `URL appears ${count} times; consider consolidating`,
|
|
13036
13127
|
href: href.slice(0, 120)
|
|
13037
13128
|
});
|
|
13038
13129
|
}
|
|
@@ -13044,6 +13135,8 @@ function validateLinks(html, options) {
|
|
|
13044
13135
|
}
|
|
13045
13136
|
|
|
13046
13137
|
// src/accessibility-checker.ts
|
|
13138
|
+
var cheerio5 = __toESM(require("cheerio"), 1);
|
|
13139
|
+
var csstree6 = __toESM(require("css-tree"), 1);
|
|
13047
13140
|
var RULE_PENALTY_CAPS = {
|
|
13048
13141
|
"img-missing-alt": 3,
|
|
13049
13142
|
"link-generic-text": 3,
|
|
@@ -13116,7 +13209,7 @@ function checkImageAlt($) {
|
|
|
13116
13209
|
issues.push(__spreadProps(__spreadValues({
|
|
13117
13210
|
severity: "info",
|
|
13118
13211
|
rule: "img-empty-alt",
|
|
13119
|
-
message: "Image has empty alt text
|
|
13212
|
+
message: "Image has empty alt text; verify it is decorative",
|
|
13120
13213
|
element: describeElement($, el)
|
|
13121
13214
|
}, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
|
|
13122
13215
|
details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
|
|
@@ -13191,7 +13284,352 @@ function checkTableAccessibility($) {
|
|
|
13191
13284
|
});
|
|
13192
13285
|
return issues;
|
|
13193
13286
|
}
|
|
13194
|
-
|
|
13287
|
+
var CLIENT_DEFAULT_BACKGROUND = { r: 255, g: 255, b: 255 };
|
|
13288
|
+
var CLIENT_DEFAULT_TEXT = { r: 0, g: 0, b: 0, a: 1 };
|
|
13289
|
+
var UNRESOLVABLE_PAINT_RE = /url\(|(?:linear|radial|conic)-gradient\(/i;
|
|
13290
|
+
var NON_RENDERING_TAGS = /* @__PURE__ */ new Set(["head", "style", "script", "title", "meta", "link"]);
|
|
13291
|
+
var CASCADE_PROPS = /* @__PURE__ */ new Set([
|
|
13292
|
+
"color",
|
|
13293
|
+
"background",
|
|
13294
|
+
"background-color",
|
|
13295
|
+
"background-image",
|
|
13296
|
+
"font-size",
|
|
13297
|
+
"font-weight",
|
|
13298
|
+
// Not for contrast itself, for deciding whether the text renders at all.
|
|
13299
|
+
"display",
|
|
13300
|
+
"visibility",
|
|
13301
|
+
"opacity"
|
|
13302
|
+
]);
|
|
13303
|
+
var DESKTOP_RENDER = { width: 640, dark: false };
|
|
13304
|
+
var MOBILE_RENDER = { width: 375, dark: false };
|
|
13305
|
+
var DARK_RENDER = { width: 640, dark: true };
|
|
13306
|
+
function mediaPx(raw) {
|
|
13307
|
+
const m = raw.match(/([\d.]+)\s*(px|em|rem)?/i);
|
|
13308
|
+
if (!m) return null;
|
|
13309
|
+
const n = parseFloat(m[1]);
|
|
13310
|
+
if (!Number.isFinite(n)) return null;
|
|
13311
|
+
return (m[2] || "px").toLowerCase() === "px" ? n : n * 16;
|
|
13312
|
+
}
|
|
13313
|
+
function mediaApplies(prelude, ctx) {
|
|
13314
|
+
const text = prelude.toLowerCase();
|
|
13315
|
+
if (/\bprint\b/.test(text) && !/\bscreen\b/.test(text)) return false;
|
|
13316
|
+
const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
|
|
13317
|
+
if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
|
|
13318
|
+
for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
|
|
13319
|
+
const px = mediaPx(m[2]);
|
|
13320
|
+
if (px === null) continue;
|
|
13321
|
+
if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
|
|
13322
|
+
}
|
|
13323
|
+
return true;
|
|
13324
|
+
}
|
|
13325
|
+
var FORWARDING_PSEUDOS = /* @__PURE__ */ new Set(["not", "is", "has", "matches", "any"]);
|
|
13326
|
+
function maxSpecificity(list) {
|
|
13327
|
+
let best = [0, 0, 0];
|
|
13328
|
+
list.children.forEach((sel) => {
|
|
13329
|
+
const s = specificityOfSelector(sel);
|
|
13330
|
+
if (moreSpecific(s, best)) best = s;
|
|
13331
|
+
});
|
|
13332
|
+
return best;
|
|
13333
|
+
}
|
|
13334
|
+
function specificityOfSelector(node) {
|
|
13335
|
+
const acc = [0, 0, 0];
|
|
13336
|
+
if (node.type !== "Selector") return acc;
|
|
13337
|
+
node.children.forEach((child) => {
|
|
13338
|
+
var _a;
|
|
13339
|
+
switch (child.type) {
|
|
13340
|
+
case "IdSelector":
|
|
13341
|
+
acc[0]++;
|
|
13342
|
+
break;
|
|
13343
|
+
case "ClassSelector":
|
|
13344
|
+
case "AttributeSelector":
|
|
13345
|
+
acc[1]++;
|
|
13346
|
+
break;
|
|
13347
|
+
case "TypeSelector":
|
|
13348
|
+
if (child.name !== "*") acc[2]++;
|
|
13349
|
+
break;
|
|
13350
|
+
case "PseudoElementSelector":
|
|
13351
|
+
acc[2]++;
|
|
13352
|
+
break;
|
|
13353
|
+
case "PseudoClassSelector": {
|
|
13354
|
+
const name = child.name.toLowerCase();
|
|
13355
|
+
if (name === "where") break;
|
|
13356
|
+
const inner = (_a = child.children) == null ? void 0 : _a.first;
|
|
13357
|
+
if (inner && inner.type === "SelectorList" && FORWARDING_PSEUDOS.has(name)) {
|
|
13358
|
+
const nested = maxSpecificity(inner);
|
|
13359
|
+
acc[0] += nested[0];
|
|
13360
|
+
acc[1] += nested[1];
|
|
13361
|
+
acc[2] += nested[2];
|
|
13362
|
+
break;
|
|
13363
|
+
}
|
|
13364
|
+
acc[1]++;
|
|
13365
|
+
break;
|
|
13366
|
+
}
|
|
13367
|
+
default:
|
|
13368
|
+
break;
|
|
13369
|
+
}
|
|
13370
|
+
});
|
|
13371
|
+
return acc;
|
|
13372
|
+
}
|
|
13373
|
+
function specificityFromTokens(selector) {
|
|
13374
|
+
const bare = selector.replace(/\([^)]*\)/g, "").replace(/["'][^"']*["']/g, "");
|
|
13375
|
+
return [
|
|
13376
|
+
(bare.match(/#[\w-]+/g) || []).length,
|
|
13377
|
+
(bare.match(/\.[\w-]+|\[[^\]]*\]|:[\w-]+/g) || []).length,
|
|
13378
|
+
(bare.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length
|
|
13379
|
+
];
|
|
13380
|
+
}
|
|
13381
|
+
function specificityOf(selector) {
|
|
13382
|
+
try {
|
|
13383
|
+
const ast = csstree6.parse(selector, { context: "selector" });
|
|
13384
|
+
return specificityOfSelector(ast);
|
|
13385
|
+
} catch (e) {
|
|
13386
|
+
return specificityFromTokens(selector);
|
|
13387
|
+
}
|
|
13388
|
+
}
|
|
13389
|
+
function moreSpecific(a, b) {
|
|
13390
|
+
for (let i = 0; i < 3; i++) {
|
|
13391
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
13392
|
+
}
|
|
13393
|
+
return false;
|
|
13394
|
+
}
|
|
13395
|
+
function wins(next, current) {
|
|
13396
|
+
if (!current) return true;
|
|
13397
|
+
if (next.important !== current.important) return next.important;
|
|
13398
|
+
if (next.presentational !== current.presentational) return current.presentational;
|
|
13399
|
+
if (next.inline !== current.inline) return next.inline;
|
|
13400
|
+
if (moreSpecific(next.specificity, current.specificity)) return true;
|
|
13401
|
+
if (moreSpecific(current.specificity, next.specificity)) return false;
|
|
13402
|
+
return next.order >= current.order;
|
|
13403
|
+
}
|
|
13404
|
+
function computeCascade($, ctx) {
|
|
13405
|
+
const cascade = /* @__PURE__ */ new Map();
|
|
13406
|
+
let order = 0;
|
|
13407
|
+
const record = (el, prop, decl) => {
|
|
13408
|
+
let props = cascade.get(el);
|
|
13409
|
+
if (!props) {
|
|
13410
|
+
props = /* @__PURE__ */ new Map();
|
|
13411
|
+
cascade.set(el, props);
|
|
13412
|
+
}
|
|
13413
|
+
if (wins(decl, props.get(prop))) props.set(prop, decl);
|
|
13414
|
+
};
|
|
13415
|
+
$("[bgcolor]").each((_, el) => {
|
|
13416
|
+
const value = $(el).attr("bgcolor");
|
|
13417
|
+
if (!value) return;
|
|
13418
|
+
record(el, "background-color", {
|
|
13419
|
+
value,
|
|
13420
|
+
important: false,
|
|
13421
|
+
inline: false,
|
|
13422
|
+
presentational: true,
|
|
13423
|
+
specificity: [0, 0, 0],
|
|
13424
|
+
order: order++
|
|
13425
|
+
});
|
|
13426
|
+
});
|
|
13427
|
+
const handleRule = (node) => {
|
|
13428
|
+
const selectorList = csstree6.generate(node.prelude).trim();
|
|
13429
|
+
if (!selectorList) return;
|
|
13430
|
+
const decls = [];
|
|
13431
|
+
node.block.children.forEach((child) => {
|
|
13432
|
+
if (child.type !== "Declaration") return;
|
|
13433
|
+
const prop = child.property.toLowerCase();
|
|
13434
|
+
if (!CASCADE_PROPS.has(prop)) return;
|
|
13435
|
+
decls.push({ prop, value: csstree6.generate(child.value), important: !!child.important });
|
|
13436
|
+
});
|
|
13437
|
+
if (!decls.length) return;
|
|
13438
|
+
for (const selector of splitTopLevel(selectorList).map((x) => x.trim()).filter(Boolean)) {
|
|
13439
|
+
const specificity = specificityOf(selector);
|
|
13440
|
+
let matched;
|
|
13441
|
+
try {
|
|
13442
|
+
matched = $(selector).toArray();
|
|
13443
|
+
} catch (e) {
|
|
13444
|
+
continue;
|
|
13445
|
+
}
|
|
13446
|
+
for (const el of matched) {
|
|
13447
|
+
for (const d of decls) {
|
|
13448
|
+
record(el, d.prop, __spreadProps(__spreadValues({}, d), {
|
|
13449
|
+
inline: false,
|
|
13450
|
+
presentational: false,
|
|
13451
|
+
specificity,
|
|
13452
|
+
order: order++
|
|
13453
|
+
}));
|
|
13454
|
+
}
|
|
13455
|
+
}
|
|
13456
|
+
}
|
|
13457
|
+
};
|
|
13458
|
+
const visitBlock = (children) => {
|
|
13459
|
+
children.forEach((node) => {
|
|
13460
|
+
if (node.type === "Rule") {
|
|
13461
|
+
handleRule(node);
|
|
13462
|
+
return;
|
|
13463
|
+
}
|
|
13464
|
+
if (node.type !== "Atrule" || node.name.toLowerCase() !== "media" || !node.block) return;
|
|
13465
|
+
const prelude = node.prelude ? csstree6.generate(node.prelude) : "";
|
|
13466
|
+
if (!mediaApplies(prelude, ctx)) return;
|
|
13467
|
+
visitBlock(node.block.children);
|
|
13468
|
+
});
|
|
13469
|
+
};
|
|
13470
|
+
$("style").each((_, styleEl) => {
|
|
13471
|
+
let ast;
|
|
13472
|
+
try {
|
|
13473
|
+
ast = csstree6.parse($(styleEl).text());
|
|
13474
|
+
} catch (e) {
|
|
13475
|
+
return;
|
|
13476
|
+
}
|
|
13477
|
+
if (ast.type !== "StyleSheet") return;
|
|
13478
|
+
visitBlock(ast.children);
|
|
13479
|
+
});
|
|
13480
|
+
$("[style]").each((_, el) => {
|
|
13481
|
+
for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
|
|
13482
|
+
const colon = part.indexOf(":");
|
|
13483
|
+
if (colon === -1) continue;
|
|
13484
|
+
const prop = part.slice(0, colon).trim().toLowerCase();
|
|
13485
|
+
if (!CASCADE_PROPS.has(prop)) continue;
|
|
13486
|
+
let value = part.slice(colon + 1).trim();
|
|
13487
|
+
const important = /!\s*important$/i.test(value);
|
|
13488
|
+
if (important) value = value.replace(/!\s*important$/i, "").trim();
|
|
13489
|
+
record(el, prop, {
|
|
13490
|
+
value,
|
|
13491
|
+
important,
|
|
13492
|
+
inline: true,
|
|
13493
|
+
presentational: false,
|
|
13494
|
+
specificity: [0, 0, 0],
|
|
13495
|
+
order: order++
|
|
13496
|
+
});
|
|
13497
|
+
}
|
|
13498
|
+
});
|
|
13499
|
+
return cascade;
|
|
13500
|
+
}
|
|
13501
|
+
function paintedColor(value) {
|
|
13502
|
+
if (!value) return null;
|
|
13503
|
+
const solid = backgroundShorthandColor(value);
|
|
13504
|
+
if (solid) {
|
|
13505
|
+
const c = parseColor(solid);
|
|
13506
|
+
if (c && c.a > 0) return [c, ...gradientStops(value)];
|
|
13507
|
+
}
|
|
13508
|
+
const stops = gradientStops(value);
|
|
13509
|
+
if (stops.length) return stops;
|
|
13510
|
+
return UNRESOLVABLE_PAINT_RE.test(value) ? "unknown" : null;
|
|
13511
|
+
}
|
|
13512
|
+
function asColors(painted) {
|
|
13513
|
+
return painted && painted !== "unknown" ? painted : [];
|
|
13514
|
+
}
|
|
13515
|
+
function elementBackground(props) {
|
|
13516
|
+
const shorthand = props.get("background");
|
|
13517
|
+
const longhand = props.get("background-color");
|
|
13518
|
+
const image = props.get("background-image");
|
|
13519
|
+
const colorDecl = shorthand && wins(shorthand, longhand) ? shorthand : longhand;
|
|
13520
|
+
const candidates = [
|
|
13521
|
+
...colorDecl ? asColors(paintedColor(colorDecl.value)) : [],
|
|
13522
|
+
...image ? asColors(paintedColor(image.value)) : []
|
|
13523
|
+
];
|
|
13524
|
+
if (candidates.length) return candidates;
|
|
13525
|
+
for (const decl of [colorDecl, image]) {
|
|
13526
|
+
if (decl && UNRESOLVABLE_PAINT_RE.test(decl.value)) return "unknown";
|
|
13527
|
+
}
|
|
13528
|
+
return null;
|
|
13529
|
+
}
|
|
13530
|
+
function resolveBackground($, el, cascade) {
|
|
13531
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13532
|
+
const props = cascade.get(node);
|
|
13533
|
+
if (!props) continue;
|
|
13534
|
+
const painted = elementBackground(props);
|
|
13535
|
+
if (painted !== null) return painted;
|
|
13536
|
+
}
|
|
13537
|
+
return null;
|
|
13538
|
+
}
|
|
13539
|
+
function isLargeText(size, weight) {
|
|
13540
|
+
const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
|
|
13541
|
+
if (!match) return false;
|
|
13542
|
+
const px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
|
|
13543
|
+
const w = weight == null ? void 0 : weight.trim().toLowerCase();
|
|
13544
|
+
const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
|
|
13545
|
+
return px >= 18 || px >= 14 && bold;
|
|
13546
|
+
}
|
|
13547
|
+
function inherited($, el, cascade, prop) {
|
|
13548
|
+
var _a, _b;
|
|
13549
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13550
|
+
const value = (_b = (_a = cascade.get(node)) == null ? void 0 : _a.get(prop)) == null ? void 0 : _b.value;
|
|
13551
|
+
if (value) return { value, source: node };
|
|
13552
|
+
}
|
|
13553
|
+
return null;
|
|
13554
|
+
}
|
|
13555
|
+
function isHiddenText($, el, cascade) {
|
|
13556
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
13557
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13558
|
+
const props = cascade.get(node);
|
|
13559
|
+
if (!props) continue;
|
|
13560
|
+
const display = (_b = (_a = props.get("display")) == null ? void 0 : _a.value) == null ? void 0 : _b.trim().toLowerCase();
|
|
13561
|
+
if (display === "none") return true;
|
|
13562
|
+
const visibility = (_d = (_c = props.get("visibility")) == null ? void 0 : _c.value) == null ? void 0 : _d.trim().toLowerCase();
|
|
13563
|
+
if (visibility === "hidden" || visibility === "collapse") return true;
|
|
13564
|
+
const opacity = (_f = (_e = props.get("opacity")) == null ? void 0 : _e.value) == null ? void 0 : _f.trim();
|
|
13565
|
+
if (opacity !== void 0 && parseFloat(opacity) === 0) return true;
|
|
13566
|
+
const size = (_h = (_g = props.get("font-size")) == null ? void 0 : _g.value) == null ? void 0 : _h.trim();
|
|
13567
|
+
if (size !== void 0 && /^0(?:px|pt|em|rem|%)?$/i.test(size)) return true;
|
|
13568
|
+
}
|
|
13569
|
+
return $(el).closest("[style*='mso-hide']").length > 0;
|
|
13570
|
+
}
|
|
13571
|
+
function hasOwnText($, el) {
|
|
13572
|
+
return $(el).contents().toArray().some((n) => n.type === "text" && typeof n.data === "string" && n.data.trim().length > 0);
|
|
13573
|
+
}
|
|
13574
|
+
function checkContrast($, cascade) {
|
|
13575
|
+
const issues = [];
|
|
13576
|
+
$("*").each((_, el) => {
|
|
13577
|
+
var _a, _b, _c, _d, _e;
|
|
13578
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
13579
|
+
if (NON_RENDERING_TAGS.has(tag)) return;
|
|
13580
|
+
if (!hasOwnText($, el)) return;
|
|
13581
|
+
if (isHiddenText($, el, cascade)) return;
|
|
13582
|
+
const declared = inherited($, el, cascade, "color");
|
|
13583
|
+
const fg = declared ? parseColor(declared.value) : CLIENT_DEFAULT_TEXT;
|
|
13584
|
+
if (!fg) return;
|
|
13585
|
+
const colorValue = (_a = declared == null ? void 0 : declared.value) != null ? _a : "the client default";
|
|
13586
|
+
const background = resolveBackground($, el, cascade);
|
|
13587
|
+
if (background === "unknown") return;
|
|
13588
|
+
const surfaces = background != null ? background : [null];
|
|
13589
|
+
let ratio = Infinity;
|
|
13590
|
+
let worst = "";
|
|
13591
|
+
for (const surface of surfaces) {
|
|
13592
|
+
let { r: bgR, g: bgG, b: bgB } = surface != null ? surface : CLIENT_DEFAULT_BACKGROUND;
|
|
13593
|
+
if (surface && surface.a < 1) {
|
|
13594
|
+
[bgR, bgG, bgB] = alphaBlend(surface, 255, 255, 255);
|
|
13595
|
+
}
|
|
13596
|
+
const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
|
|
13597
|
+
const candidate = contrastRatio(relativeLuminance(fR, fG, fB), relativeLuminance(bgR, bgG, bgB));
|
|
13598
|
+
if (candidate < ratio) {
|
|
13599
|
+
ratio = candidate;
|
|
13600
|
+
worst = formatRgb(surface != null ? surface : __spreadProps(__spreadValues({}, CLIENT_DEFAULT_BACKGROUND), { a: 1 }));
|
|
13601
|
+
}
|
|
13602
|
+
}
|
|
13603
|
+
const against = surfaces.length > 1 ? ` (worst gradient stop ${worst})` : "";
|
|
13604
|
+
const large = isLargeText(
|
|
13605
|
+
(_b = inherited($, el, cascade, "font-size")) == null ? void 0 : _b.value,
|
|
13606
|
+
(_c = inherited($, el, cascade, "font-weight")) == null ? void 0 : _c.value
|
|
13607
|
+
);
|
|
13608
|
+
const grade = wcagGrade(ratio);
|
|
13609
|
+
const styleLoc = (_e = locOfAttr((_d = declared == null ? void 0 : declared.source) != null ? _d : el, "style")) != null ? _e : locOfAttr(el, "style");
|
|
13610
|
+
if (grade === "Fail") {
|
|
13611
|
+
issues.push(__spreadProps(__spreadValues({
|
|
13612
|
+
severity: "error",
|
|
13613
|
+
rule: "low-contrast",
|
|
13614
|
+
message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG minimum`,
|
|
13615
|
+
element: describeElement($, el)
|
|
13616
|
+
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13617
|
+
details: `Foreground ${colorValue} on background${against} needs at least ${large ? "3:1" : "4.5:1"} contrast ratio.`
|
|
13618
|
+
}));
|
|
13619
|
+
} else if (!large && grade === "AA Large") {
|
|
13620
|
+
issues.push(__spreadProps(__spreadValues({
|
|
13621
|
+
severity: "warning",
|
|
13622
|
+
rule: "low-contrast",
|
|
13623
|
+
message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG AA for normal text`,
|
|
13624
|
+
element: describeElement($, el)
|
|
13625
|
+
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13626
|
+
details: `Foreground ${colorValue} on background${against} needs at least 4.5:1 for normal-sized text.`
|
|
13627
|
+
}));
|
|
13628
|
+
}
|
|
13629
|
+
});
|
|
13630
|
+
return issues;
|
|
13631
|
+
}
|
|
13632
|
+
function checkTextSize($) {
|
|
13195
13633
|
const issues = [];
|
|
13196
13634
|
let smallTextCount = 0;
|
|
13197
13635
|
$("[style]").each((_, el) => {
|
|
@@ -13216,67 +13654,6 @@ function checkTextSizeAndContrast($) {
|
|
|
13216
13654
|
}
|
|
13217
13655
|
}
|
|
13218
13656
|
}
|
|
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
13657
|
});
|
|
13281
13658
|
if (smallTextCount > 3) {
|
|
13282
13659
|
issues.push({
|
|
@@ -13336,7 +13713,8 @@ function checkAccessibilityFromDom($) {
|
|
|
13336
13713
|
issues.push(...checkImageAlt($));
|
|
13337
13714
|
issues.push(...checkLinkAccessibility($));
|
|
13338
13715
|
issues.push(...checkTableAccessibility($));
|
|
13339
|
-
issues.push(...
|
|
13716
|
+
issues.push(...checkTextSize($));
|
|
13717
|
+
issues.push(...checkContrast($, computeCascade($, DESKTOP_RENDER)));
|
|
13340
13718
|
issues.push(...checkSemanticStructure($));
|
|
13341
13719
|
const charsetIssue = checkCharsetDeclaration($);
|
|
13342
13720
|
if (charsetIssue) issues.push(charsetIssue);
|
|
@@ -13365,6 +13743,76 @@ function checkAccessibilityFromDom($) {
|
|
|
13365
13743
|
function checkAccessibility(html, options) {
|
|
13366
13744
|
return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom, options);
|
|
13367
13745
|
}
|
|
13746
|
+
function checkDarkStylesContrastFromDom($, lightIssues) {
|
|
13747
|
+
const baseline = lightIssues != null ? lightIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
|
|
13748
|
+
const dark = checkContrast($, computeCascade($, DARK_RENDER));
|
|
13749
|
+
return contrastDelta(
|
|
13750
|
+
dark,
|
|
13751
|
+
baseline,
|
|
13752
|
+
"low-contrast-dark",
|
|
13753
|
+
"Dark mode",
|
|
13754
|
+
"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."
|
|
13755
|
+
);
|
|
13756
|
+
}
|
|
13757
|
+
var DARK_CONTRAST_CLIENTS = ["gmail-android", "gmail-ios"];
|
|
13758
|
+
function lowContrastOnly(issues) {
|
|
13759
|
+
return issues.filter((i) => i.rule === "low-contrast");
|
|
13760
|
+
}
|
|
13761
|
+
function contrastDelta(candidates, baseline, rule, prefix, details) {
|
|
13762
|
+
const seen = new Set(lowContrastOnly(baseline).map((i) => i.element));
|
|
13763
|
+
return lowContrastOnly(candidates).filter((issue) => {
|
|
13764
|
+
if (seen.has(issue.element)) return false;
|
|
13765
|
+
seen.add(issue.element);
|
|
13766
|
+
return true;
|
|
13767
|
+
}).map((issue) => __spreadProps(__spreadValues(__spreadValues({
|
|
13768
|
+
severity: issue.severity,
|
|
13769
|
+
rule,
|
|
13770
|
+
message: `${prefix}: ${issue.message}`
|
|
13771
|
+
}, issue.element ? { element: issue.element } : {}), issue.loc ? { loc: issue.loc } : {}), {
|
|
13772
|
+
details
|
|
13773
|
+
}));
|
|
13774
|
+
}
|
|
13775
|
+
function checkMobileContrastFromDom($, desktopIssues) {
|
|
13776
|
+
const baseline = desktopIssues != null ? desktopIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
|
|
13777
|
+
const mobile = checkContrast($, computeCascade($, MOBILE_RENDER));
|
|
13778
|
+
return contrastDelta(
|
|
13779
|
+
mobile,
|
|
13780
|
+
baseline,
|
|
13781
|
+
"low-contrast-mobile",
|
|
13782
|
+
"At mobile width",
|
|
13783
|
+
"This colour pairing only appears below the email's breakpoint. Check the contrast of what the media query restyles, not just the desktop palette."
|
|
13784
|
+
);
|
|
13785
|
+
}
|
|
13786
|
+
function checkMobileContrast(html, options) {
|
|
13787
|
+
return fromHtml(html, [], ($) => checkMobileContrastFromDom($), options);
|
|
13788
|
+
}
|
|
13789
|
+
function checkDarkModeContrast(html, lightIssues) {
|
|
13790
|
+
if (!html || !html.trim()) return [];
|
|
13791
|
+
const inverted = [];
|
|
13792
|
+
for (const client of DARK_CONTRAST_CLIENTS) {
|
|
13793
|
+
let darkHtml;
|
|
13794
|
+
try {
|
|
13795
|
+
darkHtml = simulateDarkMode(html, client).html;
|
|
13796
|
+
} catch (e) {
|
|
13797
|
+
continue;
|
|
13798
|
+
}
|
|
13799
|
+
const $dark = cheerio5.load(darkHtml);
|
|
13800
|
+
for (const issue of checkContrast($dark, computeCascade($dark, DESKTOP_RENDER))) {
|
|
13801
|
+
const _a = issue, { loc, locs, locsTruncated } = _a, rest = __objRest(_a, ["loc", "locs", "locsTruncated"]);
|
|
13802
|
+
void loc;
|
|
13803
|
+
void locs;
|
|
13804
|
+
void locsTruncated;
|
|
13805
|
+
inverted.push(rest);
|
|
13806
|
+
}
|
|
13807
|
+
}
|
|
13808
|
+
return contrastDelta(
|
|
13809
|
+
inverted,
|
|
13810
|
+
lightIssues != null ? lightIssues : checkAccessibility(html).issues,
|
|
13811
|
+
"low-contrast-dark",
|
|
13812
|
+
"Dark mode",
|
|
13813
|
+
"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."
|
|
13814
|
+
);
|
|
13815
|
+
}
|
|
13368
13816
|
|
|
13369
13817
|
// src/image-analyzer.ts
|
|
13370
13818
|
var DATA_URI_WARN_BYTES = 100 * 1024;
|
|
@@ -13438,7 +13886,7 @@ function analyzeImagesFromDom($) {
|
|
|
13438
13886
|
issues.push(__spreadValues({
|
|
13439
13887
|
rule: "missing-dimensions",
|
|
13440
13888
|
severity: "warning",
|
|
13441
|
-
message: "Image missing width/height attributes
|
|
13889
|
+
message: "Image missing width/height attributes, causes layout shifts and Outlook rendering issues.",
|
|
13442
13890
|
src: truncateSrc(src)
|
|
13443
13891
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13444
13892
|
}
|
|
@@ -13449,7 +13897,7 @@ function analyzeImagesFromDom($) {
|
|
|
13449
13897
|
issues.push(__spreadValues({
|
|
13450
13898
|
rule: "large-data-uri",
|
|
13451
13899
|
severity: "warning",
|
|
13452
|
-
message: `Data URI is ${kb}KB
|
|
13900
|
+
message: `Data URI is ${kb}KB; consider hosting the image externally to reduce email size.`,
|
|
13453
13901
|
src: truncateSrc(src)
|
|
13454
13902
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13455
13903
|
}
|
|
@@ -13458,7 +13906,7 @@ function analyzeImagesFromDom($) {
|
|
|
13458
13906
|
issues.push(__spreadValues({
|
|
13459
13907
|
rule: "missing-alt",
|
|
13460
13908
|
severity: "warning",
|
|
13461
|
-
message: "Image missing alt attribute
|
|
13909
|
+
message: "Image missing alt attribute, hurts deliverability and accessibility.",
|
|
13462
13910
|
src: truncateSrc(src)
|
|
13463
13911
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13464
13912
|
}
|
|
@@ -13467,7 +13915,7 @@ function analyzeImagesFromDom($) {
|
|
|
13467
13915
|
issues.push(__spreadValues({
|
|
13468
13916
|
rule: "webp-format",
|
|
13469
13917
|
severity: "info",
|
|
13470
|
-
message: "WebP format detected
|
|
13918
|
+
message: "WebP format detected, not supported by all email clients. Consider PNG or JPEG.",
|
|
13471
13919
|
src: truncateSrc(src)
|
|
13472
13920
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13473
13921
|
}
|
|
@@ -13476,7 +13924,7 @@ function analyzeImagesFromDom($) {
|
|
|
13476
13924
|
issues.push(__spreadValues({
|
|
13477
13925
|
rule: "svg-format",
|
|
13478
13926
|
severity: "info",
|
|
13479
|
-
message: "SVG format detected
|
|
13927
|
+
message: "SVG format detected, not supported by most email clients. Use PNG instead.",
|
|
13480
13928
|
src: truncateSrc(src)
|
|
13481
13929
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13482
13930
|
}
|
|
@@ -13485,7 +13933,7 @@ function analyzeImagesFromDom($) {
|
|
|
13485
13933
|
issues.push(__spreadValues({
|
|
13486
13934
|
rule: "missing-display-block",
|
|
13487
13935
|
severity: "info",
|
|
13488
|
-
message: "Image without display:block
|
|
13936
|
+
message: "Image without display:block, may cause unwanted gaps in Outlook.",
|
|
13489
13937
|
src: truncateSrc(src)
|
|
13490
13938
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13491
13939
|
}
|
|
@@ -13504,7 +13952,7 @@ function analyzeImagesFromDom($) {
|
|
|
13504
13952
|
issues.push({
|
|
13505
13953
|
rule: "high-image-count",
|
|
13506
13954
|
severity: "info",
|
|
13507
|
-
message: `Email contains ${nonTrackingImages.length} images
|
|
13955
|
+
message: `Email contains ${nonTrackingImages.length} images; heavy emails may be clipped or load slowly.`
|
|
13508
13956
|
});
|
|
13509
13957
|
}
|
|
13510
13958
|
const trackingPixels = images.filter((i) => i.isTrackingPixel);
|
|
@@ -13520,7 +13968,7 @@ function analyzeImagesFromDom($) {
|
|
|
13520
13968
|
issues.push({
|
|
13521
13969
|
rule: "total-data-uri-size",
|
|
13522
13970
|
severity: "warning",
|
|
13523
|
-
message: `Total data URI size is ${kb}KB
|
|
13971
|
+
message: `Total data URI size is ${kb}KB; consider hosting images externally to reduce email size.`
|
|
13524
13972
|
});
|
|
13525
13973
|
}
|
|
13526
13974
|
return { total: images.length, totalDataUriBytes, issues, images };
|
|
@@ -13561,7 +14009,7 @@ function extractInboxPreviewFromDom($) {
|
|
|
13561
14009
|
issues.push({
|
|
13562
14010
|
rule: "subject-too-long",
|
|
13563
14011
|
severity: "warning",
|
|
13564
|
-
message: `Subject is ${subjectLength} characters
|
|
14012
|
+
message: `Subject is ${subjectLength} characters, and may be truncated in inboxes (recommended: ${MAX_SUBJECT_LENGTH} or fewer).`
|
|
13565
14013
|
});
|
|
13566
14014
|
}
|
|
13567
14015
|
const preheader = extractPreheaderText($);
|
|
@@ -13576,27 +14024,27 @@ function extractInboxPreviewFromDom($) {
|
|
|
13576
14024
|
issues.push({
|
|
13577
14025
|
rule: "preheader-too-short",
|
|
13578
14026
|
severity: "warning",
|
|
13579
|
-
message: `Preheader is only ${preheaderLength} characters
|
|
14027
|
+
message: `Preheader is only ${preheaderLength} characters, so email clients may backfill with body text (recommended: ${MIN_PREHEADER_LENGTH}+).`
|
|
13580
14028
|
});
|
|
13581
14029
|
} else if (preheaderLength > MAX_PREHEADER_LENGTH) {
|
|
13582
14030
|
issues.push({
|
|
13583
14031
|
rule: "preheader-too-long",
|
|
13584
14032
|
severity: "info",
|
|
13585
|
-
message: `Preheader is ${preheaderLength} characters
|
|
14033
|
+
message: `Preheader is ${preheaderLength} characters; most clients show 40\u2013100 characters. Text beyond that is hidden.`
|
|
13586
14034
|
});
|
|
13587
14035
|
}
|
|
13588
14036
|
if (hasZwnjPadding($)) {
|
|
13589
14037
|
issues.push({
|
|
13590
14038
|
rule: "zwnj-padding",
|
|
13591
14039
|
severity: "info",
|
|
13592
|
-
message: "Preheader uses ‌ padding hack
|
|
14040
|
+
message: "Preheader uses ‌ padding hack, works in most clients but may show garbled text in some."
|
|
13593
14041
|
});
|
|
13594
14042
|
}
|
|
13595
14043
|
if (subject && EMOJI_PATTERN.test(subject)) {
|
|
13596
14044
|
issues.push({
|
|
13597
14045
|
rule: "emoji-in-subject",
|
|
13598
14046
|
severity: "info",
|
|
13599
|
-
message: "Subject line contains emoji
|
|
14047
|
+
message: "Subject line contains emoji, renders inconsistently across email clients and may trigger spam filters."
|
|
13600
14048
|
});
|
|
13601
14049
|
}
|
|
13602
14050
|
const truncation = computeTruncation(subject, preheader);
|
|
@@ -13727,14 +14175,14 @@ function checkSizeFromDom(_$, html) {
|
|
|
13727
14175
|
issues.push({
|
|
13728
14176
|
rule: "gmail-clipped",
|
|
13729
14177
|
severity: "error",
|
|
13730
|
-
message: `Email is ${humanSize}
|
|
14178
|
+
message: `Email is ${humanSize}; Gmail will clip it at ~102 KB. Recipients see a "View entire message" link instead of your content.`,
|
|
13731
14179
|
detail: `${htmlBytes} bytes exceeds the ${GMAIL_CLIP_THRESHOLD} byte threshold.`
|
|
13732
14180
|
});
|
|
13733
14181
|
} else if (htmlBytes > GMAIL_CLIP_WARNING_THRESHOLD) {
|
|
13734
14182
|
issues.push({
|
|
13735
14183
|
rule: "gmail-clip-warning",
|
|
13736
14184
|
severity: "warning",
|
|
13737
|
-
message: `Email is ${humanSize}
|
|
14185
|
+
message: `Email is ${humanSize}, approaching Gmail's ~102 KB clip threshold. Consider trimming.`,
|
|
13738
14186
|
detail: `${htmlBytes} bytes is within ${GMAIL_CLIP_THRESHOLD - htmlBytes} bytes of the clip threshold.`
|
|
13739
14187
|
});
|
|
13740
14188
|
}
|
|
@@ -13816,22 +14264,22 @@ function checkTemplateVariablesFromDom($, source) {
|
|
|
13816
14264
|
for (const sel of attrSelectors) {
|
|
13817
14265
|
$(sel).each((_, el) => {
|
|
13818
14266
|
const attrs = ["href", "src", "alt"];
|
|
13819
|
-
for (const
|
|
13820
|
-
const value = $(el).attr(
|
|
14267
|
+
for (const attr2 of attrs) {
|
|
14268
|
+
const value = $(el).attr(attr2);
|
|
13821
14269
|
if (!value) continue;
|
|
13822
14270
|
for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
|
|
13823
14271
|
pattern.lastIndex = 0;
|
|
13824
14272
|
let match;
|
|
13825
14273
|
while ((match = pattern.exec(value)) !== null) {
|
|
13826
14274
|
const variable = match[0];
|
|
13827
|
-
const key = `attr:${
|
|
14275
|
+
const key = `attr:${attr2}:${variable}`;
|
|
13828
14276
|
if (seen.has(key)) continue;
|
|
13829
14277
|
seen.add(key);
|
|
13830
|
-
const loc = locOfAttr(el,
|
|
14278
|
+
const loc = locOfAttr(el, attr2);
|
|
13831
14279
|
issues.push(__spreadValues({
|
|
13832
14280
|
rule: "unresolved-variable",
|
|
13833
14281
|
severity: "error",
|
|
13834
|
-
message: `Unresolved ${label} variable "${variable}" found in ${
|
|
14282
|
+
message: `Unresolved ${label} variable "${variable}" found in ${attr2} attribute.`,
|
|
13835
14283
|
variable,
|
|
13836
14284
|
location: "attribute"
|
|
13837
14285
|
}, loc ? { loc } : {}));
|
|
@@ -13852,13 +14300,13 @@ function checkTemplateVariables(html, options) {
|
|
|
13852
14300
|
}
|
|
13853
14301
|
|
|
13854
14302
|
// src/overflow-checker.ts
|
|
13855
|
-
var
|
|
14303
|
+
var csstree7 = __toESM(require("css-tree"), 1);
|
|
13856
14304
|
function fixedPxWidth($el) {
|
|
13857
14305
|
const style = $el.attr("style") || "";
|
|
13858
14306
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
13859
14307
|
if (styleMatch) return parseInt(styleMatch[1], 10);
|
|
13860
|
-
const
|
|
13861
|
-
if (
|
|
14308
|
+
const attr2 = $el.attr("width");
|
|
14309
|
+
if (attr2 && /^\d+$/.test(attr2.trim())) return parseInt(attr2.trim(), 10);
|
|
13862
14310
|
return null;
|
|
13863
14311
|
}
|
|
13864
14312
|
function isFluid(style) {
|
|
@@ -13874,7 +14322,7 @@ function addWidthIssue(width, label, issues, seen, loc) {
|
|
|
13874
14322
|
const issue = __spreadValues({
|
|
13875
14323
|
rule: "fixed-width-overflow",
|
|
13876
14324
|
severity: "warning",
|
|
13877
|
-
message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame
|
|
14325
|
+
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
14326
|
detail: `Use width:100% with max-width:${EMAIL_MAX_WIDTH}px instead of a fixed width beyond the frame.`
|
|
13879
14327
|
}, loc ? { loc, locs: [loc] } : {});
|
|
13880
14328
|
seen.set(key, issue);
|
|
@@ -13928,11 +14376,11 @@ function checkOverflowFromDom($, source) {
|
|
|
13928
14376
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
13929
14377
|
let ast;
|
|
13930
14378
|
try {
|
|
13931
|
-
ast =
|
|
14379
|
+
ast = csstree7.parse(cssText, { positions: true });
|
|
13932
14380
|
} catch (e) {
|
|
13933
14381
|
return;
|
|
13934
14382
|
}
|
|
13935
|
-
|
|
14383
|
+
csstree7.walk(ast, {
|
|
13936
14384
|
visit: "Rule",
|
|
13937
14385
|
enter(node) {
|
|
13938
14386
|
if (node.type !== "Rule") return;
|
|
@@ -13942,7 +14390,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13942
14390
|
node.block.children.forEach((child) => {
|
|
13943
14391
|
if (child.type !== "Declaration") return;
|
|
13944
14392
|
const prop = child.property.toLowerCase();
|
|
13945
|
-
const val =
|
|
14393
|
+
const val = csstree7.generate(child.value);
|
|
13946
14394
|
if (prop === "width") {
|
|
13947
14395
|
const m = val.match(/^(\d+)px$/);
|
|
13948
14396
|
if (m) {
|
|
@@ -13955,7 +14403,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13955
14403
|
}
|
|
13956
14404
|
});
|
|
13957
14405
|
if (widthPx !== null && widthPx > EMAIL_MAX_WIDTH && !fluid) {
|
|
13958
|
-
const selector =
|
|
14406
|
+
const selector = csstree7.generate(node.prelude).trim().slice(0, 40);
|
|
13959
14407
|
addWidthIssue(widthPx, selector || "rule", issues, seen, widthLoc);
|
|
13960
14408
|
}
|
|
13961
14409
|
}
|
|
@@ -13998,8 +14446,195 @@ function checkOverflow(html, options) {
|
|
|
13998
14446
|
);
|
|
13999
14447
|
}
|
|
14000
14448
|
|
|
14449
|
+
// src/vml-checker.ts
|
|
14450
|
+
var SHAPE_TAGS = /* @__PURE__ */ new Set([
|
|
14451
|
+
"rect",
|
|
14452
|
+
"roundrect",
|
|
14453
|
+
"oval",
|
|
14454
|
+
"line",
|
|
14455
|
+
"polyline",
|
|
14456
|
+
"curve",
|
|
14457
|
+
"arc",
|
|
14458
|
+
"shape",
|
|
14459
|
+
"image",
|
|
14460
|
+
"background"
|
|
14461
|
+
]);
|
|
14462
|
+
var GROUP_TAGS = /* @__PURE__ */ new Set(["group"]);
|
|
14463
|
+
function msoBlocks(html) {
|
|
14464
|
+
const blocks = [];
|
|
14465
|
+
const re = /<!--\[if([^\]]*)\]>([\s\S]*?)<!\[endif\]-->/g;
|
|
14466
|
+
let m;
|
|
14467
|
+
while ((m = re.exec(html)) !== null) {
|
|
14468
|
+
const condition = m[1];
|
|
14469
|
+
if (/!\s*(mso|vml)/i.test(condition)) continue;
|
|
14470
|
+
if (!/mso|vml/i.test(condition)) continue;
|
|
14471
|
+
blocks.push({ inner: m[2], offset: m.index + m[0].length - m[2].length - "<![endif]-->".length });
|
|
14472
|
+
}
|
|
14473
|
+
return blocks;
|
|
14474
|
+
}
|
|
14475
|
+
function vmlTags(blocks) {
|
|
14476
|
+
const tags = [];
|
|
14477
|
+
const re = /<(\/?)v:([a-z]+)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
|
|
14478
|
+
for (const block of blocks) {
|
|
14479
|
+
let m;
|
|
14480
|
+
re.lastIndex = 0;
|
|
14481
|
+
while ((m = re.exec(block.inner)) !== null) {
|
|
14482
|
+
tags.push({
|
|
14483
|
+
name: m[2].toLowerCase(),
|
|
14484
|
+
closing: m[1] === "/",
|
|
14485
|
+
selfClosing: m[4] === "/",
|
|
14486
|
+
attrs: m[3],
|
|
14487
|
+
offset: block.offset + m.index,
|
|
14488
|
+
length: m[0].length
|
|
14489
|
+
});
|
|
14490
|
+
}
|
|
14491
|
+
}
|
|
14492
|
+
return tags;
|
|
14493
|
+
}
|
|
14494
|
+
function locAt(source, offset, length) {
|
|
14495
|
+
if (source === void 0) return void 0;
|
|
14496
|
+
const start = positionOf(source, offset);
|
|
14497
|
+
const end = positionOf(source, offset + length);
|
|
14498
|
+
return {
|
|
14499
|
+
line: start.line,
|
|
14500
|
+
column: start.column,
|
|
14501
|
+
endLine: end.line,
|
|
14502
|
+
endColumn: end.column,
|
|
14503
|
+
offset,
|
|
14504
|
+
length
|
|
14505
|
+
};
|
|
14506
|
+
}
|
|
14507
|
+
function attr(attrs, name) {
|
|
14508
|
+
var _a;
|
|
14509
|
+
const m = attrs.match(
|
|
14510
|
+
new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"|(?:^|\\s)${name}\\s*=\\s*'([^']*)'`, "i")
|
|
14511
|
+
);
|
|
14512
|
+
return m ? (_a = m[1]) != null ? _a : m[2] : void 0;
|
|
14513
|
+
}
|
|
14514
|
+
function add(issues, seen, key, issue) {
|
|
14515
|
+
const existing = seen.get(key);
|
|
14516
|
+
if (existing) {
|
|
14517
|
+
if (!issue.loc || !existing.locs) return;
|
|
14518
|
+
if (existing.locs.some((l) => l.offset === issue.loc.offset)) return;
|
|
14519
|
+
if (existing.locs.length >= MAX_WARNING_LOCATIONS) {
|
|
14520
|
+
existing.locsTruncated = true;
|
|
14521
|
+
return;
|
|
14522
|
+
}
|
|
14523
|
+
existing.locs.push(issue.loc);
|
|
14524
|
+
return;
|
|
14525
|
+
}
|
|
14526
|
+
seen.set(key, issue);
|
|
14527
|
+
issues.push(issue);
|
|
14528
|
+
}
|
|
14529
|
+
function withLoc(issue, loc) {
|
|
14530
|
+
return loc ? __spreadProps(__spreadValues({}, issue), { loc, locs: [loc] }) : issue;
|
|
14531
|
+
}
|
|
14532
|
+
var TEXT_HOSTS = /<\s*(center|v:textbox|div|p|table|h[1-6]|span|font|a)\b/i;
|
|
14533
|
+
function checkLooseText(issues, seen, open, inner, source) {
|
|
14534
|
+
if (TEXT_HOSTS.test(inner)) return;
|
|
14535
|
+
const text = inner.replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]*>/g, "").replace(/ |\s/g, "");
|
|
14536
|
+
if (text === "") return;
|
|
14537
|
+
add(issues, seen, `loosetext:${open.name}`, withLoc({
|
|
14538
|
+
rule: "vml-unrendered-text",
|
|
14539
|
+
severity: "error",
|
|
14540
|
+
message: `<v:${open.name}> has label text with no element around it, which Outlook does not draw.`,
|
|
14541
|
+
detail: `Verified in Outlook Classic: the shape renders its fill and shape correctly and shows no text at all, so the reader gets a blank coloured block where the label should be. Nothing errors and the HTML fallback still reads correctly in every other client, so this ships unnoticed. Wrap the text in <center> (what the bulletproof-button pattern uses) or in <v:textbox>.`
|
|
14542
|
+
}, locAt(source, open.offset, open.length)));
|
|
14543
|
+
}
|
|
14544
|
+
function checkVml(html, options) {
|
|
14545
|
+
if (!html || !html.trim()) return EMPTY_VML;
|
|
14546
|
+
const source = (options == null ? void 0 : options.positions) ? html : void 0;
|
|
14547
|
+
const tags = vmlTags(msoBlocks(html));
|
|
14548
|
+
if (tags.length === 0) return EMPTY_VML;
|
|
14549
|
+
const issues = [];
|
|
14550
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14551
|
+
const openShapes = [];
|
|
14552
|
+
let groupDepth = 0;
|
|
14553
|
+
for (const tag of tags) {
|
|
14554
|
+
const { name } = tag;
|
|
14555
|
+
const isShape = SHAPE_TAGS.has(name);
|
|
14556
|
+
const isGroup = GROUP_TAGS.has(name);
|
|
14557
|
+
const loc = locAt(source, tag.offset, tag.length);
|
|
14558
|
+
if (tag.closing) {
|
|
14559
|
+
if (isGroup) groupDepth = Math.max(0, groupDepth - 1);
|
|
14560
|
+
if (isShape) {
|
|
14561
|
+
const idx = openShapes.map((t) => t.name).lastIndexOf(name);
|
|
14562
|
+
if (idx !== -1) {
|
|
14563
|
+
const open = openShapes[idx];
|
|
14564
|
+
const inner = html.slice(open.offset + open.length, tag.offset);
|
|
14565
|
+
checkLooseText(issues, seen, open, inner, source);
|
|
14566
|
+
}
|
|
14567
|
+
if (idx === -1) {
|
|
14568
|
+
add(issues, seen, `stray:${name}`, withLoc({
|
|
14569
|
+
rule: "vml-unbalanced-tag",
|
|
14570
|
+
severity: "error",
|
|
14571
|
+
message: `Closing </v:${name}> has no matching opening tag.`,
|
|
14572
|
+
detail: `Outlook stops rendering the shape when VML tags do not balance. Check that every conditional-comment block opens and closes the tags it is responsible for.`
|
|
14573
|
+
}, loc));
|
|
14574
|
+
} else {
|
|
14575
|
+
openShapes.splice(idx, 1);
|
|
14576
|
+
}
|
|
14577
|
+
}
|
|
14578
|
+
continue;
|
|
14579
|
+
}
|
|
14580
|
+
if (isGroup && !tag.selfClosing) groupDepth++;
|
|
14581
|
+
if (isShape) {
|
|
14582
|
+
const parent = openShapes[openShapes.length - 1];
|
|
14583
|
+
if (parent && groupDepth === 0) {
|
|
14584
|
+
add(issues, seen, `nested:${parent.name}>${name}`, withLoc({
|
|
14585
|
+
rule: "vml-nested-shape",
|
|
14586
|
+
severity: "error",
|
|
14587
|
+
message: `<v:${name}> is nested inside <v:${parent.name}>. Outlook does not support nesting one VML shape inside another.`,
|
|
14588
|
+
detail: `Verified in Outlook Classic, and the damage is not local. Three things happen: the containing <v:${parent.name}> does not render at all (its fill and everything inside it disappear, leaving the inner shape stranded); the table structure around it terminates early, so content *after* the shape falls out of the layout and renders outside the email frame; and every VML shape further down the email stops drawing its text, so buttons and headings after this point ship as blank coloured blocks. Confirmed with byte-identical probes either side of one nested shape: the one before it renders its labels, the one after it does not. Every other client renders the HTML fallback correctly, which is why none of this is visible outside the Word engine. Fix: lift the inner shape out of <v:${parent.name}>, or drop the container shape and keep the inner one (a framed background can degrade to a solid fill colour on the <td> instead).`
|
|
14589
|
+
}, loc));
|
|
14590
|
+
}
|
|
14591
|
+
if (!tag.selfClosing) openShapes.push(tag);
|
|
14592
|
+
}
|
|
14593
|
+
const style = attr(tag.attrs, "style");
|
|
14594
|
+
if (style && (isShape || isGroup)) {
|
|
14595
|
+
for (const prop of ["width", "height"]) {
|
|
14596
|
+
const m = style.match(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]*)`, "i"));
|
|
14597
|
+
if (!m) continue;
|
|
14598
|
+
const value = m[1].trim();
|
|
14599
|
+
if (value === "" || /^[a-z%]+$/i.test(value)) {
|
|
14600
|
+
add(issues, seen, `dim:${name}:${prop}:${value}`, withLoc({
|
|
14601
|
+
rule: "vml-invalid-dimension",
|
|
14602
|
+
severity: "error",
|
|
14603
|
+
message: `<v:${name}> has an invalid ${prop}: "${prop}:${value}" is missing its number.`,
|
|
14604
|
+
detail: `Verified in Outlook Classic: the shape still draws, but at a size Outlook picks rather than the one you meant, and the content inside is clipped to it \u2014 roughly half the intended height in the case measured, with the text inside cut off. It fails quietly: there is no gap or broken image to notice, just content that is silently missing. Usually a template variable that resolved to an empty string. Set an explicit value in pixels, e.g. ${prop}:400px.`
|
|
14605
|
+
}, loc));
|
|
14606
|
+
}
|
|
14607
|
+
}
|
|
14608
|
+
}
|
|
14609
|
+
if (name === "roundrect") {
|
|
14610
|
+
const arcsize = attr(tag.attrs, "arcsize");
|
|
14611
|
+
if (arcsize !== void 0) {
|
|
14612
|
+
const raw = arcsize.trim();
|
|
14613
|
+
const pct = raw.endsWith("%") ? parseFloat(raw) : parseFloat(raw) * 100;
|
|
14614
|
+
if (Number.isFinite(pct) && (pct < 0 || pct > 100)) {
|
|
14615
|
+
add(issues, seen, `arcsize:${raw}`, withLoc({
|
|
14616
|
+
rule: "vml-arcsize-range",
|
|
14617
|
+
severity: "warning",
|
|
14618
|
+
message: `<v:roundrect> has arcsize="${raw}", outside the valid 0%\u2013100% range.`,
|
|
14619
|
+
detail: `arcsize is a fraction of half the shape's smaller side: 0% is square, 100% is fully circular. Verified in Outlook Classic: out-of-range values are clamped, so 120% draws exactly the same corner as 100%. Nothing visibly breaks today, which is why this is a warning and not an error \u2014 but the radius you get is the renderer's clamp rather than a value you chose, and it is not guaranteed across clients. For a fully rounded button, say arcsize="100%".`
|
|
14620
|
+
}, loc));
|
|
14621
|
+
}
|
|
14622
|
+
}
|
|
14623
|
+
}
|
|
14624
|
+
}
|
|
14625
|
+
for (const tag of openShapes) {
|
|
14626
|
+
add(issues, seen, `unclosed:${tag.name}`, withLoc({
|
|
14627
|
+
rule: "vml-unbalanced-tag",
|
|
14628
|
+
severity: "error",
|
|
14629
|
+
message: `<v:${tag.name}> is never closed.`,
|
|
14630
|
+
detail: `Outlook needs the matching </v:${tag.name}>, usually in a later conditional-comment block. Without it the shape swallows the rest of the email.`
|
|
14631
|
+
}, locAt(source, tag.offset, tag.length)));
|
|
14632
|
+
}
|
|
14633
|
+
return { hasVml: true, issues };
|
|
14634
|
+
}
|
|
14635
|
+
|
|
14001
14636
|
// src/visual-checker.ts
|
|
14002
|
-
var
|
|
14637
|
+
var csstree8 = __toESM(require("css-tree"), 1);
|
|
14003
14638
|
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
14004
14639
|
var GRADIENT_RE = /(?:linear|radial|conic)-gradient\(/i;
|
|
14005
14640
|
function isSolidColor(value) {
|
|
@@ -14072,7 +14707,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
14072
14707
|
const issue = __spreadValues({
|
|
14073
14708
|
rule: "missing-background-fallback",
|
|
14074
14709
|
severity: "warning",
|
|
14075
|
-
message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback
|
|
14710
|
+
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
14711
|
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
14712
|
fix
|
|
14078
14713
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -14092,7 +14727,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
14092
14727
|
const issue = __spreadValues({
|
|
14093
14728
|
rule: "missing-font-fallback",
|
|
14094
14729
|
severity: "warning",
|
|
14095
|
-
message: `font-family "${font.trim()}" has no web-safe fallback
|
|
14730
|
+
message: `font-family "${font.trim()}" has no web-safe fallback; clients that strip web fonts (Gmail, Outlook) fall back to Times New Roman.`,
|
|
14096
14731
|
detail: `End the stack with a web-safe font and a generic family.`,
|
|
14097
14732
|
fix
|
|
14098
14733
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -14105,7 +14740,7 @@ function ruleToMap(node) {
|
|
|
14105
14740
|
const map = /* @__PURE__ */ new Map();
|
|
14106
14741
|
node.block.children.forEach((child) => {
|
|
14107
14742
|
if (child.type === "Declaration") {
|
|
14108
|
-
map.set(child.property.toLowerCase(),
|
|
14743
|
+
map.set(child.property.toLowerCase(), csstree8.generate(child.value));
|
|
14109
14744
|
}
|
|
14110
14745
|
});
|
|
14111
14746
|
return map;
|
|
@@ -14124,11 +14759,11 @@ function checkVisualFromDom($, source) {
|
|
|
14124
14759
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
14125
14760
|
let ast;
|
|
14126
14761
|
try {
|
|
14127
|
-
ast =
|
|
14762
|
+
ast = csstree8.parse(cssText, { positions: true });
|
|
14128
14763
|
} catch (e) {
|
|
14129
14764
|
return;
|
|
14130
14765
|
}
|
|
14131
|
-
|
|
14766
|
+
csstree8.walk(ast, {
|
|
14132
14767
|
visit: "Rule",
|
|
14133
14768
|
enter(node) {
|
|
14134
14769
|
if (node.type !== "Rule") return;
|
|
@@ -14153,6 +14788,169 @@ function checkVisual(html, options) {
|
|
|
14153
14788
|
);
|
|
14154
14789
|
}
|
|
14155
14790
|
|
|
14791
|
+
// src/design-consistency.ts
|
|
14792
|
+
var csstree9 = __toESM(require("css-tree"), 1);
|
|
14793
|
+
var SAME_COLOUR_DISTANCE = 0.02;
|
|
14794
|
+
var LIMITS = {
|
|
14795
|
+
fontSize: 8,
|
|
14796
|
+
fontFamily: 2,
|
|
14797
|
+
radius: 3
|
|
14798
|
+
};
|
|
14799
|
+
var NON_COLOURS = /* @__PURE__ */ new Set(["transparent", "inherit", "initial", "unset", "currentcolor", "none"]);
|
|
14800
|
+
var TRACKED = {
|
|
14801
|
+
"color": "colors",
|
|
14802
|
+
"background-color": "backgrounds",
|
|
14803
|
+
"background": "backgrounds",
|
|
14804
|
+
"font-size": "fontSizes",
|
|
14805
|
+
"font-family": "fontFamilies",
|
|
14806
|
+
"border-radius": "radii"
|
|
14807
|
+
};
|
|
14808
|
+
function primaryTypeface(value) {
|
|
14809
|
+
var _a;
|
|
14810
|
+
return ((_a = value.split(",")[0]) != null ? _a : "").replace(/['"]/g, "").trim().toLowerCase();
|
|
14811
|
+
}
|
|
14812
|
+
function radiusLengths(value) {
|
|
14813
|
+
const lengths = value.trim().toLowerCase().split(/[\s/]+/).filter(Boolean);
|
|
14814
|
+
return lengths.filter((v) => {
|
|
14815
|
+
if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
|
|
14816
|
+
if (v === "50%") return false;
|
|
14817
|
+
const px = parseFloat(v);
|
|
14818
|
+
return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
|
|
14819
|
+
});
|
|
14820
|
+
}
|
|
14821
|
+
function collect($) {
|
|
14822
|
+
const found = {
|
|
14823
|
+
colors: [],
|
|
14824
|
+
backgrounds: [],
|
|
14825
|
+
fontSizes: [],
|
|
14826
|
+
fontFamilies: [],
|
|
14827
|
+
radii: []
|
|
14828
|
+
};
|
|
14829
|
+
const record = (prop, rawValue) => {
|
|
14830
|
+
const bucket = TRACKED[prop.toLowerCase()];
|
|
14831
|
+
if (!bucket) return;
|
|
14832
|
+
const value = rawValue.trim();
|
|
14833
|
+
if (!value) return;
|
|
14834
|
+
found[bucket].push(value);
|
|
14835
|
+
};
|
|
14836
|
+
$("[style]").each((_, el) => {
|
|
14837
|
+
for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
|
|
14838
|
+
const colon = part.indexOf(":");
|
|
14839
|
+
if (colon === -1) continue;
|
|
14840
|
+
record(part.slice(0, colon), part.slice(colon + 1));
|
|
14841
|
+
}
|
|
14842
|
+
});
|
|
14843
|
+
$("style").each((_, el) => {
|
|
14844
|
+
let ast;
|
|
14845
|
+
try {
|
|
14846
|
+
ast = csstree9.parse($(el).text());
|
|
14847
|
+
} catch (e) {
|
|
14848
|
+
return;
|
|
14849
|
+
}
|
|
14850
|
+
csstree9.walk(ast, {
|
|
14851
|
+
visit: "Declaration",
|
|
14852
|
+
enter(node) {
|
|
14853
|
+
if (node.type !== "Declaration") return;
|
|
14854
|
+
record(node.property, csstree9.generate(node.value));
|
|
14855
|
+
}
|
|
14856
|
+
});
|
|
14857
|
+
});
|
|
14858
|
+
return found;
|
|
14859
|
+
}
|
|
14860
|
+
function driftClusters(values) {
|
|
14861
|
+
const byNormalised = /* @__PURE__ */ new Map();
|
|
14862
|
+
for (const raw of values) {
|
|
14863
|
+
const value = raw.trim();
|
|
14864
|
+
if (NON_COLOURS.has(value.toLowerCase())) continue;
|
|
14865
|
+
const rgb = parseColor(value);
|
|
14866
|
+
if (!rgb || rgb.a === 0) continue;
|
|
14867
|
+
const key = formatRgb(__spreadProps(__spreadValues({}, rgb), { a: 1 }));
|
|
14868
|
+
const entry = byNormalised.get(key);
|
|
14869
|
+
if (entry) entry.spellings.add(value);
|
|
14870
|
+
else byNormalised.set(key, { spellings: /* @__PURE__ */ new Set([value]), rgb });
|
|
14871
|
+
}
|
|
14872
|
+
const distinct = [...byNormalised.entries()];
|
|
14873
|
+
const clusters = [];
|
|
14874
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
14875
|
+
for (let i = 0; i < distinct.length; i++) {
|
|
14876
|
+
const [keyA, a] = distinct[i];
|
|
14877
|
+
if (claimed.has(keyA)) continue;
|
|
14878
|
+
const cluster = [keyA];
|
|
14879
|
+
for (let j = i + 1; j < distinct.length; j++) {
|
|
14880
|
+
const [keyB, b] = distinct[j];
|
|
14881
|
+
if (claimed.has(keyB)) continue;
|
|
14882
|
+
if (!a.rgb || !b.rgb) continue;
|
|
14883
|
+
if (colorDistance(a.rgb, b.rgb) < SAME_COLOUR_DISTANCE) {
|
|
14884
|
+
cluster.push(keyB);
|
|
14885
|
+
claimed.add(keyB);
|
|
14886
|
+
}
|
|
14887
|
+
}
|
|
14888
|
+
claimed.add(keyA);
|
|
14889
|
+
if (cluster.length > 1) clusters.push(cluster);
|
|
14890
|
+
}
|
|
14891
|
+
return clusters;
|
|
14892
|
+
}
|
|
14893
|
+
function distinctValues(values, normalise) {
|
|
14894
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14895
|
+
for (const raw of values) {
|
|
14896
|
+
const key = normalise(raw);
|
|
14897
|
+
if (!key) continue;
|
|
14898
|
+
if (!seen.has(key)) seen.set(key, raw.trim());
|
|
14899
|
+
}
|
|
14900
|
+
return [...seen.values()];
|
|
14901
|
+
}
|
|
14902
|
+
function checkDesignConsistencyFromDom($) {
|
|
14903
|
+
const found = collect($);
|
|
14904
|
+
const issues = [];
|
|
14905
|
+
const colors = distinctValues(found.colors, (v) => {
|
|
14906
|
+
const c = parseColor(v.trim());
|
|
14907
|
+
return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
|
|
14908
|
+
});
|
|
14909
|
+
const backgrounds = distinctValues(found.backgrounds, (v) => {
|
|
14910
|
+
const c = parseColor(v.trim());
|
|
14911
|
+
return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
|
|
14912
|
+
});
|
|
14913
|
+
const fontSizes = distinctValues(found.fontSizes, (v) => v.trim().toLowerCase());
|
|
14914
|
+
const fontFamilies = distinctValues(found.fontFamilies, primaryTypeface);
|
|
14915
|
+
const radii = distinctValues(found.radii.flatMap(radiusLengths), (v) => v);
|
|
14916
|
+
for (const cluster of driftClusters([...found.colors, ...found.backgrounds])) {
|
|
14917
|
+
issues.push({
|
|
14918
|
+
rule: "colour-drift",
|
|
14919
|
+
severity: "info",
|
|
14920
|
+
message: `${cluster.length} near-identical colours are used where one was probably meant: ${cluster.join(", ")}.`,
|
|
14921
|
+
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.",
|
|
14922
|
+
values: cluster
|
|
14923
|
+
});
|
|
14924
|
+
}
|
|
14925
|
+
const overflowing = [
|
|
14926
|
+
["font sizes", fontSizes, LIMITS.fontSize, "Settle on a type scale and reuse it; every extra size is one more thing to keep in step."],
|
|
14927
|
+
["typefaces", fontFamilies, LIMITS.fontFamily, "Email clients strip most web fonts anyway, so each extra typeface mostly adds fallback risk."],
|
|
14928
|
+
["corner radii", radii, LIMITS.radius, "Corner rounding reads as a single decision; several close values look like an accident."]
|
|
14929
|
+
];
|
|
14930
|
+
for (const [label, values, limit, detail] of overflowing) {
|
|
14931
|
+
if (values.length <= limit) continue;
|
|
14932
|
+
issues.push({
|
|
14933
|
+
rule: "too-many-values",
|
|
14934
|
+
severity: "info",
|
|
14935
|
+
message: `${values.length} distinct ${label} in one email: ${values.slice(0, 10).join(", ")}${values.length > 10 ? ", \u2026" : ""}.`,
|
|
14936
|
+
detail,
|
|
14937
|
+
values
|
|
14938
|
+
});
|
|
14939
|
+
}
|
|
14940
|
+
return {
|
|
14941
|
+
issues,
|
|
14942
|
+
palette: { colors, backgrounds, fontSizes, fontFamilies, radii }
|
|
14943
|
+
};
|
|
14944
|
+
}
|
|
14945
|
+
function checkDesignConsistency(html, options) {
|
|
14946
|
+
return fromHtml(
|
|
14947
|
+
html,
|
|
14948
|
+
{ issues: [], palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] } },
|
|
14949
|
+
checkDesignConsistencyFromDom,
|
|
14950
|
+
options
|
|
14951
|
+
);
|
|
14952
|
+
}
|
|
14953
|
+
|
|
14156
14954
|
// src/audit.ts
|
|
14157
14955
|
var EMPTY_AUDIT = {
|
|
14158
14956
|
compatibility: { warnings: [], scores: {} },
|
|
@@ -14164,8 +14962,20 @@ var EMPTY_AUDIT = {
|
|
|
14164
14962
|
size: EMPTY_SIZE,
|
|
14165
14963
|
templateVariables: EMPTY_TEMPLATE,
|
|
14166
14964
|
overflow: EMPTY_OVERFLOW,
|
|
14167
|
-
visual: EMPTY_VISUAL
|
|
14965
|
+
visual: EMPTY_VISUAL,
|
|
14966
|
+
vml: EMPTY_VML,
|
|
14967
|
+
darkContrast: [],
|
|
14968
|
+
mobileContrast: [],
|
|
14969
|
+
design: EMPTY_DESIGN
|
|
14168
14970
|
};
|
|
14971
|
+
function dedupeByElement(issues) {
|
|
14972
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14973
|
+
return issues.filter((issue) => {
|
|
14974
|
+
if (seen.has(issue.element)) return false;
|
|
14975
|
+
seen.add(issue.element);
|
|
14976
|
+
return true;
|
|
14977
|
+
});
|
|
14978
|
+
}
|
|
14169
14979
|
function runAudit($, html, framework, options) {
|
|
14170
14980
|
var _a;
|
|
14171
14981
|
const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
|
|
@@ -14181,16 +14991,23 @@ function runAudit($, html, framework, options) {
|
|
|
14181
14991
|
const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
|
|
14182
14992
|
const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
|
|
14183
14993
|
const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
|
|
14184
|
-
|
|
14994
|
+
const vml = skip.has("vml") ? EMPTY_VML : checkVml(html, { positions: !!source });
|
|
14995
|
+
const darkContrast = skip.has("darkContrast") || skip.has("accessibility") ? [] : dedupeByElement([
|
|
14996
|
+
...checkDarkModeContrast(html, accessibility.issues),
|
|
14997
|
+
...checkDarkStylesContrastFromDom($, accessibility.issues)
|
|
14998
|
+
]);
|
|
14999
|
+
const design = skip.has("design") ? EMPTY_DESIGN : checkDesignConsistencyFromDom($);
|
|
15000
|
+
const mobileContrast = skip.has("mobileContrast") || skip.has("accessibility") ? [] : checkMobileContrastFromDom($, accessibility.issues);
|
|
15001
|
+
return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual, vml, darkContrast, mobileContrast, design };
|
|
14185
15002
|
}
|
|
14186
15003
|
function auditEmail(html, options) {
|
|
14187
15004
|
return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
|
|
14188
15005
|
}
|
|
14189
15006
|
|
|
14190
15007
|
// src/plain-text.ts
|
|
14191
|
-
var
|
|
15008
|
+
var cheerio6 = __toESM(require("cheerio"), 1);
|
|
14192
15009
|
function toPlainText(html) {
|
|
14193
|
-
const $ =
|
|
15010
|
+
const $ = cheerio6.load(html);
|
|
14194
15011
|
$("style, script, head").remove();
|
|
14195
15012
|
$("[data-skip-in-text='true']").remove();
|
|
14196
15013
|
const lines = [];
|
|
@@ -14225,7 +15042,7 @@ function toPlainText(html) {
|
|
|
14225
15042
|
if (trimmed) lines.push(trimmed);
|
|
14226
15043
|
currentLine = "";
|
|
14227
15044
|
}
|
|
14228
|
-
function
|
|
15045
|
+
function walk9(node) {
|
|
14229
15046
|
var _a;
|
|
14230
15047
|
if (node.type === "text") {
|
|
14231
15048
|
const text = node.data.replace(/\s+/g, " ");
|
|
@@ -14274,7 +15091,7 @@ function toPlainText(html) {
|
|
|
14274
15091
|
currentLine = "- ";
|
|
14275
15092
|
}
|
|
14276
15093
|
for (const child of el.children) {
|
|
14277
|
-
|
|
15094
|
+
walk9(child);
|
|
14278
15095
|
}
|
|
14279
15096
|
if (isBlock) flushLine();
|
|
14280
15097
|
}
|
|
@@ -14282,7 +15099,7 @@ function toPlainText(html) {
|
|
|
14282
15099
|
const root = body.length ? body[0] : $.root()[0];
|
|
14283
15100
|
if (root && "children" in root) {
|
|
14284
15101
|
for (const child of root.children) {
|
|
14285
|
-
|
|
15102
|
+
walk9(child);
|
|
14286
15103
|
}
|
|
14287
15104
|
}
|
|
14288
15105
|
flushLine();
|
|
@@ -14407,10 +15224,16 @@ var CompileError = class extends Error {
|
|
|
14407
15224
|
auditEmail,
|
|
14408
15225
|
caveatApplies,
|
|
14409
15226
|
checkAccessibility,
|
|
15227
|
+
checkDarkModeContrast,
|
|
15228
|
+
checkDarkStylesContrastFromDom,
|
|
15229
|
+
checkDesignConsistency,
|
|
15230
|
+
checkMobileContrast,
|
|
14410
15231
|
checkOverflow,
|
|
14411
15232
|
checkSize,
|
|
14412
15233
|
checkTemplateVariables,
|
|
14413
15234
|
checkVisual,
|
|
15235
|
+
checkVml,
|
|
15236
|
+
colorDistance,
|
|
14414
15237
|
contrastRatio,
|
|
14415
15238
|
createSession,
|
|
14416
15239
|
diffResults,
|
|
@@ -14429,6 +15252,7 @@ var CompileError = class extends Error {
|
|
|
14429
15252
|
heuristicTokenCount,
|
|
14430
15253
|
parseColor,
|
|
14431
15254
|
relativeLuminance,
|
|
15255
|
+
rgbToOklab,
|
|
14432
15256
|
simulateDarkMode,
|
|
14433
15257
|
structuralWarnings,
|
|
14434
15258
|
toPlainText,
|