@maizzle/framework
Version:
Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.
345 lines (344 loc) • 10.2 kB
JavaScript
import { buildComponentMap, findComponentTags, isFrameworkComponent, parseSfcBlocks } from "./sfc-utils.js";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
//#region src/server/linter.ts
/**
* Maizzle auto-adds role="none" to every <table> by default via the
* addAttributes transformer. Warn about missing role only when that won't
* happen:
* - useTransformers: false → whole pipeline off
* - html.attributes.add: false → auto-add disabled globally
* - add.table: false → table selector opted out
* - add.table.role: false → role attribute specifically opted out
* An empty `add.table: {}` still inherits role via defu merge, so it's fine.
*/
function tableRoleAutoAdded(config) {
if (config.useTransformers === false) return false;
const add = config.html?.attributes?.add;
if (add === false) return false;
if (!add || typeof add !== "object") return true;
const table = add.table;
if (table === false) return false;
if (table && typeof table === "object" && table.role === false) return false;
return true;
}
async function scanLint(rootFile, config, componentDirs) {
const componentMap = await buildComponentMap(config.root ?? process.cwd(), componentDirs);
const visited = /* @__PURE__ */ new Set();
const presence = {
html: false,
head: false,
body: false
};
const issues = checkFile(rootFile, componentMap, visited, presence, !tableRoleAutoAdded(config));
if (!presence.html) issues.push({
type: "warning",
category: "html",
title: "Missing <html>",
message: "Root <html> tag not found in the template or any of its components.",
slug: "html-html",
line: 1,
file: rootFile
});
if (!presence.head) issues.push({
type: "warning",
category: "html",
title: "Missing <head>",
message: "Root <head> tag not found in the template or any of its components.",
slug: "html-head",
line: 1,
file: rootFile
});
if (!presence.body) issues.push({
type: "warning",
category: "html",
title: "Missing <body>",
message: "Root <body> tag not found in the template or any of its components.",
slug: "html-body",
line: 1,
file: rootFile
});
return issues;
}
async function serveLint(url, res, config, componentDirs) {
const filePath = url.replace("/__maizzle/lint/", "").replace(/\?.*$/, "");
try {
const issues = (await scanLint(resolve(filePath), config, componentDirs)).filter((i) => !isFrameworkComponent(i.file));
issues.sort((a, b) => {
if (a.type !== b.type) return a.type === "error" ? -1 : 1;
return (a.line ?? 0) - (b.line ?? 0);
});
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(issues));
} catch (error) {
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
}
function checkFile(filePath, componentMap, visited, presence, checkTableRole) {
if (visited.has(filePath)) return [];
visited.add(filePath);
let source;
try {
source = readFileSync(filePath, "utf-8");
} catch {
return [];
}
const { template } = parseSfcBlocks(source);
const issues = [];
if (template) {
issues.push(...lintHtml(template.content, template.offset, filePath, presence, checkTableRole));
const componentTags = findComponentTags(template.content);
for (const tag of componentTags) {
const componentPath = componentMap.get(tag.toLowerCase());
if (componentPath) issues.push(...checkFile(componentPath, componentMap, visited, presence, checkTableRole));
}
}
return issues;
}
function lineAt(html, offset, lineOffset) {
return html.slice(0, offset).split("\n").length + lineOffset;
}
/**
* True if the <img> tag has a width defined via any of:
* - `width` attribute
* - inline `style` with a `width` property
* - class attribute with a Tailwind `w-` utility (any variant prefix like
* sm:, hover:), or an arbitrary `[width:…]` utility
*/
function hasWidthDefined(imgTag) {
if (/\bwidth\s*=/i.test(imgTag)) return true;
const styleMatch = imgTag.match(/\bstyle\s*=\s*["']([^"']*)["']/i);
if (styleMatch && /(^|[;\s])width\s*:/i.test(styleMatch[1])) return true;
const classMatch = imgTag.match(/\bclass\s*=\s*["']([^"']*)["']/i);
if (classMatch) {
const classes = classMatch[1];
if (/(?:^|\s)(?:[a-z0-9-]+:)*w-\S+/i.test(classes)) return true;
if (/\[width:/i.test(classes)) return true;
}
return false;
}
function lintHtml(html, lineOffset, filePath, presence, checkTableRole) {
const issues = [];
for (const m of Array.from(html.matchAll(/<([a-zA-Z][a-zA-Z0-9]*)\b([\s\S]*?)>/g))) {
const tag = m[0];
const tagName = m[1].toLowerCase();
const line = lineAt(html, m.index, lineOffset);
if (tagName === "html") presence.html = true;
else if (tagName === "head") presence.head = true;
else if (tagName === "body") presence.body = true;
/**
* Layout tables — accessibility requires role="none" so screen readers
* skip the table structure. Only surface the warning when the user
* has disabled Maizzle's auto-role-add; otherwise every build
* step output already has role="none" set.
*/
if (checkTableRole && tagName === "table") {
if (!tag.match(/\brole\s*=\s*["']([^"']*)["']/i)) {
const tableEndIdx = html.indexOf("</table>", m.index);
const inner = tableEndIdx >= 0 ? html.slice(m.index, tableEndIdx) : "";
if (!(/<th\b/i.test(inner) || /<caption\b/i.test(inner))) issues.push({
type: "warning",
category: "html",
title: "Layout table missing role",
message: "Add role=\"none\" so screen readers skip this layout table.",
slug: "html-role",
line,
file: filePath
});
}
}
if (tagName === "img") {
if (!/\balt\s*=/i.test(tag)) issues.push({
type: "warning",
category: "image",
title: "Missing alt text",
message: "Image is missing the alt attribute",
line,
file: filePath
});
const srcMatch = tag.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
if (!srcMatch) issues.push({
type: "error",
category: "image",
title: "Missing image src",
message: "Image tag has no src attribute",
line,
file: filePath
});
else if (!srcMatch[1].trim()) issues.push({
type: "error",
category: "image",
title: "Empty image src",
message: "Image src attribute is empty",
line,
file: filePath
});
else if (srcMatch[1].trim().startsWith("http:")) issues.push({
type: "warning",
category: "image",
title: "Insecure image src",
message: "Image loads over HTTP instead of HTTPS",
line,
file: filePath
});
if (!hasWidthDefined(tag)) issues.push({
type: "warning",
category: "image",
title: "Missing image width",
message: "Use a `width=\"\"` attribute for best results in Outlook",
line,
file: filePath
});
}
if (![
"link",
"script",
"source"
].includes(tagName)) {
const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']*)["']/i);
if (hrefMatch) {
const href = hrefMatch[1].trim();
if (!href) issues.push({
type: "error",
category: "html",
title: "Empty link href",
message: "Link href attribute is empty",
line,
file: filePath
});
else if (href === "#" || href === "/") issues.push({
type: "error",
category: "html",
title: "Placeholder link",
message: `Link href is "${href}"`,
line,
file: filePath
});
else if (href.startsWith("http:")) issues.push({
type: "warning",
category: "html",
title: "Insecure link",
message: "Link uses HTTP instead of HTTPS",
line,
file: filePath
});
else if (href.startsWith("http") && !/^https?:\/\/.+\..+/i.test(href)) issues.push({
type: "error",
category: "html",
title: "Invalid link",
message: `Link href "${href}" looks malformed`,
line,
file: filePath
});
}
}
if ([
"link",
"script",
"source"
].includes(tagName)) {
const attrMatch = tag.match(/\b(?:href|src)\s*=\s*["']([^"']*)["']/i);
if (attrMatch && attrMatch[1].trim().startsWith("http:")) issues.push({
type: "warning",
category: "html",
title: "Insecure resource",
message: "Resource loads over HTTP instead of HTTPS",
line,
file: filePath
});
}
}
for (const m of Array.from(html.matchAll(/url\s*\(\s*["']?(http:[^"')]+)["']?\s*\)/gi))) issues.push({
type: "warning",
category: "css",
title: "Insecure CSS url()",
message: "CSS url() loads over HTTP instead of HTTPS",
line: lineAt(html, m.index, lineOffset),
file: filePath
});
const voidElements = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr"
]);
const trackedTags = new Set([
"a",
"b",
"body",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"head",
"html",
"i",
"li",
"ol",
"p",
"span",
"strong",
"style",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"title",
"tr",
"u",
"ul"
]);
const stack = [];
const strippedLines = html.replace(/<!--[\s\S]*?-->/g, (m) => "\n".repeat((m.match(/\n/g) || []).length)).replace(/<(style|script)\b[^>]*>[\s\S]*?<\/\1>/gi, (m) => "\n".repeat((m.match(/\n/g) || []).length)).split("\n");
for (let i = 0; i < strippedLines.length; i++) {
const line = strippedLines[i];
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\//g;
let m;
while ((m = tagRegex.exec(line)) !== null) {
const fullMatch = m[0];
const tagName = m[1].toLowerCase();
if (!trackedTags.has(tagName) || voidElements.has(tagName)) continue;
if (fullMatch.endsWith("/>")) continue;
if (fullMatch.startsWith("</")) {
let lastOpen = -1;
for (let j = stack.length - 1; j >= 0; j--) if (stack[j].tag === tagName) {
lastOpen = j;
break;
}
if (lastOpen !== -1) stack.splice(lastOpen, 1);
} else stack.push({
tag: tagName,
line: i + 1 + lineOffset
});
}
}
for (const unclosed of stack) issues.push({
type: "error",
category: "html",
title: "Unclosed tag",
message: `<${unclosed.tag}> tag is not closed`,
line: unclosed.line,
file: filePath
});
return issues;
}
//#endregion
export { scanLint, serveLint };
//# sourceMappingURL=linter.js.map