@maizzle/framework
Version:
Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.
959 lines (958 loc) • 27.8 kB
JavaScript
import { tailwindcss } from "../transformers/tailwindcss.js";
import { buildComponentMap, findComponentTags, isFrameworkComponent, parseSfcBlocks } from "./sfc-utils.js";
import { scanLint } from "./linter.js";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { Parser } from "htmlparser2";
import { DomHandler } from "domhandler";
import safeParser from "postcss-safe-parser";
import valueParser from "postcss-value-parser";
//#region src/server/compatibility.ts
const API_URL = "https://www.caniemail.com/api/data.json";
const DEFAULT_CLIENTS = new Set([
"gmail",
"apple-mail",
"outlook",
"yahoo"
]);
let indexes = null;
let initPromise = null;
function mpush(m, k, v) {
const arr = m.get(k);
if (arr) arr.push(v);
else m.set(k, [v]);
}
function emptyIndexes(nicenames, familyNicenames) {
return {
nicenames,
familyNicenames,
cssProp: /* @__PURE__ */ new Map(),
cssPropValue: /* @__PURE__ */ new Map(),
cssAtRule: /* @__PURE__ */ new Map(),
cssMediaFeature: /* @__PURE__ */ new Map(),
cssPseudoClass: /* @__PURE__ */ new Map(),
cssPseudoElement: /* @__PURE__ */ new Map(),
cssFunction: /* @__PURE__ */ new Map(),
cssUnit: /* @__PURE__ */ new Map(),
htmlTag: /* @__PURE__ */ new Map(),
htmlAttr: /* @__PURE__ */ new Map(),
htmlInputType: /* @__PURE__ */ new Map(),
htmlButtonType: /* @__PURE__ */ new Map(),
imageExt: /* @__PURE__ */ new Map(),
bySlug: /* @__PURE__ */ new Map()
};
}
function hasAnyNonY(stats) {
if (!stats) return false;
for (const family in stats) for (const plat in stats[family]) for (const ver in stats[family][plat]) {
const v = stripNotes(String(stats[family][plat][ver]).trim());
if (v && v !== "y") return true;
}
return false;
}
/** Strip `#N` note markers — `"y #1"` → `"y"`. Notes document edge cases but
* don't change support semantics, so treat `y #1` as fully supported. */
function stripNotes(v) {
return v.split(/\s+/).filter((t) => t && !t.startsWith("#")).join(" ");
}
function computeSupport(stats, familyNicenames, allowedClients) {
let nY = 0, nN = 0, nU = 0, nPartial = 0, total = 0;
const affectedFamilies = /* @__PURE__ */ new Set();
for (const family in stats) {
if (allowedClients !== "all" && !allowedClients.has(family)) continue;
let familyHasNonY = false;
for (const plat in stats[family]) {
/**
* Only score the latest version per (family, platform) — legacy
* versions (Outlook 2007, etc.) otherwise flag modern-widely
* supported features as partial forever.
*/
const versions = Object.keys(stats[family][plat]).sort();
const latest = versions[versions.length - 1];
if (!latest) continue;
total++;
const v = stripNotes(String(stats[family][plat][latest]).trim());
if (v === "y") nY++;
else if (v === "n") {
nN++;
familyHasNonY = true;
} else if (v === "u") {
nU++;
familyHasNonY = true;
} else {
nPartial++;
familyHasNonY = true;
}
}
if (familyHasNonY) affectedFamilies.add(family);
}
if (!total) return null;
if (nY === total) return null;
const affected = [...affectedFamilies].map((f) => familyNicenames[f] ?? f).sort();
if (nN === total) return {
level: "unsupported",
affected
};
if (nU === total) return {
level: "unknown",
affected
};
return {
level: "mitigated",
affected
};
}
/**
* Slugs we never report. Fundamental HTML (every email uses these) plus
* CSS noise that's not actionable (comments, !important usage).
*/
const IGNORED_SLUGS = new Set([
"html-doctype",
"html-comments",
"html-html",
"html-head",
"html-body",
"html-title",
"html-meta",
"html-meta-color-scheme",
"html-style",
"html-link",
"html-div",
"html-span",
"html-br",
"html-p",
"html-a",
"html-img",
"html-table",
"html-tr",
"html-td",
"html-th",
"html-thead",
"html-tbody",
"html-tfoot",
"html-h1-h6",
"html-lists",
"html-strong",
"html-em",
"html-b",
"html-i",
"html-u",
"html-semantics",
"html-role",
"html-hidden",
"html-width",
"html-height",
"css-comments",
"css-important",
"css-margin",
"css-padding",
"css-border",
"css-font-size",
"css-font-weight",
"css-font",
"css-font-family",
"css-line-height",
"css-letter-spacing",
"css-text-align",
"css-text-decoration",
"css-text-transform",
"css-color",
"css-background",
"css-background-color",
"css-width",
"css-height",
"css-display"
]);
function classify(f, idx) {
const slug = f.slug;
/**
* Retain html-style feature for the body-only detector even though it's
* blacklisted from the normal html-tag detection path. Title is
* suffixed so the flag reads as a body-placement warning, not a
* blanket `<style>`.
*/
if (slug === "html-style") {
idx.htmlStyleInBody = {
...f,
title: `${f.title} in <body>`
};
return;
}
if (IGNORED_SLUGS.has(slug)) return;
if (f.category === "css") return classifyCss(f, slug, idx);
if (f.category === "html") return classifyHtml(f, slug, idx);
if (f.category === "image") {
const ext = slug.slice(6);
if (ext === "base64") return;
mpush(idx.imageExt, ext, f);
}
}
function classifyCss(f, slug, idx) {
switch (slug) {
case "css-important":
idx.cssImportant = f;
return;
case "css-variables":
idx.cssVariables = f;
return;
case "css-nesting":
idx.cssNesting = f;
return;
case "css-comments":
idx.cssComments = f;
return;
case "css-modern-color":
idx.cssModernColor = f;
return;
case "css-display-flex":
mpushPropValue(idx, "display", "flex", f);
return;
case "css-display-grid":
mpushPropValue(idx, "display", "grid", f);
return;
case "css-display-none":
mpushPropValue(idx, "display", "none", f);
return;
case "css-rgb":
mpush(idx.cssFunction, "rgb", f);
return;
case "css-rgba":
mpush(idx.cssFunction, "rgba", f);
return;
case "css-linear-gradient":
mpush(idx.cssFunction, "linear-gradient", f);
return;
case "css-radial-gradient":
mpush(idx.cssFunction, "radial-gradient", f);
return;
case "css-conic-gradient":
mpush(idx.cssFunction, "conic-gradient", f);
return;
}
if (slug.startsWith("css-at-media-") && slug !== "css-at-media") {
mpush(idx.cssMediaFeature, slug.slice(13), f);
return;
}
if (slug.startsWith("css-at-")) {
mpush(idx.cssAtRule, slug.slice(7), f);
return;
}
if (slug.startsWith("css-pseudo-class-")) {
mpush(idx.cssPseudoClass, slug.slice(17), f);
return;
}
if (slug.startsWith("css-pseudo-element-")) {
mpush(idx.cssPseudoElement, slug.slice(19), f);
return;
}
if (slug.startsWith("css-unit-")) {
const u = slug.slice(9);
if (u === "calc") {
mpush(idx.cssFunction, "calc", f);
return;
}
if (u === "initial") return;
const unit = u === "percent" ? "%" : u;
mpush(idx.cssUnit, unit, f);
return;
}
if (slug.startsWith("css-function-")) {
mpush(idx.cssFunction, slug.slice(13), f);
return;
}
if (slug.startsWith("css-selector-")) return;
mpush(idx.cssProp, slug.slice(4), f);
}
function mpushPropValue(idx, prop, value, f) {
const arr = idx.cssPropValue.get(prop);
if (arr) arr.push({
value,
feature: f
});
else idx.cssPropValue.set(prop, [{
value,
feature: f
}]);
}
const HTML_ATTR_SLUGS = new Set([
"align",
"background",
"cellpadding",
"cellspacing",
"height",
"width",
"valign",
"target",
"srcset",
"lang",
"dir",
"role",
"required",
"hidden"
]);
function classifyHtml(f, slug, idx) {
switch (slug) {
case "html-doctype":
idx.htmlDoctype = f;
return;
case "html-comments":
idx.htmlComments = f;
return;
case "html-anchor-links":
idx.htmlAnchorLinks = f;
return;
case "html-mailto-links":
idx.htmlMailtoLinks = f;
return;
case "html-meta-color-scheme":
idx.htmlMetaColorScheme = f;
return;
case "html-semantics":
idx.htmlSemantics = f;
return;
case "html-loading-attribute":
mpush(idx.htmlAttr, "loading", f);
return;
case "html-image-maps":
mpush(idx.htmlTag, "map", f);
mpush(idx.htmlTag, "area", f);
mpush(idx.htmlAttr, "usemap", f);
return;
case "html-lists":
for (const t of [
"ul",
"ol",
"li",
"dl",
"dt",
"dd"
]) mpush(idx.htmlTag, t, f);
return;
case "html-h1-h6":
for (const t of [
"h1",
"h2",
"h3",
"h4",
"h5",
"h6"
]) mpush(idx.htmlTag, t, f);
return;
}
if (slug.startsWith("html-input-")) {
mpush(idx.htmlInputType, slug.slice(11), f);
return;
}
if (slug.startsWith("html-button-")) {
mpush(idx.htmlButtonType, slug.slice(12), f);
return;
}
if (slug.startsWith("html-aria-")) {
mpush(idx.htmlAttr, slug.slice(5), f);
return;
}
const name = slug.slice(5);
if (HTML_ATTR_SLUGS.has(name)) {
mpush(idx.htmlAttr, name, f);
return;
}
mpush(idx.htmlTag, name, f);
}
async function initCompatibility() {
if (indexes) return indexes;
if (initPromise) return initPromise;
initPromise = (async () => {
try {
const res = await fetch(API_URL);
if (!res.ok) return null;
const data = await res.json();
const idx = emptyIndexes(data.nicenames?.support ?? {}, data.nicenames?.family ?? {});
for (const item of data.data ?? []) {
/**
* Record every slug's title/url so lint can look up caniemail
* pages for issues that map to a known feature, even ignored.
*/
if (item.slug && item.url) idx.bySlug.set(item.slug, {
title: item.title,
url: item.url
});
/**
* Index the feature if any cell anywhere in the matrix is non-y.
* Per-request aggregation (with the active client filter)
* decides whether to actually surface the issue.
*/
if (!hasAnyNonY(item.stats)) continue;
classify({
slug: item.slug,
title: item.title,
url: item.url,
category: item.category,
stats: item.stats
}, idx);
}
indexes = idx;
return idx;
} catch {
return null;
}
})();
return initPromise;
}
function collectStreams(filePath, componentMap, visited, out) {
if (visited.has(filePath)) return;
visited.add(filePath);
let source;
try {
source = readFileSync(filePath, "utf-8");
} catch {
return;
}
const { template, styles } = parseSfcBlocks(source);
const classes = /* @__PURE__ */ new Set();
if (template) extractClasses(template.content, classes);
out.push({
path: filePath,
source,
template,
styles,
classes
});
if (template) for (const tag of findComponentTags(template.content)) {
const cp = componentMap.get(tag.toLowerCase());
if (cp) collectStreams(cp, componentMap, visited, out);
}
}
function extractClasses(html, out) {
const parser = new Parser({ onopentag(_tag, attrs) {
const c = attrs.class;
if (!c) return;
for (const t of c.split(/\s+/)) if (t) out.add(t);
} }, { decodeEntities: true });
parser.write(html);
parser.end();
}
function parseWithIndices(html) {
const handler = new DomHandler(void 0, { withStartIndices: true });
const parser = new Parser(handler);
parser.write(html);
parser.end();
return handler.dom;
}
function findStyleNodes(nodes, out = []) {
for (const n of nodes) {
const el = n;
if (el.name === "style") out.push(el);
if (el.children?.length) findStyleNodes(el.children, out);
}
return out;
}
/**
* Parse each file's template, collect every `<style>` node with its source
* line (via htmlparser2 start indices), then pass the combined DOM through
* the framework's real Tailwind pipeline. The pipeline resolves imports
* (@maizzle/tailwindcss), compiles utilities from class attrs, lowers modern
* CSS via lightningcss, and resolves static calc() — so what we walk matches
* what ships.
*/
async function compileViaPipeline(streams, config, rootFile) {
const all = [];
const tracked = [];
for (const s of streams) {
if (!s.template) continue;
const templateStart = s.source.indexOf(s.template.content);
const nodes = parseWithIndices(s.template.content);
for (const styleNode of findStyleNodes(nodes)) {
const startIdx = styleNode.startIndex ?? 0;
const line = offsetToLine(s.source, templateStart + startIdx);
tracked.push({
node: styleNode,
file: s.path,
line
});
}
for (const n of nodes) all.push(n);
}
if (!tracked.length) return [];
try {
await tailwindcss(all, config, rootFile);
} catch {
return [];
}
return tracked.map((t) => {
const txt = t.node.children?.find((c) => c.type === "text");
return txt?.data ? {
file: t.file,
css: txt.data,
line: t.line
} : null;
}).filter((x) => x !== null);
}
/**
* Walk CSS AST with detectors. Calls onHit per feature hit.
* `selector` is the containing rule's selector (undefined if no rule ancestor).
*/
function walkCss(css, idx, onHit) {
let root;
try {
root = safeParser(css);
} catch {
return;
}
const containingSelector = (n) => {
let p = n?.parent;
while (p && p.type !== "root") {
if (p.type === "rule") return p.selector;
p = p.parent;
}
};
if (idx.cssComments) root.walkComments((c) => {
onHit(idx.cssComments, {
line: c.source?.start?.line,
selector: containingSelector(c)
});
});
root.walkAtRules((atRule) => {
const line = atRule.source?.start?.line;
let sel = containingSelector(atRule);
if (atRule.name === "media" && !sel) {
const innerSelectors = [];
atRule.walkRules((r) => {
innerSelectors.push(r.selector);
});
if (innerSelectors.length) sel = innerSelectors.join(", ");
}
if (atRule.name === "media") {
/**
* Pick the most specific media-feature match (prefers-color-scheme,
* hover, orientation, …). If one matches, skip the generic
* `css-at-media` to avoid duplicate rows on the same line.
*/
const specific = [];
if (idx.cssMediaFeature.size) {
for (const [feat, fs2] of idx.cssMediaFeature) if (atRule.params.includes(`(${feat}`) || atRule.params.includes(feat)) specific.push(...fs2);
}
if (specific.length) for (const f of specific) onHit(f, {
line,
selector: sel
});
else {
const fs = idx.cssAtRule.get("media");
if (fs) for (const f of fs) onHit(f, {
line,
selector: sel
});
}
} else {
const fs = idx.cssAtRule.get(atRule.name);
if (fs) for (const f of fs) onHit(f, {
line,
selector: sel
});
}
});
root.walkRules((rule) => {
const line = rule.source?.start?.line;
const sel = rule.selector;
if (idx.cssPseudoClass.size) {
for (const [name, fs] of idx.cssPseudoClass) if (new RegExp(`(^|[^:]):${escapeRe(name)}(\\b|\\()`).test(sel)) for (const f of fs) onHit(f, {
line,
selector: sel
});
}
if (idx.cssPseudoElement.size) {
for (const [name, fs] of idx.cssPseudoElement) if (new RegExp(`::${escapeRe(name)}\\b`).test(sel)) for (const f of fs) onHit(f, {
line,
selector: sel
});
}
});
root.walkDecls((decl) => {
const line = decl.source?.start?.line;
const sel = containingSelector(decl);
const prop = decl.prop;
if (idx.cssImportant && decl.important) onHit(idx.cssImportant, {
line,
selector: sel
});
if (idx.cssVariables && prop.startsWith("--")) onHit(idx.cssVariables, {
line,
selector: sel
});
const fs = idx.cssProp.get(prop);
if (fs) for (const f of fs) onHit(f, {
line,
selector: sel
});
const pvs = idx.cssPropValue.get(prop);
if (pvs) {
const v = decl.value.trim().toLowerCase();
for (const pv of pvs) if (v === pv.value) onHit(pv.feature, {
line,
selector: sel
});
}
if (idx.cssFunction.size || idx.cssUnit.size || idx.cssVariables || idx.cssModernColor) try {
valueParser(decl.value).walk((n) => {
if (n.type === "function") {
const fname = n.value.toLowerCase();
const fs2 = idx.cssFunction.get(fname);
if (fs2) for (const f of fs2) onHit(f, {
line,
selector: sel
});
if (idx.cssVariables && fname === "var") onHit(idx.cssVariables, {
line,
selector: sel
});
if (idx.cssModernColor && MODERN_COLOR_FNS.has(fname)) onHit(idx.cssModernColor, {
line,
selector: sel
});
} else if (n.type === "word") {
const m = /^-?\d*\.?\d+([a-z%]+)$/i.exec(n.value);
if (m) {
const unit = m[1].toLowerCase();
const fs2 = idx.cssUnit.get(unit);
if (fs2) for (const f of fs2) onHit(f, {
line,
selector: sel
});
}
}
});
} catch {}
});
}
const MODERN_COLOR_FNS = new Set([
"oklch",
"oklab",
"lch",
"lab",
"color",
"color-mix",
"hwb"
]);
function escapeRe(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function offsetToLine(source, offset) {
let line = 1;
for (let i = 0; i < offset && i < source.length; i++) if (source.charCodeAt(i) === 10) line++;
return line;
}
function walkTemplate(html, idx, fileLineOffset, source, templateStartOffset, onHit) {
const semanticTags = new Set([
"article",
"aside",
"details",
"figcaption",
"figure",
"footer",
"header",
"main",
"mark",
"nav",
"section",
"time",
"summary"
]);
/**
* Stack of tags that opened a body-scope: a literal <body> or a
* <Teleport to="body..."> whose rendered contents land inside body.
*/
const bodyScopeStack = [];
const parser = new Parser({
onopentag(tag, attrs) {
const startIdx = parser.startIndex;
const line = offsetToLine(source, templateStartOffset + startIdx);
const tagFs = idx.htmlTag.get(tag);
if (tagFs) for (const f of tagFs) onHit(f, line);
if (idx.htmlSemantics && semanticTags.has(tag)) onHit(idx.htmlSemantics, line);
if (tag === "style" && bodyScopeStack.length > 0 && idx.htmlStyleInBody) onHit(idx.htmlStyleInBody, line);
if (tag === "body") bodyScopeStack.push(tag);
else if (tag === "teleport" && /body/i.test(attrs.to ?? "")) bodyScopeStack.push(tag);
for (const attr in attrs) {
const attrFs = idx.htmlAttr.get(attr);
if (attrFs) for (const f of attrFs) onHit(f, line);
}
if (tag === "input" && attrs.type) {
const fs = idx.htmlInputType.get(attrs.type.toLowerCase());
if (fs) for (const f of fs) onHit(f, line);
}
if (tag === "button" && attrs.type) {
const fs = idx.htmlButtonType.get(attrs.type.toLowerCase());
if (fs) for (const f of fs) onHit(f, line);
}
if (tag === "a" && attrs.href) {
const h = attrs.href.trim();
if (idx.htmlMailtoLinks && /^mailto:/i.test(h)) onHit(idx.htmlMailtoLinks, line);
else if (idx.htmlAnchorLinks && h.startsWith("#")) onHit(idx.htmlAnchorLinks, line);
}
if (tag === "meta" && idx.htmlMetaColorScheme && attrs.name?.toLowerCase() === "color-scheme") onHit(idx.htmlMetaColorScheme, line);
if (idx.imageExt.size && (attrs.src || attrs.srcset)) {
const urls = [];
if (attrs.src) urls.push(attrs.src);
if (attrs.srcset) for (const part of attrs.srcset.split(",")) urls.push(part.trim().split(/\s+/)[0]);
for (const url of urls) {
const m = /\.([a-z0-9]+)(?:\?|#|$)/i.exec(url);
if (!m) continue;
const fs = idx.imageExt.get(m[1].toLowerCase());
if (fs) for (const f of fs) onHit(f, line);
}
}
if (attrs.style) scanInlineStyle(attrs.style, idx, line, onHit);
},
onclosetag(tag) {
if (bodyScopeStack[bodyScopeStack.length - 1] === tag) bodyScopeStack.pop();
},
onprocessinginstruction(name) {
if (idx.htmlDoctype && name.toLowerCase() === "!doctype") {
const startIdx = parser.startIndex;
onHit(idx.htmlDoctype, offsetToLine(source, templateStartOffset + startIdx));
}
},
oncomment() {
if (idx.htmlComments) {
const startIdx = parser.startIndex;
onHit(idx.htmlComments, offsetToLine(source, templateStartOffset + startIdx));
}
}
}, {
decodeEntities: false,
lowerCaseTags: true,
lowerCaseAttributeNames: true
});
parser.write(html);
parser.end();
}
function scanInlineStyle(style, idx, line, onHit) {
const wrapped = `*{${style}}`;
try {
safeParser(wrapped).walkDecls((decl) => {
if (idx.cssImportant && decl.important) onHit(idx.cssImportant, line);
const fs = idx.cssProp.get(decl.prop);
if (fs) for (const f of fs) onHit(f, line);
if (idx.cssVariables && decl.prop.startsWith("--")) onHit(idx.cssVariables, line);
const pvs = idx.cssPropValue.get(decl.prop);
if (pvs) {
const v = decl.value.trim().toLowerCase();
for (const pv of pvs) if (v === pv.value) onHit(pv.feature, line);
}
if (idx.cssFunction.size || idx.cssUnit.size || idx.cssVariables || idx.cssModernColor) try {
valueParser(decl.value).walk((n) => {
if (n.type === "function") {
const fname = n.value.toLowerCase();
const fs2 = idx.cssFunction.get(fname);
if (fs2) for (const f of fs2) onHit(f, line);
if (idx.cssVariables && fname === "var") onHit(idx.cssVariables, line);
if (idx.cssModernColor && MODERN_COLOR_FNS.has(fname)) onHit(idx.cssModernColor, line);
} else if (n.type === "word") {
const m = /^-?\d*\.?\d+([a-z%]+)$/i.exec(n.value);
if (m) {
const fs2 = idx.cssUnit.get(m[1].toLowerCase());
if (fs2) for (const f of fs2) onHit(f, line);
}
}
});
} catch {}
});
} catch {}
}
function labelFor(idx, level) {
const n = idx.nicenames;
if (level === "unsupported") return n.unsupported ?? "Not supported";
if (level === "mitigated") return n.mitigated ?? "Partially supported";
return n.unknown ?? "Support unknown";
}
async function scan(rootFile, config, componentDirs, allowedClients) {
const idx = await initCompatibility();
if (!idx) return [];
const componentMap = await buildComponentMap(config.root ?? process.cwd(), componentDirs);
const streams = [];
collectStreams(rootFile, componentMap, /* @__PURE__ */ new Set(), streams);
const issues = [];
const seen = /* @__PURE__ */ new Set();
const resolvedCache = /* @__PURE__ */ new Map();
const resolveSupport = (f) => {
let cached = resolvedCache.get(f.slug);
if (cached === void 0) {
cached = computeSupport(f.stats, idx.familyNicenames, allowedClients);
resolvedCache.set(f.slug, cached);
}
return cached;
};
const add = (f, file, line) => {
const key = `${f.slug}|${file}|${line ?? 0}`;
if (seen.has(key)) return;
const support = resolveSupport(f);
if (!support) return;
seen.add(key);
issues.push({
kind: "compat",
slug: f.slug,
title: f.title,
url: f.url,
category: f.category,
supportLevel: support.level,
supportLabel: labelFor(idx, support.level),
affectedClients: support.affected,
line,
file
});
};
/**
* Stream A: compiled CSS from real pipeline — reflects shipped output
* (Tailwind utilities resolved, @maizzle/tailwindcss imported, calc
* resolved, modern CSS lowered). Filter hits whose containing
* selector doesn't reference a user class — drops Tailwind
* preflight noise. For hits without a class selector
* (e.g. @media, user-written rules), attribute to the
* file that owned the style block.
*/
const compiledBlocks = await compileViaPipeline(streams, config, rootFile);
for (const block of compiledBlocks) walkCss(block.css, idx, (feature, node) => {
const locations = classLocations(node.selector, streams);
if (!locations.length) {
add(feature, block.file, block.line);
return;
}
/**
* @media features collapse to a single source line: the first use
* of whatever class/variant triggered the wrapper. Other
* features show up for every occurrence.
*/
if (feature.slug.startsWith("css-at-media")) add(feature, locations[0].file, locations[0].line);
else for (const { file, line } of locations) add(feature, file, line);
});
for (const s of streams) {
if (!s.template) continue;
walkTemplate(s.template.content, idx, s.template.offset, s.source, s.source.indexOf(s.template.content), (feature, line) => add(feature, s.path, line));
}
return issues;
}
/**
* Return every (file, line) where any class from the selector appears in a
* template. Scans every stream so a shared utility class used in multiple
* components surfaces once per occurrence.
*/
function classLocations(selector, streams) {
if (!selector) return [];
const classNames = extractSelectorClasses(selector);
if (!classNames.length) return [];
const out = [];
const seen = /* @__PURE__ */ new Set();
for (const cn of classNames) for (const s of streams) {
if (!s.classes.has(cn) || !s.template) continue;
const tpl = s.template.content;
const tplStart = s.source.indexOf(tpl);
let pos = 0;
while (true) {
const i = tpl.indexOf(cn, pos);
if (i < 0) break;
pos = i + cn.length;
const before = i > 0 ? tpl[i - 1] : " ";
const after = i + cn.length < tpl.length ? tpl[i + cn.length] : " ";
if (!isClassBoundary(before) || !isClassBoundary(after)) continue;
const line = offsetToLine(s.source, tplStart + i);
const key = `${s.path}|${line}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
file: s.path,
line
});
}
}
return out;
}
function isClassBoundary(c) {
return c === " " || c === " " || c === "\n" || c === "\r" || c === "\"" || c === "'";
}
function extractSelectorClasses(selector) {
const out = [];
const re = /\.((?:\\.|[\w-])+)/g;
let m;
while ((m = re.exec(selector)) !== null) out.push(m[1].replace(/\\(.)/g, "$1"));
return out;
}
const CATEGORY_ORDER = [
"css",
"html",
"image",
"others"
];
const LEVEL_ORDER = {
error: 0,
unsupported: 1,
warning: 2,
mitigated: 3,
unknown: 4
};
function orderKey(i) {
if (i.kind === "lint") return LEVEL_ORDER[i.severity] ?? 99;
return LEVEL_ORDER[i.supportLevel] ?? 99;
}
function resolveChecksConfig(config) {
const raw = config.server?.checks;
if (raw === false) return null;
return {
clients: raw?.clients === "all" ? "all" : Array.isArray(raw?.clients) && raw.clients.length ? new Set(raw.clients) : DEFAULT_CLIENTS,
level: raw?.level ?? null
};
}
function passesLevelFilter(issue, level) {
if (!level) return true;
if (level === "lint") return issue.kind === "lint";
if (issue.kind === "lint") return level === "error" ? issue.severity === "error" : issue.severity === "warning";
return level === "error" ? issue.supportLevel === "unsupported" : issue.supportLevel === "mitigated" || issue.supportLevel === "unknown";
}
async function serveCompatibility(url, res, config, componentDirs) {
const filePath = url.replace("/__maizzle/compatibility/", "").replace(/\?.*$/, "");
const checksCfg = resolveChecksConfig(config);
try {
res.setHeader("Content-Type", "application/json");
if (!checksCfg) {
/**
* Defensive: UI hides the tab using window.__MAIZZLE_CONFIG__ so
* it shouldn't reach this endpoint when disabled, but if
* something else does, return an empty list.
*/
res.end(JSON.stringify([]));
return;
}
const absolutePath = resolve(filePath);
const [compatIssues, lintIssues] = await Promise.all([scan(absolutePath, config, componentDirs, checksCfg.clients), scanLint(absolutePath, config, componentDirs)]);
const idx = await initCompatibility();
const lintAsIssues = lintIssues.map((li) => {
const info = li.slug ? idx?.bySlug.get(li.slug) : void 0;
return {
kind: "lint",
slug: li.slug,
title: li.title,
url: info?.url,
category: li.category,
severity: li.type,
message: li.message,
line: li.line,
file: li.file
};
});
let issues = [...compatIssues, ...lintAsIssues];
issues = issues.filter((i) => !isFrameworkComponent(i.file));
if (checksCfg.level) issues = issues.filter((i) => passesLevelFilter(i, checksCfg.level));
issues.sort((a, b) => {
const c = CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category);
if (c) return c;
const l = orderKey(a) - orderKey(b);
if (l) return l;
return (a.slug ?? a.title).localeCompare(b.slug ?? b.title);
});
res.end(JSON.stringify(issues));
} catch (error) {
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
}
//#endregion
export { initCompatibility, serveCompatibility };
//# sourceMappingURL=compatibility.js.map