@maizzle/framework
Version:
Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.
633 lines (632 loc) • 21.7 kB
JavaScript
import { resolveConfig } from "./config/index.js";
import { EventManager } from "./events/index.js";
import { normalizeComponentSources } from "./utils/componentSources.js";
import { createRenderer } from "./render/createRenderer.js";
import { runTransformers } from "./transformers/index.js";
import { createPlaintext } from "./plaintext.js";
import { stripForHtml, stripForPlaintext } from "./utils/output-markers.js";
import { _setCurrentTemplate } from "./composables/useCurrentTemplate.js";
import { cloneConfig } from "./utils/cloneConfig.js";
import { setActiveRenderer } from "./render/active.js";
import { serveLint } from "./server/linter.js";
import { serveCompatibility } from "./server/compatibility.js";
import { sendEmail } from "./server/email.js";
import { createWatchedFileMatcher } from "./utils/watchPaths.js";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { basename, dirname, parse, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { glob } from "tinyglobby";
import { createLogger, createServer } from "vite";
import vue from "@vitejs/plugin-vue";
import { renderUnicodeCompact } from "uqr";
import tailwindcss from "@tailwindcss/vite";
import { createHighlighter } from "shiki";
//#region src/serve.ts
const devUIDir = resolve(dirname(fileURLToPath(import.meta.url)), "server/ui");
const require = createRequire(import.meta.url);
const version = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf-8")).version;
const pkg = (name) => {
const resolved = require.resolve(name).replace(/\\/g, "/");
const marker = `node_modules/${name}`;
const idx = resolved.lastIndexOf(marker);
return resolved.slice(0, idx + marker.length);
};
/**
* Resolve a package's ESM entry via its `exports` map. Aliasing to the
* package directory bypasses `exports` (it only keys off the bare name),
* so directory resolution can fall back to a UMD/CJS bundle — which Vite 8
* flags `needsInterop` and then default-imports, breaking ESM-only deps
* like culori (named exports, no default). Point the alias at the real
* ESM entry instead.
*/
const pkgEsmEntry = (name) => {
const dir = pkg(name);
const pj = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
return resolve(dir, pj.exports?.["."]?.import ?? pj.module ?? pj.main);
};
/**
* Start the Maizzle dev server.
*
* Creates two things:
* 1. A Vite dev server for the dev UI (sidebar + preview, with Vue + Tailwind for the UI itself)
* 2. A Renderer instance for SSR rendering email templates
*
* Template rendering goes through the Renderer, not the Vite dev server.
*/
async function serve(options = {}) {
const start = performance.now();
let config = await resolveConfig(options.config);
const port = options.port ?? config.server?.port ?? 3e3;
let renderer = await createRenderer({
dts: true,
markdown: config.markdown,
root: config.root,
componentDirs: normalizeComponentSources(config.components?.source, process.cwd()),
vite: config.vite
});
/**
* Register so user-land render() calls reuse this renderer instead of
* spinning up another Vite SSR server (which collides when the host
* app is itself a Vite dev process — e.g. TanStack Start).
*/
setActiveRenderer(renderer);
const events = new EventManager();
events.registerConfig(config);
await events.fireBeforeCreate({ config });
const server = await createServer({
configFile: false,
plugins: [
vue(),
tailwindcss(),
maizzleDevPlugin(config, renderer, events, options.config)
],
resolve: {
dedupe: ["vue"],
alias: [
{
find: "@",
replacement: devUIDir
},
{
find: "vue",
replacement: resolve(pkg("vue"), "dist/vue.runtime.esm-bundler.js")
},
{
find: "culori",
replacement: pkgEsmEntry("culori")
},
...[
"vue-router",
"reka-ui",
"@vueuse/core",
"@vueuse/shared",
"@lucide/vue",
"class-variance-authority",
"clsx",
"tailwind-merge"
].map((name) => ({
find: name,
replacement: pkg(name)
}))
]
},
cacheDir: resolve(devUIDir, ".vite"),
optimizeDeps: {
noDiscovery: true,
include: [
"vue",
"vue-router",
"@lucide/vue",
"@vueuse/core",
"@vueuse/shared",
"reka-ui",
"class-variance-authority",
"clsx",
"tailwind-merge",
"culori"
]
},
server: {
port,
host: options.host,
fs: { allow: [
process.cwd(),
config.root ?? process.cwd(),
devUIDir,
...[
"vue",
"vue-router",
"reka-ui",
"@vueuse/core",
"@vueuse/shared",
"@lucide/vue",
"class-variance-authority",
"clsx",
"tailwind-merge",
"culori"
].map(pkg)
] }
},
customLogger: customLogger()
});
const originalClose = server.close.bind(server);
server.close = async () => {
setActiveRenderer(null);
await renderer.close();
return originalClose();
};
await server.listen();
const startupTime = Math.round(performance.now() - start);
if (!options.silent) printBanner(server, startupTime);
server._maizzleStartupTime = startupTime;
return server;
}
/**
* Internal Vite plugin that adds Maizzle middleware and file watching to the dev UI server.
*/
function maizzleDevPlugin(config, renderer, events, configInput) {
return {
name: "maizzle:dev",
enforce: "pre",
hotUpdate: {
order: "pre",
handler({ file }) {
/**
* Prevent Tailwind/Vue from triggering a full reload for email template
* files. Maizzle handles these via custom HMR events in the
* watcher below.
*/
if (isTemplateFile(file)) return [];
}
},
configureServer(server) {
const defaultWatchPaths = [
"maizzle.config.js",
"maizzle.config.ts",
"tailwind.config.js",
"tailwind.config.ts",
"locales/**"
];
const userWatchPaths = config.server?.watch ?? [];
const watchPaths = [...defaultWatchPaths, ...userWatchPaths];
const isWatchedFile = createWatchedFileMatcher(watchPaths, process.cwd());
for (const watchPath of watchPaths) server.watcher.add(watchPath);
/**
* Serialize watcher work onto one chain. The change handler closes and
* recreates the renderer across awaits; without serialization a second
* event firing mid-reload closes a stale renderer and leaks the new one.
* Errors are caught so one failed task doesn't break the chain.
*/
let watcherChain = Promise.resolve();
const enqueue = (task) => {
watcherChain = watcherChain.then(task).catch((err) => {
console.error("[maizzle] watcher task failed:", err);
});
return watcherChain;
};
server.watcher.on("add", (file) => enqueue(async () => {
if (isTemplateFile(file)) {
await renderer.invalidateAll();
bumpGeneration();
server.ws.send({
type: "custom",
event: "maizzle:templates-changed"
});
}
}));
server.watcher.on("unlink", (file) => enqueue(async () => {
if (isTemplateFile(file)) {
await renderer.invalidateAll();
bumpGeneration();
server.ws.send({
type: "custom",
event: "maizzle:templates-changed"
});
}
}));
server.watcher.on("change", (file) => enqueue(async () => {
if (isWatchedFile(file)) {
config = await resolveConfig(configInput);
events.clear();
events.registerConfig(config);
await renderer.close();
renderer = await createRenderer({
dts: true,
markdown: config.markdown,
root: config.root,
componentDirs: normalizeComponentSources(config.components?.source, process.cwd()),
vite: config.vite
});
setActiveRenderer(renderer);
/**
* Push UI-relevant config bits so the dev UI reacts to live edits
* without a page reload. Uses the same shape as the initial
* inject.
*/
server.ws.send({
type: "custom",
event: "maizzle:config-updated",
data: buildUiConfig(config)
});
}
/**
* Invalidate all renderer modules so component and config changes
* are picked up on the next render (Tailwind recompiles with
* fresh content).
*/
await renderer.invalidateAll();
bumpGeneration();
if (isTemplateFile(file) || isWatchedFile(file)) server.ws.send({
type: "custom",
event: "maizzle:template-updated",
data: { file }
});
}));
server.middlewares.use(async (req, res, next) => {
const url = req.url || "/";
if (url === "/__maizzle/templates") return serveTemplateList(config, res);
if (url.startsWith("/__maizzle/render/")) return await serveRenderedTemplate(url, config, renderer, events, res);
if (url.startsWith("/__maizzle/source/")) return await serveHighlightedSource(url, config, renderer, events, res);
if (url.startsWith("/__maizzle/compatibility/")) return await serveCompatibility(url, res, config, normalizeComponentSources(config.components?.source, process.cwd()));
if (url.startsWith("/__maizzle/lint/")) return await serveLint(url, res, config, normalizeComponentSources(config.components?.source, process.cwd()));
if (url.startsWith("/__maizzle/vue-source/")) return await serveVueSource(url, config, res);
if (url.startsWith("/__maizzle/plaintext/")) return await servePlaintext(url, config, renderer, events, res);
if (url.startsWith("/__maizzle/stats/")) return await serveStats(url, config, renderer, events, res);
if (url.startsWith("/__maizzle/email/") && req.method === "POST") return await serveEmailEndpoint(url, req, res, config, renderer, events);
if (url === "/__maizzle/email-config") return serveEmailConfig(config, res);
next();
});
return () => {
server.middlewares.use(async (req, res, next) => {
if (isNavigationRequest(req)) return await serveDevUI(server, res, req.url || "/", config);
next();
});
};
}
};
}
function isTemplateFile(file) {
return (file.endsWith(".vue") || file.endsWith(".md")) && !file.includes("server/ui");
}
function isNavigationRequest(req) {
const accept = req.headers?.accept || "";
return req.method === "GET" && accept.includes("text/html");
}
/**
* Shape exposed to the dev UI both at initial HTML load (as
* `window.__MAIZZLE_CONFIG__`) and on the `maizzle:config-updated` HMR event.
* Add UI-visible config bits here; consumers on both ends pick up automatically.
*/
function buildUiConfig(config) {
return { checks: config.server?.checks ?? true };
}
async function serveDevUI(server, res, url, config) {
let indexHtml = readFileSync(resolve(devUIDir, "index.html"), "utf-8");
indexHtml = indexHtml.replace("./main.ts", `/@fs/${resolve(devUIDir, "main.ts")}`);
indexHtml = indexHtml.replace("./favicon.svg", `/@fs/${resolve(devUIDir, "favicon.svg")}`);
const configScript = `<script>window.__MAIZZLE_CONFIG__ = ${JSON.stringify(buildUiConfig(config))};<\/script>`;
indexHtml = indexHtml.replace("</head>", `${configScript}</head>`);
const transformed = await server.transformIndexHtml(url, indexHtml);
res.setHeader("Content-Type", "text/html");
res.end(transformed);
}
async function serveTemplateList(config, res) {
const data = (await glob(config.content ?? ["emails/**/*.vue"])).map((t) => ({
name: basename(t).replace(/\.(vue|md)$/, ""),
path: t,
href: "/" + t.replace(/\.(vue|md)$/, "")
}));
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(data));
}
/**
* Render-result memo for the dev server. A single template save makes the
* browser fire several endpoint requests in parallel (render, source, stats,
* plaintext, email) that each need the same SSR render + transformer output.
* Keying the in-flight Promise by `${generation}:${path}` collapses those into
* one render and dedupes concurrent requests. The watcher bumps the generation
* (and clears the cache) on every file/config change, so results never go
* stale. Each endpoint applies its own tail step (doctype prepend, strip,
* highlight, …) on top of `rawHtml`, keeping output byte-identical.
*/
let renderGeneration = 0;
const renderCache = /* @__PURE__ */ new Map();
function bumpGeneration() {
renderGeneration++;
renderCache.clear();
}
function getRendered(absolutePath, config, renderer, events) {
const key = `${renderGeneration}:${absolutePath}`;
let promise = renderCache.get(key);
if (!promise) {
promise = (async () => {
_setCurrentTemplate(parse(absolutePath));
try {
/**
* Mirror the build's per-template event pipeline (see buildTemplate)
* so dev preview fires the same beforeRender / afterRender /
* afterTransform hooks and matches production output. Clone config so
* beforeRender mutations stay scoped to this render.
*/
const renderConfig = cloneConfig(config);
const template = {
source: readFileSync(absolutePath, "utf-8"),
path: parse(absolutePath)
};
const originalSource = template.source;
await events.fireBeforeRender({
config: renderConfig,
template
});
const rendered = await renderer.render(absolutePath, renderConfig, template.source !== originalSource ? { source: template.source } : void 0);
for (const { name, handler } of rendered.sfcEventHandlers) events.on(name, handler);
const templateConfig = rendered.templateConfig;
let html = await events.fireAfterRender({
config: templateConfig,
template,
html: rendered.html
});
const doctype = rendered.doctype ?? templateConfig.doctype ?? "<!DOCTYPE html>";
if (templateConfig.useTransformers !== false) html = await runTransformers(html, templateConfig, absolutePath, doctype, rendered.tailwindBlocks);
return {
rawHtml: await events.fireAfterTransform({
config: templateConfig,
template,
html
}),
doctype,
templateConfig,
rendered
};
} finally {
_setCurrentTemplate(void 0);
events.clearSfcHandlers();
}
})();
renderCache.set(key, promise);
}
return promise;
}
/**
* SSR render a .vue template using the Renderer (not the dev UI server).
*/
async function serveRenderedTemplate(url, config, renderer, events, res) {
const templateSlug = url.replace("/__maizzle/render/", "").replace(/\?.*$/, "");
const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
if (!match) {
res.statusCode = 404;
res.end("Template not found");
return;
}
const absolutePath = resolve(match);
try {
const { rawHtml, doctype } = await getRendered(absolutePath, config, renderer, events);
let html = rawHtml;
if (doctype) html = `${doctype}\n${html}`;
res.setHeader("Content-Type", "text/html");
res.end(stripForHtml(html));
} catch (error) {
res.statusCode = 500;
res.end(`<pre>${error.stack || error.message}</pre>`);
}
}
let highlighter = null;
async function getHighlighter() {
if (!highlighter) highlighter = await createHighlighter({
themes: ["laserwave"],
langs: ["html", "vue"]
});
return highlighter;
}
async function serveHighlightedSource(url, config, renderer, events, res) {
const templateSlug = url.replace("/__maizzle/source/", "").replace(/\?.*$/, "");
const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
if (!match) {
res.statusCode = 404;
res.end("Template not found");
return;
}
const absolutePath = resolve(match);
try {
const { rawHtml, doctype } = await getRendered(absolutePath, config, renderer, events);
const html = stripForHtml(doctype ? `${doctype}\n${rawHtml}` : rawHtml);
const highlighted = (await getHighlighter()).codeToHtml(html, {
lang: "html",
theme: "laserwave",
transformers: [{ line(node, line) {
node.properties["data-line"] = line;
} }]
});
res.setHeader("Content-Type", "text/html");
res.end(highlighted);
} catch (error) {
res.statusCode = 500;
res.end(`<pre>${error.stack || error.message}</pre>`);
}
}
async function serveVueSource(url, config, res) {
const templateSlug = url.replace("/__maizzle/vue-source/", "").replace(/\?.*$/, "");
const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
if (!match) {
res.statusCode = 404;
res.end("Template not found");
return;
}
try {
const source = readFileSync(resolve(match), "utf-8");
const lang = match.endsWith(".md") ? "html" : "vue";
const highlighted = (await getHighlighter()).codeToHtml(source, {
lang,
theme: "laserwave",
transformers: [{ line(node, line) {
node.properties["data-line"] = line;
} }]
});
res.setHeader("Content-Type", "text/html");
res.end(highlighted);
} catch (error) {
res.statusCode = 500;
res.end(`<pre>${error.stack || error.message}</pre>`);
}
}
async function servePlaintext(url, config, renderer, events, res) {
const templateSlug = url.replace("/__maizzle/plaintext/", "").replace(/\?.*$/, "");
const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
if (!match) {
res.statusCode = 404;
res.end("Template not found");
return;
}
const absolutePath = resolve(match);
try {
const { rawHtml } = await getRendered(absolutePath, config, renderer, events);
const plaintext = createPlaintext(stripForPlaintext(rawHtml));
res.setHeader("Content-Type", "text/plain");
res.end(plaintext);
} catch (error) {
res.statusCode = 500;
res.end(error.message);
}
}
function humanFileSize(bytes, si = false, dp = 2) {
const threshold = si ? 1e3 : 1024;
if (Math.abs(bytes) < threshold) return bytes + " B";
const units = [
"KB",
"MB",
"GB",
"TB",
"PB",
"EB",
"ZB",
"YB"
];
let u = -1;
const r = 10 ** dp;
do {
bytes /= threshold;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= threshold && u < units.length - 1);
return bytes.toFixed(dp) + " " + units[u];
}
async function serveStats(url, config, renderer, events, res) {
const templateSlug = url.replace("/__maizzle/stats/", "").replace(/\?.*$/, "");
const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
if (!match) {
res.statusCode = 404;
res.end(JSON.stringify({ error: "Template not found" }));
return;
}
const absolutePath = resolve(match);
try {
const { rawHtml } = await getRendered(absolutePath, config, renderer, events);
const html = stripForHtml(rawHtml);
const sizeBytes = new TextEncoder().encode(html).length;
const totalImages = (html.match(/<img\b[^>]*>/gi) || []).length + (html.match(/url\s*\([^)]+\)/gi) || []).length;
const links = (html.match(/<a\b[^>]*href\s*=/gi) || []).length;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
size: {
bytes: sizeBytes,
formatted: humanFileSize(sizeBytes)
},
images: totalImages,
links
}));
} catch (error) {
res.statusCode = 500;
res.end(JSON.stringify({ error: error.message }));
}
}
async function serveEmailEndpoint(url, req, res, config, renderer, events) {
const templateSlug = url.replace("/__maizzle/email/", "").replace(/\?.*$/, "");
const match = (await glob(config.content ?? ["emails/**/*.vue"])).find((t) => t.replace(/\.(vue|md)$/, "") === templateSlug);
if (!match) {
res.statusCode = 404;
res.end(JSON.stringify({
success: false,
message: "Template not found"
}));
return;
}
let body = "";
for await (const chunk of req) body += chunk;
let payload;
try {
payload = JSON.parse(body);
} catch {
res.statusCode = 400;
res.end(JSON.stringify({
success: false,
message: "Invalid JSON"
}));
return;
}
if (!payload.to?.length) {
res.statusCode = 400;
res.end(JSON.stringify({
success: false,
message: "Missing recipients"
}));
return;
}
const absolutePath = resolve(match);
try {
const { rawHtml, doctype, templateConfig } = await getRendered(absolutePath, config, renderer, events);
let html = doctype ? `${doctype}\n${rawHtml}` : rawHtml;
const text = createPlaintext(stripForPlaintext(html));
html = stripForHtml(html);
const result = await sendEmail({
to: payload.to,
subject: payload.subject,
html,
text
}, config, templateConfig);
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(result));
} catch (error) {
res.statusCode = 500;
res.end(JSON.stringify({
success: false,
message: error.message
}));
}
}
function serveEmailConfig(config, res) {
const emailConfig = config.server?.email;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({
to: emailConfig?.to ? Array.isArray(emailConfig.to) ? emailConfig.to : [emailConfig.to] : [],
from: emailConfig?.from ?? "",
subject: emailConfig?.subject ?? "",
hasTransport: !!emailConfig?.transport
}));
}
function printBanner(server, startupTime) {
const info = server.config.logger.info;
const time = startupTime ?? server._maizzleStartupTime;
const networkUrl = server.resolvedUrls?.network[0];
if (networkUrl) {
const qr = renderUnicodeCompact(networkUrl, { border: 1 });
info("");
info(qr.split("\n").map((line) => ` ${line}`).join("\n"));
}
info("");
info(` \x1b[32m\x1b[1mMAIZZLE\x1b[0m\x1b[32m v${version}\x1b[0m \x1b[2mready in\x1b[0m \x1b[1m${time}\x1b[0m ms`);
info("");
server.printUrls();
info("");
}
function customLogger() {
const logger = createLogger("info");
const warn = logger.warn;
logger.warn = (message, options) => {
if (typeof message === "string" && message.includes("<tr> cannot be child of <table>")) return;
warn(message, options);
};
return logger;
}
//#endregion
export { printBanner, serve };
//# sourceMappingURL=serve.js.map