@maizzle/framework
Version:
Maizzle is a framework that helps you quickly build HTML emails with Tailwind CSS.
478 lines (477 loc) • 20.7 kB
JavaScript
import { MaizzleConfigKey } from "../composables/useConfig.js";
import { RenderContextKey } from "../composables/renderContext.js";
import { isLaravel } from "../utils/detect.js";
import { rowSourceLocation } from "./plugins/rowSourceLocation.js";
import { rawExtract } from "./plugins/rawExtract.js";
import { codeBlockExtract } from "./plugins/codeBlockExtract.js";
import { markdownExtract } from "./plugins/markdownExtract.js";
import { componentNameFromPath } from "../utils/componentSources.js";
import { shikiToCodeBlock } from "../components/utils.js";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { glob, globSync } from "tinyglobby";
import { defu as defu$1 } from "defu";
import { createSSRApp } from "vue";
import { createServer, mergeConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Markdown from "unplugin-vue-markdown/vite";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { unheadVueComposablesImports } from "@unhead/vue";
import { renderToString } from "vue/server-renderer";
import { createHead } from "@unhead/vue/server";
//#region src/render/createRenderer.ts
const __dirname = dirname(fileURLToPath(import.meta.url));
const vuePkgDir = dirname(fileURLToPath(import.meta.resolve("vue/package.json")));
const vueServerRendererPkgDir = dirname(fileURLToPath(import.meta.resolve("@vue/server-renderer/package.json")));
const unheadVuePkgDir = resolve(dirname(fileURLToPath(import.meta.resolve("@unhead/vue"))), "..");
const vueRouterPkgDir = dirname(fileURLToPath(import.meta.resolve("vue-router/package.json")));
/**
* Lightweight Vite SSR loader for rendering Vue SFC email templates.
*
* Uses only Vue + unplugin for component/auto-import resolution.
* Tailwind CSS compilation is handled by the transformer pipeline.
*/
async function createRenderer(options = {}) {
const { dts = false, markdown: markdownOptionsRaw, root = process.cwd(), componentDirs = [], vite: userViteConfig } = options;
const { shikiTheme = "github-light", markdownSetup: userMarkdownSetup, ...restMarkdownConfig } = markdownOptionsRaw ?? {};
/**
* Sources without an explicit prefix get registered via unplugin's `dirs`
* (folder name auto-namespaces). Sources with an explicit `prefix` are
* registered through a custom resolver below so we fully control naming.
*/
const dirSources = componentDirs.filter((s) => s.prefix === void 0);
const prefixedSources = componentDirs.filter((s) => s.prefix !== void 0);
/**
* Absolute component dirs — used to skip auto-wrapping `.md` files that
* are imported as reusable components (vs. entry-point email templates).
*/
const componentDirsAbs = [resolve(root, "components"), ...componentDirs.map((s) => s.path)];
const dtsDir = isLaravel() ? resolve(process.cwd(), "resources/js/types/maizzle") : resolve(root, ".maizzle");
/**
* Built-in framework components live at this path. When a user provides
* a top-level file with the same (PascalCased) basename, drop the
* built-in from unplugin's scan so the user's component is the only
* candidate. This avoids the "naming conflicts" warning and the
* alphabetical-glob ordering pitfall that decides who wins when
* both are present in `dirs`.
*/
const frameworkComponentsDir = resolve(__dirname, "../components");
function topLevelBasenamesLower(dir) {
if (!existsSync(dir)) return /* @__PURE__ */ new Set();
const files = globSync(["*.vue", "*.md"], {
cwd: dir,
absolute: false
});
return new Set(files.map((f) => f.replace(/\.(vue|md)$/, "").toLowerCase()));
}
const frameworkFiles = globSync(["*.vue", "*.md"], {
cwd: frameworkComponentsDir,
absolute: false
});
const frameworkByLower = new Map(frameworkFiles.map((f) => [f.replace(/\.(vue|md)$/, "").toLowerCase(), f]));
const shadowedNames = /* @__PURE__ */ new Set();
for (const dir of [resolve(root, "components"), ...dirSources.map((s) => s.path)]) for (const lower of topLevelBasenamesLower(dir)) if (frameworkByLower.has(lower)) shadowedNames.add(lower);
const frameworkExcludes = [...shadowedNames].map((lower) => `${frameworkComponentsDir}/${frameworkByLower.get(lower)}`);
/**
* Pre-scanned name → absolute-path map for prefixed sources. Rebuilt
* on file add/unlink via the watcher hook plugin further down. Drives
* the runtime resolver and the d.ts we emit for IDE autocompletion.
*/
const prefixedNameMap = /* @__PURE__ */ new Map();
async function scanPrefixedSources() {
prefixedNameMap.clear();
const seen = /* @__PURE__ */ new Map();
for (const source of prefixedSources) {
const files = await glob(["**/*.vue", "**/*.md"], {
cwd: source.path,
absolute: true
});
for (const file of files) {
const name = componentNameFromPath({
filePath: file,
dirRoot: source.path,
prefix: source.prefix,
pathPrefix: source.pathPrefix
});
const existing = seen.get(name);
if (existing && existing !== file) throw new Error(`[maizzle] Component name collision: "${name}" resolved from both "${existing}" and "${file}". Rename one of the files or split them into separate sources with distinct prefixes.`);
seen.set(name, file);
prefixedNameMap.set(name, file);
}
}
}
await scanPrefixedSources();
const prefixedResolver = (name) => prefixedNameMap.get(name);
/**
* unplugin-vue-components' own d.ts only covers components found via
* `dirs`; its `types` option emits named-import entries which break
* for SFC `default` exports. Write a sibling d.ts for prefixed
* sources so editors get correct autocompletion via TypeScript
* interface merging on `vue.GlobalComponents`.
*/
const prefixedDtsPath = resolve(dtsDir, "prefixed-components.d.ts");
function writePrefixedDts() {
if (!dts) return;
if (prefixedNameMap.size === 0) {
if (existsSync(prefixedDtsPath)) rmSync(prefixedDtsPath);
return;
}
const dtsBase = dirname(prefixedDtsPath);
mkdirSync(dtsBase, { recursive: true });
writeFileSync(prefixedDtsPath, `/* eslint-disable */\n// @ts-nocheck\n// biome-ignore lint: disable\n// oxlint-disable\n// Generated by Maizzle for prefixed component sources\n\nexport {}\n\n/* prettier-ignore */\ndeclare module 'vue' {\n export interface GlobalComponents {\n${Array.from(prefixedNameMap.entries()).sort(([a], [b]) => a.localeCompare(b)).map(([name, file]) => {
const relativePath = relative(dtsBase, file).replace(/\\/g, "/");
return ` ${name}: typeof import('${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}')['default']`;
}).join("\n")}\n }\n}\n`);
}
writePrefixedDts();
/**
* Watches prefixed source dirs and rebuilds {@link prefixedNameMap} when
* files are added/removed. Vite's watcher already covers `dirSources`
* via unplugin-vue-components' own filesystem hooks.
*/
const prefixedSourceWatcher = prefixedSources.length > 0 ? {
name: "maizzle:prefixed-component-watcher",
configureServer(server) {
for (const source of prefixedSources) server.watcher.add(source.path);
const refresh = async (file) => {
if (!prefixedSources.some((s) => file.startsWith(`${s.path}/`))) return;
if (!/\.(vue|md)$/.test(file)) return;
await scanPrefixedSources();
writePrefixedDts();
};
server.watcher.on("add", refresh);
server.watcher.on("unlink", refresh);
}
} : null;
const VIRTUAL_SFC_ID = "virtual:maizzle-sfc.vue";
let virtualSfcSource = "";
/**
* Per-render source overrides keyed by absolute template path. Lets the
* build's beforeRender event rewrite a template's source before compile
* while keeping the real file id — so relative imports, asset URLs and
* component resolution still resolve against the actual file location
* (which the virtual-SFC path can't do).
*/
const sourceOverrides = /* @__PURE__ */ new Map();
/**
* Never load the host project's vite.config.ts here. Doing so pulls
* every host plugin (Nitro, TanStack Start, the Maizzle plugin
* itself, …) into this isolated SSR pipeline, where they override
* env factories, re-trigger configureServer hooks, and break
* Vite's hot channel wiring. Users who need extra Vite plugins
* for SSR pass them explicitly via the `vite` option.
*/
const maizzleConfig = {
configFile: false,
plugins: [
rawExtract(),
codeBlockExtract(),
markdownExtract(),
rowSourceLocation(),
{
name: "maizzle:virtual-sfc",
resolveId(id) {
if (id === VIRTUAL_SFC_ID) return id;
},
load(id) {
if (id === VIRTUAL_SFC_ID) return virtualSfcSource;
}
},
{
name: "maizzle:source-override",
load(id) {
const override = sourceOverrides.get(id.split("?")[0]);
if (override !== void 0) return override;
}
},
vue({
include: [/\.vue$/, /\.md$/],
template: {
transformAssetUrls: false,
compilerOptions: {
/**
* Keep template whitespace intact — the default `condense`
* mode collapses/strips whitespace between tags,
* which can alter plaintext output.
*/
whitespace: "preserve",
/**
* AMP4Email tags (<amp-carousel>, <amp-img>, <amp-list> ...)
* render verbatim — skip the component resolver. Users who
* want to wrap an amp tag in a Vue component should register
* it under a PascalCase name (e.g. `components/AmpCarousel.vue`
* → `<AmpCarousel>`).
*/
isCustomElement: (tag) => tag.startsWith("amp-")
}
}
}),
Markdown(defu$1(restMarkdownConfig, {
headEnabled: true,
wrapperDiv: false,
wrapperClasses: "prose",
wrapperComponent: (id, raw) => {
const layout = (raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1])?.match(/^[ \t]*layout[ \t]*:[ \t]*['"]?([A-Za-z][\w-]*|false|none)['"]?[ \t]*$/m)?.[1];
if (layout === "false" || layout === "none") return null;
if (layout) return layout;
return componentDirsAbs.some((d) => id === d || id.startsWith(`${d}/`)) ? null : "MarkdownLayout";
},
markdownOptions: { async highlight(code, lang) {
const { codeToHtml } = await import("shiki");
try {
return await codeToHtml(code, {
lang,
theme: shikiTheme
});
} catch {
return "";
}
} },
/**
* Run the user's `markdownSetup` first (defu would otherwise drop the
* built-in one when both are functions), then always install the
* email-safe code-block wrapping on top — mirroring the `<Markdown>`
* component so `.md` templates and the component behave identically.
*/
async markdownSetup(md) {
await userMarkdownSetup?.(md);
const defaultFence = md.renderer.rules.fence;
md.renderer.rules.fence = (...args) => Promise.resolve(defaultFence(...args)).then(shikiToCodeBlock);
const defaultCodeBlock = md.renderer.rules.code_block;
md.renderer.rules.code_block = (...args) => shikiToCodeBlock(defaultCodeBlock(...args));
}
})),
AutoImport({
dirs: [resolve(__dirname, "../composables"), resolve(__dirname, "../filters")],
imports: ["vue", unheadVueComposablesImports],
/**
* unplugin-auto-import's default `include` doesn't match `.md`, so
* auto-imports (Vue, unhead and Maizzle composables/filters) were
* never injected into Markdown templates — `useConfig()` and friends
* threw at runtime. Extend the default list with `.md` (and its
* `?vue` script sub-requests) to mirror the `.md` coverage the
* Components plugin already declares below.
*/
include: [
/\.[jt]sx?$/,
/\.vue$/,
/\.vue\?vue/,
/\.md$/,
/\.md\?vue/
],
dts: dts ? resolve(dtsDir, "auto-imports.d.ts") : false
}),
Components({
extensions: ["vue", "md"],
include: [
/\.vue$/,
/\.vue\?vue/,
/\.md$/
],
dirs: [
frameworkComponentsDir,
resolve(root, "components"),
...dirSources.map((s) => s.path)
],
/**
* Drop built-in component files whose name the user has shadowed.
* This makes the user's version the only match — no "naming
* conflicts" warning, no glob-ordering games.
*/
globsExclude: frameworkExcludes,
directoryAsNamespace: true,
collapseSamePrefixes: true,
resolvers: prefixedSources.length > 0 ? [prefixedResolver] : void 0,
dts: dts ? resolve(dtsDir, "components.d.ts") : false
}),
...prefixedSourceWatcher ? [prefixedSourceWatcher] : []
],
resolve: { alias: {
"vue/server-renderer": resolve(vueServerRendererPkgDir, "dist/server-renderer.esm-bundler.js"),
"vue": resolve(vuePkgDir, "dist/vue.runtime.esm-bundler.js"),
"vue-router": vueRouterPkgDir,
"@unhead/vue/server": resolve(unheadVuePkgDir, "dist/server.mjs"),
"@unhead/vue": resolve(unheadVuePkgDir, "dist/index.mjs")
} },
server: {
middlewareMode: true,
hmr: false,
/**
* Watcher is required so unplugin-vue-components and unplugin-auto-import
* detect added/removed component files and rewrite their .d.ts on the fly.
* (We only render via SSR — HMR is off, but chokidar still drives plugins.)
*/
fs: { allow: [
process.cwd(),
root,
...componentDirs.map((s) => s.path),
vuePkgDir,
vueServerRendererPkgDir,
unheadVuePkgDir,
vueRouterPkgDir
] }
},
appType: "custom",
logLevel: "silent",
optimizeDeps: { noDiscovery: true }
};
const server = await createServer(userViteConfig ? mergeConfig(userViteConfig, maizzleConfig) : maizzleConfig);
return {
async render(input, config, opts) {
let component;
let configKey;
let contextKey;
if (typeof input === "string") {
/**
* String input goes through Vite — must use ssrLoadModule for
* injection keys so they share the same module instance as SFC.
*/
const configModule = await server.ssrLoadModule(resolve(__dirname, "../composables/useConfig"));
const contextModule = await server.ssrLoadModule(resolve(__dirname, "../composables/renderContext"));
configKey = configModule.MaizzleConfigKey;
contextKey = contextModule.RenderContextKey;
if (input.includes("<template") || input.includes("<script")) {
virtualSfcSource = input;
const mod = server.moduleGraph.getModuleById(VIRTUAL_SFC_ID);
if (mod) server.moduleGraph.invalidateModule(mod);
component = (await server.ssrLoadModule(VIRTUAL_SFC_ID)).default;
} else {
/**
* A beforeRender handler may have rewritten the source. Register it
* under the real path id and invalidate so ssrLoadModule compiles
* the override; clear + invalidate afterwards so the override never
* leaks into a later render of the same path.
*/
const hasOverride = opts?.source !== void 0;
if (hasOverride) {
sourceOverrides.set(input, opts.source);
const mod = await server.moduleGraph.getModuleByUrl(input);
if (mod) server.moduleGraph.invalidateModule(mod);
}
try {
component = (await server.ssrLoadModule(input)).default;
} finally {
if (hasOverride) {
sourceOverrides.delete(input);
const mod = await server.moduleGraph.getModuleByUrl(input);
if (mod) server.moduleGraph.invalidateModule(mod);
}
}
}
} else {
component = input;
configKey = MaizzleConfigKey;
contextKey = RenderContextKey;
}
const renderContext = {
doctype: void 0,
sfcConfig: void 0,
sfcEventHandlers: []
};
const head = createHead({ disableDefaults: true });
const app = createSSRApp(component, opts?.props);
app.use(head);
if (config.vue) {
const plugins = typeof config.vue.plugins === "function" ? config.vue.plugins() : config.vue.plugins ?? [];
for (const plugin of plugins) app.use(plugin);
for (const [name, directive] of Object.entries(config.vue.directives ?? {})) app.directive(name, directive);
Object.assign(app.config.globalProperties, config.vue.globalProperties);
}
app.provide(configKey, config);
app.provide(contextKey, renderContext);
const ssrContext = {};
let html = await renderToString(app, ssrContext);
const { headTags, bodyTags, bodyTagsOpen, htmlAttrs, bodyAttrs } = head.render();
if (htmlAttrs) html = html.replace(/<html([^>]*)>/, `<html$1 ${htmlAttrs}>`);
if (headTags) html = html.replace("</head>", `${headTags}\n</head>`);
if (bodyAttrs) html = html.replace(/<body([^>]*)>/, `<body$1 ${bodyAttrs}>`);
if (bodyTagsOpen) html = html.replace(/<body([^>]*)>/, `<body$1>\n${bodyTagsOpen}`);
if (bodyTags) html = html.replace("</body>", `${bodyTags}\n</body>`);
/**
* Strip Vue SSR fragment markers + teleport anchor comments. These
* are rendering hygiene, not transformer concerns — must run
* regardless of `useTransformers` state. Fragment markers contain
* `-->`, which would prematurely terminate MSO conditional
* comments downstream — including in the DOM round-trip below,
* whose parser would otherwise close `<!--[if mso]>` at a
* marker's `-->` and mangle the conditional on re-serialize.
*/
const stripSsrMarkers = (str) => str.replaceAll("<!--[-->", "").replaceAll("<!--]-->", "").replaceAll("<!--teleport start anchor-->", "").replaceAll("<!--teleport anchor-->", "").replaceAll("<!--teleport start-->", "").replaceAll("<!--teleport end-->", "");
html = stripSsrMarkers(html);
const hasTeleports = ssrContext.teleports && Object.keys(ssrContext.teleports).length > 0;
const hasFonts = (renderContext.fonts?.length ?? 0) > 0;
if (hasTeleports || hasFonts) {
const { parse: parseDom, serialize: serializeDom, walk } = await import("../utils/ast/index.js");
let dom = parseDom(html);
if (hasTeleports) for (const [rawTarget, content] of Object.entries(ssrContext.teleports)) {
if (!content) continue;
const prepend = rawTarget.endsWith(":start");
const target = prepend ? rawTarget.slice(0, -6) : rawTarget;
const targetChildren = parseDom(stripSsrMarkers(content));
walk(dom, (node) => {
const el = node;
if (!el.name) return;
if (target === el.name || target.startsWith("#") && el.attribs?.id === target.slice(1) || target.startsWith(".") && el.attribs?.class?.split(/\s+/).includes(target.slice(1))) {
for (const child of targetChildren) child.parent = el;
el.children = prepend ? [...targetChildren, ...el.children || []] : [...el.children || [], ...targetChildren];
}
});
}
if (hasFonts) {
const { injectFonts } = await import("./injectFonts.js");
injectFonts(dom, renderContext.fonts, parseDom, walk);
}
html = serializeDom(dom);
}
if (renderContext.preheader) {
const { text, fillerCount } = renderContext.preheader;
const previewHtml = `<div style="display:none">${text}${" ͏ ".repeat(fillerCount)}\u00A0</div>`;
html = html.replace(/<body([^>]*)>/, `<body$1>${previewHtml}`);
}
return {
html,
doctype: renderContext.doctype,
/**
* Layer sfcConfig over config — sfcConfig is a partial override
* emitted by composables (defineConfig, useTransformers, etc.).
* A naive replacement (`sfcConfig ?? config`) drops defaults
* from the resolved config when the SFC only sets a single
* key, since the composables' inject() of globalConfig can
* return `{}` in dev when ssrLoadModule and the SFC's
* auto-imported module resolve to different module
* instances (different Symbols).
*/
templateConfig: renderContext.sfcConfig ? defu$1(renderContext.sfcConfig, config) : config,
sfcEventHandlers: renderContext.sfcEventHandlers,
plaintext: renderContext.plaintext,
outputPath: renderContext.outputPath,
tailwindBlocks: renderContext.tailwindBlocks
};
},
async invalidate(filePath) {
const mod = await server.moduleGraph.getModuleByUrl(filePath);
if (mod) server.moduleGraph.invalidateModule(mod);
},
async invalidateAll() {
for (const mod of server.moduleGraph.idToModuleMap.values()) server.moduleGraph.invalidateModule(mod);
},
async close() {
await server.close();
/**
* unplugin-auto-import schedules a 500ms-throttled, fire-and-forget
* d.ts write on its first scan. server.close() doesn't drain that
* pending write, so callers tearing down the working dir right
* after close (tests, ephemeral build pipelines) can race the
* mkdir against a missing parent directory. Wait one throttle
* window past close so the lingering write resolves while
* the dir still exists.
*/
if (dts) await new Promise((resolve) => setTimeout(resolve, 600));
}
};
}
//#endregion
export { createRenderer };
//# sourceMappingURL=createRenderer.js.map