@emailens/engine 0.10.0 → 0.10.2

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/dist/index.js CHANGED
@@ -56,7 +56,11 @@ var EMAIL_CLIENTS = [
56
56
  engine: "Microsoft Word",
57
57
  darkModeSupport: true,
58
58
  icon: "monitor",
59
- deprecated: "2026-10"
59
+ // End of support ~Q2 2029 (Microsoft: "supported until at least 2029").
60
+ // The April 2026 date some sources cite is the opt-out phase, when classic
61
+ // stops being the Windows default, not end of support. October 2026 was
62
+ // wrong: the nearest real Oct date is Oct 2025, for legacy Outlook for Mac.
63
+ deprecated: "2029-06"
60
64
  },
61
65
  {
62
66
  id: "outlook-ios",
@@ -8889,6 +8893,16 @@ function getStyleValue(style, property) {
8889
8893
  }
8890
8894
  return null;
8891
8895
  }
8896
+ function getStyleValues(style, property) {
8897
+ const values = [];
8898
+ for (const part of splitStyleDeclarations(style)) {
8899
+ const colonIndex = part.indexOf(":");
8900
+ if (colonIndex === -1) continue;
8901
+ if (part.slice(0, colonIndex).trim().toLowerCase() !== property) continue;
8902
+ values.push(part.slice(colonIndex + 1).trim());
8903
+ }
8904
+ return values;
8905
+ }
8892
8906
  function parseInlineStyle(style) {
8893
8907
  const map = /* @__PURE__ */ new Map();
8894
8908
  const declarations = splitStyleDeclarations(style);
@@ -9639,6 +9653,7 @@ function downlevelCSS(html) {
9639
9653
 
9640
9654
  // src/constants.ts
9641
9655
  var MAX_HTML_SIZE = 2 * 1024 * 1024;
9656
+ var MAX_WARNING_LOCATIONS = 100;
9642
9657
  var GENERIC_LINK_TEXT = /* @__PURE__ */ new Set([
9643
9658
  "click here",
9644
9659
  "here",
@@ -10375,360 +10390,256 @@ function transformForAllClients(html, framework) {
10375
10390
  }
10376
10391
 
10377
10392
  // src/analyze.ts
10378
- import * as cheerio3 from "cheerio";
10379
- import * as csstree3 from "css-tree";
10380
- var HTML_ELEMENT_SELECTORS = {
10381
- "<style>": "style",
10382
- "<link>": "link[rel='stylesheet']",
10383
- "<svg>": "svg",
10384
- "<video>": "video",
10385
- "<form>": "form, input, button[type='submit']",
10386
- "<audio>": "audio",
10387
- "<picture>": "picture",
10388
- "<dialog>": "dialog",
10389
- "<meter>": "meter",
10390
- "<progress>": "progress",
10391
- "<select>": "select",
10392
- "<textarea>": "textarea",
10393
- "<marquee>": "marquee",
10394
- "<object>": "object",
10395
- "<base>": "base"
10396
- };
10397
- var HTML_ELEMENT_SEVERITY = {
10398
- "<style>": "error",
10399
- "<link>": "error",
10400
- "<svg>": "error",
10401
- "<form>": "error",
10402
- "<video>": "warning",
10403
- "<audio>": "warning",
10404
- "<picture>": "warning",
10405
- "<dialog>": "warning",
10406
- "<marquee>": "warning",
10407
- "<meter>": "warning",
10408
- "<progress>": "warning",
10409
- "<select>": "warning",
10410
- "<textarea>": "warning",
10411
- "<object>": "warning",
10412
- "<base>": "warning"
10413
- };
10414
- var HTML_ELEMENT_MESSAGES = {
10415
- "<style>": (n) => `${n} strips <style> blocks. Styles must be inlined.`,
10416
- "<link>": (n) => `${n} does not support external stylesheets.`,
10417
- "<svg>": (n) => `${n} does not support inline SVG.`,
10418
- "<video>": (n) => `${n} does not support <video> elements.`,
10419
- "<form>": (n) => `${n} strips form elements.`
10420
- };
10421
- var COMPOUND_DETECTORS = [
10422
- { key: "display:flex", property: "display", valueIncludes: "flex" },
10423
- { key: "display:grid", property: "display", valueIncludes: "grid" },
10424
- { key: "display:none", property: "display", valueIncludes: "none" }
10425
- ];
10426
- var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
10427
- key: fn,
10428
- pattern: `${fn}(`
10429
- // require opening paren — matches "min(" but not "Minion"
10430
- }));
10431
- function analyzeEmailFromDom($, framework) {
10432
- const warnings = [];
10433
- const seenWarnings = /* @__PURE__ */ new Set();
10434
- function addWarning(w) {
10435
- const key = `${w.client}:${w.property}:${w.severity}:${w.selector || ""}`;
10436
- if (!seenWarnings.has(key)) {
10437
- seenWarnings.add(key);
10438
- warnings.push(w);
10393
+ import * as csstree5 from "css-tree";
10394
+
10395
+ // src/rules/value-caveats.ts
10396
+ var VALUE_CAVEAT_PROPS = /* @__PURE__ */ new Set([
10397
+ "background",
10398
+ "border-radius",
10399
+ "display",
10400
+ "font-size",
10401
+ "font-weight",
10402
+ "letter-spacing",
10403
+ "margin",
10404
+ "overflow",
10405
+ "position",
10406
+ "text-align",
10407
+ "transition"
10408
+ ]);
10409
+ function normalize(value) {
10410
+ return value.replace(/\/\*[\s\S]*?\*\//g, " ").toLowerCase().replace(/!\s*important/g, " ").replace(/;+\s*$/, "").replace(/\s+/g, " ").trim();
10411
+ }
10412
+ var preparedValues = /* @__PURE__ */ new WeakMap();
10413
+ function prepare(values) {
10414
+ let out = preparedValues.get(values);
10415
+ if (!out) {
10416
+ out = [...new Set(values.map(normalize))];
10417
+ preparedValues.set(values, out);
10418
+ }
10419
+ return out;
10420
+ }
10421
+ function topLevelSplit(value, sep) {
10422
+ const parts = [];
10423
+ let depth = 0;
10424
+ let start = 0;
10425
+ for (let i = 0; i < value.length; i++) {
10426
+ const c = value[i];
10427
+ if (c === "(") depth++;
10428
+ else if (c === ")") depth = Math.max(0, depth - 1);
10429
+ else if (c === sep && depth === 0) {
10430
+ parts.push(value.slice(start, i));
10431
+ start = i + 1;
10439
10432
  }
10440
10433
  }
10441
- function describeSelector(el) {
10442
- var _a;
10443
- const $el = $(el);
10444
- const tag = ((_a = el.tagName) == null ? void 0 : _a.toLowerCase()) || "";
10445
- const cls = $el.attr("class");
10446
- const id = $el.attr("id");
10447
- if (id) return `${tag}#${id}`;
10448
- if (cls) return `${tag}.${cls.split(/\s+/)[0]}`;
10449
- const href = $el.attr("href");
10450
- if (href) return `${tag}[href]`;
10451
- return tag;
10434
+ parts.push(value.slice(start));
10435
+ return parts.map((p) => p.trim()).filter(Boolean);
10436
+ }
10437
+ function tokens(value) {
10438
+ return topLevelSplit(value, " ");
10439
+ }
10440
+ function hasUnit(value, units) {
10441
+ return new RegExp(`\\d(?:${units.join("|")})\\b`).test(value);
10442
+ }
10443
+ function bareNumbers(value) {
10444
+ return tokens(value).filter((t) => /^[+-]?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/.test(t)).map(Number);
10445
+ }
10446
+ function hasNegative(value) {
10447
+ return /(?:^|[\s,(])-\.?\d/.test(value);
10448
+ }
10449
+ function dashed(v) {
10450
+ return v.replace(/\s+/g, "-");
10451
+ }
10452
+ function unprefixed(token) {
10453
+ return token.replace(/^-(?:webkit|moz|ms|o)-/, "");
10454
+ }
10455
+ function quotedValues(note) {
10456
+ var _a;
10457
+ const supported = [];
10458
+ const banned = [];
10459
+ for (const m of note.matchAll(/`([^`]+)`/g)) {
10460
+ const clause = (_a = note.slice(0, m.index).split(/[.;]/).pop()) != null ? _a : "";
10461
+ (/\bsupports\b/i.test(clause) ? supported : banned).push(dashed(m[1].toLowerCase().trim()));
10452
10462
  }
10453
- for (const feature of HTML_ELEMENT_FEATURES) {
10454
- const selector = HTML_ELEMENT_SELECTORS[feature];
10455
- if (!selector) continue;
10456
- if ($(selector).length === 0) continue;
10457
- const supportData = CSS_SUPPORT[feature];
10458
- if (!supportData) continue;
10459
- const baseSeverity = HTML_ELEMENT_SEVERITY[feature] || "warning";
10460
- for (const client of EMAIL_CLIENTS) {
10461
- const support = supportData[client.id];
10462
- if (support === "unsupported") {
10463
- const msgFn = HTML_ELEMENT_MESSAGES[feature];
10464
- const message = msgFn ? msgFn(client.name) : `${client.name} does not support ${feature}.`;
10465
- const sug = getSuggestion(feature, client.id, framework);
10466
- const fix = getCodeFix(feature, client.id, framework);
10467
- addWarning(__spreadValues({
10468
- severity: baseSeverity,
10469
- client: client.id,
10470
- property: feature,
10471
- message,
10472
- suggestion: sug.text,
10473
- fix,
10474
- fixType: getFixType(feature)
10475
- }, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(feature, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
10476
- } else if (support === "partial" && feature === "<style>") {
10477
- const sug = getSuggestion("<style>:partial", client.id, framework);
10478
- const fix = getCodeFix("<style>", client.id, framework);
10479
- addWarning(__spreadValues({
10480
- severity: "warning",
10481
- client: client.id,
10482
- property: "<style>",
10483
- message: `${client.name} has partial <style> support (head only, with limitations). Inline styles recommended.`,
10484
- suggestion: sug.text,
10485
- fix,
10486
- fixType: getFixType("<style>")
10487
- }, framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<style>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
10488
- }
10463
+ return { supported, banned };
10464
+ }
10465
+ function isColorOnly(value) {
10466
+ if (value.startsWith("#")) return /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.test(value);
10467
+ if (parseColor(value)) return true;
10468
+ return ["none", "inherit", "initial", "unset", "revert", "currentcolor"].includes(value) || // A custom property is usually a palette colour, and we cannot resolve it.
10469
+ value.startsWith("var(");
10470
+ }
10471
+ var POSITION_KEYWORDS = ["relative", "absolute", "fixed", "sticky"];
10472
+ var RELATIVE_FONT_UNITS = [
10473
+ "cqmin",
10474
+ "cqmax",
10475
+ "vmin",
10476
+ "vmax",
10477
+ "rlh",
10478
+ "rex",
10479
+ "rch",
10480
+ "ric",
10481
+ "rem",
10482
+ "svw",
10483
+ "svh",
10484
+ "lvw",
10485
+ "lvh",
10486
+ "dvw",
10487
+ "dvh",
10488
+ "cqw",
10489
+ "cqh",
10490
+ "cqi",
10491
+ "cqb",
10492
+ "cap",
10493
+ "ic",
10494
+ "lh",
10495
+ "em",
10496
+ "ex",
10497
+ "ch",
10498
+ "vw",
10499
+ "vh",
10500
+ "vi",
10501
+ "vb"
10502
+ ];
10503
+ var NOT_A_PROPERTY_NAME = /* @__PURE__ */ new Set([
10504
+ "all",
10505
+ "allow-discrete",
10506
+ "normal",
10507
+ "ease",
10508
+ "ease-in",
10509
+ "ease-out",
10510
+ "ease-in-out",
10511
+ "linear",
10512
+ "step-start",
10513
+ "step-end",
10514
+ "none",
10515
+ "initial",
10516
+ "inherit",
10517
+ "unset",
10518
+ "revert",
10519
+ "revert-layer"
10520
+ ]);
10521
+ function namesAProperty(layer) {
10522
+ return tokens(layer).some((t) => /^[a-z][a-z0-9-]*$/.test(t) && !NOT_A_PROPERTY_NAME.has(t));
10523
+ }
10524
+ function triggers(prop, value, note, noteLc) {
10525
+ switch (prop) {
10526
+ case "margin": {
10527
+ const negative = noteLc.includes("negative");
10528
+ const auto = noteLc.includes("auto");
10529
+ if (!negative && !auto) return true;
10530
+ return negative && hasNegative(value) || auto && /\bauto\b/.test(value);
10489
10531
  }
10490
- }
10491
- const parsedAtRules = /* @__PURE__ */ new Set();
10492
- const parsedProperties = /* @__PURE__ */ new Set();
10493
- const propertyLines = /* @__PURE__ */ new Map();
10494
- const propertyValues = /* @__PURE__ */ new Map();
10495
- const detectedCssFunctions = /* @__PURE__ */ new Set();
10496
- const detectedPseudoClasses = /* @__PURE__ */ new Set();
10497
- const detectedPseudoElements = /* @__PURE__ */ new Set();
10498
- $("style").each((_, el) => {
10499
- const cssText = $(el).text();
10500
- try {
10501
- const ast = csstree3.parse(cssText, { parseCustomProperty: true, positions: true });
10502
- csstree3.walk(ast, {
10503
- enter(node) {
10504
- if (node.type === "Atrule") {
10505
- parsedAtRules.add(`@${node.name}`);
10506
- }
10507
- if (node.type === "PseudoClassSelector") {
10508
- detectedPseudoClasses.add(`:${node.name}`);
10509
- }
10510
- if (node.type === "PseudoElementSelector") {
10511
- detectedPseudoElements.add(`::${node.name}`);
10512
- }
10513
- if (node.type === "Declaration") {
10514
- const prop = node.property.toLowerCase();
10515
- parsedProperties.add(prop);
10516
- if (node.loc && !propertyLines.has(prop)) {
10517
- propertyLines.set(prop, node.loc.start.line);
10518
- }
10519
- const valueStr = csstree3.generate(node.value);
10520
- const seenValues = propertyValues.get(prop);
10521
- if (seenValues) seenValues.push(valueStr);
10522
- else propertyValues.set(prop, [valueStr]);
10523
- for (const det of COMPOUND_DETECTORS) {
10524
- if (prop === det.property && valueStr.includes(det.valueIncludes)) {
10525
- parsedProperties.add(det.key);
10526
- if (node.loc && !propertyLines.has(det.key)) {
10527
- propertyLines.set(det.key, node.loc.start.line);
10528
- }
10529
- }
10530
- }
10531
- for (const fn of CSS_FUNCTION_DETECTORS) {
10532
- if (valueStr.includes(fn.pattern)) {
10533
- detectedCssFunctions.add(fn.key);
10534
- if (node.loc && !propertyLines.has(fn.key)) {
10535
- propertyLines.set(fn.key, node.loc.start.line);
10536
- }
10537
- }
10538
- }
10539
- }
10540
- }
10541
- });
10542
- } catch (e) {
10532
+ case "position": {
10533
+ const used = POSITION_KEYWORDS.find((k) => tokens(value).some((t) => unprefixed(t) === k));
10534
+ if (!used) return false;
10535
+ const m = note.match(/supports\s+.+?\s+but not\s+([^.]+)/i);
10536
+ if (m) return m[1].toLowerCase().includes(used);
10537
+ return true;
10543
10538
  }
10544
- });
10545
- for (const atRule of AT_RULE_FEATURES) {
10546
- if (!parsedAtRules.has(atRule)) continue;
10547
- checkPropertySupport(atRule, addWarning, framework);
10548
- }
10549
- const cssPropertiesToCheck = Object.keys(CSS_SUPPORT).filter(
10550
- (k) => !k.startsWith("<") && !k.startsWith("@")
10551
- );
10552
- $("[style]").each((_, el) => {
10553
- var _a;
10554
- const style = $(el).attr("style") || "";
10555
- const props = parseStyleProperties(style);
10556
- const selector = describeSelector(el);
10557
- for (const prop of props) {
10558
- for (const det of COMPOUND_DETECTORS) {
10559
- if (prop === det.property) {
10560
- const value2 = getStyleValue(style, prop);
10561
- if (value2 == null ? void 0 : value2.includes(det.valueIncludes)) {
10562
- checkPropertySupport(det.key, addWarning, framework, selector);
10563
- }
10564
- }
10539
+ case "overflow": {
10540
+ if (!/\b(?:auto|scroll|overlay)\b/.test(value)) return false;
10541
+ if (!noteLc.includes("cannot scroll") && !noteLc.includes("overflow-block")) return true;
10542
+ return noteLc.includes("cannot scroll");
10543
+ }
10544
+ case "font-size": {
10545
+ if (noteLc.includes("percentage") || noteLc.includes("relative")) {
10546
+ return hasUnit(value, RELATIVE_FONT_UNITS) || /\d\s*%/.test(value) || tokens(value).some((t) => t === "smaller" || t === "larger");
10565
10547
  }
10566
- if (cssPropertiesToCheck.includes(prop)) {
10567
- checkPropertySupport(prop, addWarning, framework, selector, void 0, (_a = getStyleValue(style, prop)) != null ? _a : void 0);
10548
+ if (noteLc.includes("`rem`")) return hasUnit(value, ["rem"]);
10549
+ return true;
10550
+ }
10551
+ case "display": {
10552
+ const only = note.match(/only supports\s+([^.]*)/i);
10553
+ if (only) {
10554
+ const allowed = quotedValues(only[1]).banned.map((q) => q.replace(/^display:-?/, "")).filter((q) => /^[a-z-]+$/.test(q));
10555
+ if (!allowed.length) return true;
10556
+ return !allowed.includes(dashed(value));
10568
10557
  }
10569
- const value = getStyleValue(style, prop);
10570
- if (value) {
10571
- for (const fn of CSS_FUNCTION_DETECTORS) {
10572
- if (value.includes(fn.pattern)) {
10573
- checkPropertySupport(fn.key, addWarning, framework, selector);
10574
- }
10575
- }
10558
+ const { banned } = quotedValues(note);
10559
+ if (banned.length) {
10560
+ const v = dashed(value);
10561
+ return banned.includes(v) || banned.includes(unprefixed(v));
10576
10562
  }
10563
+ if (noteLc.includes("two-value syntax")) return tokens(value).length > 1;
10564
+ return true;
10577
10565
  }
10578
- });
10579
- for (const prop of parsedProperties) {
10580
- if (prop.includes(":")) continue;
10581
- if (!cssPropertiesToCheck.includes(prop)) continue;
10582
- const values = propertyValues.get(prop);
10583
- checkPropertySupport(
10584
- prop,
10585
- addWarning,
10586
- framework,
10587
- void 0,
10588
- propertyLines.get(prop),
10589
- values ? values.join(" ") : void 0
10590
- );
10591
- }
10592
- for (const compound of COMPOUND_VALUE_FEATURES) {
10593
- if (compound.startsWith(":") || compound.startsWith("::")) continue;
10594
- if (parsedProperties.has(compound)) {
10595
- checkPropertySupport(compound, addWarning, framework, void 0, propertyLines.get(compound));
10566
+ case "font-weight": {
10567
+ const nums = bareNumbers(value);
10568
+ if (!nums.length) return false;
10569
+ if (noteLc.includes("font weight")) return nums.some((n) => n !== 400 && n !== 700);
10570
+ if (noteLc.includes("only the following numeric values")) {
10571
+ return nums.some((n) => n % 100 !== 0 || n < 100 || n > 900);
10572
+ }
10573
+ return true;
10596
10574
  }
10597
- }
10598
- for (const pseudo of detectedPseudoClasses) {
10599
- if (CSS_SUPPORT[pseudo]) {
10600
- checkPropertySupport(pseudo, addWarning, framework);
10575
+ case "border-radius": {
10576
+ if (noteLc.includes("slash")) return topLevelSplit(value, "/").length > 1;
10577
+ return true;
10601
10578
  }
10602
- }
10603
- for (const pseudo of detectedPseudoElements) {
10604
- if (CSS_SUPPORT[pseudo]) {
10605
- checkPropertySupport(pseudo, addWarning, framework);
10579
+ case "text-align": {
10580
+ const { supported, banned } = quotedValues(note);
10581
+ if (!banned.length) return true;
10582
+ return tokens(value).some(
10583
+ (t) => !supported.includes(t) && (banned.includes(t) || banned.includes(unprefixed(t)))
10584
+ );
10606
10585
  }
10586
+ case "background": {
10587
+ if (noteLc.includes("only `background-color`")) return !isColorOnly(value);
10588
+ if (noteLc.includes("multiple values")) {
10589
+ return topLevelSplit(value, ",").length > 1 || topLevelSplit(value, "/").length > 1;
10590
+ }
10591
+ return true;
10592
+ }
10593
+ case "letter-spacing": {
10594
+ const negative = noteLc.includes("negative");
10595
+ const em = noteLc.includes("`em`");
10596
+ if (!negative && !em) return true;
10597
+ return negative && hasNegative(value) || em && hasUnit(value, ["em"]);
10598
+ }
10599
+ case "transition": {
10600
+ if (noteLc.includes("`all`")) {
10601
+ if (["none", "inherit", "initial", "unset", "revert"].includes(value)) return false;
10602
+ return topLevelSplit(value, ",").some((layer) => !namesAProperty(layer));
10603
+ }
10604
+ return true;
10605
+ }
10606
+ default:
10607
+ return true;
10607
10608
  }
10608
- for (const fn of detectedCssFunctions) {
10609
- checkPropertySupport(fn, addWarning, framework, void 0, propertyLines.get(fn));
10610
- }
10611
- const severityOrder = { error: 0, warning: 1, info: 2 };
10612
- warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
10613
- return warnings;
10614
10609
  }
10615
- function analyzeEmail(html, framework) {
10610
+ function caveatApplies(prop, values, notes) {
10611
+ if (!VALUE_CAVEAT_PROPS.has(prop)) return true;
10612
+ if (!(values == null ? void 0 : values.length)) return true;
10613
+ const note = (notes != null ? notes : []).join(" ");
10614
+ const noteLc = note.toLowerCase();
10615
+ return prepare(values).some((value) => triggers(prop, value, note, noteLc));
10616
+ }
10617
+
10618
+ // src/dark-mode-checker.ts
10619
+ import * as csstree4 from "css-tree";
10620
+
10621
+ // src/dark-mode.ts
10622
+ import * as cheerio3 from "cheerio";
10623
+ import * as csstree3 from "css-tree";
10624
+ var LIGHT_THRESHOLD = 0.7;
10625
+ var DARK_THRESHOLD = 0.15;
10626
+ var PREFERS_COLOR_SCHEME_CLIENTS = [
10627
+ "apple-mail-macos",
10628
+ "apple-mail-ios",
10629
+ "samsung-mail",
10630
+ "thunderbird",
10631
+ "hey-mail",
10632
+ "superhuman"
10633
+ ];
10634
+ function simulateDarkMode(html, clientId) {
10635
+ var _a, _b;
10616
10636
  if (!html || !html.trim()) {
10617
- return [];
10637
+ return { html: html || "", warnings: [] };
10618
10638
  }
10619
10639
  if (html.length > MAX_HTML_SIZE) {
10620
10640
  throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
10621
10641
  }
10622
10642
  const $ = cheerio3.load(html);
10623
- return analyzeEmailFromDom($, framework);
10624
- }
10625
- function getFixType(prop) {
10626
- return STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css";
10627
- }
10628
- var VALUE_CAVEAT_PROPS = /* @__PURE__ */ new Set(["margin", "position", "overflow"]);
10629
- var POSITION_KEYWORDS = ["relative", "absolute", "fixed", "sticky"];
10630
- function valueTriggersCaveat(prop, value, notes) {
10631
- const note = (notes != null ? notes : []).join(" ");
10632
- const noteLc = note.toLowerCase();
10633
- if (prop === "margin") {
10634
- if (/(?:^|[\s:(])-\.?\d/.test(value) && noteLc.includes("negative")) return true;
10635
- if (/\bauto\b/.test(value) && noteLc.includes("auto")) return true;
10636
- return false;
10637
- }
10638
- if (prop === "position") {
10639
- const used = POSITION_KEYWORDS.find((k) => new RegExp(`\\b${k}\\b`).test(value));
10640
- if (!used) return false;
10641
- const m = note.match(/supports\s+.+?\s+but not\s+([^.]+)/i);
10642
- if (m) return m[1].toLowerCase().includes(used);
10643
- return used === "fixed" || used === "sticky";
10644
- }
10645
- if (prop === "overflow") {
10646
- if (!/\b(?:auto|scroll)\b/.test(value)) return false;
10647
- return noteLc.includes("cannot scroll");
10648
- }
10649
- return true;
10650
- }
10651
- function noteSuffix(notes) {
10652
- if (!(notes == null ? void 0 : notes.length)) return "";
10653
- const cleaned = notes.map((n) => n.replace(/^(?:Partial|Buggy|Not supported)\.\s*/i, "").trim()).filter(Boolean);
10654
- return cleaned.length ? ` ${cleaned.join(" ")}` : "";
10655
- }
10656
- function checkPropertySupport(prop, addWarning, framework, selector, line, value) {
10657
- var _a;
10658
- const supportData = CSS_SUPPORT[prop];
10659
- if (!supportData) return;
10660
- const fixType = getFixType(prop);
10661
- const valueGated = VALUE_CAVEAT_PROPS.has(prop);
10662
- for (const client of EMAIL_CLIENTS) {
10663
- const support = supportData[client.id] || "unknown";
10664
- const notes = (_a = CSS_SUPPORT_NOTES[prop]) == null ? void 0 : _a[client.id];
10665
- if (support === "unsupported") {
10666
- const sug = getSuggestion(prop, client.id, framework);
10667
- const fix = getCodeFix(prop, client.id, framework);
10668
- addWarning(__spreadValues(__spreadValues(__spreadValues({
10669
- severity: "warning",
10670
- client: client.id,
10671
- property: prop,
10672
- message: `${client.name} does not support "${prop}".${noteSuffix(notes)}`,
10673
- suggestion: sug.text,
10674
- fix,
10675
- fixType
10676
- }, selector ? { selector } : {}), line !== void 0 ? { line } : {}), framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(prop, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
10677
- } else if (support === "partial") {
10678
- if (valueGated && value !== void 0 && !valueTriggersCaveat(prop, value, notes)) continue;
10679
- const sug = getSuggestion(prop, client.id, framework);
10680
- const fix = getCodeFix(prop, client.id, framework);
10681
- addWarning(__spreadValues(__spreadValues(__spreadValues({
10682
- severity: "info",
10683
- client: client.id,
10684
- property: prop,
10685
- message: `${client.name} has partial support for "${prop}".${noteSuffix(notes)}`,
10686
- suggestion: sug.text,
10687
- fix,
10688
- fixType
10689
- }, selector ? { selector } : {}), line !== void 0 ? { line } : {}), framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(prop, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
10690
- }
10691
- }
10692
- }
10693
- function generateCompatibilityScore(warnings) {
10694
- const result = {};
10695
- for (const client of EMAIL_CLIENTS) {
10696
- const clientWarnings = warnings.filter((w) => w.client === client.id);
10697
- const errorProps = new Set(clientWarnings.filter((w) => w.severity === "error").map((w) => w.property));
10698
- const warnProps = new Set(clientWarnings.filter((w) => w.severity === "warning").map((w) => w.property));
10699
- const infoProps = new Set(clientWarnings.filter((w) => w.severity === "info").map((w) => w.property));
10700
- const errors = errorProps.size;
10701
- const warns = warnProps.size;
10702
- const info = infoProps.size;
10703
- const score = Math.max(0, Math.min(100, 100 - errors * 10 - warns * 3));
10704
- result[client.id] = { score, errors, warnings: warns, info };
10705
- }
10706
- return result;
10707
- }
10708
- function warningsForClient(warnings, clientId) {
10709
- return warnings.filter((w) => w.client === clientId);
10710
- }
10711
- function errorWarnings(warnings) {
10712
- return warnings.filter((w) => w.severity === "error");
10713
- }
10714
- function structuralWarnings(warnings) {
10715
- return warnings.filter((w) => w.fixType === "structural");
10716
- }
10717
-
10718
- // src/dark-mode.ts
10719
- import * as cheerio4 from "cheerio";
10720
- import * as csstree4 from "css-tree";
10721
- var LIGHT_THRESHOLD = 0.7;
10722
- var DARK_THRESHOLD = 0.15;
10723
- function simulateDarkMode(html, clientId) {
10724
- var _a, _b;
10725
- if (!html || !html.trim()) {
10726
- return { html: html || "", warnings: [] };
10727
- }
10728
- if (html.length > MAX_HTML_SIZE) {
10729
- throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
10730
- }
10731
- const $ = cheerio4.load(html);
10732
10643
  const warnings = [];
10733
10644
  $("img").each((_, el) => {
10734
10645
  const src = $(el).attr("src") || "";
@@ -10938,46 +10849,802 @@ function applyColorInversion($, mode) {
10938
10849
  $("style").each((_, el) => {
10939
10850
  const cssText = $(el).text();
10940
10851
  try {
10941
- const ast = csstree4.parse(cssText, { parseCustomProperty: true });
10852
+ const ast = csstree3.parse(cssText, { parseCustomProperty: true });
10942
10853
  let modified = false;
10943
- csstree4.walk(ast, {
10854
+ csstree3.walk(ast, {
10944
10855
  enter(node) {
10945
10856
  if (node.type !== "Declaration") return;
10946
10857
  const prop = node.property.toLowerCase();
10947
10858
  if (!COLOR_PROPS.has(prop) && prop !== "background") return;
10948
- const valueStr = csstree4.generate(node.value);
10859
+ const valueStr = csstree3.generate(node.value);
10949
10860
  if (prop === "background") {
10950
10861
  const bgColor = extractBackgroundColor(valueStr);
10951
10862
  if (bgColor) {
10952
10863
  const inverted = invertColor(bgColor, mode);
10953
10864
  if (inverted) {
10954
10865
  const newValue = valueStr.replace(bgColor, inverted);
10955
- node.value = csstree4.parse(newValue, { context: "value" });
10866
+ node.value = csstree3.parse(newValue, { context: "value" });
10956
10867
  modified = true;
10957
10868
  }
10958
10869
  }
10959
10870
  } else {
10960
10871
  const inverted = invertColor(valueStr, mode);
10961
10872
  if (inverted) {
10962
- node.value = csstree4.parse(inverted, { context: "value" });
10873
+ node.value = csstree3.parse(inverted, { context: "value" });
10963
10874
  modified = true;
10964
10875
  }
10965
10876
  }
10966
10877
  }
10967
- });
10968
- if (modified) {
10969
- $(el).text(csstree4.generate(ast));
10878
+ });
10879
+ if (modified) {
10880
+ $(el).text(csstree3.generate(ast));
10881
+ }
10882
+ } catch (e) {
10883
+ }
10884
+ });
10885
+ $("[bgcolor]").each((_, el) => {
10886
+ const bgcolor = $(el).attr("bgcolor") || "";
10887
+ const inverted = invertColor(bgcolor, mode);
10888
+ if (inverted) {
10889
+ $(el).attr("bgcolor", inverted);
10890
+ }
10891
+ });
10892
+ }
10893
+
10894
+ // src/source-location.ts
10895
+ function toLoc(p) {
10896
+ return {
10897
+ line: p.startLine,
10898
+ column: p.startCol,
10899
+ endLine: p.endLine,
10900
+ endColumn: p.endCol,
10901
+ offset: p.startOffset,
10902
+ length: p.endOffset - p.startOffset
10903
+ };
10904
+ }
10905
+ function locOfElement(el) {
10906
+ var _a;
10907
+ const raw = el == null ? void 0 : el.sourceCodeLocation;
10908
+ if (!raw) return void 0;
10909
+ return toLoc((_a = raw.startTag) != null ? _a : raw);
10910
+ }
10911
+ function locOfAttr(el, attr) {
10912
+ var _a, _b, _c, _d;
10913
+ const raw = el == null ? void 0 : el.sourceCodeLocation;
10914
+ if (!raw) return void 0;
10915
+ const attrLoc = (_d = (_a = raw.attrs) == null ? void 0 : _a[attr]) != null ? _d : (_c = (_b = raw.startTag) == null ? void 0 : _b.attrs) == null ? void 0 : _c[attr];
10916
+ return attrLoc ? toLoc(attrLoc) : locOfElement(el);
10917
+ }
10918
+ function locOfFirst($, selector) {
10919
+ const el = $(selector).first()[0];
10920
+ return el ? locOfElement(el) : void 0;
10921
+ }
10922
+ function cssBlockAnchor(styleEl, cssText, source) {
10923
+ var _a;
10924
+ const children = styleEl == null ? void 0 : styleEl.children;
10925
+ if (!children || children.length !== 1) return void 0;
10926
+ const loc = (_a = children[0]) == null ? void 0 : _a.sourceCodeLocation;
10927
+ if (!loc) return void 0;
10928
+ const mapper = source ? crMapper(source.slice(loc.startOffset, loc.endOffset), cssText) : null;
10929
+ const extraBefore = mapper ? (index) => mapper(index) - index : crOffsetter(cssText, loc.endOffset - loc.startOffset);
10930
+ return __spreadValues({ loc, extraBefore }, mapper ? { source } : {});
10931
+ }
10932
+ function crMapper(raw, decoded) {
10933
+ if (raw.length === decoded.length) return (index) => index;
10934
+ const points = [];
10935
+ const extras = [];
10936
+ let r = 0;
10937
+ let d = 0;
10938
+ let extra = 0;
10939
+ while (d < decoded.length) {
10940
+ if (r >= raw.length) return null;
10941
+ if (raw[r] === decoded[d]) {
10942
+ r++;
10943
+ d++;
10944
+ continue;
10945
+ }
10946
+ if (raw[r] === "\r" && decoded[d] === "\n") {
10947
+ const consumed = raw[r + 1] === "\n" ? 2 : 1;
10948
+ extra += consumed - 1;
10949
+ points.push(d);
10950
+ extras.push(extra);
10951
+ r += consumed;
10952
+ d += 1;
10953
+ continue;
10954
+ }
10955
+ return null;
10956
+ }
10957
+ if (r !== raw.length) return null;
10958
+ return (index) => index + lookup(points, extras, index);
10959
+ }
10960
+ function lookup(points, extras, index) {
10961
+ let lo = 0;
10962
+ let hi = points.length - 1;
10963
+ let found = 0;
10964
+ while (lo <= hi) {
10965
+ const mid = lo + hi >> 1;
10966
+ if (points[mid] < index) {
10967
+ found = extras[mid];
10968
+ lo = mid + 1;
10969
+ } else {
10970
+ hi = mid - 1;
10971
+ }
10972
+ }
10973
+ return found;
10974
+ }
10975
+ function findRawOffset(raw, decoded, index, token) {
10976
+ if (!token) return -1;
10977
+ let occurrence = 0;
10978
+ for (let at = decoded.indexOf(token); at !== -1 && at < index; at = decoded.indexOf(token, at + 1)) {
10979
+ occurrence++;
10980
+ }
10981
+ let found = -1;
10982
+ let from = 0;
10983
+ for (let i = 0; i <= occurrence; i++) {
10984
+ found = raw.indexOf(token, from);
10985
+ if (found === -1) return -1;
10986
+ from = found + 1;
10987
+ }
10988
+ return found;
10989
+ }
10990
+ function positionOf(source, offset) {
10991
+ const prefix = source.slice(0, offset);
10992
+ return { line: prefix.split("\n").length, column: offset - prefix.lastIndexOf("\n") };
10993
+ }
10994
+ function crOffsetter(text, rawLength) {
10995
+ const removed = rawLength - text.length;
10996
+ if (removed === 0) return () => 0;
10997
+ const newlines = countNewlines(text, text.length);
10998
+ if (removed !== newlines || newlines === 0) return null;
10999
+ return (index) => countNewlines(text, index);
11000
+ }
11001
+ function countNewlines(text, upTo) {
11002
+ let n = 0;
11003
+ for (let i = 0; i < upTo && i < text.length; i++) if (text.charCodeAt(i) === 10) n++;
11004
+ return n;
11005
+ }
11006
+ function locInCssBlock(anchor, cssLoc) {
11007
+ if (!anchor || !cssLoc) return void 0;
11008
+ const { loc: block, extraBefore } = anchor;
11009
+ if (!extraBefore) {
11010
+ return {
11011
+ line: block.startLine,
11012
+ column: block.startCol,
11013
+ endLine: block.startLine,
11014
+ endColumn: block.startCol,
11015
+ offset: block.startOffset,
11016
+ length: 0
11017
+ };
11018
+ }
11019
+ const line = block.startLine + cssLoc.start.line - 1;
11020
+ const column = cssLoc.start.line === 1 ? block.startCol + cssLoc.start.column - 1 : cssLoc.start.column;
11021
+ const endLine = block.startLine + cssLoc.end.line - 1;
11022
+ const endColumn = cssLoc.end.line === 1 ? block.startCol + cssLoc.end.column - 1 : cssLoc.end.column;
11023
+ const start = block.startOffset + cssLoc.start.offset + extraBefore(cssLoc.start.offset);
11024
+ const end = block.startOffset + cssLoc.end.offset + extraBefore(cssLoc.end.offset);
11025
+ if (anchor.source) {
11026
+ const from = positionOf(anchor.source, start);
11027
+ const to = positionOf(anchor.source, end);
11028
+ return {
11029
+ line: from.line,
11030
+ column: from.column,
11031
+ endLine: to.line,
11032
+ endColumn: to.column,
11033
+ offset: start,
11034
+ length: end - start
11035
+ };
11036
+ }
11037
+ return { line, column, endLine, endColumn, offset: start, length: end - start };
11038
+ }
11039
+ function locInTextNode(node, index, length, source) {
11040
+ var _a;
11041
+ const anchor = node == null ? void 0 : node.sourceCodeLocation;
11042
+ if (!anchor) return void 0;
11043
+ const data = (_a = node.data) != null ? _a : "";
11044
+ const rawLength = anchor.endOffset - anchor.startOffset;
11045
+ if (source) {
11046
+ const raw = source.slice(anchor.startOffset, anchor.endOffset);
11047
+ const token = data.slice(index, index + length);
11048
+ const at = findRawOffset(raw, data, index, token);
11049
+ if (at !== -1) {
11050
+ const start2 = anchor.startOffset + at;
11051
+ const from = positionOf(source, start2);
11052
+ const to = positionOf(source, start2 + token.length);
11053
+ return {
11054
+ line: from.line,
11055
+ column: from.column,
11056
+ endLine: to.line,
11057
+ endColumn: to.column,
11058
+ offset: start2,
11059
+ length: token.length
11060
+ };
11061
+ }
11062
+ }
11063
+ const extraBefore = crOffsetter(data, rawLength);
11064
+ if (!extraBefore) {
11065
+ const clamped = Math.min(length, rawLength);
11066
+ return {
11067
+ line: anchor.startLine,
11068
+ column: anchor.startCol,
11069
+ endLine: anchor.startLine,
11070
+ endColumn: anchor.startCol + clamped,
11071
+ offset: anchor.startOffset,
11072
+ length: clamped
11073
+ };
11074
+ }
11075
+ const start = positionAt(data, index, anchor);
11076
+ const end = positionAt(data, index + length, anchor);
11077
+ const startOffset = anchor.startOffset + index + extraBefore(index);
11078
+ const endOffset = anchor.startOffset + index + length + extraBefore(index + length);
11079
+ return {
11080
+ line: start.line,
11081
+ column: start.column,
11082
+ endLine: end.line,
11083
+ endColumn: end.column,
11084
+ offset: startOffset,
11085
+ length: endOffset - startOffset
11086
+ };
11087
+ }
11088
+ function positionAt(data, index, anchor) {
11089
+ const prefix = data.slice(0, index);
11090
+ const newlines = prefix.split("\n").length - 1;
11091
+ if (newlines === 0) {
11092
+ return { line: anchor.startLine, column: anchor.startCol + index };
11093
+ }
11094
+ return { line: anchor.startLine + newlines, column: index - prefix.lastIndexOf("\n") };
11095
+ }
11096
+
11097
+ // src/dark-mode-checker.ts
11098
+ var DARK_MEDIA_RE = /\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
11099
+ var MAX_UNCOVERED_ELEMENTS = 3;
11100
+ function backgroundShorthandColor(value) {
11101
+ if (!value) return null;
11102
+ const trimmed = value.trim();
11103
+ if (parseColor(trimmed)) return trimmed;
11104
+ for (const token of trimmed.split(/\s+/)) {
11105
+ if (token.includes("(")) continue;
11106
+ if (parseColor(token)) return token;
11107
+ }
11108
+ return null;
11109
+ }
11110
+ function isLight(value) {
11111
+ const c = parseColor(value);
11112
+ if (!c || c.a < 0.5) return false;
11113
+ return relativeLuminance(c.r, c.g, c.b) > LIGHT_THRESHOLD;
11114
+ }
11115
+ function collectDarkBlocks($) {
11116
+ const block = { any: [], important: [], rules: 0 };
11117
+ let found = false;
11118
+ $("style").each((_, el) => {
11119
+ const cssText = $(el).text();
11120
+ if (!DARK_MEDIA_RE.test(cssText)) return;
11121
+ let ast;
11122
+ try {
11123
+ ast = csstree4.parse(cssText);
11124
+ } catch (e) {
11125
+ found = true;
11126
+ return;
11127
+ }
11128
+ csstree4.walk(ast, {
11129
+ visit: "Atrule",
11130
+ enter(node) {
11131
+ if (node.type !== "Atrule" || node.name.toLowerCase() !== "media") return;
11132
+ if (!node.prelude || !DARK_MEDIA_RE.test(csstree4.generate(node.prelude))) return;
11133
+ found = true;
11134
+ if (!node.block) return;
11135
+ csstree4.walk(node.block, {
11136
+ visit: "Rule",
11137
+ enter(rule) {
11138
+ if (rule.type !== "Rule") return;
11139
+ block.rules++;
11140
+ let setsBackground = false;
11141
+ let important = false;
11142
+ rule.block.children.forEach((child) => {
11143
+ if (child.type !== "Declaration") return;
11144
+ const prop = child.property.toLowerCase();
11145
+ if (prop !== "background" && prop !== "background-color") return;
11146
+ setsBackground = true;
11147
+ if (child.important) important = true;
11148
+ });
11149
+ if (!setsBackground) return;
11150
+ const selector = csstree4.generate(rule.prelude).trim();
11151
+ if (!selector) return;
11152
+ block.any.push(selector);
11153
+ if (important) block.important.push(selector);
11154
+ }
11155
+ });
11156
+ }
11157
+ });
11158
+ });
11159
+ return found ? block : null;
11160
+ }
11161
+ function matchedElements($, selectors) {
11162
+ const matched = /* @__PURE__ */ new Set();
11163
+ for (const selector of selectors) {
11164
+ try {
11165
+ $(selector).each((_, el) => {
11166
+ matched.add(el);
11167
+ });
11168
+ } catch (e) {
11169
+ }
11170
+ }
11171
+ return matched;
11172
+ }
11173
+ function describeSelector($, el) {
11174
+ var _a;
11175
+ const $el = $(el);
11176
+ const tag = ((_a = el.tagName) == null ? void 0 : _a.toLowerCase()) || "element";
11177
+ const id = $el.attr("id");
11178
+ if (id) return `${tag}#${id}`;
11179
+ const cls = $el.attr("class");
11180
+ if (cls) return `${tag}.${cls.split(/\s+/)[0]}`;
11181
+ return tag;
11182
+ }
11183
+ function checkDarkModeFromDom($) {
11184
+ var _a, _b;
11185
+ const darkBlock = collectDarkBlocks($);
11186
+ if (!darkBlock) return [];
11187
+ const warnings = [];
11188
+ const hasOptIn = $("meta").toArray().some((el) => {
11189
+ const name = ($(el).attr("name") || "").trim().toLowerCase();
11190
+ return name === "color-scheme" || name === "supported-color-schemes";
11191
+ });
11192
+ if (!hasOptIn) {
11193
+ const headLoc = locOfFirst($, "head");
11194
+ for (const clientId of PREFERS_COLOR_SCHEME_CLIENTS) {
11195
+ warnings.push(__spreadValues({
11196
+ severity: "warning",
11197
+ client: clientId,
11198
+ property: "dark-mode-opt-in",
11199
+ message: `The email has @media (prefers-color-scheme: dark) styles but no dark-mode opt-in meta tag. ${(_b = (_a = getClient(clientId)) == null ? void 0 : _a.name) != null ? _b : clientId} may keep the email in light mode, so the dark styles never activate.`,
11200
+ suggestion: 'Add both opt-in tags to <head>: <meta name="color-scheme" content="light dark"> and <meta name="supported-color-schemes" content="light dark">.',
11201
+ fixType: "structural"
11202
+ }, headLoc ? { loc: headLoc, locs: [headLoc] } : {}));
11203
+ }
11204
+ }
11205
+ if (darkBlock.rules === 0) return warnings;
11206
+ const coveredByImportant = matchedElements($, darkBlock.important);
11207
+ const coveredByAny = matchedElements($, darkBlock.any);
11208
+ let uncovered = 0;
11209
+ $("[bgcolor], [style]").each((_, el) => {
11210
+ var _a2;
11211
+ if (uncovered >= MAX_UNCOVERED_ELEMENTS) return false;
11212
+ const $el = $(el);
11213
+ const style = parseInlineStyle($el.attr("style") || "");
11214
+ const inline = (_a2 = style.get("background-color")) != null ? _a2 : backgroundShorthandColor(style.get("background"));
11215
+ const color = inline != null ? inline : $el.attr("bgcolor");
11216
+ if (!color || !isLight(color)) return;
11217
+ if (inline ? coveredByImportant.has(el) : coveredByAny.has(el)) return;
11218
+ uncovered++;
11219
+ const selector = describeSelector($, el);
11220
+ const loc = locOfAttr(el, inline ? "style" : "bgcolor");
11221
+ for (const clientId of PREFERS_COLOR_SCHEME_CLIENTS) {
11222
+ warnings.push(__spreadValues({
11223
+ severity: "warning",
11224
+ client: clientId,
11225
+ property: "dark-mode-coverage",
11226
+ message: `<${selector}> keeps its hardcoded light background (${color}) in dark mode \u2014 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).`,
11227
+ 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.`,
11228
+ fixType: "css",
11229
+ selector
11230
+ }, loc ? { loc, locs: [loc] } : {}));
11231
+ }
11232
+ });
11233
+ return warnings;
11234
+ }
11235
+
11236
+ // src/parse-html.ts
11237
+ import * as cheerio4 from "cheerio";
11238
+ function loadHtml(html, options) {
11239
+ return (options == null ? void 0 : options.positions) ? cheerio4.load(html, { sourceCodeLocationInfo: true }) : cheerio4.load(html);
11240
+ }
11241
+ function fromHtml(html, empty, fn, options) {
11242
+ if (!html || !html.trim()) return empty;
11243
+ if (html.length > MAX_HTML_SIZE) {
11244
+ throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
11245
+ }
11246
+ return fn(loadHtml(html, options), html);
11247
+ }
11248
+
11249
+ // src/analyze.ts
11250
+ var HTML_ELEMENT_SELECTORS = {
11251
+ "<style>": "style",
11252
+ "<link>": "link[rel='stylesheet']",
11253
+ "<svg>": "svg",
11254
+ "<video>": "video",
11255
+ "<form>": "form, input, button[type='submit']",
11256
+ "<audio>": "audio",
11257
+ "<picture>": "picture",
11258
+ "<dialog>": "dialog",
11259
+ "<meter>": "meter",
11260
+ "<progress>": "progress",
11261
+ "<select>": "select",
11262
+ "<textarea>": "textarea",
11263
+ "<marquee>": "marquee",
11264
+ "<object>": "object",
11265
+ "<base>": "base"
11266
+ };
11267
+ var HTML_ELEMENT_SEVERITY = {
11268
+ "<style>": "error",
11269
+ "<link>": "error",
11270
+ "<svg>": "error",
11271
+ "<form>": "error",
11272
+ "<video>": "warning",
11273
+ "<audio>": "warning",
11274
+ "<picture>": "warning",
11275
+ "<dialog>": "warning",
11276
+ "<marquee>": "warning",
11277
+ "<meter>": "warning",
11278
+ "<progress>": "warning",
11279
+ "<select>": "warning",
11280
+ "<textarea>": "warning",
11281
+ "<object>": "warning",
11282
+ "<base>": "warning"
11283
+ };
11284
+ var HTML_ELEMENT_MESSAGES = {
11285
+ "<style>": (n) => `${n} strips <style> blocks. Styles must be inlined.`,
11286
+ "<link>": (n) => `${n} does not support external stylesheets.`,
11287
+ "<svg>": (n) => `${n} does not support inline SVG.`,
11288
+ "<video>": (n) => `${n} does not support <video> elements.`,
11289
+ "<form>": (n) => `${n} strips form elements.`
11290
+ };
11291
+ var COMPOUND_DETECTORS = [
11292
+ { key: "display:flex", property: "display", valueIncludes: "flex" },
11293
+ { key: "display:grid", property: "display", valueIncludes: "grid" },
11294
+ { key: "display:none", property: "display", valueIncludes: "none" }
11295
+ ];
11296
+ var CSS_FUNCTION_DETECTORS = CSS_FUNCTION_FEATURES.map((fn) => ({
11297
+ key: fn,
11298
+ pattern: `${fn}(`
11299
+ // require opening paren — matches "min(" but not "Minion"
11300
+ }));
11301
+ function analyzeEmailFromDom($, framework, source) {
11302
+ const warnings = [];
11303
+ const seenWarnings = /* @__PURE__ */ new Map();
11304
+ function addWarning(w) {
11305
+ const key = `${w.client}:${w.property}:${w.severity}:${w.selector || ""}`;
11306
+ const existing = seenWarnings.get(key);
11307
+ if (!existing) {
11308
+ seenWarnings.set(key, w);
11309
+ warnings.push(w);
11310
+ return;
11311
+ }
11312
+ if (!existing.locs || !w.locs) return;
11313
+ for (const loc of w.locs) {
11314
+ if (existing.locs.some((l) => l.offset === loc.offset)) continue;
11315
+ if (existing.locs.length >= MAX_WARNING_LOCATIONS) {
11316
+ existing.locsTruncated = true;
11317
+ break;
11318
+ }
11319
+ existing.locs.push(loc);
11320
+ }
11321
+ }
11322
+ function describeSelector2(el) {
11323
+ var _a;
11324
+ const $el = $(el);
11325
+ const tag = ((_a = el.tagName) == null ? void 0 : _a.toLowerCase()) || "";
11326
+ const cls = $el.attr("class");
11327
+ const id = $el.attr("id");
11328
+ if (id) return `${tag}#${id}`;
11329
+ if (cls) return `${tag}.${cls.split(/\s+/)[0]}`;
11330
+ const href = $el.attr("href");
11331
+ if (href) return `${tag}[href]`;
11332
+ return tag;
11333
+ }
11334
+ for (const feature of HTML_ELEMENT_FEATURES) {
11335
+ const selector = HTML_ELEMENT_SELECTORS[feature];
11336
+ if (!selector) continue;
11337
+ const matches = $(selector);
11338
+ if (matches.length === 0) continue;
11339
+ const supportData = CSS_SUPPORT[feature];
11340
+ if (!supportData) continue;
11341
+ const baseSeverity = HTML_ELEMENT_SEVERITY[feature] || "warning";
11342
+ const found = matches.toArray().map((m) => locOfElement(m)).filter((l) => l !== void 0);
11343
+ const featureOccurrences = found.length ? __spreadValues({
11344
+ locs: found.slice(0, MAX_WARNING_LOCATIONS)
11345
+ }, found.length > MAX_WARNING_LOCATIONS ? { truncated: true } : {}) : void 0;
11346
+ const featureLoc = featureOccurrences == null ? void 0 : featureOccurrences.locs[0];
11347
+ for (const client of EMAIL_CLIENTS) {
11348
+ const support = supportData[client.id];
11349
+ if (support === "unsupported") {
11350
+ const msgFn = HTML_ELEMENT_MESSAGES[feature];
11351
+ const message = msgFn ? msgFn(client.name) : `${client.name} does not support ${feature}.`;
11352
+ const sug = getSuggestion(feature, client.id, framework);
11353
+ const fix = getCodeFix(feature, client.id, framework);
11354
+ addWarning(__spreadValues(__spreadValues({
11355
+ severity: baseSeverity,
11356
+ client: client.id,
11357
+ property: feature,
11358
+ message,
11359
+ suggestion: sug.text,
11360
+ fix,
11361
+ fixType: getFixType(feature)
11362
+ }, featureOccurrences ? occurrenceFields(featureOccurrences) : {}), framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(feature, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
11363
+ } else if (support === "partial" && feature === "<style>") {
11364
+ const sug = getSuggestion("<style>:partial", client.id, framework);
11365
+ const fix = getCodeFix("<style>", client.id, framework);
11366
+ addWarning(__spreadValues(__spreadValues({
11367
+ severity: "warning",
11368
+ client: client.id,
11369
+ property: "<style>",
11370
+ message: `${client.name} has partial <style> support (head only, with limitations). Inline styles recommended.`,
11371
+ suggestion: sug.text,
11372
+ fix,
11373
+ fixType: getFixType("<style>")
11374
+ }, featureOccurrences ? occurrenceFields(featureOccurrences) : {}), framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback("<style>", client.id, framework)) ? { fixIsGenericFallback: true } : {}));
11375
+ }
11376
+ }
11377
+ }
11378
+ const parsedAtRules = /* @__PURE__ */ new Set();
11379
+ const selectorLocs = /* @__PURE__ */ new Map();
11380
+ const parsedProperties = /* @__PURE__ */ new Set();
11381
+ const propertyLines = /* @__PURE__ */ new Map();
11382
+ const propertyLocs = /* @__PURE__ */ new Map();
11383
+ const propertyValues = /* @__PURE__ */ new Map();
11384
+ const detectedCssFunctions = /* @__PURE__ */ new Set();
11385
+ const detectedPseudoClasses = /* @__PURE__ */ new Set();
11386
+ const detectedPseudoElements = /* @__PURE__ */ new Set();
11387
+ let blockAnchor;
11388
+ function recordSelectorLoc(key, cssLoc) {
11389
+ if (!cssLoc) return;
11390
+ const loc = locInCssBlock(blockAnchor, cssLoc);
11391
+ if (!loc) return;
11392
+ const seen = selectorLocs.get(key);
11393
+ if (!seen) {
11394
+ selectorLocs.set(key, { locs: [loc] });
11395
+ return;
11396
+ }
11397
+ if (seen.locs.some((l) => l.offset === loc.offset)) return;
11398
+ if (seen.locs.length >= MAX_WARNING_LOCATIONS) {
11399
+ seen.truncated = true;
11400
+ return;
11401
+ }
11402
+ seen.locs.push(loc);
11403
+ }
11404
+ function recordLoc(key, cssLoc, value) {
11405
+ const loc = locInCssBlock(blockAnchor, cssLoc);
11406
+ if (!loc) return;
11407
+ const seen = propertyLocs.get(key);
11408
+ if (!seen) {
11409
+ propertyLocs.set(key, __spreadValues({ locs: [loc] }, value !== void 0 ? { values: [value] } : {}));
11410
+ return;
11411
+ }
11412
+ if (seen.locs.some((l) => l.offset === loc.offset)) return;
11413
+ if (seen.locs.length >= MAX_WARNING_LOCATIONS) {
11414
+ seen.truncated = true;
11415
+ return;
11416
+ }
11417
+ seen.locs.push(loc);
11418
+ if (seen.values && value !== void 0) seen.values.push(value);
11419
+ }
11420
+ $("style").each((_, el) => {
11421
+ const cssText = $(el).text();
11422
+ blockAnchor = cssBlockAnchor(el, cssText, source);
11423
+ try {
11424
+ const ast = csstree5.parse(cssText, { parseCustomProperty: true, positions: true });
11425
+ csstree5.walk(ast, {
11426
+ enter(node) {
11427
+ if (node.type === "Atrule") {
11428
+ parsedAtRules.add(`@${node.name}`);
11429
+ recordSelectorLoc(`@${node.name}`, node.loc);
11430
+ }
11431
+ if (node.type === "PseudoClassSelector") {
11432
+ detectedPseudoClasses.add(`:${node.name}`);
11433
+ recordSelectorLoc(`:${node.name}`, node.loc);
11434
+ }
11435
+ if (node.type === "PseudoElementSelector") {
11436
+ detectedPseudoElements.add(`::${node.name}`);
11437
+ recordSelectorLoc(`::${node.name}`, node.loc);
11438
+ }
11439
+ if (node.type === "Declaration") {
11440
+ const prop = node.property.toLowerCase();
11441
+ parsedProperties.add(prop);
11442
+ const valueStr = csstree5.generate(node.value);
11443
+ const seenValues = propertyValues.get(prop);
11444
+ if (seenValues) seenValues.push(valueStr);
11445
+ else propertyValues.set(prop, [valueStr]);
11446
+ if (node.loc) {
11447
+ if (!propertyLines.has(prop)) propertyLines.set(prop, node.loc.start.line);
11448
+ recordLoc(prop, node.loc, valueStr);
11449
+ }
11450
+ for (const det of COMPOUND_DETECTORS) {
11451
+ if (prop === det.property && valueStr.toLowerCase().includes(det.valueIncludes)) {
11452
+ parsedProperties.add(det.key);
11453
+ if (node.loc) {
11454
+ if (!propertyLines.has(det.key)) propertyLines.set(det.key, node.loc.start.line);
11455
+ recordLoc(det.key, node.loc);
11456
+ }
11457
+ }
11458
+ }
11459
+ for (const fn of CSS_FUNCTION_DETECTORS) {
11460
+ if (valueStr.includes(fn.pattern)) {
11461
+ detectedCssFunctions.add(fn.key);
11462
+ if (node.loc) {
11463
+ if (!propertyLines.has(fn.key)) propertyLines.set(fn.key, node.loc.start.line);
11464
+ recordLoc(fn.key, node.loc);
11465
+ }
11466
+ }
11467
+ }
11468
+ }
11469
+ }
11470
+ });
11471
+ } catch (e) {
11472
+ }
11473
+ });
11474
+ for (const atRule of AT_RULE_FEATURES) {
11475
+ if (!parsedAtRules.has(atRule)) continue;
11476
+ checkPropertySupport(atRule, addWarning, framework, void 0, void 0, void 0, selectorLocs.get(atRule));
11477
+ }
11478
+ const cssPropertiesToCheck = Object.keys(CSS_SUPPORT).filter(
11479
+ (k) => !k.startsWith("<") && !k.startsWith("@")
11480
+ );
11481
+ $("[style]").each((_, el) => {
11482
+ const style = $(el).attr("style") || "";
11483
+ const props = parseStyleProperties(style);
11484
+ const selector = describeSelector2(el);
11485
+ const locs = elementLocs(locOfAttr(el, "style"));
11486
+ for (const prop of props) {
11487
+ for (const det of COMPOUND_DETECTORS) {
11488
+ if (prop === det.property) {
11489
+ const value2 = getStyleValue(style, prop);
11490
+ if (value2 == null ? void 0 : value2.toLowerCase().includes(det.valueIncludes)) {
11491
+ checkPropertySupport(det.key, addWarning, framework, selector, void 0, void 0, locs);
11492
+ }
11493
+ }
11494
+ }
11495
+ if (cssPropertiesToCheck.includes(prop)) {
11496
+ const declared = getStyleValues(style, prop);
11497
+ checkPropertySupport(
11498
+ prop,
11499
+ addWarning,
11500
+ framework,
11501
+ selector,
11502
+ void 0,
11503
+ declared.length ? declared : void 0,
11504
+ locs
11505
+ );
11506
+ }
11507
+ const value = getStyleValue(style, prop);
11508
+ if (value) {
11509
+ for (const fn of CSS_FUNCTION_DETECTORS) {
11510
+ if (value.includes(fn.pattern)) {
11511
+ checkPropertySupport(fn.key, addWarning, framework, selector, void 0, void 0, locs);
11512
+ }
11513
+ }
10970
11514
  }
10971
- } catch (e) {
10972
11515
  }
10973
11516
  });
10974
- $("[bgcolor]").each((_, el) => {
10975
- const bgcolor = $(el).attr("bgcolor") || "";
10976
- const inverted = invertColor(bgcolor, mode);
10977
- if (inverted) {
10978
- $(el).attr("bgcolor", inverted);
11517
+ for (const prop of parsedProperties) {
11518
+ if (prop.includes(":")) continue;
11519
+ if (!cssPropertiesToCheck.includes(prop)) continue;
11520
+ const values = propertyValues.get(prop);
11521
+ checkPropertySupport(
11522
+ prop,
11523
+ addWarning,
11524
+ framework,
11525
+ void 0,
11526
+ propertyLines.get(prop),
11527
+ values,
11528
+ propertyLocs.get(prop)
11529
+ );
11530
+ }
11531
+ for (const compound of COMPOUND_VALUE_FEATURES) {
11532
+ if (compound.startsWith(":") || compound.startsWith("::")) continue;
11533
+ if (parsedProperties.has(compound)) {
11534
+ checkPropertySupport(compound, addWarning, framework, void 0, propertyLines.get(compound), void 0, propertyLocs.get(compound));
10979
11535
  }
10980
- });
11536
+ }
11537
+ for (const pseudo of detectedPseudoClasses) {
11538
+ if (CSS_SUPPORT[pseudo]) {
11539
+ checkPropertySupport(pseudo, addWarning, framework, void 0, void 0, void 0, selectorLocs.get(pseudo));
11540
+ }
11541
+ }
11542
+ for (const pseudo of detectedPseudoElements) {
11543
+ if (CSS_SUPPORT[pseudo]) {
11544
+ checkPropertySupport(pseudo, addWarning, framework, void 0, void 0, void 0, selectorLocs.get(pseudo));
11545
+ }
11546
+ }
11547
+ for (const fn of detectedCssFunctions) {
11548
+ checkPropertySupport(fn, addWarning, framework, void 0, propertyLines.get(fn), void 0, propertyLocs.get(fn));
11549
+ }
11550
+ for (const w of checkDarkModeFromDom($)) addWarning(w);
11551
+ const severityOrder = { error: 0, warning: 1, info: 2 };
11552
+ warnings.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
11553
+ return warnings;
11554
+ }
11555
+ function analyzeEmail(html, framework, options) {
11556
+ if (!html || !html.trim()) {
11557
+ return [];
11558
+ }
11559
+ if (html.length > MAX_HTML_SIZE) {
11560
+ throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
11561
+ }
11562
+ const $ = loadHtml(html, options);
11563
+ return analyzeEmailFromDom($, framework, (options == null ? void 0 : options.positions) ? html : void 0);
11564
+ }
11565
+ function getFixType(prop) {
11566
+ return STRUCTURAL_FIX_PROPERTIES.has(prop) ? "structural" : "css";
11567
+ }
11568
+ function noteSuffix(notes) {
11569
+ if (!(notes == null ? void 0 : notes.length)) return "";
11570
+ const cleaned = notes.map((n) => n.replace(/^(?:Partial|Buggy|Not supported)\.\s*/i, "").trim()).filter(Boolean);
11571
+ return cleaned.length ? ` ${cleaned.join(" ")}` : "";
11572
+ }
11573
+ function checkPropertySupport(prop, addWarning, framework, selector, line, values, occurrences) {
11574
+ var _a, _b, _c, _d, _e, _f;
11575
+ const loc = occurrences == null ? void 0 : occurrences.locs[0];
11576
+ const reportedLine = (_a = loc == null ? void 0 : loc.line) != null ? _a : line;
11577
+ const supportData = CSS_SUPPORT[prop];
11578
+ if (!supportData) return;
11579
+ const fixType = getFixType(prop);
11580
+ for (const client of EMAIL_CLIENTS) {
11581
+ const support = supportData[client.id] || "unknown";
11582
+ const notes = (_b = CSS_SUPPORT_NOTES[prop]) == null ? void 0 : _b[client.id];
11583
+ if (support === "unsupported") {
11584
+ const sug = getSuggestion(prop, client.id, framework);
11585
+ const fix = getCodeFix(prop, client.id, framework);
11586
+ addWarning(__spreadValues(__spreadValues(__spreadValues(__spreadValues({
11587
+ severity: "warning",
11588
+ client: client.id,
11589
+ property: prop,
11590
+ message: `${client.name} does not support "${prop}".${noteSuffix(notes)}`,
11591
+ suggestion: sug.text,
11592
+ fix,
11593
+ fixType
11594
+ }, selector ? { selector } : {}), reportedLine !== void 0 ? { line: reportedLine } : {}), occurrences ? occurrenceFields(occurrences) : {}), framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(prop, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
11595
+ } else if (support === "partial") {
11596
+ if (!caveatApplies(prop, values, notes)) continue;
11597
+ const hits = triggeringOccurrences(prop, occurrences, notes);
11598
+ const sug = getSuggestion(prop, client.id, framework);
11599
+ const fix = getCodeFix(prop, client.id, framework);
11600
+ addWarning(__spreadValues(__spreadValues(__spreadValues(__spreadValues({
11601
+ severity: "info",
11602
+ client: client.id,
11603
+ property: prop,
11604
+ message: `${client.name} has partial support for "${prop}".${noteSuffix(notes)}`,
11605
+ suggestion: sug.text,
11606
+ fix,
11607
+ fixType
11608
+ }, selector ? { selector } : {}), ((_d = (_c = hits == null ? void 0 : hits.locs[0]) == null ? void 0 : _c.line) != null ? _d : reportedLine) !== void 0 ? { line: (_f = (_e = hits == null ? void 0 : hits.locs[0]) == null ? void 0 : _e.line) != null ? _f : reportedLine } : {}), hits ? occurrenceFields(hits) : {}), framework && (sug.isGenericFallback || fix && isCodeFixGenericFallback(prop, client.id, framework)) ? { fixIsGenericFallback: true } : {}));
11609
+ }
11610
+ }
11611
+ }
11612
+ function generateCompatibilityScore(warnings) {
11613
+ const result = {};
11614
+ for (const client of EMAIL_CLIENTS) {
11615
+ const clientWarnings = warnings.filter((w) => w.client === client.id);
11616
+ const errorProps = new Set(clientWarnings.filter((w) => w.severity === "error").map((w) => w.property));
11617
+ const warnProps = new Set(clientWarnings.filter((w) => w.severity === "warning").map((w) => w.property));
11618
+ const infoProps = new Set(clientWarnings.filter((w) => w.severity === "info").map((w) => w.property));
11619
+ const errors = errorProps.size;
11620
+ const warns = warnProps.size;
11621
+ const info = infoProps.size;
11622
+ const score = Math.max(0, Math.min(100, 100 - errors * 10 - warns * 3));
11623
+ result[client.id] = { score, errors, warnings: warns, info };
11624
+ }
11625
+ return result;
11626
+ }
11627
+ function occurrenceFields({ locs, truncated }) {
11628
+ return __spreadValues({ loc: locs[0], locs: [...locs] }, truncated ? { locsTruncated: true } : {});
11629
+ }
11630
+ function triggeringOccurrences(prop, occurrences, notes) {
11631
+ const values = occurrences == null ? void 0 : occurrences.values;
11632
+ if (!occurrences || !values) return occurrences;
11633
+ const locs = occurrences.locs.filter((_, i) => caveatApplies(prop, [values[i]], notes));
11634
+ if (!locs.length || locs.length === occurrences.locs.length) return occurrences;
11635
+ return __spreadValues({ locs }, occurrences.truncated ? { truncated: true } : {});
11636
+ }
11637
+ function elementLocs(loc) {
11638
+ return loc ? { locs: [loc] } : void 0;
11639
+ }
11640
+ function warningsForClient(warnings, clientId) {
11641
+ return warnings.filter((w) => w.client === clientId);
11642
+ }
11643
+ function errorWarnings(warnings) {
11644
+ return warnings.filter((w) => w.severity === "error");
11645
+ }
11646
+ function structuralWarnings(warnings) {
11647
+ return warnings.filter((w) => w.fixType === "structural");
10981
11648
  }
10982
11649
 
10983
11650
  // src/diff.ts
@@ -11310,16 +11977,6 @@ function extractCode(response) {
11310
11977
  return response.trim();
11311
11978
  }
11312
11979
 
11313
- // src/parse-html.ts
11314
- import * as cheerio5 from "cheerio";
11315
- function fromHtml(html, empty, fn) {
11316
- if (!html || !html.trim()) return empty;
11317
- if (html.length > MAX_HTML_SIZE) {
11318
- throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
11319
- }
11320
- return fn(cheerio5.load(html), html);
11321
- }
11322
-
11323
11980
  // src/spam-scorer.ts
11324
11981
  var SPAM_TRIGGER_PHRASES = [
11325
11982
  "act now",
@@ -11788,6 +12445,8 @@ function validateLinksFromDom($) {
11788
12445
  const href = $(el).attr("href") || "";
11789
12446
  const text = $(el).text().trim();
11790
12447
  const category = classifyHref(href);
12448
+ const elLoc = locOfElement(el);
12449
+ const hrefLoc = href ? locOfAttr(el, "href") : elLoc;
11791
12450
  switch (category) {
11792
12451
  case "https":
11793
12452
  breakdown.https++;
@@ -11818,95 +12477,95 @@ function validateLinksFromDom($) {
11818
12477
  hrefCounts.set(href, (hrefCounts.get(href) || 0) + 1);
11819
12478
  }
11820
12479
  if (!href || !href.trim()) {
11821
- issues.push({
12480
+ issues.push(__spreadValues({
11822
12481
  severity: "error",
11823
12482
  rule: "empty-href",
11824
12483
  message: "Link has no href attribute",
11825
12484
  text: text.slice(0, 80) || "(no text)"
11826
- });
12485
+ }, elLoc ? { loc: elLoc } : {}));
11827
12486
  return;
11828
12487
  }
11829
12488
  if (category === "javascript" && !isPlaceholderHref(href)) {
11830
- issues.push({
12489
+ issues.push(__spreadValues({
11831
12490
  severity: "error",
11832
12491
  rule: "javascript-href",
11833
12492
  message: "Link uses javascript: protocol",
11834
12493
  href: href.slice(0, 100),
11835
12494
  text: text.slice(0, 80) || "(no text)"
11836
- });
12495
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11837
12496
  return;
11838
12497
  }
11839
12498
  if (isPlaceholderHref(href)) {
11840
- issues.push({
12499
+ issues.push(__spreadValues({
11841
12500
  severity: "warning",
11842
12501
  rule: "placeholder-href",
11843
12502
  message: "Link has a placeholder href (# or javascript:void)",
11844
12503
  href,
11845
12504
  text: text.slice(0, 80) || "(no text)"
11846
- });
12505
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11847
12506
  return;
11848
12507
  }
11849
12508
  if (category === "http") {
11850
- issues.push({
12509
+ issues.push(__spreadValues({
11851
12510
  severity: "warning",
11852
12511
  rule: "insecure-link",
11853
12512
  message: "Link uses HTTP instead of HTTPS",
11854
12513
  href: href.slice(0, 120),
11855
12514
  text: text.slice(0, 80) || "(no text)"
11856
- });
12515
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11857
12516
  }
11858
12517
  if (category === "protocol-relative") {
11859
- issues.push({
12518
+ issues.push(__spreadValues({
11860
12519
  severity: "warning",
11861
12520
  rule: "protocol-relative",
11862
12521
  message: "Protocol-relative URL may break in email clients \u2014 use https:// explicitly",
11863
12522
  href: href.slice(0, 120),
11864
12523
  text: text.slice(0, 80) || "(no text)"
11865
- });
12524
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11866
12525
  }
11867
12526
  if (text && GENERIC_LINK_TEXT.has(text.toLowerCase())) {
11868
- issues.push({
12527
+ issues.push(__spreadValues({
11869
12528
  severity: "warning",
11870
12529
  rule: "generic-link-text",
11871
12530
  message: `Link text "${text}" is vague \u2014 use descriptive text for accessibility and engagement`,
11872
12531
  href: href.slice(0, 120),
11873
12532
  text
11874
- });
12533
+ }, elLoc ? { loc: elLoc } : {}));
11875
12534
  }
11876
12535
  if (!text && !$(el).attr("aria-label") && !$(el).find("img[alt]").length) {
11877
- issues.push({
12536
+ issues.push(__spreadValues({
11878
12537
  severity: "error",
11879
12538
  rule: "empty-link-text",
11880
12539
  message: "Link has no visible text or aria-label",
11881
12540
  href: href.slice(0, 120)
11882
- });
12541
+ }, elLoc ? { loc: elLoc } : {}));
11883
12542
  }
11884
12543
  if (category === "mailto" && href.trim().toLowerCase() === "mailto:") {
11885
- issues.push({
12544
+ issues.push(__spreadValues({
11886
12545
  severity: "error",
11887
12546
  rule: "empty-mailto",
11888
12547
  message: "mailto: link has no email address",
11889
12548
  href,
11890
12549
  text: text.slice(0, 80) || "(no text)"
11891
- });
12550
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11892
12551
  }
11893
12552
  if (category === "tel" && href.trim().toLowerCase() === "tel:") {
11894
- issues.push({
12553
+ issues.push(__spreadValues({
11895
12554
  severity: "error",
11896
12555
  rule: "empty-tel",
11897
12556
  message: "tel: link has no phone number",
11898
12557
  href,
11899
12558
  text: text.slice(0, 80) || "(no text)"
11900
- });
12559
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11901
12560
  }
11902
12561
  if (href.length > 2e3) {
11903
- issues.push({
12562
+ issues.push(__spreadValues({
11904
12563
  severity: "info",
11905
12564
  rule: "long-url",
11906
12565
  message: "URL exceeds 2000 characters \u2014 may be truncated by some email clients",
11907
12566
  href: href.slice(0, 120) + "...",
11908
12567
  text: text.slice(0, 80) || "(no text)"
11909
- });
12568
+ }, hrefLoc ? { loc: hrefLoc } : {}));
11910
12569
  }
11911
12570
  });
11912
12571
  links.each((_, el) => {
@@ -11915,14 +12574,15 @@ function validateLinksFromDom($) {
11915
12574
  if (trimmed.startsWith("#") && trimmed.length > 1) {
11916
12575
  const targetId = trimmed.slice(1);
11917
12576
  const target = $(`[id="${targetId}"]`);
12577
+ const anchorLoc = locOfAttr(el, "href");
11918
12578
  if (target.length === 0) {
11919
- issues.push({
12579
+ issues.push(__spreadValues({
11920
12580
  severity: "error",
11921
12581
  rule: "broken-anchor",
11922
12582
  message: `Anchor link "${trimmed}" points to an element that does not exist`,
11923
12583
  href: trimmed,
11924
12584
  text: $(el).text().trim().slice(0, 80) || "(no text)"
11925
- });
12585
+ }, anchorLoc ? { loc: anchorLoc } : {}));
11926
12586
  }
11927
12587
  }
11928
12588
  });
@@ -11938,8 +12598,8 @@ function validateLinksFromDom($) {
11938
12598
  }
11939
12599
  return { totalLinks, issues, breakdown };
11940
12600
  }
11941
- function validateLinks(html) {
11942
- return fromHtml(html, EMPTY_LINKS, validateLinksFromDom);
12601
+ function validateLinks(html, options) {
12602
+ return fromHtml(html, EMPTY_LINKS, validateLinksFromDom, options);
11943
12603
  }
11944
12604
 
11945
12605
  // src/accessibility-checker.ts
@@ -11964,24 +12624,31 @@ function describeElement($, el) {
11964
12624
  function checkLangAttribute($) {
11965
12625
  const lang = $("html").attr("lang");
11966
12626
  if (!lang || !lang.trim()) {
11967
- return {
12627
+ const loc = locOfFirst($, "html");
12628
+ return __spreadProps(__spreadValues({
11968
12629
  severity: "error",
11969
12630
  rule: "missing-lang",
11970
- message: "Missing lang attribute on <html> element",
12631
+ message: "Missing lang attribute on <html> element"
12632
+ }, loc ? { loc } : {}), {
11971
12633
  details: 'Screen readers use the lang attribute to determine pronunciation. Add lang="en" (or appropriate language code).'
11972
- };
12634
+ });
11973
12635
  }
11974
12636
  return null;
11975
12637
  }
12638
+ function titleLoc($) {
12639
+ return $("title").length ? locOfFirst($, "title") : locOfFirst($, "head");
12640
+ }
11976
12641
  function checkTitle($) {
11977
12642
  const title = $("title").text().trim();
11978
12643
  if (!title) {
11979
- return {
12644
+ const loc = titleLoc($);
12645
+ return __spreadProps(__spreadValues({
11980
12646
  severity: "warning",
11981
12647
  rule: "missing-title",
11982
- message: "Missing or empty <title> element",
12648
+ message: "Missing or empty <title> element"
12649
+ }, loc ? { loc } : {}), {
11983
12650
  details: "The <title> helps screen readers identify the email content."
11984
- };
12651
+ });
11985
12652
  }
11986
12653
  return null;
11987
12654
  }
@@ -11991,34 +12658,38 @@ function checkImageAlt($) {
11991
12658
  const alt = $(el).attr("alt");
11992
12659
  const src = $(el).attr("src") || "";
11993
12660
  const role = $(el).attr("role");
12661
+ const elLoc = locOfElement(el);
11994
12662
  if (role === "presentation" || role === "none") return;
11995
12663
  if (alt === void 0) {
11996
- issues.push({
12664
+ issues.push(__spreadProps(__spreadValues({
11997
12665
  severity: "error",
11998
12666
  rule: "img-missing-alt",
11999
12667
  message: "Image missing alt attribute",
12000
- element: describeElement($, el),
12668
+ element: describeElement($, el)
12669
+ }, elLoc ? { loc: elLoc } : {}), {
12001
12670
  details: 'Every image must have an alt attribute. Use alt="" for decorative images.'
12002
- });
12671
+ }));
12003
12672
  } else if (alt.trim() === "") {
12004
12673
  const isLikelyContent = !src.includes("spacer") && !src.includes("pixel") && !src.includes("tracking") && !src.includes("1x1") && !src.includes("transparent");
12005
12674
  if (isLikelyContent && ($(el).attr("width") || "0") !== "1") {
12006
- issues.push({
12675
+ issues.push(__spreadProps(__spreadValues({
12007
12676
  severity: "info",
12008
12677
  rule: "img-empty-alt",
12009
12678
  message: "Image has empty alt text \u2014 verify it is decorative",
12010
- element: describeElement($, el),
12679
+ element: describeElement($, el)
12680
+ }, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
12011
12681
  details: "Empty alt is correct for decorative images, but content images need descriptive alt text."
12012
- });
12682
+ }));
12013
12683
  }
12014
12684
  } else if (/\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i.test(alt)) {
12015
- issues.push({
12685
+ issues.push(__spreadProps(__spreadValues({
12016
12686
  severity: "error",
12017
12687
  rule: "img-filename-alt",
12018
12688
  message: "Image alt text is a filename, not a description",
12019
- element: describeElement($, el),
12689
+ element: describeElement($, el)
12690
+ }, locOfAttr(el, "alt") ? { loc: locOfAttr(el, "alt") } : {}), {
12020
12691
  details: `Alt "${alt}" should describe the image content, not the file name.`
12021
- });
12692
+ }));
12022
12693
  }
12023
12694
  });
12024
12695
  return issues;
@@ -12026,28 +12697,31 @@ function checkImageAlt($) {
12026
12697
  function checkLinkAccessibility($) {
12027
12698
  const issues = [];
12028
12699
  $("a").each((_, el) => {
12700
+ const elLoc = locOfElement(el);
12029
12701
  const text = $(el).text().trim().toLowerCase();
12030
12702
  const ariaLabel = $(el).attr("aria-label");
12031
12703
  const title = $(el).attr("title");
12032
12704
  const imgAlt = $(el).find("img").attr("alt");
12033
12705
  if (!text && !ariaLabel && !title && !imgAlt) {
12034
- issues.push({
12706
+ issues.push(__spreadProps(__spreadValues({
12035
12707
  severity: "error",
12036
12708
  rule: "link-no-accessible-name",
12037
12709
  message: "Link has no accessible name",
12038
- element: describeElement($, el),
12710
+ element: describeElement($, el)
12711
+ }, elLoc ? { loc: elLoc } : {}), {
12039
12712
  details: "Links need visible text, aria-label, or an image with alt text."
12040
- });
12713
+ }));
12041
12714
  return;
12042
12715
  }
12043
12716
  if (text && GENERIC_LINK_TEXT.has(text) && !ariaLabel) {
12044
- issues.push({
12717
+ issues.push(__spreadProps(__spreadValues({
12045
12718
  severity: "warning",
12046
12719
  rule: "link-generic-text",
12047
12720
  message: `Link text "${$(el).text().trim()}" is not descriptive`,
12048
- element: describeElement($, el),
12721
+ element: describeElement($, el)
12722
+ }, elLoc ? { loc: elLoc } : {}), {
12049
12723
  details: "Screen readers often list links out of context. Use text that describes the destination."
12050
- });
12724
+ }));
12051
12725
  }
12052
12726
  });
12053
12727
  return issues;
@@ -12057,18 +12731,20 @@ function checkTableAccessibility($) {
12057
12731
  $("table").each((_, el) => {
12058
12732
  if ($(el).parents('table[role="presentation"], table[role="none"]').length > 0) return;
12059
12733
  const role = $(el).attr("role");
12734
+ const tableLoc = locOfElement(el);
12060
12735
  const hasHeaders = $(el).find("th").length > 0;
12061
12736
  const looksLikeLayout = !hasHeaders;
12062
12737
  if (looksLikeLayout && role !== "presentation" && role !== "none") {
12063
12738
  const nestedTables = $(el).find("table").length;
12064
12739
  if (nestedTables > 0 || $(el).find("td").length > 2) {
12065
- issues.push({
12740
+ issues.push(__spreadProps(__spreadValues({
12066
12741
  severity: "info",
12067
12742
  rule: "table-missing-role",
12068
- message: 'Layout table missing role="presentation"',
12743
+ message: 'Layout table missing role="presentation"'
12744
+ }, tableLoc ? { loc: tableLoc } : {}), {
12069
12745
  element: `<table> with ${$(el).find("td").length} cells`,
12070
12746
  details: `Add role="presentation" to tables used for layout so screen readers don't announce them as data tables.`
12071
- });
12747
+ }));
12072
12748
  }
12073
12749
  }
12074
12750
  });
@@ -12079,6 +12755,7 @@ function checkTextSizeAndContrast($) {
12079
12755
  let smallTextCount = 0;
12080
12756
  $("[style]").each((_, el) => {
12081
12757
  const style = $(el).attr("style") || "";
12758
+ const styleLoc = locOfAttr(el, "style");
12082
12759
  const fontSizeMatch = style.match(/font-size\s*:\s*(\d+(?:\.\d+)?)(px|pt)/i);
12083
12760
  if (fontSizeMatch) {
12084
12761
  const size = parseFloat(fontSizeMatch[1]);
@@ -12087,13 +12764,14 @@ function checkTextSizeAndContrast($) {
12087
12764
  if (pxSize < 9 && pxSize > 0) {
12088
12765
  smallTextCount++;
12089
12766
  if (smallTextCount <= 3) {
12090
- issues.push({
12767
+ issues.push(__spreadProps(__spreadValues({
12091
12768
  severity: "warning",
12092
12769
  rule: "small-text",
12093
12770
  message: `Very small text (${fontSizeMatch[0].trim()})`,
12094
- element: describeElement($, el),
12771
+ element: describeElement($, el)
12772
+ }, styleLoc ? { loc: styleLoc } : {}), {
12095
12773
  details: "Text smaller than 9px is difficult to read, especially on mobile devices."
12096
- });
12774
+ }));
12097
12775
  }
12098
12776
  }
12099
12777
  }
@@ -12138,21 +12816,23 @@ function checkTextSizeAndContrast($) {
12138
12816
  }
12139
12817
  const grade = wcagGrade(ratio);
12140
12818
  if (grade === "Fail") {
12141
- issues.push({
12819
+ issues.push(__spreadProps(__spreadValues({
12142
12820
  severity: "error",
12143
12821
  rule: "low-contrast",
12144
12822
  message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG minimum`,
12145
- element: describeElement($, el),
12823
+ element: describeElement($, el)
12824
+ }, styleLoc ? { loc: styleLoc } : {}), {
12146
12825
  details: `Foreground ${colorValue} on background needs at least ${isLargeText ? "3:1" : "4.5:1"} contrast ratio.`
12147
- });
12826
+ }));
12148
12827
  } else if (!isLargeText && grade === "AA Large") {
12149
- issues.push({
12828
+ issues.push(__spreadProps(__spreadValues({
12150
12829
  severity: "warning",
12151
12830
  rule: "low-contrast",
12152
12831
  message: `Low contrast ratio ${ratio.toFixed(1)}:1 \u2014 fails WCAG AA for normal text`,
12153
- element: describeElement($, el),
12832
+ element: describeElement($, el)
12833
+ }, styleLoc ? { loc: styleLoc } : {}), {
12154
12834
  details: `Foreground ${colorValue} on background needs at least 4.5:1 for normal-sized text.`
12155
- });
12835
+ }));
12156
12836
  }
12157
12837
  }
12158
12838
  }
@@ -12175,29 +12855,32 @@ function checkCharsetDeclaration($) {
12175
12855
  const content = httpEquiv.attr("content") || "";
12176
12856
  if (/charset\s*=/i.test(content)) return null;
12177
12857
  }
12178
- return {
12858
+ const loc = locOfFirst($, "head");
12859
+ return __spreadProps(__spreadValues({
12179
12860
  severity: "warning",
12180
12861
  rule: "missing-charset",
12181
- message: "Missing charset declaration",
12862
+ message: "Missing charset declaration"
12863
+ }, loc ? { loc } : {}), {
12182
12864
  details: 'Add <meta charset="utf-8"> in <head> to prevent encoding issues across email clients.'
12183
- };
12865
+ });
12184
12866
  }
12185
12867
  function checkSemanticStructure($) {
12186
12868
  const issues = [];
12187
12869
  const headings = [];
12188
12870
  $("h1, h2, h3, h4, h5, h6").each((_, el) => {
12189
12871
  const level = parseInt(el.tagName.replace(/h/i, ""), 10);
12190
- headings.push({ level, text: $(el).text().trim().slice(0, 60) });
12872
+ headings.push({ level, text: $(el).text().trim().slice(0, 60), loc: locOfElement(el) });
12191
12873
  });
12192
12874
  for (let i = 1; i < headings.length; i++) {
12193
12875
  const gap = headings[i].level - headings[i - 1].level;
12194
12876
  if (gap > 1) {
12195
- issues.push({
12877
+ issues.push(__spreadProps(__spreadValues({
12196
12878
  severity: "info",
12197
12879
  rule: "heading-skip",
12198
- message: `Heading level skipped: h${headings[i - 1].level} to h${headings[i].level}`,
12880
+ message: `Heading level skipped: h${headings[i - 1].level} to h${headings[i].level}`
12881
+ }, headings[i].loc ? { loc: headings[i].loc } : {}), {
12199
12882
  details: "Skipped heading levels can confuse screen readers. Use sequential heading levels."
12200
- });
12883
+ }));
12201
12884
  break;
12202
12885
  }
12203
12886
  }
@@ -12238,8 +12921,8 @@ function checkAccessibilityFromDom($) {
12238
12921
  const score = Math.max(0, 100 - penalty);
12239
12922
  return { score, issues };
12240
12923
  }
12241
- function checkAccessibility(html) {
12242
- return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom);
12924
+ function checkAccessibility(html, options) {
12925
+ return fromHtml(html, EMPTY_ACCESSIBILITY, checkAccessibilityFromDom, options);
12243
12926
  }
12244
12927
 
12245
12928
  // src/image-analyzer.ts
@@ -12286,6 +12969,8 @@ function analyzeImagesFromDom($) {
12286
12969
  const height = (_c = img.attr("height")) != null ? _c : null;
12287
12970
  const style = (img.attr("style") || "").toLowerCase();
12288
12971
  const imgIssues = [];
12972
+ const elLoc = locOfElement(el);
12973
+ const srcLoc = src ? locOfAttr(el, "src") : elLoc;
12289
12974
  const tracking = isTrackingPixel(img);
12290
12975
  let dataUriBytes = 0;
12291
12976
  if (src.startsWith("data:")) {
@@ -12309,59 +12994,59 @@ function analyzeImagesFromDom($) {
12309
12994
  const hasStyleHeight = /height\s*:/.test(style);
12310
12995
  if (!hasStyleWidth && !hasStyleHeight) {
12311
12996
  imgIssues.push("missing-dimensions");
12312
- issues.push({
12997
+ issues.push(__spreadValues({
12313
12998
  rule: "missing-dimensions",
12314
12999
  severity: "warning",
12315
13000
  message: "Image missing width/height attributes \u2014 causes layout shifts and Outlook rendering issues.",
12316
13001
  src: truncateSrc(src)
12317
- });
13002
+ }, elLoc ? { loc: elLoc } : {}));
12318
13003
  }
12319
13004
  }
12320
13005
  if (dataUriBytes > DATA_URI_WARN_BYTES) {
12321
13006
  const kb = Math.round(dataUriBytes / 1024);
12322
13007
  imgIssues.push("large-data-uri");
12323
- issues.push({
13008
+ issues.push(__spreadValues({
12324
13009
  rule: "large-data-uri",
12325
13010
  severity: "warning",
12326
13011
  message: `Data URI is ${kb}KB \u2014 consider hosting the image externally to reduce email size.`,
12327
13012
  src: truncateSrc(src)
12328
- });
13013
+ }, srcLoc ? { loc: srcLoc } : {}));
12329
13014
  }
12330
13015
  if (alt === null) {
12331
13016
  imgIssues.push("missing-alt");
12332
- issues.push({
13017
+ issues.push(__spreadValues({
12333
13018
  rule: "missing-alt",
12334
13019
  severity: "warning",
12335
13020
  message: "Image missing alt attribute \u2014 hurts deliverability and accessibility.",
12336
13021
  src: truncateSrc(src)
12337
- });
13022
+ }, elLoc ? { loc: elLoc } : {}));
12338
13023
  }
12339
13024
  if (src.toLowerCase().endsWith(".webp") || src.includes("image/webp")) {
12340
13025
  imgIssues.push("webp-format");
12341
- issues.push({
13026
+ issues.push(__spreadValues({
12342
13027
  rule: "webp-format",
12343
13028
  severity: "info",
12344
13029
  message: "WebP format detected \u2014 not supported by all email clients. Consider PNG or JPEG.",
12345
13030
  src: truncateSrc(src)
12346
- });
13031
+ }, srcLoc ? { loc: srcLoc } : {}));
12347
13032
  }
12348
13033
  if (src.toLowerCase().endsWith(".svg") || src.includes("image/svg")) {
12349
13034
  imgIssues.push("svg-format");
12350
- issues.push({
13035
+ issues.push(__spreadValues({
12351
13036
  rule: "svg-format",
12352
13037
  severity: "info",
12353
13038
  message: "SVG format detected \u2014 not supported by most email clients. Use PNG instead.",
12354
13039
  src: truncateSrc(src)
12355
- });
13040
+ }, srcLoc ? { loc: srcLoc } : {}));
12356
13041
  }
12357
13042
  if (!style.includes("display:block") && !style.includes("display: block")) {
12358
13043
  imgIssues.push("missing-display-block");
12359
- issues.push({
13044
+ issues.push(__spreadValues({
12360
13045
  rule: "missing-display-block",
12361
13046
  severity: "info",
12362
13047
  message: "Image without display:block \u2014 may cause unwanted gaps in Outlook.",
12363
13048
  src: truncateSrc(src)
12364
- });
13049
+ }, elLoc ? { loc: elLoc } : {}));
12365
13050
  }
12366
13051
  images.push({
12367
13052
  src: truncateSrc(src),
@@ -12399,8 +13084,8 @@ function analyzeImagesFromDom($) {
12399
13084
  }
12400
13085
  return { total: images.length, totalDataUriBytes, issues, images };
12401
13086
  }
12402
- function analyzeImages(html) {
12403
- return fromHtml(html, EMPTY_IMAGES, analyzeImagesFromDom);
13087
+ function analyzeImages(html, options) {
13088
+ return fromHtml(html, EMPTY_IMAGES, analyzeImagesFromDom, options);
12404
13089
  }
12405
13090
 
12406
13091
  // src/inbox-preview.ts
@@ -12618,11 +13303,57 @@ function checkSize(html) {
12618
13303
  return fromHtml(html, EMPTY_SIZE, checkSizeFromDom);
12619
13304
  }
12620
13305
 
13306
+ // src/dom-text.ts
13307
+ function visibleTextNodes($) {
13308
+ var _a, _b, _c, _d;
13309
+ const nodes = [];
13310
+ const stack = [...(_b = (_a = $.root()[0]) == null ? void 0 : _a.children) != null ? _b : []].reverse();
13311
+ while (stack.length > 0) {
13312
+ const node = stack.pop();
13313
+ const tag = (_c = node.tagName) == null ? void 0 : _c.toLowerCase();
13314
+ if (tag === "style" || tag === "script" || tag === "head") continue;
13315
+ if (node.type === "text") {
13316
+ nodes.push(node);
13317
+ continue;
13318
+ }
13319
+ const children = (_d = node.children) != null ? _d : [];
13320
+ for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
13321
+ }
13322
+ return nodes;
13323
+ }
13324
+
12621
13325
  // src/template-checker.ts
12622
- function checkTemplateVariablesFromDom($) {
13326
+ function checkTemplateVariablesFromDom($, source) {
13327
+ var _a;
12623
13328
  const issues = [];
12624
13329
  const seen = /* @__PURE__ */ new Set();
12625
- const textContent = extractTextContent($);
13330
+ const textNodes = visibleTextNodes($);
13331
+ const positioned = textNodes.some((n) => n.sourceCodeLocation);
13332
+ for (const node of positioned ? textNodes : []) {
13333
+ const data = (_a = node.data) != null ? _a : "";
13334
+ for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
13335
+ pattern.lastIndex = 0;
13336
+ let match;
13337
+ while ((match = pattern.exec(data)) !== null) {
13338
+ const variable = match[0];
13339
+ const key = `text:${variable}`;
13340
+ if (seen.has(key)) continue;
13341
+ seen.add(key);
13342
+ const loc = locInTextNode(node, match.index, variable.length, source);
13343
+ issues.push(__spreadValues({
13344
+ rule: "unresolved-variable",
13345
+ severity: "error",
13346
+ message: `Unresolved ${label} variable "${variable}" found in text content.`,
13347
+ variable,
13348
+ location: "text"
13349
+ }, loc ? { loc } : {}));
13350
+ }
13351
+ }
13352
+ }
13353
+ const textContent = textNodes.map((n) => {
13354
+ var _a2;
13355
+ return (_a2 = n.data) != null ? _a2 : "";
13356
+ }).join("");
12626
13357
  for (const [pattern, label] of TEMPLATE_VARIABLE_PATTERNS) {
12627
13358
  pattern.lastIndex = 0;
12628
13359
  let match;
@@ -12655,13 +13386,14 @@ function checkTemplateVariablesFromDom($) {
12655
13386
  const key = `attr:${attr}:${variable}`;
12656
13387
  if (seen.has(key)) continue;
12657
13388
  seen.add(key);
12658
- issues.push({
13389
+ const loc = locOfAttr(el, attr);
13390
+ issues.push(__spreadValues({
12659
13391
  rule: "unresolved-variable",
12660
13392
  severity: "error",
12661
13393
  message: `Unresolved ${label} variable "${variable}" found in ${attr} attribute.`,
12662
13394
  variable,
12663
13395
  location: "attribute"
12664
- });
13396
+ }, loc ? { loc } : {}));
12665
13397
  }
12666
13398
  }
12667
13399
  }
@@ -12669,17 +13401,17 @@ function checkTemplateVariablesFromDom($) {
12669
13401
  }
12670
13402
  return { unresolvedCount: issues.length, issues };
12671
13403
  }
12672
- function extractTextContent($) {
12673
- const clone = $.root().clone();
12674
- clone.find("style, script, head").remove();
12675
- return clone.text();
12676
- }
12677
- function checkTemplateVariables(html) {
12678
- return fromHtml(html, EMPTY_TEMPLATE, checkTemplateVariablesFromDom);
13404
+ function checkTemplateVariables(html, options) {
13405
+ return fromHtml(
13406
+ html,
13407
+ EMPTY_TEMPLATE,
13408
+ ($, h) => checkTemplateVariablesFromDom($, (options == null ? void 0 : options.positions) ? h : void 0),
13409
+ options
13410
+ );
12679
13411
  }
12680
13412
 
12681
13413
  // src/overflow-checker.ts
12682
- import * as csstree5 from "css-tree";
13414
+ import * as csstree6 from "css-tree";
12683
13415
  function fixedPxWidth($el) {
12684
13416
  const style = $el.attr("style") || "";
12685
13417
  const styleMatch = style.match(/(?:^|[;\s])width\s*:\s*(\d+)px/i);
@@ -12691,89 +13423,142 @@ function fixedPxWidth($el) {
12691
13423
  function isFluid(style) {
12692
13424
  return /max-width\s*:\s*100%/i.test(style) || /width\s*:\s*100%/i.test(style);
12693
13425
  }
12694
- function addWidthIssue(width, label, issues, seen) {
13426
+ function addWidthIssue(width, label, issues, seen, loc) {
12695
13427
  const key = `w:${label}:${width}`;
12696
- if (seen.has(key)) return;
12697
- seen.add(key);
12698
- issues.push({
13428
+ const existing = seen.get(key);
13429
+ if (existing) {
13430
+ addOccurrence(existing, loc);
13431
+ return;
13432
+ }
13433
+ const issue = __spreadValues({
12699
13434
  rule: "fixed-width-overflow",
12700
13435
  severity: "warning",
12701
13436
  message: `${label} has a fixed width of ${width}px, wider than the ${EMAIL_MAX_WIDTH}px email frame \u2014 it will force horizontal scrolling, especially on mobile.`,
12702
13437
  detail: `Use width:100% with max-width:${EMAIL_MAX_WIDTH}px instead of a fixed width beyond the frame.`
12703
- });
13438
+ }, loc ? { loc, locs: [loc] } : {});
13439
+ seen.set(key, issue);
13440
+ issues.push(issue);
13441
+ }
13442
+ function locateInNodes(nodes, starts, index, length, source) {
13443
+ var _a;
13444
+ let lo = 0;
13445
+ let hi = starts.length - 1;
13446
+ let found = -1;
13447
+ while (lo <= hi) {
13448
+ const mid = lo + hi >> 1;
13449
+ if (starts[mid] <= index) {
13450
+ found = mid;
13451
+ lo = mid + 1;
13452
+ } else {
13453
+ hi = mid - 1;
13454
+ }
13455
+ }
13456
+ if (found === -1) return void 0;
13457
+ const node = nodes[found];
13458
+ const within = index - starts[found];
13459
+ const available = ((_a = node.data) != null ? _a : "").length - within;
13460
+ return locInTextNode(node, within, Math.min(length, available), source);
13461
+ }
13462
+ function addOccurrence(issue, loc) {
13463
+ if (!loc || !issue.locs) return;
13464
+ if (issue.locs.some((l) => l.offset === loc.offset)) return;
13465
+ if (issue.locs.length >= MAX_WARNING_LOCATIONS) {
13466
+ issue.locsTruncated = true;
13467
+ return;
13468
+ }
13469
+ issue.locs.push(loc);
12704
13470
  }
12705
- function checkOverflowFromDom($) {
13471
+ function checkOverflowFromDom($, source) {
13472
+ var _a;
12706
13473
  const issues = [];
12707
- const seen = /* @__PURE__ */ new Set();
13474
+ const seen = /* @__PURE__ */ new Map();
13475
+ const tokensSeen = /* @__PURE__ */ new Set();
12708
13476
  $("[width], [style*='width']").each((_, el) => {
12709
13477
  const $el = $(el);
12710
13478
  const width = fixedPxWidth($el);
12711
13479
  if (width === null || width <= EMAIL_MAX_WIDTH) return;
12712
13480
  if (isFluid($el.attr("style") || "")) return;
12713
13481
  const tag = (el.tagName || "element").toLowerCase();
12714
- addWidthIssue(width, `<${tag}>`, issues, seen);
13482
+ const fromStyle = /(?:^|[;\s])width\s*:\s*\d+px/i.test($el.attr("style") || "");
13483
+ addWidthIssue(width, `<${tag}>`, issues, seen, locOfAttr(el, fromStyle ? "style" : "width"));
12715
13484
  });
12716
13485
  $("style").each((_, el) => {
13486
+ const cssText = $(el).text();
13487
+ const anchor = cssBlockAnchor(el, cssText, source);
12717
13488
  let ast;
12718
13489
  try {
12719
- ast = csstree5.parse($(el).text());
13490
+ ast = csstree6.parse(cssText, { positions: true });
12720
13491
  } catch (e) {
12721
13492
  return;
12722
13493
  }
12723
- csstree5.walk(ast, {
13494
+ csstree6.walk(ast, {
12724
13495
  visit: "Rule",
12725
13496
  enter(node) {
12726
13497
  if (node.type !== "Rule") return;
12727
13498
  let widthPx = null;
12728
13499
  let fluid = false;
13500
+ let widthLoc;
12729
13501
  node.block.children.forEach((child) => {
12730
13502
  if (child.type !== "Declaration") return;
12731
13503
  const prop = child.property.toLowerCase();
12732
- const val = csstree5.generate(child.value);
13504
+ const val = csstree6.generate(child.value);
12733
13505
  if (prop === "width") {
12734
13506
  const m = val.match(/^(\d+)px$/);
12735
- if (m) widthPx = parseInt(m[1], 10);
13507
+ if (m) {
13508
+ widthPx = parseInt(m[1], 10);
13509
+ widthLoc = locInCssBlock(anchor, child.loc);
13510
+ }
12736
13511
  if (/\b100%/.test(val)) fluid = true;
12737
13512
  } else if (prop === "max-width" && /\b100%/.test(val)) {
12738
13513
  fluid = true;
12739
13514
  }
12740
13515
  });
12741
13516
  if (widthPx !== null && widthPx > EMAIL_MAX_WIDTH && !fluid) {
12742
- const selector = csstree5.generate(node.prelude).trim().slice(0, 40);
12743
- addWidthIssue(widthPx, selector || "rule", issues, seen);
13517
+ const selector = csstree6.generate(node.prelude).trim().slice(0, 40);
13518
+ addWidthIssue(widthPx, selector || "rule", issues, seen, widthLoc);
12744
13519
  }
12745
13520
  }
12746
13521
  });
12747
13522
  });
12748
13523
  const usesWrapGuard = /overflow-wrap|word-break|word-wrap/i.test($.html());
12749
13524
  if (!usesWrapGuard) {
12750
- const $body = $("body");
13525
+ const nodes = visibleTextNodes($);
13526
+ const starts = [];
12751
13527
  let text = "";
12752
- if ($body.length) {
12753
- const clone = $body.clone();
12754
- clone.find("style, script").remove();
12755
- text = clone.text();
12756
- }
12757
- for (const token of text.split(/\s+/)) {
12758
- if (token.length <= UNBREAKABLE_STRING_LENGTH || seen.has(token)) continue;
12759
- seen.add(token);
13528
+ for (const node of nodes) {
13529
+ starts.push(text.length);
13530
+ text += (_a = node.data) != null ? _a : "";
13531
+ }
13532
+ let at = 0;
13533
+ for (const token of text.split(/(\s+)/)) {
13534
+ const start = at;
13535
+ at += token.length;
13536
+ if (/^\s*$/.test(token)) continue;
13537
+ if (token.length <= UNBREAKABLE_STRING_LENGTH || tokensSeen.has(token)) continue;
13538
+ tokensSeen.add(token);
12760
13539
  const preview = token.length > 50 ? `${token.slice(0, 50)}\u2026` : token;
12761
- issues.push({
13540
+ const loc = locateInNodes(nodes, starts, start, token.length, source);
13541
+ issues.push(__spreadValues({
12762
13542
  rule: "unbreakable-string",
12763
13543
  severity: "warning",
12764
13544
  message: `A ${token.length}-character unbroken string ("${preview}") can't wrap and will force horizontal scrolling on narrow screens.`,
12765
13545
  detail: `Add overflow-wrap: anywhere (or word-break: break-word) to its container.`
12766
- });
13546
+ }, loc ? { loc, locs: [loc] } : {}));
12767
13547
  }
12768
13548
  }
12769
13549
  return { hasOverflow: issues.length > 0, issues };
12770
13550
  }
12771
- function checkOverflow(html) {
12772
- return fromHtml(html, EMPTY_OVERFLOW, checkOverflowFromDom);
13551
+ function checkOverflow(html, options) {
13552
+ return fromHtml(
13553
+ html,
13554
+ EMPTY_OVERFLOW,
13555
+ ($, h) => checkOverflowFromDom($, (options == null ? void 0 : options.positions) ? h : void 0),
13556
+ options
13557
+ );
12773
13558
  }
12774
13559
 
12775
13560
  // src/visual-checker.ts
12776
- import * as csstree6 from "css-tree";
13561
+ import * as csstree7 from "css-tree";
12777
13562
  var CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set(["inherit", "initial", "unset", "revert", "revert-layer"]);
12778
13563
  var GRADIENT_RE = /(?:linear|radial|conic)-gradient\(/i;
12779
13564
  function isSolidColor(value) {
@@ -12782,8 +13567,8 @@ function isSolidColor(value) {
12782
13567
  return c !== null && c.a !== 0;
12783
13568
  }
12784
13569
  function firstColor(value) {
12785
- const tokens = value.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|\b[a-zA-Z]{3,}\b/g) || [];
12786
- for (const t of tokens) {
13570
+ const tokens2 = value.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|\b[a-zA-Z]{3,}\b/g) || [];
13571
+ for (const t of tokens2) {
12787
13572
  const lc = t.toLowerCase();
12788
13573
  if (lc === "transparent") continue;
12789
13574
  if (/^(?:linear|radial|conic|gradient|deg|turn|rad|grad|to|at|from|in|circle|ellipse|closest|farthest|side|corner|url)$/.test(lc)) continue;
@@ -12820,8 +13605,17 @@ function hasFontFallback(value) {
12820
13605
  return WEB_SAFE_FONTS.has(t) || GENERIC_FONT_FAMILIES.has(t) || t.startsWith("-apple-system") || t === "blinkmacsystemfont";
12821
13606
  });
12822
13607
  }
12823
- function inspectDeclarations(style, issues, seen) {
12824
- var _a, _b;
13608
+ function addOccurrence2(issue, loc) {
13609
+ if (!loc || !issue.locs) return;
13610
+ if (issue.locs.some((l) => l.offset === loc.offset)) return;
13611
+ if (issue.locs.length >= MAX_WARNING_LOCATIONS) {
13612
+ issue.locsTruncated = true;
13613
+ return;
13614
+ }
13615
+ issue.locs.push(loc);
13616
+ }
13617
+ function inspectDeclarations(style, issues, seen, locs) {
13618
+ var _a, _b, _c;
12825
13619
  const combined = `${(_a = style.get("background-image")) != null ? _a : ""} ${(_b = style.get("background")) != null ? _b : ""}`;
12826
13620
  const isGradient = GRADIENT_RE.test(combined);
12827
13621
  const isImage = isGradient || /url\(/i.test(combined);
@@ -12829,30 +13623,40 @@ function inspectDeclarations(style, issues, seen) {
12829
13623
  const stop = isGradient ? firstColor(combined) : null;
12830
13624
  const fix = stop ? `background-color: ${stop};` : `background-color: <solid colour matching the image>;`;
12831
13625
  const key = `bg:${fix}`;
12832
- if (!seen.has(key)) {
12833
- seen.add(key);
12834
- issues.push({
13626
+ const loc = (_c = locs == null ? void 0 : locs.get("background-image")) != null ? _c : locs == null ? void 0 : locs.get("background");
13627
+ const existing = seen.get(key);
13628
+ if (existing) {
13629
+ addOccurrence2(existing, loc);
13630
+ } else {
13631
+ const issue = __spreadValues({
12835
13632
  rule: "missing-background-fallback",
12836
13633
  severity: "warning",
12837
13634
  message: `${isGradient ? "Gradient" : "Background image"} has no background-color fallback \u2014 it renders as a blank area (and can hide overlaid text) in Outlook and other clients that drop image backgrounds.`,
12838
13635
  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.`,
12839
13636
  fix
12840
- });
13637
+ }, loc ? { loc, locs: [loc] } : {});
13638
+ seen.set(key, issue);
13639
+ issues.push(issue);
12841
13640
  }
12842
13641
  }
12843
13642
  const font = style.get("font-family");
12844
13643
  if (font && !CSS_WIDE_KEYWORDS.has(font.trim().toLowerCase()) && !hasFontFallback(font)) {
12845
13644
  const fix = `font-family: ${font.trim()}, Arial, sans-serif;`;
12846
13645
  const key = `font:${font.trim().toLowerCase()}`;
12847
- if (!seen.has(key)) {
12848
- seen.add(key);
12849
- issues.push({
13646
+ const loc = locs == null ? void 0 : locs.get("font-family");
13647
+ const existing = seen.get(key);
13648
+ if (existing) {
13649
+ addOccurrence2(existing, loc);
13650
+ } else {
13651
+ const issue = __spreadValues({
12850
13652
  rule: "missing-font-fallback",
12851
13653
  severity: "warning",
12852
13654
  message: `font-family "${font.trim()}" has no web-safe fallback \u2014 clients that strip web fonts (Gmail, Outlook) fall back to Times New Roman.`,
12853
13655
  detail: `End the stack with a web-safe font and a generic family.`,
12854
13656
  fix
12855
- });
13657
+ }, loc ? { loc, locs: [loc] } : {});
13658
+ seen.set(key, issue);
13659
+ issues.push(issue);
12856
13660
  }
12857
13661
  }
12858
13662
  }
@@ -12860,35 +13664,52 @@ function ruleToMap(node) {
12860
13664
  const map = /* @__PURE__ */ new Map();
12861
13665
  node.block.children.forEach((child) => {
12862
13666
  if (child.type === "Declaration") {
12863
- map.set(child.property.toLowerCase(), csstree6.generate(child.value));
13667
+ map.set(child.property.toLowerCase(), csstree7.generate(child.value));
12864
13668
  }
12865
13669
  });
12866
13670
  return map;
12867
13671
  }
12868
- function checkVisualFromDom($) {
13672
+ function checkVisualFromDom($, source) {
12869
13673
  const issues = [];
12870
- const seen = /* @__PURE__ */ new Set();
13674
+ const seen = /* @__PURE__ */ new Map();
12871
13675
  $("[style]").each((_, el) => {
12872
- inspectDeclarations(parseInlineStyle($(el).attr("style") || ""), issues, seen);
13676
+ const attrLoc = locOfAttr(el, "style");
13677
+ const style = parseInlineStyle($(el).attr("style") || "");
13678
+ const locs = attrLoc ? new Map([...style.keys()].map((prop) => [prop, attrLoc])) : void 0;
13679
+ inspectDeclarations(style, issues, seen, locs);
12873
13680
  });
12874
13681
  $("style").each((_, el) => {
13682
+ const cssText = $(el).text();
13683
+ const anchor = cssBlockAnchor(el, cssText, source);
12875
13684
  let ast;
12876
13685
  try {
12877
- ast = csstree6.parse($(el).text());
13686
+ ast = csstree7.parse(cssText, { positions: true });
12878
13687
  } catch (e) {
12879
13688
  return;
12880
13689
  }
12881
- csstree6.walk(ast, {
13690
+ csstree7.walk(ast, {
12882
13691
  visit: "Rule",
12883
13692
  enter(node) {
12884
- if (node.type === "Rule") inspectDeclarations(ruleToMap(node), issues, seen);
13693
+ if (node.type !== "Rule") return;
13694
+ const locs = /* @__PURE__ */ new Map();
13695
+ node.block.children.forEach((child) => {
13696
+ if (child.type !== "Declaration") return;
13697
+ const loc = locInCssBlock(anchor, child.loc);
13698
+ if (loc) locs.set(child.property.toLowerCase(), loc);
13699
+ });
13700
+ inspectDeclarations(ruleToMap(node), issues, seen, locs);
12885
13701
  }
12886
13702
  });
12887
13703
  });
12888
13704
  return { issues };
12889
13705
  }
12890
- function checkVisual(html) {
12891
- return fromHtml(html, EMPTY_VISUAL, checkVisualFromDom);
13706
+ function checkVisual(html, options) {
13707
+ return fromHtml(
13708
+ html,
13709
+ EMPTY_VISUAL,
13710
+ ($, h) => checkVisualFromDom($, (options == null ? void 0 : options.positions) ? h : void 0),
13711
+ options
13712
+ );
12892
13713
  }
12893
13714
 
12894
13715
  // src/audit.ts
@@ -12907,7 +13728,8 @@ var EMPTY_AUDIT = {
12907
13728
  function runAudit($, html, framework, options) {
12908
13729
  var _a;
12909
13730
  const skip = new Set((_a = options == null ? void 0 : options.skip) != null ? _a : []);
12910
- const warnings = skip.has("compatibility") ? [] : analyzeEmailFromDom($, framework);
13731
+ const source = (options == null ? void 0 : options.positions) ? html : void 0;
13732
+ const warnings = skip.has("compatibility") ? [] : analyzeEmailFromDom($, framework, source);
12911
13733
  const scores = skip.has("compatibility") ? {} : generateCompatibilityScore(warnings);
12912
13734
  const spam = skip.has("spam") ? EMPTY_SPAM : analyzeSpamFromDom($, options == null ? void 0 : options.spam);
12913
13735
  const links = skip.has("links") ? EMPTY_LINKS : validateLinksFromDom($);
@@ -12915,19 +13737,19 @@ function runAudit($, html, framework, options) {
12915
13737
  const images = skip.has("images") ? EMPTY_IMAGES : analyzeImagesFromDom($);
12916
13738
  const inboxPreview = skip.has("inboxPreview") ? EMPTY_INBOX_PREVIEW : extractInboxPreviewFromDom($);
12917
13739
  const size = skip.has("size") ? EMPTY_SIZE : checkSizeFromDom($, html);
12918
- const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($);
12919
- const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($);
12920
- const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($);
13740
+ const templateVariables = skip.has("templateVariables") ? EMPTY_TEMPLATE : checkTemplateVariablesFromDom($, source);
13741
+ const overflow = skip.has("overflow") ? EMPTY_OVERFLOW : checkOverflowFromDom($, source);
13742
+ const visual = skip.has("visual") ? EMPTY_VISUAL : checkVisualFromDom($, source);
12921
13743
  return { compatibility: { warnings, scores }, spam, links, accessibility, images, inboxPreview, size, templateVariables, overflow, visual };
12922
13744
  }
12923
13745
  function auditEmail(html, options) {
12924
- return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options));
13746
+ return fromHtml(html, EMPTY_AUDIT, ($, h) => runAudit($, h, options == null ? void 0 : options.framework, options), options);
12925
13747
  }
12926
13748
 
12927
13749
  // src/plain-text.ts
12928
- import * as cheerio6 from "cheerio";
13750
+ import * as cheerio5 from "cheerio";
12929
13751
  function toPlainText(html) {
12930
- const $ = cheerio6.load(html);
13752
+ const $ = cheerio5.load(html);
12931
13753
  $("style, script, head").remove();
12932
13754
  $("[data-skip-in-text='true']").remove();
12933
13755
  const lines = [];
@@ -12962,7 +13784,7 @@ function toPlainText(html) {
12962
13784
  if (trimmed) lines.push(trimmed);
12963
13785
  currentLine = "";
12964
13786
  }
12965
- function walk7(node) {
13787
+ function walk8(node) {
12966
13788
  var _a;
12967
13789
  if (node.type === "text") {
12968
13790
  const text = node.data.replace(/\s+/g, " ");
@@ -13011,7 +13833,7 @@ function toPlainText(html) {
13011
13833
  currentLine = "- ";
13012
13834
  }
13013
13835
  for (const child of el.children) {
13014
- walk7(child);
13836
+ walk8(child);
13015
13837
  }
13016
13838
  if (isBlock) flushLine();
13017
13839
  }
@@ -13019,7 +13841,7 @@ function toPlainText(html) {
13019
13841
  const root = body.length ? body[0] : $.root()[0];
13020
13842
  if (root && "children" in root) {
13021
13843
  for (const child of root.children) {
13022
- walk7(child);
13844
+ walk8(child);
13023
13845
  }
13024
13846
  }
13025
13847
  flushLine();
@@ -13029,7 +13851,6 @@ function toPlainText(html) {
13029
13851
  }
13030
13852
 
13031
13853
  // src/session.ts
13032
- import * as cheerio7 from "cheerio";
13033
13854
  function createSession(html, options) {
13034
13855
  if (!html || !html.trim()) {
13035
13856
  const fw = options == null ? void 0 : options.framework;
@@ -13056,16 +13877,17 @@ function createSession(html, options) {
13056
13877
  if (html.length > MAX_HTML_SIZE) {
13057
13878
  throw new Error(`HTML input exceeds ${MAX_HTML_SIZE / 1024}KB limit.`);
13058
13879
  }
13059
- const $ = cheerio7.load(html);
13880
+ const $ = loadHtml(html, options);
13060
13881
  const framework = options == null ? void 0 : options.framework;
13882
+ const source = (options == null ? void 0 : options.positions) ? html : void 0;
13061
13883
  return {
13062
13884
  html,
13063
13885
  framework,
13064
13886
  audit(opts) {
13065
- return runAudit($, html, framework, opts);
13887
+ return runAudit($, html, framework, __spreadProps(__spreadValues({}, opts), { positions: options == null ? void 0 : options.positions }));
13066
13888
  },
13067
13889
  analyze() {
13068
- return analyzeEmailFromDom($, framework);
13890
+ return analyzeEmailFromDom($, framework, source);
13069
13891
  },
13070
13892
  score(warnings) {
13071
13893
  return generateCompatibilityScore(warnings);
@@ -13089,13 +13911,13 @@ function createSession(html, options) {
13089
13911
  return checkSizeFromDom($, html);
13090
13912
  },
13091
13913
  checkTemplateVariables() {
13092
- return checkTemplateVariablesFromDom($);
13914
+ return checkTemplateVariablesFromDom($, source);
13093
13915
  },
13094
13916
  checkOverflow() {
13095
- return checkOverflowFromDom($);
13917
+ return checkOverflowFromDom($, source);
13096
13918
  },
13097
13919
  checkVisual() {
13098
- return checkVisualFromDom($);
13920
+ return checkVisualFromDom($, source);
13099
13921
  },
13100
13922
  // Transforms create isolated copies since they mutate the DOM
13101
13923
  transformForClient(clientId) {
@@ -13115,18 +13937,22 @@ export {
13115
13937
  COMPOUND_VALUE_FEATURES,
13116
13938
  CSS_FUNCTION_FEATURES,
13117
13939
  CSS_SUPPORT,
13940
+ CSS_SUPPORT_NOTES,
13118
13941
  CompileError,
13119
13942
  EMAIL_CLIENTS,
13120
13943
  EMPTY_DELIVERABILITY,
13121
13944
  GENERIC_LINK_TEXT,
13122
13945
  HTML_ELEMENT_FEATURES,
13123
13946
  MAX_HTML_SIZE,
13947
+ MAX_WARNING_LOCATIONS,
13124
13948
  STRUCTURAL_FIX_PROPERTIES,
13949
+ VALUE_CAVEAT_PROPS,
13125
13950
  alphaBlend,
13126
13951
  analyzeEmail,
13127
13952
  analyzeImages,
13128
13953
  analyzeSpam,
13129
13954
  auditEmail,
13955
+ caveatApplies,
13130
13956
  checkAccessibility,
13131
13957
  checkOverflow,
13132
13958
  checkSize,