UNPKG

@maizzle/framework

Version:

Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.

633 lines (632 loc) 21.7 kB
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