@emailens/engine 0.11.3 → 0.11.6
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 +128 -13
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +128 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
[](https://github.com/emailens/engine/actions/workflows/ci.yml)
|
|
11
11
|
[](https://www.npmjs.com/package/@emailens/engine)
|
|
12
12
|
[](./LICENSE)
|
|
13
|
-
[]()
|
|
14
14
|
[](https://nodejs.org/)
|
|
15
15
|
[](https://github.com/emailens/mcp)
|
|
16
16
|
[](https://github.com/emailens/engine/stargazers)
|
package/dist/index.cjs
CHANGED
|
@@ -10690,9 +10690,12 @@ function transformForClient(html, clientId, framework) {
|
|
|
10690
10690
|
]
|
|
10691
10691
|
};
|
|
10692
10692
|
}
|
|
10693
|
-
const
|
|
10693
|
+
const wordEngine = clientId === "outlook-windows-legacy" && /<!--\[if/i.test(html);
|
|
10694
|
+
const source = wordEngine ? resolveMsoBranch(html) : html;
|
|
10694
10695
|
const downleveled = downlevelCSS(source);
|
|
10695
|
-
|
|
10696
|
+
const result = applyTransform(downleveled, config, framework);
|
|
10697
|
+
if (wordEngine) result.html = vmlToCss(result.html);
|
|
10698
|
+
return result;
|
|
10696
10699
|
}
|
|
10697
10700
|
function transformForAllClients(html, framework) {
|
|
10698
10701
|
if (!html || !html.trim()) {
|
|
@@ -10706,14 +10709,18 @@ function transformForAllClients(html, framework) {
|
|
|
10706
10709
|
throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
|
|
10707
10710
|
}
|
|
10708
10711
|
const downleveled = downlevelCSS(html);
|
|
10709
|
-
const
|
|
10710
|
-
|
|
10711
|
-
|
|
10712
|
-
|
|
10712
|
+
const hasOutlookBranch = /<!--\[if/i.test(html);
|
|
10713
|
+
const outlookSource = hasOutlookBranch ? downlevelCSS(resolveMsoBranch(html)) : downleveled;
|
|
10714
|
+
return Object.keys(CLIENT_CONFIGS).map((clientId) => {
|
|
10715
|
+
const wordEngine = clientId === "outlook-windows-legacy" && hasOutlookBranch;
|
|
10716
|
+
const result = applyTransform(
|
|
10717
|
+
wordEngine ? outlookSource : downleveled,
|
|
10713
10718
|
CLIENT_CONFIGS[clientId],
|
|
10714
10719
|
framework
|
|
10715
|
-
)
|
|
10716
|
-
|
|
10720
|
+
);
|
|
10721
|
+
if (wordEngine) result.html = vmlToCss(result.html);
|
|
10722
|
+
return result;
|
|
10723
|
+
});
|
|
10717
10724
|
}
|
|
10718
10725
|
|
|
10719
10726
|
// src/analyze.ts
|
|
@@ -11613,6 +11620,7 @@ function fromHtml(html, empty, fn, options) {
|
|
|
11613
11620
|
}
|
|
11614
11621
|
|
|
11615
11622
|
// src/analyze.ts
|
|
11623
|
+
var WORD_ENGINE_CLIENT = "outlook-windows-legacy";
|
|
11616
11624
|
var HTML_ELEMENT_SELECTORS = {
|
|
11617
11625
|
"<style>": "style",
|
|
11618
11626
|
"<link>": "link[rel='stylesheet']",
|
|
@@ -11953,7 +11961,32 @@ function analyzeEmail(html, framework, options) {
|
|
|
11953
11961
|
throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
|
|
11954
11962
|
}
|
|
11955
11963
|
const $ = loadHtml(html, options);
|
|
11956
|
-
return
|
|
11964
|
+
return analyzeAllBranches($, html, framework, (options == null ? void 0 : options.positions) ? html : void 0);
|
|
11965
|
+
}
|
|
11966
|
+
function analyzeAllBranches($, html, framework, source) {
|
|
11967
|
+
return withOutlookBranch(html, analyzeEmailFromDom($, framework, source), framework);
|
|
11968
|
+
}
|
|
11969
|
+
function withOutlookBranch(html, warnings, framework) {
|
|
11970
|
+
if (!/<!--\[if/i.test(html)) return warnings;
|
|
11971
|
+
let branchWarnings;
|
|
11972
|
+
try {
|
|
11973
|
+
const resolved = resolveMsoBranch(html);
|
|
11974
|
+
if (resolved === html) return warnings;
|
|
11975
|
+
branchWarnings = analyzeEmailFromDom(loadHtml(resolved), framework).filter((w) => w.client === WORD_ENGINE_CLIENT);
|
|
11976
|
+
} catch (e) {
|
|
11977
|
+
return warnings;
|
|
11978
|
+
}
|
|
11979
|
+
const kept = warnings.filter((w) => w.client !== WORD_ENGINE_CLIENT);
|
|
11980
|
+
const firstPassWord = warnings.filter((w) => w.client === WORD_ENGINE_CLIENT);
|
|
11981
|
+
const byKey = new Map(firstPassWord.map((w) => [`${w.property}:${w.severity}`, w]));
|
|
11982
|
+
const merged = branchWarnings.map((w) => {
|
|
11983
|
+
var _a;
|
|
11984
|
+
return (_a = byKey.get(`${w.property}:${w.severity}`)) != null ? _a : w;
|
|
11985
|
+
});
|
|
11986
|
+
for (const w of firstPassWord) {
|
|
11987
|
+
if (!merged.some((m) => m.property === w.property && m.severity === w.severity)) merged.push(w);
|
|
11988
|
+
}
|
|
11989
|
+
return [...kept, ...merged];
|
|
11957
11990
|
}
|
|
11958
11991
|
function getFixType(prop) {
|
|
11959
11992
|
return STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css";
|
|
@@ -14419,6 +14452,7 @@ function checkTemplateVariables(html, options) {
|
|
|
14419
14452
|
|
|
14420
14453
|
// src/overflow-checker.ts
|
|
14421
14454
|
var csstree7 = __toESM(require("css-tree"), 1);
|
|
14455
|
+
var RESPONSIVE_MIN_WIDTH = 320;
|
|
14422
14456
|
function fixedPxWidth($el) {
|
|
14423
14457
|
const style = $el.attr("style") || "";
|
|
14424
14458
|
const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
|
|
@@ -14553,6 +14587,30 @@ function checkOverflowFromDom($, source) {
|
|
|
14553
14587
|
}, loc ? { loc, locs: [loc] } : {}));
|
|
14554
14588
|
}
|
|
14555
14589
|
}
|
|
14590
|
+
if (!/@media/i.test($.html())) {
|
|
14591
|
+
let widest = 0;
|
|
14592
|
+
let widestLabel = "";
|
|
14593
|
+
let widestLoc;
|
|
14594
|
+
$("table, td, div").each((_, el) => {
|
|
14595
|
+
const $el = $(el);
|
|
14596
|
+
const width = fixedPxWidth($el);
|
|
14597
|
+
if (width === null || width < RESPONSIVE_MIN_WIDTH) return;
|
|
14598
|
+
if (isFluid($el.attr("style") || "")) return;
|
|
14599
|
+
if (width <= widest) return;
|
|
14600
|
+
widest = width;
|
|
14601
|
+
widestLabel = `<${(el.tagName || "element").toLowerCase()}>`;
|
|
14602
|
+
const fromStyle = /(?:^|[;\s])width\s*:\s*\d+px/i.test($el.attr("style") || "");
|
|
14603
|
+
widestLoc = locOfAttr(el, fromStyle ? "style" : "width");
|
|
14604
|
+
});
|
|
14605
|
+
if (widest > 0) {
|
|
14606
|
+
issues.push(__spreadValues({
|
|
14607
|
+
rule: "no-responsive-rules",
|
|
14608
|
+
severity: "warning",
|
|
14609
|
+
message: `${widestLabel} is a fixed ${widest}px wide and the email has no @media rules, so nothing tells a narrow screen what to do.`,
|
|
14610
|
+
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.`
|
|
14611
|
+
}, widestLoc ? { loc: widestLoc, locs: [widestLoc] } : {}));
|
|
14612
|
+
}
|
|
14613
|
+
}
|
|
14556
14614
|
return { hasOverflow: issues.length > 0, issues };
|
|
14557
14615
|
}
|
|
14558
14616
|
function checkOverflow(html, options) {
|
|
@@ -14659,13 +14717,70 @@ function checkLooseText(issues, seen, open, inner, source) {
|
|
|
14659
14717
|
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>.`
|
|
14660
14718
|
}, locAt(source, open.offset, open.length)));
|
|
14661
14719
|
}
|
|
14720
|
+
var GHOST_TAGS = /* @__PURE__ */ new Set(["table", "tr", "td", "th", "tbody", "thead", "tfoot"]);
|
|
14721
|
+
function ghostTags(blocks) {
|
|
14722
|
+
const tags = [];
|
|
14723
|
+
const re = /<(\/?)(table|tr|td|th|tbody|thead|tfoot)\b((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
|
|
14724
|
+
for (const block of blocks) {
|
|
14725
|
+
let m;
|
|
14726
|
+
re.lastIndex = 0;
|
|
14727
|
+
while ((m = re.exec(block.inner)) !== null) {
|
|
14728
|
+
tags.push({
|
|
14729
|
+
name: m[2].toLowerCase(),
|
|
14730
|
+
closing: m[1] === "/",
|
|
14731
|
+
selfClosing: m[4] === "/",
|
|
14732
|
+
attrs: m[3],
|
|
14733
|
+
offset: block.offset + m.index,
|
|
14734
|
+
length: m[0].length
|
|
14735
|
+
});
|
|
14736
|
+
}
|
|
14737
|
+
}
|
|
14738
|
+
return tags;
|
|
14739
|
+
}
|
|
14740
|
+
function checkGhostTables(issues, seen, blocks, source) {
|
|
14741
|
+
var _a;
|
|
14742
|
+
const tags = ghostTags(blocks);
|
|
14743
|
+
if (tags.length === 0) return;
|
|
14744
|
+
const opens = /* @__PURE__ */ new Map();
|
|
14745
|
+
const strays = [];
|
|
14746
|
+
for (const tag of tags) {
|
|
14747
|
+
if (tag.selfClosing) continue;
|
|
14748
|
+
const list = (_a = opens.get(tag.name)) != null ? _a : [];
|
|
14749
|
+
if (tag.closing) {
|
|
14750
|
+
if (list.length === 0) strays.push(tag);
|
|
14751
|
+
else list.pop();
|
|
14752
|
+
} else list.push(tag);
|
|
14753
|
+
opens.set(tag.name, list);
|
|
14754
|
+
}
|
|
14755
|
+
for (const [name, list] of opens) {
|
|
14756
|
+
if (!GHOST_TAGS.has(name)) continue;
|
|
14757
|
+
for (const tag of list.slice(0, 1)) {
|
|
14758
|
+
add(issues, seen, `ghost-open:${name}`, withLoc({
|
|
14759
|
+
rule: "ghost-table-unbalanced",
|
|
14760
|
+
severity: "error",
|
|
14761
|
+
message: `<${name}> is opened inside an Outlook conditional comment and never closed.`,
|
|
14762
|
+
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.`
|
|
14763
|
+
}, locAt(source, tag.offset, tag.length)));
|
|
14764
|
+
}
|
|
14765
|
+
}
|
|
14766
|
+
for (const tag of strays.slice(0, 1)) {
|
|
14767
|
+
add(issues, seen, `ghost-stray:${tag.name}`, withLoc({
|
|
14768
|
+
rule: "ghost-table-unbalanced",
|
|
14769
|
+
severity: "error",
|
|
14770
|
+
message: `Closing </${tag.name}> inside an Outlook conditional comment has no matching opening tag.`,
|
|
14771
|
+
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.`
|
|
14772
|
+
}, locAt(source, tag.offset, tag.length)));
|
|
14773
|
+
}
|
|
14774
|
+
}
|
|
14662
14775
|
function checkVml(html, options) {
|
|
14663
14776
|
if (!html || !html.trim()) return EMPTY_VML;
|
|
14664
14777
|
const source = (options == null ? void 0 : options.positions) ? html : void 0;
|
|
14665
|
-
const
|
|
14666
|
-
|
|
14778
|
+
const blocks = msoBlocks(html);
|
|
14779
|
+
const tags = vmlTags(blocks);
|
|
14667
14780
|
const issues = [];
|
|
14668
14781
|
const seen = /* @__PURE__ */ new Map();
|
|
14782
|
+
checkGhostTables(issues, seen, blocks, source);
|
|
14783
|
+
if (tags.length === 0) return { hasVml: false, issues };
|
|
14669
14784
|
const openShapes = [];
|
|
14670
14785
|
let groupDepth = 0;
|
|
14671
14786
|
for (const tag of tags) {
|
|
@@ -15098,7 +15213,7 @@ function runAudit($, html, framework, options) {
|
|
|
15098
15213
|
var _a;
|
|
15099
15214
|
const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
|
|
15100
15215
|
const source = (options == null ? void 0 : options.positions) ? html : void 0;
|
|
15101
|
-
const warnings = skip.has("compatibility") ? [] :
|
|
15216
|
+
const warnings = skip.has("compatibility") ? [] : analyzeAllBranches($, html, framework, source);
|
|
15102
15217
|
const scores = skip.has("compatibility") ? {} : generateCompatibilityScore(warnings);
|
|
15103
15218
|
const spam = skip.has("spam") ? EMPTY_SPAM : analyzeSpamFromDom($, options == null ? void 0 : options.spam);
|
|
15104
15219
|
const links = skip.has("links") ? EMPTY_LINKS : validateLinksFromDom($);
|
|
@@ -15263,7 +15378,7 @@ function createSession(html, options) {
|
|
|
15263
15378
|
return runAudit($, html, framework, __spreadProps(__spreadValues({}, opts), { positions: options == null ? void 0 : options.positions }));
|
|
15264
15379
|
},
|
|
15265
15380
|
analyze() {
|
|
15266
|
-
return
|
|
15381
|
+
return analyzeAllBranches($, html, framework, source);
|
|
15267
15382
|
},
|
|
15268
15383
|
score(warnings) {
|
|
15269
15384
|
return generateCompatibilityScore(warnings);
|