@emailens/engine 0.11.5 → 0.11.7
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 +1 -1
- package/dist/index.cjs +116 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +116 -6
- package/dist/index.js.map +1 -1
- package/package.json +127 -127
package/dist/index.js
CHANGED
|
@@ -9768,8 +9768,10 @@ function arcsizeToRadius(arcsize, width, height) {
|
|
|
9768
9768
|
if (!Number.isFinite(pct)) return 0;
|
|
9769
9769
|
return Math.min(100, Math.max(0, pct)) / 100 * (Math.min(width, height) / 2);
|
|
9770
9770
|
}
|
|
9771
|
+
var DOWNLEVEL_REVEALED = /<!--\[if[^\]]*!\s*(?:mso|vml)[^\]]*\]>\s*<!--(?:>|\s*-->)([\s\S]*?)<!--\s*<!\[endif\]-->/gi;
|
|
9772
|
+
var DOWNLEVEL_HIDDEN = /<!--\[if(?![^\]]*!)[^\]]*(?:mso|vml)[^\]]*\]>([\s\S]*?)<!\[endif\]-->/gi;
|
|
9771
9773
|
function resolveMsoBranch(html) {
|
|
9772
|
-
return html.replace(
|
|
9774
|
+
return html.replace(DOWNLEVEL_REVEALED, "").replace(DOWNLEVEL_HIDDEN, "$1");
|
|
9773
9775
|
}
|
|
9774
9776
|
function vmlToCss(html) {
|
|
9775
9777
|
let out = html;
|
|
@@ -11501,6 +11503,7 @@ function fromHtml(html, empty, fn, options) {
|
|
|
11501
11503
|
}
|
|
11502
11504
|
|
|
11503
11505
|
// src/analyze.ts
|
|
11506
|
+
var WORD_ENGINE_CLIENT = "outlook-windows-legacy";
|
|
11504
11507
|
var HTML_ELEMENT_SELECTORS = {
|
|
11505
11508
|
"<style>": "style",
|
|
11506
11509
|
"<link>": "link[rel='stylesheet']",
|
|
@@ -11841,7 +11844,32 @@ function analyzeEmail(html, framework, options) {
|
|
|
11841
11844
|
throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
|
|
11842
11845
|
}
|
|
11843
11846
|
const $ = loadHtml(html, options);
|
|
11844
|
-
return
|
|
11847
|
+
return analyzeAllBranches($, html, framework, (options == null ? void 0 : options.positions) ? html : void 0);
|
|
11848
|
+
}
|
|
11849
|
+
function analyzeAllBranches($, html, framework, source) {
|
|
11850
|
+
return withOutlookBranch(html, analyzeEmailFromDom($, framework, source), framework);
|
|
11851
|
+
}
|
|
11852
|
+
function withOutlookBranch(html, warnings, framework) {
|
|
11853
|
+
if (!/<!--\[if/i.test(html)) return warnings;
|
|
11854
|
+
let branchWarnings;
|
|
11855
|
+
try {
|
|
11856
|
+
const resolved = resolveMsoBranch(html);
|
|
11857
|
+
if (resolved === html) return warnings;
|
|
11858
|
+
branchWarnings = analyzeEmailFromDom(loadHtml(resolved), framework).filter((w) => w.client === WORD_ENGINE_CLIENT);
|
|
11859
|
+
} catch (e) {
|
|
11860
|
+
return warnings;
|
|
11861
|
+
}
|
|
11862
|
+
const kept = warnings.filter((w) => w.client !== WORD_ENGINE_CLIENT);
|
|
11863
|
+
const firstPassWord = warnings.filter((w) => w.client === WORD_ENGINE_CLIENT);
|
|
11864
|
+
const byKey = new Map(firstPassWord.map((w) => [`${w.property}:${w.severity}`, w]));
|
|
11865
|
+
const merged = branchWarnings.map((w) => {
|
|
11866
|
+
var _a;
|
|
11867
|
+
return (_a = byKey.get(`${w.property}:${w.severity}`)) != null ? _a : w;
|
|
11868
|
+
});
|
|
11869
|
+
for (const w of firstPassWord) {
|
|
11870
|
+
if (!merged.some((m) => m.property === w.property && m.severity === w.severity)) merged.push(w);
|
|
11871
|
+
}
|
|
11872
|
+
return [...kept, ...merged];
|
|
11845
11873
|
}
|
|
11846
11874
|
function getFixType(prop) {
|
|
11847
11875
|
return STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css";
|
|
@@ -14307,6 +14335,7 @@ function checkTemplateVariables(html, options) {
|
|
|
14307
14335
|
|
|
14308
14336
|
// src/overflow-checker.ts
|
|
14309
14337
|
import * as csstree7 from "css-tree";
|
|
14338
|
+
var RESPONSIVE_MIN_WIDTH = 320;
|
|
14310
14339
|
function fixedPxWidth($el) {
|
|
14311
14340
|
const style = $el.attr("style") || "";
|
|
14312
14341
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
@@ -14441,6 +14470,30 @@ function checkOverflowFromDom($, source) {
|
|
|
14441
14470
|
}, loc ? { loc, locs: [loc] } : {}));
|
|
14442
14471
|
}
|
|
14443
14472
|
}
|
|
14473
|
+
if (!/@media/i.test($.html())) {
|
|
14474
|
+
let widest = 0;
|
|
14475
|
+
let widestLabel = "";
|
|
14476
|
+
let widestLoc;
|
|
14477
|
+
$("table, td, div").each((_, el) => {
|
|
14478
|
+
const $el = $(el);
|
|
14479
|
+
const width = fixedPxWidth($el);
|
|
14480
|
+
if (width === null || width < RESPONSIVE_MIN_WIDTH) return;
|
|
14481
|
+
if (isFluid($el.attr("style") || "")) return;
|
|
14482
|
+
if (width <= widest) return;
|
|
14483
|
+
widest = width;
|
|
14484
|
+
widestLabel = `<${(el.tagName || "element").toLowerCase()}>`;
|
|
14485
|
+
const fromStyle = /(?:^|[;\s])width\s*:\s*\d+px/i.test($el.attr("style") || "");
|
|
14486
|
+
widestLoc = locOfAttr(el, fromStyle ? "style" : "width");
|
|
14487
|
+
});
|
|
14488
|
+
if (widest > 0) {
|
|
14489
|
+
issues.push(__spreadValues({
|
|
14490
|
+
rule: "no-responsive-rules",
|
|
14491
|
+
severity: "warning",
|
|
14492
|
+
message: `${widestLabel} is a fixed ${widest}px wide and the email has no @media rules, so nothing tells a narrow screen what to do.`,
|
|
14493
|
+
detail: `Each mobile client then decides for itself: one scales the layout to fit, another leaves it to scroll, a third stretches the container while any child with a px max-width stays put and leaves gutters. Give the container width:100% with max-width:${widest}px, and add a breakpoint for the parts that need to stack.`
|
|
14494
|
+
}, widestLoc ? { loc: widestLoc, locs: [widestLoc] } : {}));
|
|
14495
|
+
}
|
|
14496
|
+
}
|
|
14444
14497
|
return { hasOverflow: issues.length > 0, issues };
|
|
14445
14498
|
}
|
|
14446
14499
|
function checkOverflow(html, options) {
|
|
@@ -14547,13 +14600,70 @@ function checkLooseText(issues, seen, open, inner, source) {
|
|
|
14547
14600
|
detail: `Verified in Outlook Classic: the shape renders its fill and shape correctly and shows no text at all, so the reader gets a blank coloured block where the label should be. Nothing errors and the HTML fallback still reads correctly in every other client, so this ships unnoticed. Wrap the text in <center> (what the bulletproof-button pattern uses) or in <v:textbox>.`
|
|
14548
14601
|
}, locAt(source, open.offset, open.length)));
|
|
14549
14602
|
}
|
|
14603
|
+
var GHOST_TAGS = /* @__PURE__ */ new Set(["table", "tr", "td", "th", "tbody", "thead", "tfoot"]);
|
|
14604
|
+
function ghostTags(blocks) {
|
|
14605
|
+
const tags = [];
|
|
14606
|
+
const re = /<(\/?)(table|tr|td|th|tbody|thead|tfoot)\b((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
|
|
14607
|
+
for (const block of blocks) {
|
|
14608
|
+
let m;
|
|
14609
|
+
re.lastIndex = 0;
|
|
14610
|
+
while ((m = re.exec(block.inner)) !== null) {
|
|
14611
|
+
tags.push({
|
|
14612
|
+
name: m[2].toLowerCase(),
|
|
14613
|
+
closing: m[1] === "/",
|
|
14614
|
+
selfClosing: m[4] === "/",
|
|
14615
|
+
attrs: m[3],
|
|
14616
|
+
offset: block.offset + m.index,
|
|
14617
|
+
length: m[0].length
|
|
14618
|
+
});
|
|
14619
|
+
}
|
|
14620
|
+
}
|
|
14621
|
+
return tags;
|
|
14622
|
+
}
|
|
14623
|
+
function checkGhostTables(issues, seen, blocks, source) {
|
|
14624
|
+
var _a;
|
|
14625
|
+
const tags = ghostTags(blocks);
|
|
14626
|
+
if (tags.length === 0) return;
|
|
14627
|
+
const opens = /* @__PURE__ */ new Map();
|
|
14628
|
+
const strays = [];
|
|
14629
|
+
for (const tag of tags) {
|
|
14630
|
+
if (tag.selfClosing) continue;
|
|
14631
|
+
const list = (_a = opens.get(tag.name)) != null ? _a : [];
|
|
14632
|
+
if (tag.closing) {
|
|
14633
|
+
if (list.length === 0) strays.push(tag);
|
|
14634
|
+
else list.pop();
|
|
14635
|
+
} else list.push(tag);
|
|
14636
|
+
opens.set(tag.name, list);
|
|
14637
|
+
}
|
|
14638
|
+
for (const [name, list] of opens) {
|
|
14639
|
+
if (!GHOST_TAGS.has(name)) continue;
|
|
14640
|
+
for (const tag of list.slice(0, 1)) {
|
|
14641
|
+
add(issues, seen, `ghost-open:${name}`, withLoc({
|
|
14642
|
+
rule: "ghost-table-unbalanced",
|
|
14643
|
+
severity: "error",
|
|
14644
|
+
message: `<${name}> is opened inside an Outlook conditional comment and never closed.`,
|
|
14645
|
+
detail: `The closing half of a ghost wrapper lives in its own <!--[if mso]> block, usually much further down the file, and is easy to lose in an edit. Outlook is left with a table that never ends, so it swallows the rest of the email; every other client sees only comment nodes and renders normally, which is why this survives review. Add the matching </${name}> in a closing conditional block.`
|
|
14646
|
+
}, locAt(source, tag.offset, tag.length)));
|
|
14647
|
+
}
|
|
14648
|
+
}
|
|
14649
|
+
for (const tag of strays.slice(0, 1)) {
|
|
14650
|
+
add(issues, seen, `ghost-stray:${tag.name}`, withLoc({
|
|
14651
|
+
rule: "ghost-table-unbalanced",
|
|
14652
|
+
severity: "error",
|
|
14653
|
+
message: `Closing </${tag.name}> inside an Outlook conditional comment has no matching opening tag.`,
|
|
14654
|
+
detail: `The opening half of the ghost wrapper is missing or was removed. Outlook receives a stray closing tag, which ends a table it never started and can truncate the layout from that point.`
|
|
14655
|
+
}, locAt(source, tag.offset, tag.length)));
|
|
14656
|
+
}
|
|
14657
|
+
}
|
|
14550
14658
|
function checkVml(html, options) {
|
|
14551
14659
|
if (!html || !html.trim()) return EMPTY_VML;
|
|
14552
14660
|
const source = (options == null ? void 0 : options.positions) ? html : void 0;
|
|
14553
|
-
const
|
|
14554
|
-
|
|
14661
|
+
const blocks = msoBlocks(html);
|
|
14662
|
+
const tags = vmlTags(blocks);
|
|
14555
14663
|
const issues = [];
|
|
14556
14664
|
const seen = /* @__PURE__ */ new Map();
|
|
14665
|
+
checkGhostTables(issues, seen, blocks, source);
|
|
14666
|
+
if (tags.length === 0) return { hasVml: false, issues };
|
|
14557
14667
|
const openShapes = [];
|
|
14558
14668
|
let groupDepth = 0;
|
|
14559
14669
|
for (const tag of tags) {
|
|
@@ -14986,7 +15096,7 @@ function runAudit($, html, framework, options) {
|
|
|
14986
15096
|
var _a;
|
|
14987
15097
|
const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
|
|
14988
15098
|
const source = (options == null ? void 0 : options.positions) ? html : void 0;
|
|
14989
|
-
const warnings = skip.has("compatibility") ? [] :
|
|
15099
|
+
const warnings = skip.has("compatibility") ? [] : analyzeAllBranches($, html, framework, source);
|
|
14990
15100
|
const scores = skip.has("compatibility") ? {} : generateCompatibilityScore(warnings);
|
|
14991
15101
|
const spam = skip.has("spam") ? EMPTY_SPAM : analyzeSpamFromDom($, options == null ? void 0 : options.spam);
|
|
14992
15102
|
const links = skip.has("links") ? EMPTY_LINKS : validateLinksFromDom($);
|
|
@@ -15151,7 +15261,7 @@ function createSession(html, options) {
|
|
|
15151
15261
|
return runAudit($, html, framework, __spreadProps(__spreadValues({}, opts), { positions: options == null ? void 0 : options.positions }));
|
|
15152
15262
|
},
|
|
15153
15263
|
analyze() {
|
|
15154
|
-
return
|
|
15264
|
+
return analyzeAllBranches($, html, framework, source);
|
|
15155
15265
|
},
|
|
15156
15266
|
score(warnings) {
|
|
15157
15267
|
return generateCompatibilityScore(warnings);
|