@emailens/engine 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -27
- package/dist/{chunk-HBKZMR7V.js → chunk-UFSYNSDP.js} +1 -1
- package/dist/chunk-UFSYNSDP.js.map +1 -0
- package/dist/compile/index.cjs.map +1 -1
- package/dist/compile/index.d.cts +2 -2
- package/dist/compile/index.d.ts +2 -2
- package/dist/compile/index.js +2 -2
- package/dist/index.cjs +1063 -170
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +107 -15
- package/dist/index.d.ts +107 -15
- package/dist/index.js +1055 -170
- package/dist/index.js.map +1 -1
- package/dist/{react-email-DwBWB2kr.d.ts → react-email-BWIfwG7U.d.cts} +5 -5
- package/dist/{react-email-QKHRTEJW.js → react-email-CVZZNVZ2.js} +2 -2
- package/dist/{react-email-DTzVpGgB.d.cts → react-email-D24HkW2Z.d.ts} +5 -5
- package/dist/server.cjs +10 -10
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +4 -4
- package/dist/server.d.ts +4 -4
- package/dist/server.js +10 -10
- package/dist/server.js.map +1 -1
- package/dist/{types-BLR3-Fzo.d.cts → types-BFhPXh3c.d.cts} +25 -9
- package/dist/{types-BLR3-Fzo.d.ts → types-BFhPXh3c.d.ts} +25 -9
- package/package.json +7 -3
- package/dist/chunk-HBKZMR7V.js.map +0 -1
- /package/dist/{react-email-QKHRTEJW.js.map → react-email-CVZZNVZ2.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -7495,12 +7495,12 @@ h1 {
|
|
|
7495
7495
|
description: "Account for padding in width manually (no box-sizing)",
|
|
7496
7496
|
before: `<div style="width: 300px; padding: 20px;
|
|
7497
7497
|
box-sizing: border-box;">
|
|
7498
|
-
Content
|
|
7498
|
+
Content; total width stays 300px
|
|
7499
7499
|
</div>`,
|
|
7500
7500
|
after: `<!-- Set width to content-width (300 - 40 = 260px) -->
|
|
7501
7501
|
<div style="width: 300px;">
|
|
7502
7502
|
<div style="padding: 20px;">
|
|
7503
|
-
Content
|
|
7503
|
+
Content; padding on inner element
|
|
7504
7504
|
</div>
|
|
7505
7505
|
</div>`
|
|
7506
7506
|
},
|
|
@@ -7581,7 +7581,7 @@ h1 {
|
|
|
7581
7581
|
// ── transition (not supported) ────────────────────────────────────────
|
|
7582
7582
|
"transition": {
|
|
7583
7583
|
language: "css",
|
|
7584
|
-
description: "Transitions don't work in email
|
|
7584
|
+
description: "Transitions don't work in email; style the default state well",
|
|
7585
7585
|
before: `.button {
|
|
7586
7586
|
background-color: #6d28d9;
|
|
7587
7587
|
transition: background-color 0.2s;
|
|
@@ -7601,7 +7601,7 @@ h1 {
|
|
|
7601
7601
|
// ── background-size (Outlook) → VML ───────────────────────────────────
|
|
7602
7602
|
"background-size": {
|
|
7603
7603
|
language: "html",
|
|
7604
|
-
description: "Outlook ignores background-size
|
|
7604
|
+
description: "Outlook ignores background-size; use VML or sized images",
|
|
7605
7605
|
before: `<td style="background: url('bg.jpg') center/cover no-repeat;">
|
|
7606
7606
|
Content
|
|
7607
7607
|
</td>`,
|
|
@@ -7623,7 +7623,7 @@ h1 {
|
|
|
7623
7623
|
// ── overflow (Gmail strips it) ────────────────────────────────────────
|
|
7624
7624
|
"overflow": {
|
|
7625
7625
|
language: "html",
|
|
7626
|
-
description: "Content will always be visible
|
|
7626
|
+
description: "Content will always be visible; design for full content display",
|
|
7627
7627
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7628
7628
|
Long content that gets clipped...
|
|
7629
7629
|
</div>`,
|
|
@@ -7725,7 +7725,7 @@ h1 {
|
|
|
7725
7725
|
// ── max-height → fixed height ────────────────────────────────────────
|
|
7726
7726
|
"max-height": {
|
|
7727
7727
|
language: "html",
|
|
7728
|
-
description: "Outlook ignores max-height
|
|
7728
|
+
description: "Outlook ignores max-height; truncate content server-side",
|
|
7729
7729
|
before: `<div style="max-height: 200px; overflow: hidden;">
|
|
7730
7730
|
Long content...
|
|
7731
7731
|
</div>`,
|
|
@@ -7956,7 +7956,7 @@ var JSX_FIX_DATABASE = {
|
|
|
7956
7956
|
</Row>`,
|
|
7957
7957
|
after: `import { Row, Column } from "@react-email/components";
|
|
7958
7958
|
|
|
7959
|
-
{/* Use padding on Column
|
|
7959
|
+
{/* Use padding on Column; gap is not supported in email clients */}
|
|
7960
7960
|
<Row>
|
|
7961
7961
|
<Column style={{ paddingRight: "16px" }}>Item 1</Column>
|
|
7962
7962
|
<Column style={{ paddingRight: "16px" }}>Item 2</Column>
|
|
@@ -7966,7 +7966,7 @@ var JSX_FIX_DATABASE = {
|
|
|
7966
7966
|
// ── <style> (Gmail JSX) ─────────────────────────────────────────────
|
|
7967
7967
|
"<style>::gmail::jsx": {
|
|
7968
7968
|
language: "jsx",
|
|
7969
|
-
description: "React Email inlines styles via style props
|
|
7969
|
+
description: "React Email inlines styles via style props; manual <style> blocks won't survive Gmail",
|
|
7970
7970
|
before: `import { Head } from "@react-email/components";
|
|
7971
7971
|
|
|
7972
7972
|
<Head>
|
|
@@ -8027,7 +8027,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8027
8027
|
// ── @media (JSX) ────────────────────────────────────────────────────
|
|
8028
8028
|
"@media::jsx": {
|
|
8029
8029
|
language: "jsx",
|
|
8030
|
-
description: "Design mobile-first
|
|
8030
|
+
description: "Design mobile-first; @media queries are stripped by many clients",
|
|
8031
8031
|
before: `import { Head } from "@react-email/components";
|
|
8032
8032
|
|
|
8033
8033
|
<Head>
|
|
@@ -8169,7 +8169,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8169
8169
|
</div>`,
|
|
8170
8170
|
after: `import { Img } from "@react-email/components";
|
|
8171
8171
|
|
|
8172
|
-
{/* CSS transforms are not supported in email
|
|
8172
|
+
{/* CSS transforms are not supported in email: pre-render as an image */}
|
|
8173
8173
|
<Img
|
|
8174
8174
|
src="https://example.com/rotated-content.png"
|
|
8175
8175
|
width={200}
|
|
@@ -8185,7 +8185,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8185
8185
|
before: `<span style={{ animation: "pulse 2s infinite" }}>New!</span>`,
|
|
8186
8186
|
after: `import { Img } from "@react-email/components";
|
|
8187
8187
|
|
|
8188
|
-
{/* CSS animations are not supported
|
|
8188
|
+
{/* CSS animations are not supported: use an animated GIF */}
|
|
8189
8189
|
<Img
|
|
8190
8190
|
src="https://example.com/badge-animated.gif"
|
|
8191
8191
|
width={60}
|
|
@@ -8197,7 +8197,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8197
8197
|
// ── transition (JSX) ─────────────────────────────────────────────
|
|
8198
8198
|
"transition::jsx": {
|
|
8199
8199
|
language: "jsx",
|
|
8200
|
-
description: "Transitions don't work in email
|
|
8200
|
+
description: "Transitions don't work in email; style the default state well",
|
|
8201
8201
|
before: `<a
|
|
8202
8202
|
href="#"
|
|
8203
8203
|
style={{
|
|
@@ -8210,7 +8210,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8210
8210
|
</a>`,
|
|
8211
8211
|
after: `import { Button } from "@react-email/components";
|
|
8212
8212
|
|
|
8213
|
-
{/* Transitions are not supported
|
|
8213
|
+
{/* Transitions are not supported: style the default state: */}
|
|
8214
8214
|
<Button
|
|
8215
8215
|
href="https://example.com"
|
|
8216
8216
|
style={{
|
|
@@ -8227,13 +8227,13 @@ var JSX_FIX_DATABASE = {
|
|
|
8227
8227
|
// ── overflow (JSX) ───────────────────────────────────────────────
|
|
8228
8228
|
"overflow::jsx": {
|
|
8229
8229
|
language: "jsx",
|
|
8230
|
-
description: "Content will always be visible
|
|
8230
|
+
description: "Content will always be visible; design for full content display",
|
|
8231
8231
|
before: `<div style={{ maxHeight: "200px", overflow: "hidden" }}>
|
|
8232
8232
|
Long content that gets clipped...
|
|
8233
8233
|
</div>`,
|
|
8234
8234
|
after: `import { Link, Text } from "@react-email/components";
|
|
8235
8235
|
|
|
8236
|
-
{/* overflow:hidden is stripped
|
|
8236
|
+
{/* overflow:hidden is stripped; show full content or truncate server-side */}
|
|
8237
8237
|
<div>
|
|
8238
8238
|
<Text>Shortened content that fits...</Text>
|
|
8239
8239
|
<Link href="https://example.com/full">Read more</Link>
|
|
@@ -8246,7 +8246,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8246
8246
|
before: `<div style={{ visibility: "hidden" }}>
|
|
8247
8247
|
Hidden preheader text
|
|
8248
8248
|
</div>`,
|
|
8249
|
-
after: `{/* visibility:hidden is stripped by most clients
|
|
8249
|
+
after: `{/* visibility:hidden is stripped by most clients; use the preheader trick.
|
|
8250
8250
|
msoHide is non-standard but needed to hide content in Outlook. */}
|
|
8251
8251
|
<div
|
|
8252
8252
|
style={{
|
|
@@ -8284,7 +8284,7 @@ var JSX_FIX_DATABASE = {
|
|
|
8284
8284
|
// ── background-size (JSX) ────────────────────────────────────────
|
|
8285
8285
|
"background-size::jsx": {
|
|
8286
8286
|
language: "jsx",
|
|
8287
|
-
description: "Outlook ignores background-size
|
|
8287
|
+
description: "Outlook ignores background-size; use sized images instead",
|
|
8288
8288
|
before: `<div style={{
|
|
8289
8289
|
background: "url('bg.jpg') center/cover no-repeat",
|
|
8290
8290
|
}}>
|
|
@@ -8310,12 +8310,12 @@ var JSX_FIX_DATABASE = {
|
|
|
8310
8310
|
padding: "20px",
|
|
8311
8311
|
boxSizing: "border-box",
|
|
8312
8312
|
}}>
|
|
8313
|
-
Content
|
|
8313
|
+
Content (total width stays 300px)
|
|
8314
8314
|
</div>`,
|
|
8315
8315
|
after: `{/* Set outer width, use inner element for padding */}
|
|
8316
8316
|
<div style={{ width: "300px" }}>
|
|
8317
8317
|
<div style={{ padding: "20px" }}>
|
|
8318
|
-
Content
|
|
8318
|
+
Content; padding on inner element
|
|
8319
8319
|
</div>
|
|
8320
8320
|
</div>`
|
|
8321
8321
|
},
|
|
@@ -8355,7 +8355,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8355
8355
|
// ── word-break (MJML) ────────────────────────────────────────────────
|
|
8356
8356
|
"word-break::mjml": {
|
|
8357
8357
|
language: "mjml",
|
|
8358
|
-
description: "MJML renders text in table cells by default
|
|
8358
|
+
description: "MJML renders text in table cells by default; word-break works via mj-text",
|
|
8359
8359
|
before: `<mj-text>
|
|
8360
8360
|
<span style="word-break: break-all;">Long URL here</span>
|
|
8361
8361
|
</mj-text>`,
|
|
@@ -8409,7 +8409,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8409
8409
|
// ── border-radius (Outlook MJML) ────────────────────────────────────
|
|
8410
8410
|
"border-radius::outlook::mjml": {
|
|
8411
8411
|
language: "mjml",
|
|
8412
|
-
description: "MJML limitation: border-radius is unsupported in Outlook
|
|
8412
|
+
description: "MJML limitation: border-radius is unsupported in Outlook; MJML does not generate VML",
|
|
8413
8413
|
before: `<mj-button border-radius="6px" background-color="#6d28d9">
|
|
8414
8414
|
Click Here
|
|
8415
8415
|
</mj-button>`,
|
|
@@ -8449,7 +8449,7 @@ var MJML_FIX_DATABASE = {
|
|
|
8449
8449
|
</div>
|
|
8450
8450
|
</mj-raw>`,
|
|
8451
8451
|
after: `<!-- Flexbox in MJML is not Outlook-compatible.
|
|
8452
|
-
Use mj-section and mj-column
|
|
8452
|
+
Use mj-section and mj-column: MJML compiles these to table-based layouts. -->
|
|
8453
8453
|
<mj-section>
|
|
8454
8454
|
<mj-column width="50%">
|
|
8455
8455
|
<mj-text>Column 1</mj-text>
|
|
@@ -8509,7 +8509,7 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8509
8509
|
// ── @font-face (Maizzle) ────────────────────────────────────────────
|
|
8510
8510
|
"@font-face::maizzle": {
|
|
8511
8511
|
language: "maizzle",
|
|
8512
|
-
description: 'Add fonts via the googleFonts key in config.js
|
|
8512
|
+
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.',
|
|
8513
8513
|
before: `<style>
|
|
8514
8514
|
@font-face {
|
|
8515
8515
|
font-family: 'Inter';
|
|
@@ -8522,12 +8522,12 @@ var MAIZZLE_FIX_DATABASE = {
|
|
|
8522
8522
|
// ── <style> (Gmail Maizzle) ─────────────────────────────────────────
|
|
8523
8523
|
"<style>::gmail::maizzle": {
|
|
8524
8524
|
language: "maizzle",
|
|
8525
|
-
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
|
|
8525
|
+
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.",
|
|
8526
8526
|
before: `<style>
|
|
8527
8527
|
.custom { color: #6d28d9; }
|
|
8528
8528
|
</style>
|
|
8529
8529
|
<div class="custom">Hello</div>`,
|
|
8530
|
-
after: `<!-- Prefer Tailwind classes
|
|
8530
|
+
after: `<!-- Prefer Tailwind classes; Maizzle inlines them automatically during build -->
|
|
8531
8531
|
<div class="text-[#6d28d9]">Hello</div>`
|
|
8532
8532
|
},
|
|
8533
8533
|
// ── max-width (Outlook Maizzle) ─────────────────────────────────────
|
|
@@ -8616,7 +8616,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8616
8616
|
"opacity": "Use solid colors instead of opacity.",
|
|
8617
8617
|
// ── word-break ────────────────────────────────────────────────────────
|
|
8618
8618
|
"word-break": "Wrap long text in a <table><td> to force wrapping in clients that don't support word-break.",
|
|
8619
|
-
"word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width
|
|
8619
|
+
"word-break::outlook": "Outlook's Word engine ignores word-break. Place text inside a <td> with a constrained width; tables always wrap.",
|
|
8620
8620
|
// ── overflow-wrap ─────────────────────────────────────────────────────
|
|
8621
8621
|
"overflow-wrap": "Wrap text in a <table><td> to force wrapping. overflow-wrap is ignored by Outlook and unreliable in Yahoo.",
|
|
8622
8622
|
// ── white-space ───────────────────────────────────────────────────────
|
|
@@ -8652,7 +8652,7 @@ var HTML_SUGGESTION_DATABASE = {
|
|
|
8652
8652
|
// src/fix-snippets/jsx-suggestions.ts
|
|
8653
8653
|
var JSX_SUGGESTION_DATABASE = {
|
|
8654
8654
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8655
|
-
"<style>::jsx": "Move styles to inline style props
|
|
8655
|
+
"<style>::jsx": "Move styles to inline style props; React Email components accept style objects directly.",
|
|
8656
8656
|
"<style>:partial::jsx": "Use inline style props on React Email components. Reserve <style> in <Head> for progressive enhancement only.",
|
|
8657
8657
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8658
8658
|
"<link>::jsx": "Use the React Email <Head> component for font imports; place all other styles inline via style props.",
|
|
@@ -8714,7 +8714,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8714
8714
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8715
8715
|
"@media::mjml": "MJML generates responsive @media queries automatically. Use mj-breakpoint and mj-column widths.",
|
|
8716
8716
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
8717
|
-
"display:flex::mjml": "Use mj-section and mj-column
|
|
8717
|
+
"display:flex::mjml": "Use mj-section and mj-column; MJML compiles these to table-based layouts.",
|
|
8718
8718
|
// ── display:grid ──────────────────────────────────────────────────────
|
|
8719
8719
|
"display:grid::mjml": "Use mj-section and mj-column for grid-like layouts.",
|
|
8720
8720
|
// ── linear-gradient ───────────────────────────────────────────────────
|
|
@@ -8722,7 +8722,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8722
8722
|
// ── box-shadow ────────────────────────────────────────────────────────
|
|
8723
8723
|
"box-shadow::mjml": "Use a border attribute on mj-section or mj-column as an alternative.",
|
|
8724
8724
|
// ── border-radius ─────────────────────────────────────────────────────
|
|
8725
|
-
"border-radius::mjml": 'MJML does not generate VML
|
|
8725
|
+
"border-radius::mjml": 'MJML does not generate VML; border-radius will not render in Outlook. Set border-radius="0" or accept flat corners.',
|
|
8726
8726
|
// ── max-width ─────────────────────────────────────────────────────────
|
|
8727
8727
|
"max-width::mjml": "Set the width attribute on mj-body or mj-section for maximum compatibility.",
|
|
8728
8728
|
// ── gap ───────────────────────────────────────────────────────────────
|
|
@@ -8730,7 +8730,7 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8730
8730
|
// ── float ─────────────────────────────────────────────────────────────
|
|
8731
8731
|
"float::mjml": "Use mj-section with multiple mj-column elements for side-by-side layout.",
|
|
8732
8732
|
// ── background-image ──────────────────────────────────────────────────
|
|
8733
|
-
"background-image::mjml": "Use background-url attribute on mj-section
|
|
8733
|
+
"background-image::mjml": "Use background-url attribute on mj-section; MJML generates VML automatically.",
|
|
8734
8734
|
// ── position ──────────────────────────────────────────────────────────
|
|
8735
8735
|
"position::mjml": "Use mj-section and mj-column for layout positioning.",
|
|
8736
8736
|
// ── opacity ───────────────────────────────────────────────────────────
|
|
@@ -8742,10 +8742,10 @@ var MJML_SUGGESTION_DATABASE = {
|
|
|
8742
8742
|
// src/fix-snippets/maizzle-suggestions.ts
|
|
8743
8743
|
var MAIZZLE_SUGGESTION_DATABASE = {
|
|
8744
8744
|
// ── <style> ───────────────────────────────────────────────────────────
|
|
8745
|
-
"<style>::maizzle": "Prefer Tailwind utility classes
|
|
8745
|
+
"<style>::maizzle": "Prefer Tailwind utility classes; Maizzle inlines CSS via juice during build (inlineCSS: true in config.js).",
|
|
8746
8746
|
"<style>:partial::maizzle": "Use Tailwind utility classes for critical styles. Maizzle automatically inlines them at build time.",
|
|
8747
8747
|
// ── <link> ────────────────────────────────────────────────────────────
|
|
8748
|
-
"<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes
|
|
8748
|
+
"<link>::maizzle": "External stylesheets are stripped. Use Tailwind CSS classes; Maizzle inlines them at build time.",
|
|
8749
8749
|
// ── <svg> ─────────────────────────────────────────────────────────────
|
|
8750
8750
|
"<svg>::maizzle": "Replace inline SVG with an <img> tag pointing to a hosted PNG.",
|
|
8751
8751
|
// ── <video> ───────────────────────────────────────────────────────────
|
|
@@ -8753,7 +8753,7 @@ var MAIZZLE_SUGGESTION_DATABASE = {
|
|
|
8753
8753
|
// ── <form> ────────────────────────────────────────────────────────────
|
|
8754
8754
|
"<form>::maizzle": "Replace the form with a CTA link/button pointing to a hosted form page.",
|
|
8755
8755
|
// ── @font-face ────────────────────────────────────────────────────────
|
|
8756
|
-
"@font-face::maizzle": "Use the googleFonts key in config.js
|
|
8756
|
+
"@font-face::maizzle": "Use the googleFonts key in config.js; Maizzle injects the Google Fonts link tag automatically.",
|
|
8757
8757
|
// ── @media ────────────────────────────────────────────────────────────
|
|
8758
8758
|
"@media::maizzle": "Use Tailwind responsive utility classes and Maizzle's breakpoints config instead of hand-written @media.",
|
|
8759
8759
|
// ── display:flex ──────────────────────────────────────────────────────
|
|
@@ -8924,6 +8924,30 @@ function serializeStyle(map) {
|
|
|
8924
8924
|
});
|
|
8925
8925
|
return parts.join("; ");
|
|
8926
8926
|
}
|
|
8927
|
+
function splitTopLevel(value) {
|
|
8928
|
+
const parts = [];
|
|
8929
|
+
let current = "";
|
|
8930
|
+
let depth = 0;
|
|
8931
|
+
let quote = null;
|
|
8932
|
+
for (const ch of value) {
|
|
8933
|
+
if (quote) {
|
|
8934
|
+
if (ch === quote) quote = null;
|
|
8935
|
+
} else if (ch === '"' || ch === "'") {
|
|
8936
|
+
quote = ch;
|
|
8937
|
+
} else if (ch === "(") {
|
|
8938
|
+
depth++;
|
|
8939
|
+
} else if (ch === ")") {
|
|
8940
|
+
depth = Math.max(0, depth - 1);
|
|
8941
|
+
} else if (ch === "," && depth === 0) {
|
|
8942
|
+
parts.push(current);
|
|
8943
|
+
current = "";
|
|
8944
|
+
continue;
|
|
8945
|
+
}
|
|
8946
|
+
current += ch;
|
|
8947
|
+
}
|
|
8948
|
+
parts.push(current);
|
|
8949
|
+
return parts;
|
|
8950
|
+
}
|
|
8927
8951
|
|
|
8928
8952
|
// src/downlevel.ts
|
|
8929
8953
|
import * as cheerio from "cheerio";
|
|
@@ -9299,6 +9323,71 @@ function alphaBlend(fg, bgR, bgG, bgB) {
|
|
|
9299
9323
|
Math.round(fg.b * a + bgB * (1 - a))
|
|
9300
9324
|
];
|
|
9301
9325
|
}
|
|
9326
|
+
function backgroundShorthandColor(value) {
|
|
9327
|
+
if (!value) return null;
|
|
9328
|
+
const trimmed = value.trim();
|
|
9329
|
+
if (parseColor(trimmed)) return trimmed;
|
|
9330
|
+
for (const token of trimmed.split(/\s+/)) {
|
|
9331
|
+
if (token.includes("(")) continue;
|
|
9332
|
+
if (parseColor(token)) return token;
|
|
9333
|
+
}
|
|
9334
|
+
return null;
|
|
9335
|
+
}
|
|
9336
|
+
function functionArgs(value, nameRe) {
|
|
9337
|
+
const out = [];
|
|
9338
|
+
const re = new RegExp(nameRe.source + "\\(", nameRe.flags.includes("i") ? "gi" : "g");
|
|
9339
|
+
let match;
|
|
9340
|
+
while ((match = re.exec(value)) !== null) {
|
|
9341
|
+
let depth = 1;
|
|
9342
|
+
let i = match.index + match[0].length;
|
|
9343
|
+
const start = i;
|
|
9344
|
+
while (i < value.length && depth > 0) {
|
|
9345
|
+
if (value[i] === "(") depth++;
|
|
9346
|
+
else if (value[i] === ")") depth--;
|
|
9347
|
+
i++;
|
|
9348
|
+
}
|
|
9349
|
+
if (depth === 0) out.push(value.slice(start, i - 1));
|
|
9350
|
+
re.lastIndex = i;
|
|
9351
|
+
}
|
|
9352
|
+
return out;
|
|
9353
|
+
}
|
|
9354
|
+
function gradientStops(value) {
|
|
9355
|
+
if (!value) return [];
|
|
9356
|
+
const stops = [];
|
|
9357
|
+
for (const args of functionArgs(value, /(?:repeating-)?(?:linear|radial|conic)-gradient/i)) {
|
|
9358
|
+
for (const part of splitTopLevel(args)) {
|
|
9359
|
+
const trimmed = part.trim();
|
|
9360
|
+
if (!trimmed || /^(?:to\b|at\b|from\b|in\b|[\d.]+(?:deg|turn|rad|grad)\b)/i.test(trimmed)) continue;
|
|
9361
|
+
const fnMatch = trimmed.match(/^(?:rgba?|hsla?|color|lab|lch|oklab|oklch)\([^)]*\)/i);
|
|
9362
|
+
const token = fnMatch ? fnMatch[0] : trimmed.split(/\s+/)[0];
|
|
9363
|
+
const parsed = parseColor(token);
|
|
9364
|
+
if (parsed && parsed.a > 0) stops.push(parsed);
|
|
9365
|
+
}
|
|
9366
|
+
}
|
|
9367
|
+
return stops;
|
|
9368
|
+
}
|
|
9369
|
+
function gammaToLinear(c) {
|
|
9370
|
+
const s = c / 255;
|
|
9371
|
+
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
9372
|
+
}
|
|
9373
|
+
function rgbToOklab(rgba) {
|
|
9374
|
+
const r = gammaToLinear(rgba.r);
|
|
9375
|
+
const g = gammaToLinear(rgba.g);
|
|
9376
|
+
const b = gammaToLinear(rgba.b);
|
|
9377
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
9378
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
9379
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
9380
|
+
return [
|
|
9381
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
9382
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
9383
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s
|
|
9384
|
+
];
|
|
9385
|
+
}
|
|
9386
|
+
function colorDistance(a, b) {
|
|
9387
|
+
const [l1, a1, b1] = rgbToOklab(a);
|
|
9388
|
+
const [l2, a2, b2] = rgbToOklab(b);
|
|
9389
|
+
return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
|
|
9390
|
+
}
|
|
9302
9391
|
|
|
9303
9392
|
// src/downlevel.ts
|
|
9304
9393
|
var LOGICAL_PROPERTY_MAP = {
|
|
@@ -9739,6 +9828,10 @@ var GENERIC_FONT_FAMILIES = /* @__PURE__ */ new Set([
|
|
|
9739
9828
|
"ui-monospace"
|
|
9740
9829
|
]);
|
|
9741
9830
|
var EMPTY_DELIVERABILITY = { domain: "", checks: [], score: 0, issues: [] };
|
|
9831
|
+
var EMPTY_DESIGN = {
|
|
9832
|
+
issues: [],
|
|
9833
|
+
palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] }
|
|
9834
|
+
};
|
|
9742
9835
|
|
|
9743
9836
|
// src/transform.ts
|
|
9744
9837
|
function hasPseudoSelector(selector) {
|
|
@@ -10109,7 +10202,7 @@ var CLIENT_CONFIGS = {
|
|
|
10109
10202
|
"outlook-macos": {
|
|
10110
10203
|
id: "outlook-macos",
|
|
10111
10204
|
// caniemail: the new (WebKit) Outlook for Mac supports position/transform/
|
|
10112
|
-
// animation/box-shadow/opacity
|
|
10205
|
+
// animation/box-shadow/opacity; only transition is unsupported. It is NOT
|
|
10113
10206
|
// the Word engine, so do not mirror outlook-web's broader strip set.
|
|
10114
10207
|
strippedProperties: /* @__PURE__ */ new Set(["transition"]),
|
|
10115
10208
|
stripMode: "strip",
|
|
@@ -10201,7 +10294,7 @@ var CLIENT_CONFIGS = {
|
|
|
10201
10294
|
additionalChecks: heyAdditionalChecks
|
|
10202
10295
|
},
|
|
10203
10296
|
// ponytail: strip sets below are data-driven from caniemail (the same source
|
|
10204
|
-
// as CSS_SUPPORT), not hand-guessed
|
|
10297
|
+
// as CSS_SUPPORT), not hand-guessed; no manual re-verification debt.
|
|
10205
10298
|
"protonmail": {
|
|
10206
10299
|
id: "protonmail",
|
|
10207
10300
|
// caniemail: strips animation + external stylesheets + forms; supports
|
|
@@ -10215,7 +10308,7 @@ var CLIENT_CONFIGS = {
|
|
|
10215
10308
|
},
|
|
10216
10309
|
"aol": {
|
|
10217
10310
|
id: "aol",
|
|
10218
|
-
// caniemail: restrictive
|
|
10311
|
+
// caniemail: restrictive; drops animation, box-shadow, opacity, transform,
|
|
10219
10312
|
// external stylesheets, and SVG. Forms are kept.
|
|
10220
10313
|
strippedProperties: /* @__PURE__ */ new Set(["animation", "box-shadow", "opacity", "transform"]),
|
|
10221
10314
|
stripMode: "strip",
|
|
@@ -10769,7 +10862,7 @@ function simulateDarkMode(html, clientId) {
|
|
|
10769
10862
|
client: clientId,
|
|
10770
10863
|
property: "dark-mode",
|
|
10771
10864
|
message: "Superhuman respects @media (prefers-color-scheme: dark). Many Superhuman users run in dark mode.",
|
|
10772
|
-
suggestion: "Add @media (prefers-color-scheme: dark) styles
|
|
10865
|
+
suggestion: "Add @media (prefers-color-scheme: dark) styles; Superhuman's power-user audience often prefers dark mode."
|
|
10773
10866
|
});
|
|
10774
10867
|
}
|
|
10775
10868
|
break;
|
|
@@ -11145,16 +11238,6 @@ function positionAt(data, index, anchor) {
|
|
|
11145
11238
|
// src/dark-mode-checker.ts
|
|
11146
11239
|
var DARK_MEDIA_RE = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
|
|
11147
11240
|
var MAX_UNCOVERED_ELEMENTS = 3;
|
|
11148
|
-
function backgroundShorthandColor(value) {
|
|
11149
|
-
if (!value) return null;
|
|
11150
|
-
const trimmed = value.trim();
|
|
11151
|
-
if (parseColor(trimmed)) return trimmed;
|
|
11152
|
-
for (const token of trimmed.split(/\s+/)) {
|
|
11153
|
-
if (token.includes("(")) continue;
|
|
11154
|
-
if (parseColor(token)) return token;
|
|
11155
|
-
}
|
|
11156
|
-
return null;
|
|
11157
|
-
}
|
|
11158
11241
|
function isLight(value) {
|
|
11159
11242
|
const c = parseColor(value);
|
|
11160
11243
|
if (!c || c.a < 0.5) return false;
|
|
@@ -11272,7 +11355,7 @@ function checkDarkModeFromDom($, source) {
|
|
|
11272
11355
|
severity: "warning",
|
|
11273
11356
|
client: clientId,
|
|
11274
11357
|
property: "dark-mode-coverage",
|
|
11275
|
-
message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode
|
|
11358
|
+
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).`,
|
|
11276
11359
|
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.`,
|
|
11277
11360
|
fixType: "css",
|
|
11278
11361
|
selector
|
|
@@ -11345,7 +11428,7 @@ var COMPOUND_DETECTORS = [
|
|
|
11345
11428
|
var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
|
|
11346
11429
|
key: fn,
|
|
11347
11430
|
pattern: `${fn}(`
|
|
11348
|
-
// require opening paren
|
|
11431
|
+
// require opening paren, matches "min(" but not "Minion"
|
|
11349
11432
|
}));
|
|
11350
11433
|
function analyzeEmailFromDom($, framework, source) {
|
|
11351
11434
|
const warnings = [];
|
|
@@ -11843,14 +11926,14 @@ ${originalHtml}
|
|
|
11843
11926
|
- **${w.property}** (${clientName})${fixTypeLabel}: ${w.message}`;
|
|
11844
11927
|
if (w.fixType === "structural") {
|
|
11845
11928
|
issueSection += `
|
|
11846
|
-
- **Fix type: structural
|
|
11929
|
+
- **Fix type: structural**. CSS-only changes will NOT work. HTML restructuring required.`;
|
|
11847
11930
|
}
|
|
11848
11931
|
if (w.suggestion) {
|
|
11849
11932
|
issueSection += `
|
|
11850
11933
|
- Suggestion: ${w.suggestion}`;
|
|
11851
11934
|
}
|
|
11852
11935
|
if (w.fix) {
|
|
11853
|
-
const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML
|
|
11936
|
+
const fixLabel = w.fixIsGenericFallback && format !== "html" ? `Fix (generic HTML, adapt to ${format.toUpperCase()} syntax)` : "Fix";
|
|
11854
11937
|
issueSection += `
|
|
11855
11938
|
- ${fixLabel}:`;
|
|
11856
11939
|
issueSection += `
|
|
@@ -11882,10 +11965,10 @@ Client-agnostic rendering bugs. Apply each fix directly to the code.
|
|
|
11882
11965
|
sections.push(s);
|
|
11883
11966
|
}
|
|
11884
11967
|
const formatInstructions = {
|
|
11885
|
-
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
|
|
11886
|
-
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
|
|
11887
|
-
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
|
|
11888
|
-
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
|
|
11968
|
+
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.`,
|
|
11969
|
+
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.`,
|
|
11970
|
+
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.`,
|
|
11971
|
+
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.`
|
|
11889
11972
|
};
|
|
11890
11973
|
sections.push(
|
|
11891
11974
|
`## Instructions
|
|
@@ -12017,7 +12100,7 @@ var AI_FIX_SYSTEM_PROMPT = `You are an expert email developer specializing in cr
|
|
|
12017
12100
|
Rules:
|
|
12018
12101
|
- Return ONLY the fixed code inside a single code fence. No explanations before or after.
|
|
12019
12102
|
- Preserve all existing content, text, links, and visual design.
|
|
12020
|
-
- For structural issues (fixType: "structural"), you MUST restructure the HTML
|
|
12103
|
+
- For structural issues (fixType: "structural"), you MUST restructure the HTML; CSS-only changes will not work.
|
|
12021
12104
|
- Common structural patterns:
|
|
12022
12105
|
- word-break/overflow-wrap unsupported \u2192 wrap text in <table><tr><td> with constrained width
|
|
12023
12106
|
- display:flex/grid \u2192 convert to <table> layout (match the original column count and proportions)
|
|
@@ -12028,7 +12111,7 @@ Rules:
|
|
|
12028
12111
|
- <svg> \u2192 replace with <img> pointing to a hosted PNG
|
|
12029
12112
|
- For CSS-only issues (fixType: "css"), swap properties or add fallbacks.
|
|
12030
12113
|
- 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.
|
|
12031
|
-
- Apply ALL fixes from the issues list
|
|
12114
|
+
- Apply ALL fixes from the issues list: do not skip any.
|
|
12032
12115
|
- Use the framework syntax specified (JSX/MJML/Maizzle/HTML).
|
|
12033
12116
|
- For JSX: use camelCase style props, React Email components, and proper TypeScript types.
|
|
12034
12117
|
- For MJML: use mj-* elements and attributes.
|
|
@@ -12053,6 +12136,263 @@ function extractCode(response) {
|
|
|
12053
12136
|
return response.trim();
|
|
12054
12137
|
}
|
|
12055
12138
|
|
|
12139
|
+
// src/rules/feature-urls.ts
|
|
12140
|
+
var FEATURE_URLS = {
|
|
12141
|
+
"!important": "https://www.caniemail.com/features/css-important/",
|
|
12142
|
+
"::after": "https://www.caniemail.com/features/css-pseudo-element-after/",
|
|
12143
|
+
"::before": "https://www.caniemail.com/features/css-pseudo-element-before/",
|
|
12144
|
+
"::first-letter": "https://www.caniemail.com/features/css-pseudo-element-first-letter/",
|
|
12145
|
+
"::first-line": "https://www.caniemail.com/features/css-pseudo-element-first-line/",
|
|
12146
|
+
"::marker": "https://www.caniemail.com/features/css-pseudo-element-marker/",
|
|
12147
|
+
"::placeholder": "https://www.caniemail.com/features/css-pseudo-element-placeholder/",
|
|
12148
|
+
":adjacent-sibling": "https://www.caniemail.com/features/css-selector-adjacent-sibling/",
|
|
12149
|
+
":attribute-selector": "https://www.caniemail.com/features/css-selector-attribute/",
|
|
12150
|
+
":chaining": "https://www.caniemail.com/features/css-selector-chaining/",
|
|
12151
|
+
":checked": "https://www.caniemail.com/features/css-pseudo-class-checked/",
|
|
12152
|
+
":child-combinator": "https://www.caniemail.com/features/css-selector-child/",
|
|
12153
|
+
":class-selector": "https://www.caniemail.com/features/css-selector-class/",
|
|
12154
|
+
":descendant-combinator": "https://www.caniemail.com/features/css-selector-descendant/",
|
|
12155
|
+
":first-child": "https://www.caniemail.com/features/css-pseudo-class-first-child/",
|
|
12156
|
+
":first-of-type": "https://www.caniemail.com/features/css-pseudo-class-first-of-type/",
|
|
12157
|
+
":focus": "https://www.caniemail.com/features/css-pseudo-class-focus/",
|
|
12158
|
+
":focus-visible": "https://www.caniemail.com/features/css-pseudo-class-focus-visible/",
|
|
12159
|
+
":focus-within": "https://www.caniemail.com/features/css-pseudo-class-focus-within/",
|
|
12160
|
+
":general-sibling": "https://www.caniemail.com/features/css-selector-general-sibling/",
|
|
12161
|
+
":grouping": "https://www.caniemail.com/features/css-selector-grouping/",
|
|
12162
|
+
":has": "https://www.caniemail.com/features/css-pseudo-class-has/",
|
|
12163
|
+
":hover": "https://www.caniemail.com/features/css-pseudo-class-hover/",
|
|
12164
|
+
":id-selector": "https://www.caniemail.com/features/css-selector-id/",
|
|
12165
|
+
":lang": "https://www.caniemail.com/features/css-pseudo-class-lang/",
|
|
12166
|
+
":last-child": "https://www.caniemail.com/features/css-pseudo-class-last-child/",
|
|
12167
|
+
":last-of-type": "https://www.caniemail.com/features/css-pseudo-class-last-of-type/",
|
|
12168
|
+
":link": "https://www.caniemail.com/features/css-pseudo-class-link/",
|
|
12169
|
+
":not": "https://www.caniemail.com/features/css-pseudo-class-not/",
|
|
12170
|
+
":nth-child": "https://www.caniemail.com/features/css-pseudo-class-nth-child/",
|
|
12171
|
+
":nth-last-child": "https://www.caniemail.com/features/css-pseudo-class-nth-last-child/",
|
|
12172
|
+
":nth-of-type": "https://www.caniemail.com/features/css-pseudo-class-nth-of-type/",
|
|
12173
|
+
":only-child": "https://www.caniemail.com/features/css-pseudo-class-only-child/",
|
|
12174
|
+
":only-of-type": "https://www.caniemail.com/features/css-pseudo-class-only-of-type/",
|
|
12175
|
+
":target": "https://www.caniemail.com/features/css-pseudo-class-target/",
|
|
12176
|
+
":type-selector": "https://www.caniemail.com/features/css-selector-type/",
|
|
12177
|
+
":universal-selector": "https://www.caniemail.com/features/css-selector-universal/",
|
|
12178
|
+
":visited": "https://www.caniemail.com/features/css-pseudo-class-visited/",
|
|
12179
|
+
"<abbr>": "https://www.caniemail.com/features/html-abbr/",
|
|
12180
|
+
"<acronym>": "https://www.caniemail.com/features/html-acronym/",
|
|
12181
|
+
"<address>": "https://www.caniemail.com/features/html-address/",
|
|
12182
|
+
"<audio>": "https://www.caniemail.com/features/html-audio/",
|
|
12183
|
+
"<base>": "https://www.caniemail.com/features/html-base/",
|
|
12184
|
+
"<bdi>": "https://www.caniemail.com/features/html-bdi/",
|
|
12185
|
+
"<blockquote>": "https://www.caniemail.com/features/html-blockquote/",
|
|
12186
|
+
"<body>": "https://www.caniemail.com/features/html-body/",
|
|
12187
|
+
"<code>": "https://www.caniemail.com/features/html-code/",
|
|
12188
|
+
"<del>": "https://www.caniemail.com/features/html-del/",
|
|
12189
|
+
"<dfn>": "https://www.caniemail.com/features/html-dfn/",
|
|
12190
|
+
"<dialog>": "https://www.caniemail.com/features/html-dialog/",
|
|
12191
|
+
"<div>": "https://www.caniemail.com/features/html-div/",
|
|
12192
|
+
"<form>": "https://www.caniemail.com/features/html-form/",
|
|
12193
|
+
"<h1>": "https://www.caniemail.com/features/html-h1-h6/",
|
|
12194
|
+
"<hr>": "https://www.caniemail.com/features/html-hr/",
|
|
12195
|
+
"<img>": "https://www.caniemail.com/features/html-img/",
|
|
12196
|
+
"<link>": "https://www.caniemail.com/features/html-link/",
|
|
12197
|
+
"<marquee>": "https://www.caniemail.com/features/html-marquee/",
|
|
12198
|
+
"<meter>": "https://www.caniemail.com/features/html-meter/",
|
|
12199
|
+
"<object>": "https://www.caniemail.com/features/html-object/",
|
|
12200
|
+
"<p>": "https://www.caniemail.com/features/html-p/",
|
|
12201
|
+
"<picture>": "https://www.caniemail.com/features/html-picture/",
|
|
12202
|
+
"<pre>": "https://www.caniemail.com/features/html-pre/",
|
|
12203
|
+
"<progress>": "https://www.caniemail.com/features/html-progress/",
|
|
12204
|
+
"<rp>": "https://www.caniemail.com/features/html-rp/",
|
|
12205
|
+
"<rt>": "https://www.caniemail.com/features/html-rt/",
|
|
12206
|
+
"<ruby>": "https://www.caniemail.com/features/html-ruby/",
|
|
12207
|
+
"<select>": "https://www.caniemail.com/features/html-select/",
|
|
12208
|
+
"<small>": "https://www.caniemail.com/features/html-small/",
|
|
12209
|
+
"<span>": "https://www.caniemail.com/features/html-span/",
|
|
12210
|
+
"<strike>": "https://www.caniemail.com/features/html-strike/",
|
|
12211
|
+
"<strong>": "https://www.caniemail.com/features/html-strong/",
|
|
12212
|
+
"<style>": "https://www.caniemail.com/features/html-style/",
|
|
12213
|
+
"<svg>": "https://www.caniemail.com/features/html-svg/",
|
|
12214
|
+
"<table>": "https://www.caniemail.com/features/html-table/",
|
|
12215
|
+
"<textarea>": "https://www.caniemail.com/features/html-textarea/",
|
|
12216
|
+
"<ul>": "https://www.caniemail.com/features/html-lists/",
|
|
12217
|
+
"<video>": "https://www.caniemail.com/features/html-video/",
|
|
12218
|
+
"<wbr>": "https://www.caniemail.com/features/html-wbr/",
|
|
12219
|
+
"@font-face": "https://www.caniemail.com/features/css-at-font-face/",
|
|
12220
|
+
"@import": "https://www.caniemail.com/features/css-at-import/",
|
|
12221
|
+
"@keyframes": "https://www.caniemail.com/features/css-at-keyframes/",
|
|
12222
|
+
"@media": "https://www.caniemail.com/features/css-at-media/",
|
|
12223
|
+
"@supports": "https://www.caniemail.com/features/css-at-supports/",
|
|
12224
|
+
"accent-color": "https://www.caniemail.com/features/css-accent-color/",
|
|
12225
|
+
"align-items": "https://www.caniemail.com/features/css-align-items/",
|
|
12226
|
+
"animation": "https://www.caniemail.com/features/css-animation/",
|
|
12227
|
+
"aspect-ratio": "https://www.caniemail.com/features/css-aspect-ratio/",
|
|
12228
|
+
"backdrop-filter": "https://www.caniemail.com/features/css-backdrop-filter/",
|
|
12229
|
+
"background": "https://www.caniemail.com/features/css-background/",
|
|
12230
|
+
"background-blend-mode": "https://www.caniemail.com/features/css-background-blend-mode/",
|
|
12231
|
+
"background-clip": "https://www.caniemail.com/features/css-background-clip/",
|
|
12232
|
+
"background-color": "https://www.caniemail.com/features/css-background-color/",
|
|
12233
|
+
"background-image": "https://www.caniemail.com/features/css-background-image/",
|
|
12234
|
+
"background-origin": "https://www.caniemail.com/features/css-background-origin/",
|
|
12235
|
+
"background-position": "https://www.caniemail.com/features/css-background-position/",
|
|
12236
|
+
"background-repeat": "https://www.caniemail.com/features/css-background-repeat/",
|
|
12237
|
+
"background-size": "https://www.caniemail.com/features/css-background-size/",
|
|
12238
|
+
"bimi": "https://www.caniemail.com/features/bimi/",
|
|
12239
|
+
"block-size": "https://www.caniemail.com/features/css-block-inline-size/",
|
|
12240
|
+
"border": "https://www.caniemail.com/features/css-border/",
|
|
12241
|
+
"border-collapse": "https://www.caniemail.com/features/css-border-collapse/",
|
|
12242
|
+
"border-image": "https://www.caniemail.com/features/css-border-image/",
|
|
12243
|
+
"border-inline": "https://www.caniemail.com/features/css-border-inline-block/",
|
|
12244
|
+
"border-inline-individual": "https://www.caniemail.com/features/css-border-inline-block-individual/",
|
|
12245
|
+
"border-inline-longhand": "https://www.caniemail.com/features/css-border-inline-block-longhand/",
|
|
12246
|
+
"border-radius": "https://www.caniemail.com/features/css-border-radius/",
|
|
12247
|
+
"border-radius-logical": "https://www.caniemail.com/features/css-border-radius-logical/",
|
|
12248
|
+
"border-spacing": "https://www.caniemail.com/features/css-border-spacing/",
|
|
12249
|
+
"box-shadow": "https://www.caniemail.com/features/css-box-shadow/",
|
|
12250
|
+
"box-sizing": "https://www.caniemail.com/features/css-box-sizing/",
|
|
12251
|
+
"calc": "https://www.caniemail.com/features/css-unit-calc/",
|
|
12252
|
+
"caption-side": "https://www.caniemail.com/features/css-caption-side/",
|
|
12253
|
+
"clamp": "https://www.caniemail.com/features/css-function-clamp/",
|
|
12254
|
+
"clear": "https://www.caniemail.com/features/css-clear/",
|
|
12255
|
+
"clip-path": "https://www.caniemail.com/features/css-clip-path/",
|
|
12256
|
+
"color-scheme": "https://www.caniemail.com/features/css-color-scheme/",
|
|
12257
|
+
"column-count": "https://www.caniemail.com/features/css-column-count/",
|
|
12258
|
+
"columns": "https://www.caniemail.com/features/css-column-layout-properties/",
|
|
12259
|
+
"conic-gradient": "https://www.caniemail.com/features/css-conic-gradient/",
|
|
12260
|
+
"css-comments": "https://www.caniemail.com/features/css-comments/",
|
|
12261
|
+
"cursor": "https://www.caniemail.com/features/css-cursor/",
|
|
12262
|
+
"custom-properties": "https://www.caniemail.com/features/css-variables/",
|
|
12263
|
+
"direction": "https://www.caniemail.com/features/css-direction/",
|
|
12264
|
+
"display": "https://www.caniemail.com/features/css-display/",
|
|
12265
|
+
"display:flex": "https://www.caniemail.com/features/css-display-flex/",
|
|
12266
|
+
"display:grid": "https://www.caniemail.com/features/css-display-grid/",
|
|
12267
|
+
"display:none": "https://www.caniemail.com/features/css-display-none/",
|
|
12268
|
+
"empty-cells": "https://www.caniemail.com/features/css-empty-cells/",
|
|
12269
|
+
"filter": "https://www.caniemail.com/features/css-filter/",
|
|
12270
|
+
"fit-content": "https://www.caniemail.com/features/css-intrinsic-size/",
|
|
12271
|
+
"flex-direction": "https://www.caniemail.com/features/css-flex-direction/",
|
|
12272
|
+
"flex-wrap": "https://www.caniemail.com/features/css-flex-wrap/",
|
|
12273
|
+
"float": "https://www.caniemail.com/features/css-float/",
|
|
12274
|
+
"font": "https://www.caniemail.com/features/css-font/",
|
|
12275
|
+
"font-kerning": "https://www.caniemail.com/features/css-font-kerning/",
|
|
12276
|
+
"font-size": "https://www.caniemail.com/features/css-font-size/",
|
|
12277
|
+
"font-size-adjust": "https://www.caniemail.com/features/css-font-size-adjust/",
|
|
12278
|
+
"font-stretch": "https://www.caniemail.com/features/css-font-stretch/",
|
|
12279
|
+
"font-weight": "https://www.caniemail.com/features/css-font-weight/",
|
|
12280
|
+
"gap": "https://www.caniemail.com/features/css-gap/",
|
|
12281
|
+
"grid-template-columns": "https://www.caniemail.com/features/css-grid-template/",
|
|
12282
|
+
"height": "https://www.caniemail.com/features/css-height/",
|
|
12283
|
+
"hyphenate-character": "https://www.caniemail.com/features/css-hyphenate-character/",
|
|
12284
|
+
"hyphenate-limit-chars": "https://www.caniemail.com/features/css-hyphenate-limit-chars/",
|
|
12285
|
+
"hyphens": "https://www.caniemail.com/features/css-hyphens/",
|
|
12286
|
+
"inert": "https://www.caniemail.com/features/css-inert-attribute/",
|
|
12287
|
+
"inline-size": "https://www.caniemail.com/features/css-inline-size/",
|
|
12288
|
+
"inset": "https://www.caniemail.com/features/css-inset/",
|
|
12289
|
+
"justify-content": "https://www.caniemail.com/features/css-justify-content/",
|
|
12290
|
+
"left": "https://www.caniemail.com/features/css-left-right-top-bottom/",
|
|
12291
|
+
"letter-spacing": "https://www.caniemail.com/features/css-letter-spacing/",
|
|
12292
|
+
"light-dark": "https://www.caniemail.com/features/css-function-light-dark/",
|
|
12293
|
+
"line-height": "https://www.caniemail.com/features/css-line-height/",
|
|
12294
|
+
"linear-gradient": "https://www.caniemail.com/features/css-linear-gradient/",
|
|
12295
|
+
"list-style": "https://www.caniemail.com/features/css-list-style/",
|
|
12296
|
+
"list-style-image": "https://www.caniemail.com/features/css-list-style-image/",
|
|
12297
|
+
"list-style-position": "https://www.caniemail.com/features/css-list-style-position/",
|
|
12298
|
+
"list-style-type": "https://www.caniemail.com/features/css-list-style-type/",
|
|
12299
|
+
"margin": "https://www.caniemail.com/features/css-margin/",
|
|
12300
|
+
"margin-block-start-end": "https://www.caniemail.com/features/css-margin-block-start-end/",
|
|
12301
|
+
"margin-inline-block": "https://www.caniemail.com/features/css-margin-inline-block/",
|
|
12302
|
+
"margin-inline-start-end": "https://www.caniemail.com/features/css-margin-inline-start-end/",
|
|
12303
|
+
"mask-image": "https://www.caniemail.com/features/css-mask-image/",
|
|
12304
|
+
"max": "https://www.caniemail.com/features/css-function-max/",
|
|
12305
|
+
"max-block-size": "https://www.caniemail.com/features/css-max-block-size/",
|
|
12306
|
+
"max-height": "https://www.caniemail.com/features/css-max-height/",
|
|
12307
|
+
"max-inline-size": "https://www.caniemail.com/features/css-max-inline-size/",
|
|
12308
|
+
"max-width": "https://www.caniemail.com/features/css-max-width/",
|
|
12309
|
+
"min": "https://www.caniemail.com/features/css-function-min/",
|
|
12310
|
+
"min-block-size": "https://www.caniemail.com/features/css-min-block-size/",
|
|
12311
|
+
"min-height": "https://www.caniemail.com/features/css-min-height/",
|
|
12312
|
+
"min-inline-size": "https://www.caniemail.com/features/css-min-inline-size/",
|
|
12313
|
+
"min-width": "https://www.caniemail.com/features/css-min-width/",
|
|
12314
|
+
"mix-blend-mode": "https://www.caniemail.com/features/css-mix-blend-mode/",
|
|
12315
|
+
"modern-color": "https://www.caniemail.com/features/css-modern-color/",
|
|
12316
|
+
"nesting": "https://www.caniemail.com/features/css-nesting/",
|
|
12317
|
+
"object-fit": "https://www.caniemail.com/features/css-object-fit/",
|
|
12318
|
+
"object-position": "https://www.caniemail.com/features/css-object-position/",
|
|
12319
|
+
"opacity": "https://www.caniemail.com/features/css-opacity/",
|
|
12320
|
+
"orphans": "https://www.caniemail.com/features/css-orphans/",
|
|
12321
|
+
"outline": "https://www.caniemail.com/features/css-outline/",
|
|
12322
|
+
"outline-offset": "https://www.caniemail.com/features/css-outline-offset/",
|
|
12323
|
+
"overflow": "https://www.caniemail.com/features/css-overflow/",
|
|
12324
|
+
"overflow-wrap": "https://www.caniemail.com/features/css-word-wrap/",
|
|
12325
|
+
"padding": "https://www.caniemail.com/features/css-padding/",
|
|
12326
|
+
"padding-block-start-end": "https://www.caniemail.com/features/css-padding-block-start-end/",
|
|
12327
|
+
"padding-inline-block": "https://www.caniemail.com/features/css-padding-inline-block/",
|
|
12328
|
+
"padding-inline-start-end": "https://www.caniemail.com/features/css-padding-inline-start-end/",
|
|
12329
|
+
"position": "https://www.caniemail.com/features/css-position/",
|
|
12330
|
+
"pseudo-class-active": "https://www.caniemail.com/features/css-pseudo-class-active/",
|
|
12331
|
+
"pseudo-class-default": "https://www.caniemail.com/features/css-pseudo-class-default/",
|
|
12332
|
+
"pseudo-class-nth-last-of-type": "https://www.caniemail.com/features/css-pseudo-class-nth-last-of-type/",
|
|
12333
|
+
"radial-gradient": "https://www.caniemail.com/features/css-radial-gradient/",
|
|
12334
|
+
"resize": "https://www.caniemail.com/features/css-resize/",
|
|
12335
|
+
"rgb": "https://www.caniemail.com/features/css-rgb/",
|
|
12336
|
+
"rgba": "https://www.caniemail.com/features/css-rgba/",
|
|
12337
|
+
"scroll-snap": "https://www.caniemail.com/features/css-scroll-snap/",
|
|
12338
|
+
"shape-margin": "https://www.caniemail.com/features/css-shape-margin/",
|
|
12339
|
+
"shape-outside": "https://www.caniemail.com/features/css-shape-outside/",
|
|
12340
|
+
"system-ui": "https://www.caniemail.com/features/css-sytem-ui/",
|
|
12341
|
+
"tab-size": "https://www.caniemail.com/features/css-tab-size/",
|
|
12342
|
+
"table-layout": "https://www.caniemail.com/features/css-table-layout/",
|
|
12343
|
+
"text-align": "https://www.caniemail.com/features/css-text-align/",
|
|
12344
|
+
"text-align-last": "https://www.caniemail.com/features/css-text-align-last/",
|
|
12345
|
+
"text-decoration": "https://www.caniemail.com/features/css-text-decoration/",
|
|
12346
|
+
"text-decoration-color": "https://www.caniemail.com/features/css-text-decoration-color/",
|
|
12347
|
+
"text-decoration-line": "https://www.caniemail.com/features/css-text-decoration-line/",
|
|
12348
|
+
"text-decoration-skip-ink": "https://www.caniemail.com/features/css-text-decoration-skip-ink/",
|
|
12349
|
+
"text-decoration-style": "https://www.caniemail.com/features/css-text-decoration-style/",
|
|
12350
|
+
"text-decoration-thickness": "https://www.caniemail.com/features/css-text-decoration-thickness/",
|
|
12351
|
+
"text-emphasis": "https://www.caniemail.com/features/css-text-emphasis/",
|
|
12352
|
+
"text-emphasis-position": "https://www.caniemail.com/features/css-text-emphasis-position/",
|
|
12353
|
+
"text-indent": "https://www.caniemail.com/features/css-text-indent/",
|
|
12354
|
+
"text-justify": "https://www.caniemail.com/features/css-text-justify/",
|
|
12355
|
+
"text-orientation": "https://www.caniemail.com/features/css-text-orientation/",
|
|
12356
|
+
"text-overflow": "https://www.caniemail.com/features/css-text-overflow/",
|
|
12357
|
+
"text-shadow": "https://www.caniemail.com/features/css-text-shadow/",
|
|
12358
|
+
"text-transform": "https://www.caniemail.com/features/css-text-transform/",
|
|
12359
|
+
"text-underline-offset": "https://www.caniemail.com/features/css-text-underline-offset/",
|
|
12360
|
+
"text-underline-position": "https://www.caniemail.com/features/css-text-underline-position/",
|
|
12361
|
+
"text-wrap": "https://www.caniemail.com/features/css-text-wrap/",
|
|
12362
|
+
"transform": "https://www.caniemail.com/features/css-transform/",
|
|
12363
|
+
"transition": "https://www.caniemail.com/features/css-transition/",
|
|
12364
|
+
"unit-ch": "https://www.caniemail.com/features/css-unit-ch/",
|
|
12365
|
+
"unit-cm": "https://www.caniemail.com/features/css-unit-cm/",
|
|
12366
|
+
"unit-em": "https://www.caniemail.com/features/css-unit-em/",
|
|
12367
|
+
"unit-ex": "https://www.caniemail.com/features/css-unit-ex/",
|
|
12368
|
+
"unit-in": "https://www.caniemail.com/features/css-unit-in/",
|
|
12369
|
+
"unit-initial": "https://www.caniemail.com/features/css-unit-initial/",
|
|
12370
|
+
"unit-mm": "https://www.caniemail.com/features/css-unit-mm/",
|
|
12371
|
+
"unit-pc": "https://www.caniemail.com/features/css-unit-pc/",
|
|
12372
|
+
"unit-percent": "https://www.caniemail.com/features/css-unit-percent/",
|
|
12373
|
+
"unit-pt": "https://www.caniemail.com/features/css-unit-pt/",
|
|
12374
|
+
"unit-px": "https://www.caniemail.com/features/css-unit-px/",
|
|
12375
|
+
"unit-rem": "https://www.caniemail.com/features/css-unit-rem/",
|
|
12376
|
+
"unit-vh": "https://www.caniemail.com/features/css-unit-vh/",
|
|
12377
|
+
"unit-vmax": "https://www.caniemail.com/features/css-unit-vmax/",
|
|
12378
|
+
"unit-vmin": "https://www.caniemail.com/features/css-unit-vmin/",
|
|
12379
|
+
"unit-vw": "https://www.caniemail.com/features/css-unit-vw/",
|
|
12380
|
+
"user-select": "https://www.caniemail.com/features/css-user-select/",
|
|
12381
|
+
"vertical-align": "https://www.caniemail.com/features/css-vertical-align/",
|
|
12382
|
+
"visibility": "https://www.caniemail.com/features/css-visibility/",
|
|
12383
|
+
"white-space": "https://www.caniemail.com/features/css-white-space/",
|
|
12384
|
+
"white-space-collapse": "https://www.caniemail.com/features/css-white-space-collapse/",
|
|
12385
|
+
"widows": "https://www.caniemail.com/features/css-widows/",
|
|
12386
|
+
"width": "https://www.caniemail.com/features/css-width/",
|
|
12387
|
+
"word-break": "https://www.caniemail.com/features/css-word-break/",
|
|
12388
|
+
"word-spacing": "https://www.caniemail.com/features/css-word-spacing/",
|
|
12389
|
+
"writing-mode": "https://www.caniemail.com/features/css-writing-mode/",
|
|
12390
|
+
"z-index": "https://www.caniemail.com/features/css-z-index/"
|
|
12391
|
+
};
|
|
12392
|
+
function featureUrl(code) {
|
|
12393
|
+
return FEATURE_URLS[code];
|
|
12394
|
+
}
|
|
12395
|
+
|
|
12056
12396
|
// src/spam-scorer.ts
|
|
12057
12397
|
var SPAM_TRIGGER_PHRASES = [
|
|
12058
12398
|
"act now",
|
|
@@ -12182,7 +12522,7 @@ function checkCapsRatio(text) {
|
|
|
12182
12522
|
return {
|
|
12183
12523
|
rule: "caps-ratio",
|
|
12184
12524
|
severity: "warning",
|
|
12185
|
-
message: `${Math.round(ratio * 100)}% of words are ALL CAPS
|
|
12525
|
+
message: `${Math.round(ratio * 100)}% of words are ALL CAPS; spam filters flag excessive capitalization.`,
|
|
12186
12526
|
detail: `Found ${capsWords.length} of ${words.length} words in all caps.`
|
|
12187
12527
|
};
|
|
12188
12528
|
}
|
|
@@ -12197,7 +12537,7 @@ function checkExcessivePunctuation(text) {
|
|
|
12197
12537
|
return {
|
|
12198
12538
|
rule: "excessive-punctuation",
|
|
12199
12539
|
severity: "warning",
|
|
12200
|
-
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$")
|
|
12540
|
+
message: `Excessive special characters detected (${exclamations} "!", ${dollars} "$"), a common spam trigger.`
|
|
12201
12541
|
};
|
|
12202
12542
|
}
|
|
12203
12543
|
return null;
|
|
@@ -12239,14 +12579,14 @@ function checkUnsubscribe($, text, options) {
|
|
|
12239
12579
|
return {
|
|
12240
12580
|
rule: "missing-unsubscribe",
|
|
12241
12581
|
severity: "info",
|
|
12242
|
-
message: "No unsubscribe link found, but email appears transactional
|
|
12582
|
+
message: "No unsubscribe link found, but email appears transactional, so it may not be required.",
|
|
12243
12583
|
detail: `Detected transactional signals: ${signalMatches.join(", ")}${hasOtp ? ", OTP code" : ""}`
|
|
12244
12584
|
};
|
|
12245
12585
|
}
|
|
12246
12586
|
return {
|
|
12247
12587
|
rule: "missing-unsubscribe",
|
|
12248
12588
|
severity: "error",
|
|
12249
|
-
message: "No unsubscribe link found
|
|
12589
|
+
message: "No unsubscribe link found; required by CAN-SPAM and GDPR. Most spam filters penalize this.",
|
|
12250
12590
|
detail: 'Add an <a> link with "unsubscribe" text or href.'
|
|
12251
12591
|
};
|
|
12252
12592
|
}
|
|
@@ -12296,7 +12636,7 @@ function checkHiddenText($) {
|
|
|
12296
12636
|
return {
|
|
12297
12637
|
rule: "hidden-text",
|
|
12298
12638
|
severity: "error",
|
|
12299
|
-
message: "Hidden text detected
|
|
12639
|
+
message: "Hidden text detected: a major spam filter red flag.",
|
|
12300
12640
|
detail
|
|
12301
12641
|
};
|
|
12302
12642
|
}
|
|
@@ -12319,7 +12659,7 @@ function checkUrlShorteners($) {
|
|
|
12319
12659
|
issues.push({
|
|
12320
12660
|
rule: "url-shortener",
|
|
12321
12661
|
severity: "warning",
|
|
12322
|
-
message: `URL shortener detected (${shortener})
|
|
12662
|
+
message: `URL shortener detected (${shortener}); spam filters distrust shortened links.`,
|
|
12323
12663
|
detail: href
|
|
12324
12664
|
});
|
|
12325
12665
|
}
|
|
@@ -12334,7 +12674,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12334
12674
|
return {
|
|
12335
12675
|
rule: "image-only",
|
|
12336
12676
|
severity: "error",
|
|
12337
|
-
message: `Image-heavy email with almost no text (${text.length} chars, ${images} images)
|
|
12677
|
+
message: `Image-heavy email with almost no text (${text.length} chars, ${images} images), likely to be flagged as spam or clipped.`
|
|
12338
12678
|
};
|
|
12339
12679
|
}
|
|
12340
12680
|
const ratio = images / (text.length / 100);
|
|
@@ -12342,7 +12682,7 @@ function checkImageToTextRatio($, text) {
|
|
|
12342
12682
|
return {
|
|
12343
12683
|
rule: "high-image-ratio",
|
|
12344
12684
|
severity: "warning",
|
|
12345
|
-
message: `High image-to-text ratio (${images} images for ${text.length} chars of text)
|
|
12685
|
+
message: `High image-to-text ratio (${images} images for ${text.length} chars of text); consider adding more text content.`
|
|
12346
12686
|
};
|
|
12347
12687
|
}
|
|
12348
12688
|
return null;
|
|
@@ -12373,7 +12713,7 @@ function checkDeceptiveLinks($) {
|
|
|
12373
12713
|
issues.push({
|
|
12374
12714
|
rule: "deceptive-link",
|
|
12375
12715
|
severity: "error",
|
|
12376
|
-
message: `Link text shows "${textDomain}" but links to "${hrefDomain}"
|
|
12716
|
+
message: `Link text shows "${textDomain}" but links to "${hrefDomain}": a phishing red flag.`,
|
|
12377
12717
|
detail: `Text: ${text}
|
|
12378
12718
|
Href: ${href}`
|
|
12379
12719
|
});
|
|
@@ -12412,7 +12752,7 @@ function checkPhysicalAddress($, text, options) {
|
|
|
12412
12752
|
return {
|
|
12413
12753
|
rule: "missing-physical-address",
|
|
12414
12754
|
severity: "warning",
|
|
12415
|
-
message: "No physical mailing address detected
|
|
12755
|
+
message: "No physical mailing address detected; required by CAN-SPAM for marketing emails.",
|
|
12416
12756
|
detail: "Include a street address or P.O. Box in the email footer."
|
|
12417
12757
|
};
|
|
12418
12758
|
}
|
|
@@ -12435,7 +12775,7 @@ function checkAllCapsTitle($) {
|
|
|
12435
12775
|
return {
|
|
12436
12776
|
rule: "all-caps-subject",
|
|
12437
12777
|
severity: "warning",
|
|
12438
|
-
message: "Email title/subject is ALL CAPS
|
|
12778
|
+
message: "Email title/subject is ALL CAPS: a common spam indicator."
|
|
12439
12779
|
};
|
|
12440
12780
|
}
|
|
12441
12781
|
return null;
|
|
@@ -12594,7 +12934,7 @@ function validateLinksFromDom($) {
|
|
|
12594
12934
|
issues.push(__spreadValues({
|
|
12595
12935
|
severity: "warning",
|
|
12596
12936
|
rule: "protocol-relative",
|
|
12597
|
-
message: "Protocol-relative URL may break in email clients
|
|
12937
|
+
message: "Protocol-relative URL may break in email clients; use https:// explicitly",
|
|
12598
12938
|
href: href.slice(0, 120),
|
|
12599
12939
|
text: text.slice(0, 80) || "(no text)"
|
|
12600
12940
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -12603,7 +12943,7 @@ function validateLinksFromDom($) {
|
|
|
12603
12943
|
issues.push(__spreadValues({
|
|
12604
12944
|
severity: "warning",
|
|
12605
12945
|
rule: "generic-link-text",
|
|
12606
|
-
message: `Link text "${text}" is vague
|
|
12946
|
+
message: `Link text "${text}" is vague; use descriptive text for accessibility and engagement`,
|
|
12607
12947
|
href: href.slice(0, 120),
|
|
12608
12948
|
text
|
|
12609
12949
|
}, elLoc ? { loc: elLoc } : {}));
|
|
@@ -12638,7 +12978,7 @@ function validateLinksFromDom($) {
|
|
|
12638
12978
|
issues.push(__spreadValues({
|
|
12639
12979
|
severity: "info",
|
|
12640
12980
|
rule: "long-url",
|
|
12641
|
-
message: "URL exceeds 2000 characters
|
|
12981
|
+
message: "URL exceeds 2000 characters, may be truncated by some email clients",
|
|
12642
12982
|
href: href.slice(0, 120) + "...",
|
|
12643
12983
|
text: text.slice(0, 80) || "(no text)"
|
|
12644
12984
|
}, hrefLoc ? { loc: hrefLoc } : {}));
|
|
@@ -12667,7 +13007,7 @@ function validateLinksFromDom($) {
|
|
|
12667
13007
|
issues.push({
|
|
12668
13008
|
severity: "info",
|
|
12669
13009
|
rule: "duplicate-links",
|
|
12670
|
-
message: `URL appears ${count} times
|
|
13010
|
+
message: `URL appears ${count} times; consider consolidating`,
|
|
12671
13011
|
href: href.slice(0, 120)
|
|
12672
13012
|
});
|
|
12673
13013
|
}
|
|
@@ -12679,6 +13019,8 @@ function validateLinks(html, options) {
|
|
|
12679
13019
|
}
|
|
12680
13020
|
|
|
12681
13021
|
// src/accessibility-checker.ts
|
|
13022
|
+
import * as cheerio5 from "cheerio";
|
|
13023
|
+
import * as csstree6 from "css-tree";
|
|
12682
13024
|
var RULE_PENALTY_CAPS = {
|
|
12683
13025
|
"img-missing-alt": 3,
|
|
12684
13026
|
"link-generic-text": 3,
|
|
@@ -12751,7 +13093,7 @@ function checkImageAlt($) {
|
|
|
12751
13093
|
issues.push(__spreadProps(__spreadValues({
|
|
12752
13094
|
severity: "info",
|
|
12753
13095
|
rule: "img-empty-alt",
|
|
12754
|
-
message: "Image has empty alt text
|
|
13096
|
+
message: "Image has empty alt text; verify it is decorative",
|
|
12755
13097
|
element: describeElement($, el)
|
|
12756
13098
|
}, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
|
|
12757
13099
|
details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
|
|
@@ -12826,7 +13168,352 @@ function checkTableAccessibility($) {
|
|
|
12826
13168
|
});
|
|
12827
13169
|
return issues;
|
|
12828
13170
|
}
|
|
12829
|
-
|
|
13171
|
+
var CLIENT_DEFAULT_BACKGROUND = { r: 255, g: 255, b: 255 };
|
|
13172
|
+
var CLIENT_DEFAULT_TEXT = { r: 0, g: 0, b: 0, a: 1 };
|
|
13173
|
+
var UNRESOLVABLE_PAINT_RE = /url\(|(?:linear|radial|conic)-gradient\(/i;
|
|
13174
|
+
var NON_RENDERING_TAGS = /* @__PURE__ */ new Set(["head", "style", "script", "title", "meta", "link"]);
|
|
13175
|
+
var CASCADE_PROPS = /* @__PURE__ */ new Set([
|
|
13176
|
+
"color",
|
|
13177
|
+
"background",
|
|
13178
|
+
"background-color",
|
|
13179
|
+
"background-image",
|
|
13180
|
+
"font-size",
|
|
13181
|
+
"font-weight",
|
|
13182
|
+
// Not for contrast itself, for deciding whether the text renders at all.
|
|
13183
|
+
"display",
|
|
13184
|
+
"visibility",
|
|
13185
|
+
"opacity"
|
|
13186
|
+
]);
|
|
13187
|
+
var DESKTOP_RENDER = { width: 640, dark: false };
|
|
13188
|
+
var MOBILE_RENDER = { width: 375, dark: false };
|
|
13189
|
+
var DARK_RENDER = { width: 640, dark: true };
|
|
13190
|
+
function mediaPx(raw) {
|
|
13191
|
+
const m = raw.match(/([\d.]+)\s*(px|em|rem)?/i);
|
|
13192
|
+
if (!m) return null;
|
|
13193
|
+
const n = parseFloat(m[1]);
|
|
13194
|
+
if (!Number.isFinite(n)) return null;
|
|
13195
|
+
return (m[2] || "px").toLowerCase() === "px" ? n : n * 16;
|
|
13196
|
+
}
|
|
13197
|
+
function mediaApplies(prelude, ctx) {
|
|
13198
|
+
const text = prelude.toLowerCase();
|
|
13199
|
+
if (/\bprint\b/.test(text) && !/\bscreen\b/.test(text)) return false;
|
|
13200
|
+
const scheme = text.match(/prefers-color-scheme\s*:\s*(dark|light)/);
|
|
13201
|
+
if (scheme && scheme[1] === "dark" !== ctx.dark) return false;
|
|
13202
|
+
for (const m of text.matchAll(/(max|min)-width\s*:\s*([^)]+)/g)) {
|
|
13203
|
+
const px = mediaPx(m[2]);
|
|
13204
|
+
if (px === null) continue;
|
|
13205
|
+
if (m[1] === "max" ? ctx.width > px : ctx.width < px) return false;
|
|
13206
|
+
}
|
|
13207
|
+
return true;
|
|
13208
|
+
}
|
|
13209
|
+
var FORWARDING_PSEUDOS = /* @__PURE__ */ new Set(["not", "is", "has", "matches", "any"]);
|
|
13210
|
+
function maxSpecificity(list) {
|
|
13211
|
+
let best = [0, 0, 0];
|
|
13212
|
+
list.children.forEach((sel) => {
|
|
13213
|
+
const s = specificityOfSelector(sel);
|
|
13214
|
+
if (moreSpecific(s, best)) best = s;
|
|
13215
|
+
});
|
|
13216
|
+
return best;
|
|
13217
|
+
}
|
|
13218
|
+
function specificityOfSelector(node) {
|
|
13219
|
+
const acc = [0, 0, 0];
|
|
13220
|
+
if (node.type !== "Selector") return acc;
|
|
13221
|
+
node.children.forEach((child) => {
|
|
13222
|
+
var _a;
|
|
13223
|
+
switch (child.type) {
|
|
13224
|
+
case "IdSelector":
|
|
13225
|
+
acc[0]++;
|
|
13226
|
+
break;
|
|
13227
|
+
case "ClassSelector":
|
|
13228
|
+
case "AttributeSelector":
|
|
13229
|
+
acc[1]++;
|
|
13230
|
+
break;
|
|
13231
|
+
case "TypeSelector":
|
|
13232
|
+
if (child.name !== "*") acc[2]++;
|
|
13233
|
+
break;
|
|
13234
|
+
case "PseudoElementSelector":
|
|
13235
|
+
acc[2]++;
|
|
13236
|
+
break;
|
|
13237
|
+
case "PseudoClassSelector": {
|
|
13238
|
+
const name = child.name.toLowerCase();
|
|
13239
|
+
if (name === "where") break;
|
|
13240
|
+
const inner = (_a = child.children) == null ? void 0 : _a.first;
|
|
13241
|
+
if (inner && inner.type === "SelectorList" && FORWARDING_PSEUDOS.has(name)) {
|
|
13242
|
+
const nested = maxSpecificity(inner);
|
|
13243
|
+
acc[0] += nested[0];
|
|
13244
|
+
acc[1] += nested[1];
|
|
13245
|
+
acc[2] += nested[2];
|
|
13246
|
+
break;
|
|
13247
|
+
}
|
|
13248
|
+
acc[1]++;
|
|
13249
|
+
break;
|
|
13250
|
+
}
|
|
13251
|
+
default:
|
|
13252
|
+
break;
|
|
13253
|
+
}
|
|
13254
|
+
});
|
|
13255
|
+
return acc;
|
|
13256
|
+
}
|
|
13257
|
+
function specificityFromTokens(selector) {
|
|
13258
|
+
const bare = selector.replace(/\([^)]*\)/g, "").replace(/["'][^"']*["']/g, "");
|
|
13259
|
+
return [
|
|
13260
|
+
(bare.match(/#[\w-]+/g) || []).length,
|
|
13261
|
+
(bare.match(/\.[\w-]+|\[[^\]]*\]|:[\w-]+/g) || []).length,
|
|
13262
|
+
(bare.match(/(?:^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length
|
|
13263
|
+
];
|
|
13264
|
+
}
|
|
13265
|
+
function specificityOf(selector) {
|
|
13266
|
+
try {
|
|
13267
|
+
const ast = csstree6.parse(selector, { context: "selector" });
|
|
13268
|
+
return specificityOfSelector(ast);
|
|
13269
|
+
} catch (e) {
|
|
13270
|
+
return specificityFromTokens(selector);
|
|
13271
|
+
}
|
|
13272
|
+
}
|
|
13273
|
+
function moreSpecific(a, b) {
|
|
13274
|
+
for (let i = 0; i < 3; i++) {
|
|
13275
|
+
if (a[i] !== b[i]) return a[i] > b[i];
|
|
13276
|
+
}
|
|
13277
|
+
return false;
|
|
13278
|
+
}
|
|
13279
|
+
function wins(next, current) {
|
|
13280
|
+
if (!current) return true;
|
|
13281
|
+
if (next.important !== current.important) return next.important;
|
|
13282
|
+
if (next.presentational !== current.presentational) return current.presentational;
|
|
13283
|
+
if (next.inline !== current.inline) return next.inline;
|
|
13284
|
+
if (moreSpecific(next.specificity, current.specificity)) return true;
|
|
13285
|
+
if (moreSpecific(current.specificity, next.specificity)) return false;
|
|
13286
|
+
return next.order >= current.order;
|
|
13287
|
+
}
|
|
13288
|
+
function computeCascade($, ctx) {
|
|
13289
|
+
const cascade = /* @__PURE__ */ new Map();
|
|
13290
|
+
let order = 0;
|
|
13291
|
+
const record = (el, prop, decl) => {
|
|
13292
|
+
let props = cascade.get(el);
|
|
13293
|
+
if (!props) {
|
|
13294
|
+
props = /* @__PURE__ */ new Map();
|
|
13295
|
+
cascade.set(el, props);
|
|
13296
|
+
}
|
|
13297
|
+
if (wins(decl, props.get(prop))) props.set(prop, decl);
|
|
13298
|
+
};
|
|
13299
|
+
$("[bgcolor]").each((_, el) => {
|
|
13300
|
+
const value = $(el).attr("bgcolor");
|
|
13301
|
+
if (!value) return;
|
|
13302
|
+
record(el, "background-color", {
|
|
13303
|
+
value,
|
|
13304
|
+
important: false,
|
|
13305
|
+
inline: false,
|
|
13306
|
+
presentational: true,
|
|
13307
|
+
specificity: [0, 0, 0],
|
|
13308
|
+
order: order++
|
|
13309
|
+
});
|
|
13310
|
+
});
|
|
13311
|
+
const handleRule = (node) => {
|
|
13312
|
+
const selectorList = csstree6.generate(node.prelude).trim();
|
|
13313
|
+
if (!selectorList) return;
|
|
13314
|
+
const decls = [];
|
|
13315
|
+
node.block.children.forEach((child) => {
|
|
13316
|
+
if (child.type !== "Declaration") return;
|
|
13317
|
+
const prop = child.property.toLowerCase();
|
|
13318
|
+
if (!CASCADE_PROPS.has(prop)) return;
|
|
13319
|
+
decls.push({ prop, value: csstree6.generate(child.value), important: !!child.important });
|
|
13320
|
+
});
|
|
13321
|
+
if (!decls.length) return;
|
|
13322
|
+
for (const selector of splitTopLevel(selectorList).map((x) => x.trim()).filter(Boolean)) {
|
|
13323
|
+
const specificity = specificityOf(selector);
|
|
13324
|
+
let matched;
|
|
13325
|
+
try {
|
|
13326
|
+
matched = $(selector).toArray();
|
|
13327
|
+
} catch (e) {
|
|
13328
|
+
continue;
|
|
13329
|
+
}
|
|
13330
|
+
for (const el of matched) {
|
|
13331
|
+
for (const d of decls) {
|
|
13332
|
+
record(el, d.prop, __spreadProps(__spreadValues({}, d), {
|
|
13333
|
+
inline: false,
|
|
13334
|
+
presentational: false,
|
|
13335
|
+
specificity,
|
|
13336
|
+
order: order++
|
|
13337
|
+
}));
|
|
13338
|
+
}
|
|
13339
|
+
}
|
|
13340
|
+
}
|
|
13341
|
+
};
|
|
13342
|
+
const visitBlock = (children) => {
|
|
13343
|
+
children.forEach((node) => {
|
|
13344
|
+
if (node.type === "Rule") {
|
|
13345
|
+
handleRule(node);
|
|
13346
|
+
return;
|
|
13347
|
+
}
|
|
13348
|
+
if (node.type !== "Atrule" || node.name.toLowerCase() !== "media" || !node.block) return;
|
|
13349
|
+
const prelude = node.prelude ? csstree6.generate(node.prelude) : "";
|
|
13350
|
+
if (!mediaApplies(prelude, ctx)) return;
|
|
13351
|
+
visitBlock(node.block.children);
|
|
13352
|
+
});
|
|
13353
|
+
};
|
|
13354
|
+
$("style").each((_, styleEl) => {
|
|
13355
|
+
let ast;
|
|
13356
|
+
try {
|
|
13357
|
+
ast = csstree6.parse($(styleEl).text());
|
|
13358
|
+
} catch (e) {
|
|
13359
|
+
return;
|
|
13360
|
+
}
|
|
13361
|
+
if (ast.type !== "StyleSheet") return;
|
|
13362
|
+
visitBlock(ast.children);
|
|
13363
|
+
});
|
|
13364
|
+
$("[style]").each((_, el) => {
|
|
13365
|
+
for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
|
|
13366
|
+
const colon = part.indexOf(":");
|
|
13367
|
+
if (colon === -1) continue;
|
|
13368
|
+
const prop = part.slice(0, colon).trim().toLowerCase();
|
|
13369
|
+
if (!CASCADE_PROPS.has(prop)) continue;
|
|
13370
|
+
let value = part.slice(colon + 1).trim();
|
|
13371
|
+
const important = /!\s*important$/i.test(value);
|
|
13372
|
+
if (important) value = value.replace(/!\s*important$/i, "").trim();
|
|
13373
|
+
record(el, prop, {
|
|
13374
|
+
value,
|
|
13375
|
+
important,
|
|
13376
|
+
inline: true,
|
|
13377
|
+
presentational: false,
|
|
13378
|
+
specificity: [0, 0, 0],
|
|
13379
|
+
order: order++
|
|
13380
|
+
});
|
|
13381
|
+
}
|
|
13382
|
+
});
|
|
13383
|
+
return cascade;
|
|
13384
|
+
}
|
|
13385
|
+
function paintedColor(value) {
|
|
13386
|
+
if (!value) return null;
|
|
13387
|
+
const solid = backgroundShorthandColor(value);
|
|
13388
|
+
if (solid) {
|
|
13389
|
+
const c = parseColor(solid);
|
|
13390
|
+
if (c && c.a > 0) return [c, ...gradientStops(value)];
|
|
13391
|
+
}
|
|
13392
|
+
const stops = gradientStops(value);
|
|
13393
|
+
if (stops.length) return stops;
|
|
13394
|
+
return UNRESOLVABLE_PAINT_RE.test(value) ? "unknown" : null;
|
|
13395
|
+
}
|
|
13396
|
+
function asColors(painted) {
|
|
13397
|
+
return painted && painted !== "unknown" ? painted : [];
|
|
13398
|
+
}
|
|
13399
|
+
function elementBackground(props) {
|
|
13400
|
+
const shorthand = props.get("background");
|
|
13401
|
+
const longhand = props.get("background-color");
|
|
13402
|
+
const image = props.get("background-image");
|
|
13403
|
+
const colorDecl = shorthand && wins(shorthand, longhand) ? shorthand : longhand;
|
|
13404
|
+
const candidates = [
|
|
13405
|
+
...colorDecl ? asColors(paintedColor(colorDecl.value)) : [],
|
|
13406
|
+
...image ? asColors(paintedColor(image.value)) : []
|
|
13407
|
+
];
|
|
13408
|
+
if (candidates.length) return candidates;
|
|
13409
|
+
for (const decl of [colorDecl, image]) {
|
|
13410
|
+
if (decl && UNRESOLVABLE_PAINT_RE.test(decl.value)) return "unknown";
|
|
13411
|
+
}
|
|
13412
|
+
return null;
|
|
13413
|
+
}
|
|
13414
|
+
function resolveBackground($, el, cascade) {
|
|
13415
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13416
|
+
const props = cascade.get(node);
|
|
13417
|
+
if (!props) continue;
|
|
13418
|
+
const painted = elementBackground(props);
|
|
13419
|
+
if (painted !== null) return painted;
|
|
13420
|
+
}
|
|
13421
|
+
return null;
|
|
13422
|
+
}
|
|
13423
|
+
function isLargeText(size, weight) {
|
|
13424
|
+
const match = size == null ? void 0 : size.match(/^(\d+(?:\.\d+)?)(px|pt)/i);
|
|
13425
|
+
if (!match) return false;
|
|
13426
|
+
const px = match[2].toLowerCase() === "pt" ? parseFloat(match[1]) * 1.333 : parseFloat(match[1]);
|
|
13427
|
+
const w = weight == null ? void 0 : weight.trim().toLowerCase();
|
|
13428
|
+
const bold = w === "bold" || w === "bolder" || !!w && parseInt(w, 10) >= 700;
|
|
13429
|
+
return px >= 18 || px >= 14 && bold;
|
|
13430
|
+
}
|
|
13431
|
+
function inherited($, el, cascade, prop) {
|
|
13432
|
+
var _a, _b;
|
|
13433
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13434
|
+
const value = (_b = (_a = cascade.get(node)) == null ? void 0 : _a.get(prop)) == null ? void 0 : _b.value;
|
|
13435
|
+
if (value) return { value, source: node };
|
|
13436
|
+
}
|
|
13437
|
+
return null;
|
|
13438
|
+
}
|
|
13439
|
+
function isHiddenText($, el, cascade) {
|
|
13440
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
13441
|
+
for (const node of [el, ...$(el).parents().toArray()]) {
|
|
13442
|
+
const props = cascade.get(node);
|
|
13443
|
+
if (!props) continue;
|
|
13444
|
+
const display = (_b = (_a = props.get("display")) == null ? void 0 : _a.value) == null ? void 0 : _b.trim().toLowerCase();
|
|
13445
|
+
if (display === "none") return true;
|
|
13446
|
+
const visibility = (_d = (_c = props.get("visibility")) == null ? void 0 : _c.value) == null ? void 0 : _d.trim().toLowerCase();
|
|
13447
|
+
if (visibility === "hidden" || visibility === "collapse") return true;
|
|
13448
|
+
const opacity = (_f = (_e = props.get("opacity")) == null ? void 0 : _e.value) == null ? void 0 : _f.trim();
|
|
13449
|
+
if (opacity !== void 0 && parseFloat(opacity) === 0) return true;
|
|
13450
|
+
const size = (_h = (_g = props.get("font-size")) == null ? void 0 : _g.value) == null ? void 0 : _h.trim();
|
|
13451
|
+
if (size !== void 0 && /^0(?:px|pt|em|rem|%)?$/i.test(size)) return true;
|
|
13452
|
+
}
|
|
13453
|
+
return $(el).closest("[style*='mso-hide']").length > 0;
|
|
13454
|
+
}
|
|
13455
|
+
function hasOwnText($, el) {
|
|
13456
|
+
return $(el).contents().toArray().some((n) => n.type === "text" && typeof n.data === "string" && n.data.trim().length > 0);
|
|
13457
|
+
}
|
|
13458
|
+
function checkContrast($, cascade) {
|
|
13459
|
+
const issues = [];
|
|
13460
|
+
$("*").each((_, el) => {
|
|
13461
|
+
var _a, _b, _c, _d, _e;
|
|
13462
|
+
const tag = (el.tagName || "").toLowerCase();
|
|
13463
|
+
if (NON_RENDERING_TAGS.has(tag)) return;
|
|
13464
|
+
if (!hasOwnText($, el)) return;
|
|
13465
|
+
if (isHiddenText($, el, cascade)) return;
|
|
13466
|
+
const declared = inherited($, el, cascade, "color");
|
|
13467
|
+
const fg = declared ? parseColor(declared.value) : CLIENT_DEFAULT_TEXT;
|
|
13468
|
+
if (!fg) return;
|
|
13469
|
+
const colorValue = (_a = declared == null ? void 0 : declared.value) != null ? _a : "the client default";
|
|
13470
|
+
const background = resolveBackground($, el, cascade);
|
|
13471
|
+
if (background === "unknown") return;
|
|
13472
|
+
const surfaces = background != null ? background : [null];
|
|
13473
|
+
let ratio = Infinity;
|
|
13474
|
+
let worst = "";
|
|
13475
|
+
for (const surface of surfaces) {
|
|
13476
|
+
let { r: bgR, g: bgG, b: bgB } = surface != null ? surface : CLIENT_DEFAULT_BACKGROUND;
|
|
13477
|
+
if (surface && surface.a < 1) {
|
|
13478
|
+
[bgR, bgG, bgB] = alphaBlend(surface, 255, 255, 255);
|
|
13479
|
+
}
|
|
13480
|
+
const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
|
|
13481
|
+
const candidate = contrastRatio(relativeLuminance(fR, fG, fB), relativeLuminance(bgR, bgG, bgB));
|
|
13482
|
+
if (candidate < ratio) {
|
|
13483
|
+
ratio = candidate;
|
|
13484
|
+
worst = formatRgb(surface != null ? surface : __spreadProps(__spreadValues({}, CLIENT_DEFAULT_BACKGROUND), { a: 1 }));
|
|
13485
|
+
}
|
|
13486
|
+
}
|
|
13487
|
+
const against = surfaces.length > 1 ? ` (worst gradient stop ${worst})` : "";
|
|
13488
|
+
const large = isLargeText(
|
|
13489
|
+
(_b = inherited($, el, cascade, "font-size")) == null ? void 0 : _b.value,
|
|
13490
|
+
(_c = inherited($, el, cascade, "font-weight")) == null ? void 0 : _c.value
|
|
13491
|
+
);
|
|
13492
|
+
const grade = wcagGrade(ratio);
|
|
13493
|
+
const styleLoc = (_e = locOfAttr((_d = declared == null ? void 0 : declared.source) != null ? _d : el, "style")) != null ? _e : locOfAttr(el, "style");
|
|
13494
|
+
if (grade === "Fail") {
|
|
13495
|
+
issues.push(__spreadProps(__spreadValues({
|
|
13496
|
+
severity: "error",
|
|
13497
|
+
rule: "low-contrast",
|
|
13498
|
+
message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG minimum`,
|
|
13499
|
+
element: describeElement($, el)
|
|
13500
|
+
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13501
|
+
details: `Foreground ${colorValue} on background${against} needs at least ${large ? "3:1" : "4.5:1"} contrast ratio.`
|
|
13502
|
+
}));
|
|
13503
|
+
} else if (!large && grade === "AA Large") {
|
|
13504
|
+
issues.push(__spreadProps(__spreadValues({
|
|
13505
|
+
severity: "warning",
|
|
13506
|
+
rule: "low-contrast",
|
|
13507
|
+
message: `Low contrast ratio ${ratio.toFixed(1)}:1, fails WCAG AA for normal text`,
|
|
13508
|
+
element: describeElement($, el)
|
|
13509
|
+
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
13510
|
+
details: `Foreground ${colorValue} on background${against} needs at least 4.5:1 for normal-sized text.`
|
|
13511
|
+
}));
|
|
13512
|
+
}
|
|
13513
|
+
});
|
|
13514
|
+
return issues;
|
|
13515
|
+
}
|
|
13516
|
+
function checkTextSize($) {
|
|
12830
13517
|
const issues = [];
|
|
12831
13518
|
let smallTextCount = 0;
|
|
12832
13519
|
$("[style]").each((_, el) => {
|
|
@@ -12851,67 +13538,6 @@ function checkTextSizeAndContrast($) {
|
|
|
12851
13538
|
}
|
|
12852
13539
|
}
|
|
12853
13540
|
}
|
|
12854
|
-
const colorValue = getStyleValue(style, "color");
|
|
12855
|
-
if (colorValue) {
|
|
12856
|
-
const fg = parseColor(colorValue);
|
|
12857
|
-
if (fg) {
|
|
12858
|
-
let bgR = 255, bgG = 255, bgB = 255;
|
|
12859
|
-
let current = $(el);
|
|
12860
|
-
let foundBg = false;
|
|
12861
|
-
const elements = [current, ...$(el).parents("[style]").toArray().map((p) => $(p))];
|
|
12862
|
-
for (const ancestor of elements) {
|
|
12863
|
-
const ancestorStyle = (typeof ancestor === "function" ? ancestor : $(ancestor)).attr("style") || "";
|
|
12864
|
-
const bgValue = getStyleValue(ancestorStyle, "background-color");
|
|
12865
|
-
if (bgValue) {
|
|
12866
|
-
const bg = parseColor(bgValue);
|
|
12867
|
-
if (bg && bg.a > 0) {
|
|
12868
|
-
if (bg.a < 1) {
|
|
12869
|
-
[bgR, bgG, bgB] = alphaBlend(bg, 255, 255, 255);
|
|
12870
|
-
} else {
|
|
12871
|
-
bgR = bg.r;
|
|
12872
|
-
bgG = bg.g;
|
|
12873
|
-
bgB = bg.b;
|
|
12874
|
-
}
|
|
12875
|
-
foundBg = true;
|
|
12876
|
-
break;
|
|
12877
|
-
}
|
|
12878
|
-
}
|
|
12879
|
-
}
|
|
12880
|
-
const [fR, fG, fB] = fg.a < 1 ? alphaBlend(fg, bgR, bgG, bgB) : [fg.r, fg.g, fg.b];
|
|
12881
|
-
const fgLum = relativeLuminance(fR, fG, fB);
|
|
12882
|
-
const bgLum = relativeLuminance(bgR, bgG, bgB);
|
|
12883
|
-
const ratio = contrastRatio(fgLum, bgLum);
|
|
12884
|
-
let isLargeText = false;
|
|
12885
|
-
if (fontSizeMatch) {
|
|
12886
|
-
const size = parseFloat(fontSizeMatch[1]);
|
|
12887
|
-
const unit = fontSizeMatch[2].toLowerCase();
|
|
12888
|
-
const pxSize = unit === "pt" ? size * 1.333 : size;
|
|
12889
|
-
const fontWeight = getStyleValue(style, "font-weight");
|
|
12890
|
-
const isBold = fontWeight === "bold" || fontWeight === "bolder" || fontWeight && parseInt(fontWeight, 10) >= 700;
|
|
12891
|
-
isLargeText = pxSize >= 18 || pxSize >= 14 && !!isBold;
|
|
12892
|
-
}
|
|
12893
|
-
const grade = wcagGrade(ratio);
|
|
12894
|
-
if (grade === "Fail") {
|
|
12895
|
-
issues.push(__spreadProps(__spreadValues({
|
|
12896
|
-
severity: "error",
|
|
12897
|
-
rule: "low-contrast",
|
|
12898
|
-
message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG minimum`,
|
|
12899
|
-
element: describeElement($, el)
|
|
12900
|
-
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
12901
|
-
details: `Foreground ${colorValue} on background needs at least ${isLargeText ? "3:1" : "4.5:1"} contrast ratio.`
|
|
12902
|
-
}));
|
|
12903
|
-
} else if (!isLargeText && grade === "AA Large") {
|
|
12904
|
-
issues.push(__spreadProps(__spreadValues({
|
|
12905
|
-
severity: "warning",
|
|
12906
|
-
rule: "low-contrast",
|
|
12907
|
-
message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG AA for normal text`,
|
|
12908
|
-
element: describeElement($, el)
|
|
12909
|
-
}, styleLoc ? { loc: styleLoc } : {}), {
|
|
12910
|
-
details: `Foreground ${colorValue} on background needs at least 4.5:1 for normal-sized text.`
|
|
12911
|
-
}));
|
|
12912
|
-
}
|
|
12913
|
-
}
|
|
12914
|
-
}
|
|
12915
13541
|
});
|
|
12916
13542
|
if (smallTextCount > 3) {
|
|
12917
13543
|
issues.push({
|
|
@@ -12971,7 +13597,8 @@ function checkAccessibilityFromDom($) {
|
|
|
12971
13597
|
issues.push(...checkImageAlt($));
|
|
12972
13598
|
issues.push(...checkLinkAccessibility($));
|
|
12973
13599
|
issues.push(...checkTableAccessibility($));
|
|
12974
|
-
issues.push(...
|
|
13600
|
+
issues.push(...checkTextSize($));
|
|
13601
|
+
issues.push(...checkContrast($, computeCascade($, DESKTOP_RENDER)));
|
|
12975
13602
|
issues.push(...checkSemanticStructure($));
|
|
12976
13603
|
const charsetIssue = checkCharsetDeclaration($);
|
|
12977
13604
|
if (charsetIssue) issues.push(charsetIssue);
|
|
@@ -13000,6 +13627,76 @@ function checkAccessibilityFromDom($) {
|
|
|
13000
13627
|
function checkAccessibility(html, options) {
|
|
13001
13628
|
return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom, options);
|
|
13002
13629
|
}
|
|
13630
|
+
function checkDarkStylesContrastFromDom($, lightIssues) {
|
|
13631
|
+
const baseline = lightIssues != null ? lightIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
|
|
13632
|
+
const dark = checkContrast($, computeCascade($, DARK_RENDER));
|
|
13633
|
+
return contrastDelta(
|
|
13634
|
+
dark,
|
|
13635
|
+
baseline,
|
|
13636
|
+
"low-contrast-dark",
|
|
13637
|
+
"Dark mode",
|
|
13638
|
+
"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."
|
|
13639
|
+
);
|
|
13640
|
+
}
|
|
13641
|
+
var DARK_CONTRAST_CLIENTS = ["gmail-android", "gmail-ios"];
|
|
13642
|
+
function lowContrastOnly(issues) {
|
|
13643
|
+
return issues.filter((i) => i.rule === "low-contrast");
|
|
13644
|
+
}
|
|
13645
|
+
function contrastDelta(candidates, baseline, rule, prefix, details) {
|
|
13646
|
+
const seen = new Set(lowContrastOnly(baseline).map((i) => i.element));
|
|
13647
|
+
return lowContrastOnly(candidates).filter((issue) => {
|
|
13648
|
+
if (seen.has(issue.element)) return false;
|
|
13649
|
+
seen.add(issue.element);
|
|
13650
|
+
return true;
|
|
13651
|
+
}).map((issue) => __spreadProps(__spreadValues(__spreadValues({
|
|
13652
|
+
severity: issue.severity,
|
|
13653
|
+
rule,
|
|
13654
|
+
message: `${prefix}: ${issue.message}`
|
|
13655
|
+
}, issue.element ? { element: issue.element } : {}), issue.loc ? { loc: issue.loc } : {}), {
|
|
13656
|
+
details
|
|
13657
|
+
}));
|
|
13658
|
+
}
|
|
13659
|
+
function checkMobileContrastFromDom($, desktopIssues) {
|
|
13660
|
+
const baseline = desktopIssues != null ? desktopIssues : checkContrast($, computeCascade($, DESKTOP_RENDER));
|
|
13661
|
+
const mobile = checkContrast($, computeCascade($, MOBILE_RENDER));
|
|
13662
|
+
return contrastDelta(
|
|
13663
|
+
mobile,
|
|
13664
|
+
baseline,
|
|
13665
|
+
"low-contrast-mobile",
|
|
13666
|
+
"At mobile width",
|
|
13667
|
+
"This colour pairing only appears below the email's breakpoint. Check the contrast of what the media query restyles, not just the desktop palette."
|
|
13668
|
+
);
|
|
13669
|
+
}
|
|
13670
|
+
function checkMobileContrast(html, options) {
|
|
13671
|
+
return fromHtml(html, [], ($) => checkMobileContrastFromDom($), options);
|
|
13672
|
+
}
|
|
13673
|
+
function checkDarkModeContrast(html, lightIssues) {
|
|
13674
|
+
if (!html || !html.trim()) return [];
|
|
13675
|
+
const inverted = [];
|
|
13676
|
+
for (const client of DARK_CONTRAST_CLIENTS) {
|
|
13677
|
+
let darkHtml;
|
|
13678
|
+
try {
|
|
13679
|
+
darkHtml = simulateDarkMode(html, client).html;
|
|
13680
|
+
} catch (e) {
|
|
13681
|
+
continue;
|
|
13682
|
+
}
|
|
13683
|
+
const $dark = cheerio5.load(darkHtml);
|
|
13684
|
+
for (const issue of checkContrast($dark, computeCascade($dark, DESKTOP_RENDER))) {
|
|
13685
|
+
const _a = issue, { loc, locs, locsTruncated } = _a, rest = __objRest(_a, ["loc", "locs", "locsTruncated"]);
|
|
13686
|
+
void loc;
|
|
13687
|
+
void locs;
|
|
13688
|
+
void locsTruncated;
|
|
13689
|
+
inverted.push(rest);
|
|
13690
|
+
}
|
|
13691
|
+
}
|
|
13692
|
+
return contrastDelta(
|
|
13693
|
+
inverted,
|
|
13694
|
+
lightIssues != null ? lightIssues : checkAccessibility(html).issues,
|
|
13695
|
+
"low-contrast-dark",
|
|
13696
|
+
"Dark mode",
|
|
13697
|
+
"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."
|
|
13698
|
+
);
|
|
13699
|
+
}
|
|
13003
13700
|
|
|
13004
13701
|
// src/image-analyzer.ts
|
|
13005
13702
|
var DATA_URI_WARN_BYTES = 100 * 1024;
|
|
@@ -13073,7 +13770,7 @@ function analyzeImagesFromDom($) {
|
|
|
13073
13770
|
issues.push(__spreadValues({
|
|
13074
13771
|
rule: "missing-dimensions",
|
|
13075
13772
|
severity: "warning",
|
|
13076
|
-
message: "Image missing width/height attributes
|
|
13773
|
+
message: "Image missing width/height attributes, causes layout shifts and Outlook rendering issues.",
|
|
13077
13774
|
src: truncateSrc(src)
|
|
13078
13775
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13079
13776
|
}
|
|
@@ -13084,7 +13781,7 @@ function analyzeImagesFromDom($) {
|
|
|
13084
13781
|
issues.push(__spreadValues({
|
|
13085
13782
|
rule: "large-data-uri",
|
|
13086
13783
|
severity: "warning",
|
|
13087
|
-
message: `Data URI is ${kb}KB
|
|
13784
|
+
message: `Data URI is ${kb}KB; consider hosting the image externally to reduce email size.`,
|
|
13088
13785
|
src: truncateSrc(src)
|
|
13089
13786
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13090
13787
|
}
|
|
@@ -13093,7 +13790,7 @@ function analyzeImagesFromDom($) {
|
|
|
13093
13790
|
issues.push(__spreadValues({
|
|
13094
13791
|
rule: "missing-alt",
|
|
13095
13792
|
severity: "warning",
|
|
13096
|
-
message: "Image missing alt attribute
|
|
13793
|
+
message: "Image missing alt attribute, hurts deliverability and accessibility.",
|
|
13097
13794
|
src: truncateSrc(src)
|
|
13098
13795
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13099
13796
|
}
|
|
@@ -13102,7 +13799,7 @@ function analyzeImagesFromDom($) {
|
|
|
13102
13799
|
issues.push(__spreadValues({
|
|
13103
13800
|
rule: "webp-format",
|
|
13104
13801
|
severity: "info",
|
|
13105
|
-
message: "WebP format detected
|
|
13802
|
+
message: "WebP format detected, not supported by all email clients. Consider PNG or JPEG.",
|
|
13106
13803
|
src: truncateSrc(src)
|
|
13107
13804
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13108
13805
|
}
|
|
@@ -13111,7 +13808,7 @@ function analyzeImagesFromDom($) {
|
|
|
13111
13808
|
issues.push(__spreadValues({
|
|
13112
13809
|
rule: "svg-format",
|
|
13113
13810
|
severity: "info",
|
|
13114
|
-
message: "SVG format detected
|
|
13811
|
+
message: "SVG format detected, not supported by most email clients. Use PNG instead.",
|
|
13115
13812
|
src: truncateSrc(src)
|
|
13116
13813
|
}, srcLoc ? { loc: srcLoc } : {}));
|
|
13117
13814
|
}
|
|
@@ -13120,7 +13817,7 @@ function analyzeImagesFromDom($) {
|
|
|
13120
13817
|
issues.push(__spreadValues({
|
|
13121
13818
|
rule: "missing-display-block",
|
|
13122
13819
|
severity: "info",
|
|
13123
|
-
message: "Image without display:block
|
|
13820
|
+
message: "Image without display:block, may cause unwanted gaps in Outlook.",
|
|
13124
13821
|
src: truncateSrc(src)
|
|
13125
13822
|
}, elLoc ? { loc: elLoc } : {}));
|
|
13126
13823
|
}
|
|
@@ -13139,7 +13836,7 @@ function analyzeImagesFromDom($) {
|
|
|
13139
13836
|
issues.push({
|
|
13140
13837
|
rule: "high-image-count",
|
|
13141
13838
|
severity: "info",
|
|
13142
|
-
message: `Email contains ${nonTrackingImages.length} images
|
|
13839
|
+
message: `Email contains ${nonTrackingImages.length} images; heavy emails may be clipped or load slowly.`
|
|
13143
13840
|
});
|
|
13144
13841
|
}
|
|
13145
13842
|
const trackingPixels = images.filter((i) => i.isTrackingPixel);
|
|
@@ -13155,7 +13852,7 @@ function analyzeImagesFromDom($) {
|
|
|
13155
13852
|
issues.push({
|
|
13156
13853
|
rule: "total-data-uri-size",
|
|
13157
13854
|
severity: "warning",
|
|
13158
|
-
message: `Total data URI size is ${kb}KB
|
|
13855
|
+
message: `Total data URI size is ${kb}KB; consider hosting images externally to reduce email size.`
|
|
13159
13856
|
});
|
|
13160
13857
|
}
|
|
13161
13858
|
return { total: images.length, totalDataUriBytes, issues, images };
|
|
@@ -13196,7 +13893,7 @@ function extractInboxPreviewFromDom($) {
|
|
|
13196
13893
|
issues.push({
|
|
13197
13894
|
rule: "subject-too-long",
|
|
13198
13895
|
severity: "warning",
|
|
13199
|
-
message: `Subject is ${subjectLength} characters
|
|
13896
|
+
message: `Subject is ${subjectLength} characters, and may be truncated in inboxes (recommended: ${MAX_SUBJECT_LENGTH} or fewer).`
|
|
13200
13897
|
});
|
|
13201
13898
|
}
|
|
13202
13899
|
const preheader = extractPreheaderText($);
|
|
@@ -13211,27 +13908,27 @@ function extractInboxPreviewFromDom($) {
|
|
|
13211
13908
|
issues.push({
|
|
13212
13909
|
rule: "preheader-too-short",
|
|
13213
13910
|
severity: "warning",
|
|
13214
|
-
message: `Preheader is only ${preheaderLength} characters
|
|
13911
|
+
message: `Preheader is only ${preheaderLength} characters, so email clients may backfill with body text (recommended: ${MIN_PREHEADER_LENGTH}+).`
|
|
13215
13912
|
});
|
|
13216
13913
|
} else if (preheaderLength > MAX_PREHEADER_LENGTH) {
|
|
13217
13914
|
issues.push({
|
|
13218
13915
|
rule: "preheader-too-long",
|
|
13219
13916
|
severity: "info",
|
|
13220
|
-
message: `Preheader is ${preheaderLength} characters
|
|
13917
|
+
message: `Preheader is ${preheaderLength} characters; most clients show 40\u2013100 characters. Text beyond that is hidden.`
|
|
13221
13918
|
});
|
|
13222
13919
|
}
|
|
13223
13920
|
if (hasZwnjPadding($)) {
|
|
13224
13921
|
issues.push({
|
|
13225
13922
|
rule: "zwnj-padding",
|
|
13226
13923
|
severity: "info",
|
|
13227
|
-
message: "Preheader uses ‌ padding hack
|
|
13924
|
+
message: "Preheader uses ‌ padding hack, works in most clients but may show garbled text in some."
|
|
13228
13925
|
});
|
|
13229
13926
|
}
|
|
13230
13927
|
if (subject && EMOJI_PATTERN.test(subject)) {
|
|
13231
13928
|
issues.push({
|
|
13232
13929
|
rule: "emoji-in-subject",
|
|
13233
13930
|
severity: "info",
|
|
13234
|
-
message: "Subject line contains emoji
|
|
13931
|
+
message: "Subject line contains emoji, renders inconsistently across email clients and may trigger spam filters."
|
|
13235
13932
|
});
|
|
13236
13933
|
}
|
|
13237
13934
|
const truncation = computeTruncation(subject, preheader);
|
|
@@ -13362,14 +14059,14 @@ function checkSizeFromDom(_$, html) {
|
|
|
13362
14059
|
issues.push({
|
|
13363
14060
|
rule: "gmail-clipped",
|
|
13364
14061
|
severity: "error",
|
|
13365
|
-
message: `Email is ${humanSize}
|
|
14062
|
+
message: `Email is ${humanSize}; Gmail will clip it at ~102 KB. Recipients see a "View entire message" link instead of your content.`,
|
|
13366
14063
|
detail: `${htmlBytes} bytes exceeds the ${GMAIL_CLIP_THRESHOLD} byte threshold.`
|
|
13367
14064
|
});
|
|
13368
14065
|
} else if (htmlBytes > GMAIL_CLIP_WARNING_THRESHOLD) {
|
|
13369
14066
|
issues.push({
|
|
13370
14067
|
rule: "gmail-clip-warning",
|
|
13371
14068
|
severity: "warning",
|
|
13372
|
-
message: `Email is ${humanSize}
|
|
14069
|
+
message: `Email is ${humanSize}, approaching Gmail's ~102 KB clip threshold. Consider trimming.`,
|
|
13373
14070
|
detail: `${htmlBytes} bytes is within ${GMAIL_CLIP_THRESHOLD - htmlBytes} bytes of the clip threshold.`
|
|
13374
14071
|
});
|
|
13375
14072
|
}
|
|
@@ -13487,7 +14184,7 @@ function checkTemplateVariables(html, options) {
|
|
|
13487
14184
|
}
|
|
13488
14185
|
|
|
13489
14186
|
// src/overflow-checker.ts
|
|
13490
|
-
import * as
|
|
14187
|
+
import * as csstree7 from "css-tree";
|
|
13491
14188
|
function fixedPxWidth($el) {
|
|
13492
14189
|
const style = $el.attr("style") || "";
|
|
13493
14190
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
@@ -13509,7 +14206,7 @@ function addWidthIssue(width, label, issues, seen, loc) {
|
|
|
13509
14206
|
const issue = __spreadValues({
|
|
13510
14207
|
rule: "fixed-width-overflow",
|
|
13511
14208
|
severity: "warning",
|
|
13512
|
-
message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame
|
|
14209
|
+
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.`,
|
|
13513
14210
|
detail: `Use width:100% with max-width:${EMAIL_MAX_WIDTH}px instead of a fixed width beyond the frame.`
|
|
13514
14211
|
}, loc ? { loc, locs: [loc] } : {});
|
|
13515
14212
|
seen.set(key, issue);
|
|
@@ -13563,11 +14260,11 @@ function checkOverflowFromDom($, source) {
|
|
|
13563
14260
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
13564
14261
|
let ast;
|
|
13565
14262
|
try {
|
|
13566
|
-
ast =
|
|
14263
|
+
ast = csstree7.parse(cssText, { positions: true });
|
|
13567
14264
|
} catch (e) {
|
|
13568
14265
|
return;
|
|
13569
14266
|
}
|
|
13570
|
-
|
|
14267
|
+
csstree7.walk(ast, {
|
|
13571
14268
|
visit: "Rule",
|
|
13572
14269
|
enter(node) {
|
|
13573
14270
|
if (node.type !== "Rule") return;
|
|
@@ -13577,7 +14274,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13577
14274
|
node.block.children.forEach((child) => {
|
|
13578
14275
|
if (child.type !== "Declaration") return;
|
|
13579
14276
|
const prop = child.property.toLowerCase();
|
|
13580
|
-
const val =
|
|
14277
|
+
const val = csstree7.generate(child.value);
|
|
13581
14278
|
if (prop === "width") {
|
|
13582
14279
|
const m = val.match(/^(\d+)px$/);
|
|
13583
14280
|
if (m) {
|
|
@@ -13590,7 +14287,7 @@ function checkOverflowFromDom($, source) {
|
|
|
13590
14287
|
}
|
|
13591
14288
|
});
|
|
13592
14289
|
if (widthPx !== null && widthPx > EMAIL_MAX_WIDTH && !fluid) {
|
|
13593
|
-
const selector =
|
|
14290
|
+
const selector = csstree7.generate(node.prelude).trim().slice(0, 40);
|
|
13594
14291
|
addWidthIssue(widthPx, selector || "rule", issues, seen, widthLoc);
|
|
13595
14292
|
}
|
|
13596
14293
|
}
|
|
@@ -13634,7 +14331,7 @@ function checkOverflow(html, options) {
|
|
|
13634
14331
|
}
|
|
13635
14332
|
|
|
13636
14333
|
// src/visual-checker.ts
|
|
13637
|
-
import * as
|
|
14334
|
+
import * as csstree8 from "css-tree";
|
|
13638
14335
|
var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
|
|
13639
14336
|
var GRADIENT_RE = /(?:linear|radial|conic)-gradient\(/i;
|
|
13640
14337
|
function isSolidColor(value) {
|
|
@@ -13707,7 +14404,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
13707
14404
|
const issue = __spreadValues({
|
|
13708
14405
|
rule: "missing-background-fallback",
|
|
13709
14406
|
severity: "warning",
|
|
13710
|
-
message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback
|
|
14407
|
+
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.`,
|
|
13711
14408
|
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.`,
|
|
13712
14409
|
fix
|
|
13713
14410
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -13727,7 +14424,7 @@ function inspectDeclarations(style, issues, seen, locs) {
|
|
|
13727
14424
|
const issue = __spreadValues({
|
|
13728
14425
|
rule: "missing-font-fallback",
|
|
13729
14426
|
severity: "warning",
|
|
13730
|
-
message: `font-family "${font.trim()}" has no web-safe fallback
|
|
14427
|
+
message: `font-family "${font.trim()}" has no web-safe fallback; clients that strip web fonts (Gmail, Outlook) fall back to Times New Roman.`,
|
|
13731
14428
|
detail: `End the stack with a web-safe font and a generic family.`,
|
|
13732
14429
|
fix
|
|
13733
14430
|
}, loc ? { loc, locs: [loc] } : {});
|
|
@@ -13740,7 +14437,7 @@ function ruleToMap(node) {
|
|
|
13740
14437
|
const map = /* @__PURE__ */ new Map();
|
|
13741
14438
|
node.block.children.forEach((child) => {
|
|
13742
14439
|
if (child.type === "Declaration") {
|
|
13743
|
-
map.set(child.property.toLowerCase(),
|
|
14440
|
+
map.set(child.property.toLowerCase(), csstree8.generate(child.value));
|
|
13744
14441
|
}
|
|
13745
14442
|
});
|
|
13746
14443
|
return map;
|
|
@@ -13759,11 +14456,11 @@ function checkVisualFromDom($, source) {
|
|
|
13759
14456
|
const anchor = cssBlockAnchor(el, cssText, source);
|
|
13760
14457
|
let ast;
|
|
13761
14458
|
try {
|
|
13762
|
-
ast =
|
|
14459
|
+
ast = csstree8.parse(cssText, { positions: true });
|
|
13763
14460
|
} catch (e) {
|
|
13764
14461
|
return;
|
|
13765
14462
|
}
|
|
13766
|
-
|
|
14463
|
+
csstree8.walk(ast, {
|
|
13767
14464
|
visit: "Rule",
|
|
13768
14465
|
enter(node) {
|
|
13769
14466
|
if (node.type !== "Rule") return;
|
|
@@ -13788,6 +14485,169 @@ function checkVisual(html, options) {
|
|
|
13788
14485
|
);
|
|
13789
14486
|
}
|
|
13790
14487
|
|
|
14488
|
+
// src/design-consistency.ts
|
|
14489
|
+
import * as csstree9 from "css-tree";
|
|
14490
|
+
var SAME_COLOUR_DISTANCE = 0.02;
|
|
14491
|
+
var LIMITS = {
|
|
14492
|
+
fontSize: 8,
|
|
14493
|
+
fontFamily: 2,
|
|
14494
|
+
radius: 3
|
|
14495
|
+
};
|
|
14496
|
+
var NON_COLOURS = /* @__PURE__ */ new Set(["transparent", "inherit", "initial", "unset", "currentcolor", "none"]);
|
|
14497
|
+
var TRACKED = {
|
|
14498
|
+
"color": "colors",
|
|
14499
|
+
"background-color": "backgrounds",
|
|
14500
|
+
"background": "backgrounds",
|
|
14501
|
+
"font-size": "fontSizes",
|
|
14502
|
+
"font-family": "fontFamilies",
|
|
14503
|
+
"border-radius": "radii"
|
|
14504
|
+
};
|
|
14505
|
+
function primaryTypeface(value) {
|
|
14506
|
+
var _a;
|
|
14507
|
+
return ((_a = value.split(",")[0]) != null ? _a : "").replace(/['"]/g, "").trim().toLowerCase();
|
|
14508
|
+
}
|
|
14509
|
+
function radiusLengths(value) {
|
|
14510
|
+
const lengths = value.trim().toLowerCase().split(/[\s/]+/).filter(Boolean);
|
|
14511
|
+
return lengths.filter((v) => {
|
|
14512
|
+
if (/^0(?:px|rem|em|%)?$/.test(v)) return false;
|
|
14513
|
+
if (v === "50%") return false;
|
|
14514
|
+
const px = parseFloat(v);
|
|
14515
|
+
return !(v.endsWith("px") && Number.isFinite(px) && px >= 500);
|
|
14516
|
+
});
|
|
14517
|
+
}
|
|
14518
|
+
function collect($) {
|
|
14519
|
+
const found = {
|
|
14520
|
+
colors: [],
|
|
14521
|
+
backgrounds: [],
|
|
14522
|
+
fontSizes: [],
|
|
14523
|
+
fontFamilies: [],
|
|
14524
|
+
radii: []
|
|
14525
|
+
};
|
|
14526
|
+
const record = (prop, rawValue) => {
|
|
14527
|
+
const bucket = TRACKED[prop.toLowerCase()];
|
|
14528
|
+
if (!bucket) return;
|
|
14529
|
+
const value = rawValue.trim();
|
|
14530
|
+
if (!value) return;
|
|
14531
|
+
found[bucket].push(value);
|
|
14532
|
+
};
|
|
14533
|
+
$("[style]").each((_, el) => {
|
|
14534
|
+
for (const part of splitStyleDeclarations($(el).attr("style") || "")) {
|
|
14535
|
+
const colon = part.indexOf(":");
|
|
14536
|
+
if (colon === -1) continue;
|
|
14537
|
+
record(part.slice(0, colon), part.slice(colon + 1));
|
|
14538
|
+
}
|
|
14539
|
+
});
|
|
14540
|
+
$("style").each((_, el) => {
|
|
14541
|
+
let ast;
|
|
14542
|
+
try {
|
|
14543
|
+
ast = csstree9.parse($(el).text());
|
|
14544
|
+
} catch (e) {
|
|
14545
|
+
return;
|
|
14546
|
+
}
|
|
14547
|
+
csstree9.walk(ast, {
|
|
14548
|
+
visit: "Declaration",
|
|
14549
|
+
enter(node) {
|
|
14550
|
+
if (node.type !== "Declaration") return;
|
|
14551
|
+
record(node.property, csstree9.generate(node.value));
|
|
14552
|
+
}
|
|
14553
|
+
});
|
|
14554
|
+
});
|
|
14555
|
+
return found;
|
|
14556
|
+
}
|
|
14557
|
+
function driftClusters(values) {
|
|
14558
|
+
const byNormalised = /* @__PURE__ */ new Map();
|
|
14559
|
+
for (const raw of values) {
|
|
14560
|
+
const value = raw.trim();
|
|
14561
|
+
if (NON_COLOURS.has(value.toLowerCase())) continue;
|
|
14562
|
+
const rgb = parseColor(value);
|
|
14563
|
+
if (!rgb || rgb.a === 0) continue;
|
|
14564
|
+
const key = formatRgb(__spreadProps(__spreadValues({}, rgb), { a: 1 }));
|
|
14565
|
+
const entry = byNormalised.get(key);
|
|
14566
|
+
if (entry) entry.spellings.add(value);
|
|
14567
|
+
else byNormalised.set(key, { spellings: /* @__PURE__ */ new Set([value]), rgb });
|
|
14568
|
+
}
|
|
14569
|
+
const distinct = [...byNormalised.entries()];
|
|
14570
|
+
const clusters = [];
|
|
14571
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
14572
|
+
for (let i = 0; i < distinct.length; i++) {
|
|
14573
|
+
const [keyA, a] = distinct[i];
|
|
14574
|
+
if (claimed.has(keyA)) continue;
|
|
14575
|
+
const cluster = [keyA];
|
|
14576
|
+
for (let j = i + 1; j < distinct.length; j++) {
|
|
14577
|
+
const [keyB, b] = distinct[j];
|
|
14578
|
+
if (claimed.has(keyB)) continue;
|
|
14579
|
+
if (!a.rgb || !b.rgb) continue;
|
|
14580
|
+
if (colorDistance(a.rgb, b.rgb) < SAME_COLOUR_DISTANCE) {
|
|
14581
|
+
cluster.push(keyB);
|
|
14582
|
+
claimed.add(keyB);
|
|
14583
|
+
}
|
|
14584
|
+
}
|
|
14585
|
+
claimed.add(keyA);
|
|
14586
|
+
if (cluster.length > 1) clusters.push(cluster);
|
|
14587
|
+
}
|
|
14588
|
+
return clusters;
|
|
14589
|
+
}
|
|
14590
|
+
function distinctValues(values, normalise) {
|
|
14591
|
+
const seen = /* @__PURE__ */ new Map();
|
|
14592
|
+
for (const raw of values) {
|
|
14593
|
+
const key = normalise(raw);
|
|
14594
|
+
if (!key) continue;
|
|
14595
|
+
if (!seen.has(key)) seen.set(key, raw.trim());
|
|
14596
|
+
}
|
|
14597
|
+
return [...seen.values()];
|
|
14598
|
+
}
|
|
14599
|
+
function checkDesignConsistencyFromDom($) {
|
|
14600
|
+
const found = collect($);
|
|
14601
|
+
const issues = [];
|
|
14602
|
+
const colors = distinctValues(found.colors, (v) => {
|
|
14603
|
+
const c = parseColor(v.trim());
|
|
14604
|
+
return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
|
|
14605
|
+
});
|
|
14606
|
+
const backgrounds = distinctValues(found.backgrounds, (v) => {
|
|
14607
|
+
const c = parseColor(v.trim());
|
|
14608
|
+
return c ? formatRgb(__spreadProps(__spreadValues({}, c), { a: 1 })) : "";
|
|
14609
|
+
});
|
|
14610
|
+
const fontSizes = distinctValues(found.fontSizes, (v) => v.trim().toLowerCase());
|
|
14611
|
+
const fontFamilies = distinctValues(found.fontFamilies, primaryTypeface);
|
|
14612
|
+
const radii = distinctValues(found.radii.flatMap(radiusLengths), (v) => v);
|
|
14613
|
+
for (const cluster of driftClusters([...found.colors, ...found.backgrounds])) {
|
|
14614
|
+
issues.push({
|
|
14615
|
+
rule: "colour-drift",
|
|
14616
|
+
severity: "info",
|
|
14617
|
+
message: `${cluster.length} near-identical colours are used where one was probably meant: ${cluster.join(", ")}.`,
|
|
14618
|
+
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.",
|
|
14619
|
+
values: cluster
|
|
14620
|
+
});
|
|
14621
|
+
}
|
|
14622
|
+
const overflowing = [
|
|
14623
|
+
["font sizes", fontSizes, LIMITS.fontSize, "Settle on a type scale and reuse it; every extra size is one more thing to keep in step."],
|
|
14624
|
+
["typefaces", fontFamilies, LIMITS.fontFamily, "Email clients strip most web fonts anyway, so each extra typeface mostly adds fallback risk."],
|
|
14625
|
+
["corner radii", radii, LIMITS.radius, "Corner rounding reads as a single decision; several close values look like an accident."]
|
|
14626
|
+
];
|
|
14627
|
+
for (const [label, values, limit, detail] of overflowing) {
|
|
14628
|
+
if (values.length <= limit) continue;
|
|
14629
|
+
issues.push({
|
|
14630
|
+
rule: "too-many-values",
|
|
14631
|
+
severity: "info",
|
|
14632
|
+
message: `${values.length} distinct ${label} in one email: ${values.slice(0, 10).join(", ")}${values.length > 10 ? ", \u2026" : ""}.`,
|
|
14633
|
+
detail,
|
|
14634
|
+
values
|
|
14635
|
+
});
|
|
14636
|
+
}
|
|
14637
|
+
return {
|
|
14638
|
+
issues,
|
|
14639
|
+
palette: { colors, backgrounds, fontSizes, fontFamilies, radii }
|
|
14640
|
+
};
|
|
14641
|
+
}
|
|
14642
|
+
function checkDesignConsistency(html, options) {
|
|
14643
|
+
return fromHtml(
|
|
14644
|
+
html,
|
|
14645
|
+
{ issues: [], palette: { colors: [], backgrounds: [], fontSizes: [], fontFamilies: [], radii: [] } },
|
|
14646
|
+
checkDesignConsistencyFromDom,
|
|
14647
|
+
options
|
|
14648
|
+
);
|
|
14649
|
+
}
|
|
14650
|
+
|
|
13791
14651
|
// src/audit.ts
|
|
13792
14652
|
var EMPTY_AUDIT = {
|
|
13793
14653
|
compatibility: { warnings: [], scores: {} },
|
|
@@ -13799,8 +14659,19 @@ var EMPTY_AUDIT = {
|
|
|
13799
14659
|
size: EMPTY_SIZE,
|
|
13800
14660
|
templateVariables: EMPTY_TEMPLATE,
|
|
13801
14661
|
overflow: EMPTY_OVERFLOW,
|
|
13802
|
-
visual: EMPTY_VISUAL
|
|
14662
|
+
visual: EMPTY_VISUAL,
|
|
14663
|
+
darkContrast: [],
|
|
14664
|
+
mobileContrast: [],
|
|
14665
|
+
design: EMPTY_DESIGN
|
|
13803
14666
|
};
|
|
14667
|
+
function dedupeByElement(issues) {
|
|
14668
|
+
const seen = /* @__PURE__ */ new Set();
|
|
14669
|
+
return issues.filter((issue) => {
|
|
14670
|
+
if (seen.has(issue.element)) return false;
|
|
14671
|
+
seen.add(issue.element);
|
|
14672
|
+
return true;
|
|
14673
|
+
});
|
|
14674
|
+
}
|
|
13804
14675
|
function runAudit($, html, framework, options) {
|
|
13805
14676
|
var _a;
|
|
13806
14677
|
const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
|
|
@@ -13816,16 +14687,22 @@ function runAudit($, html, framework, options) {
|
|
|
13816
14687
|
const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
|
|
13817
14688
|
const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
|
|
13818
14689
|
const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
|
|
13819
|
-
|
|
14690
|
+
const darkContrast = skip.has("darkContrast") || skip.has("accessibility") ? [] : dedupeByElement([
|
|
14691
|
+
...checkDarkModeContrast(html, accessibility.issues),
|
|
14692
|
+
...checkDarkStylesContrastFromDom($, accessibility.issues)
|
|
14693
|
+
]);
|
|
14694
|
+
const design = skip.has("design") ? EMPTY_DESIGN : checkDesignConsistencyFromDom($);
|
|
14695
|
+
const mobileContrast = skip.has("mobileContrast") || skip.has("accessibility") ? [] : checkMobileContrastFromDom($, accessibility.issues);
|
|
14696
|
+
return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual, darkContrast, mobileContrast, design };
|
|
13820
14697
|
}
|
|
13821
14698
|
function auditEmail(html, options) {
|
|
13822
14699
|
return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
|
|
13823
14700
|
}
|
|
13824
14701
|
|
|
13825
14702
|
// src/plain-text.ts
|
|
13826
|
-
import * as
|
|
14703
|
+
import * as cheerio6 from "cheerio";
|
|
13827
14704
|
function toPlainText(html) {
|
|
13828
|
-
const $ =
|
|
14705
|
+
const $ = cheerio6.load(html);
|
|
13829
14706
|
$("style, script, head").remove();
|
|
13830
14707
|
$("[data-skip-in-text='true']").remove();
|
|
13831
14708
|
const lines = [];
|
|
@@ -13860,7 +14737,7 @@ function toPlainText(html) {
|
|
|
13860
14737
|
if (trimmed) lines.push(trimmed);
|
|
13861
14738
|
currentLine = "";
|
|
13862
14739
|
}
|
|
13863
|
-
function
|
|
14740
|
+
function walk9(node) {
|
|
13864
14741
|
var _a;
|
|
13865
14742
|
if (node.type === "text") {
|
|
13866
14743
|
const text = node.data.replace(/\s+/g, " ");
|
|
@@ -13909,7 +14786,7 @@ function toPlainText(html) {
|
|
|
13909
14786
|
currentLine = "- ";
|
|
13910
14787
|
}
|
|
13911
14788
|
for (const child of el.children) {
|
|
13912
|
-
|
|
14789
|
+
walk9(child);
|
|
13913
14790
|
}
|
|
13914
14791
|
if (isBlock) flushLine();
|
|
13915
14792
|
}
|
|
@@ -13917,7 +14794,7 @@ function toPlainText(html) {
|
|
|
13917
14794
|
const root = body.length ? body[0] : $.root()[0];
|
|
13918
14795
|
if (root && "children" in root) {
|
|
13919
14796
|
for (const child of root.children) {
|
|
13920
|
-
|
|
14797
|
+
walk9(child);
|
|
13921
14798
|
}
|
|
13922
14799
|
}
|
|
13923
14800
|
flushLine();
|
|
@@ -14017,6 +14894,7 @@ export {
|
|
|
14017
14894
|
CompileError,
|
|
14018
14895
|
EMAIL_CLIENTS,
|
|
14019
14896
|
EMPTY_DELIVERABILITY,
|
|
14897
|
+
FEATURE_URLS,
|
|
14020
14898
|
GENERIC_LINK_TEXT,
|
|
14021
14899
|
HTML_ELEMENT_FEATURES,
|
|
14022
14900
|
MAX_HTML_SIZE,
|
|
@@ -14030,10 +14908,15 @@ export {
|
|
|
14030
14908
|
auditEmail,
|
|
14031
14909
|
caveatApplies,
|
|
14032
14910
|
checkAccessibility,
|
|
14911
|
+
checkDarkModeContrast,
|
|
14912
|
+
checkDarkStylesContrastFromDom,
|
|
14913
|
+
checkDesignConsistency,
|
|
14914
|
+
checkMobileContrast,
|
|
14033
14915
|
checkOverflow,
|
|
14034
14916
|
checkSize,
|
|
14035
14917
|
checkTemplateVariables,
|
|
14036
14918
|
checkVisual,
|
|
14919
|
+
colorDistance,
|
|
14037
14920
|
contrastRatio,
|
|
14038
14921
|
createSession,
|
|
14039
14922
|
diffResults,
|
|
@@ -14041,6 +14924,7 @@ export {
|
|
|
14041
14924
|
errorWarnings,
|
|
14042
14925
|
estimateAiFixTokens,
|
|
14043
14926
|
extractInboxPreview,
|
|
14927
|
+
featureUrl,
|
|
14044
14928
|
formatRgb,
|
|
14045
14929
|
generateAiFix,
|
|
14046
14930
|
generateCompatibilityScore,
|
|
@@ -14051,6 +14935,7 @@ export {
|
|
|
14051
14935
|
heuristicTokenCount,
|
|
14052
14936
|
parseColor,
|
|
14053
14937
|
relativeLuminance,
|
|
14938
|
+
rgbToOklab,
|
|
14054
14939
|
simulateDarkMode,
|
|
14055
14940
|
structuralWarnings,
|
|
14056
14941
|
toPlainText,
|