feat(builder): add dynamic blocks email and dnd packages
Quality / quality (ubuntu-latest) (push) Failing after 11m26s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-03 14:00:41 +05:30
1 parent 06433e4ac4
commit 0bd2a6860d
37 files changed
+3060 -1

No files matched your search

+278
View File
@@ -0,0 +1,278 @@
import { createRequire } from "node:module";
import {
DEFAULT_LIMITS,
type BlockDocument,
type BlockNode,
type Diagnostic,
type Result,
} from "@wrnexus/blocks";
interface MjmlAst {
tagName: string;
attributes?: Record<string, string>;
children?: MjmlAst[];
content?: string;
}
interface MjmlCompileError {
line?: number;
message: string;
tagName?: string;
}
const require = createRequire(import.meta.url);
const parseMjml = require("mjml-parser-xml") as (
value: string,
options?: Record<string, unknown>,
) => MjmlAst;
const compileMjml = require("mjml") as (
value: string,
options?: Record<string, unknown>,
) => Promise<{ html: string; errors: MjmlCompileError[] }>;
const supported = new Set([
"mjml",
"mj-head",
"mj-title",
"mj-preview",
"mj-attributes",
"mj-breakpoint",
"mj-body",
"mj-wrapper",
"mj-section",
"mj-group",
"mj-column",
"mj-text",
"mj-image",
"mj-button",
"mj-divider",
"mj-spacer",
]);
const containers = new Set([
"mjml",
"mj-body",
"mj-wrapper",
"mj-section",
"mj-group",
"mj-column",
]);
export interface MjmlDiagnostic extends Diagnostic {
line?: number;
column?: number;
element?: string;
attribute?: string;
}
export function importMjml(
source: string,
options: { id(): string; subject: string; preheader?: string },
): Result<BlockDocument, MjmlDiagnostic[]> {
if (new TextEncoder().encode(source).byteLength > DEFAULT_LIMITS.maxBytes)
return {
ok: false,
errors: [{ code: "mjml.size", message: "MJML source is too large.", severity: "error" }],
};
if (/<mj-(?:raw|include)\b|<script\b|\{[%{[]/i.test(source))
return {
ok: false,
errors: [
{
code: "mjml.unsafe",
message: "MJML raw/include, scripts, and template syntax are not supported.",
severity: "error",
},
],
};
let ast: MjmlAst;
try {
ast = parseMjml(source, { ignoreIncludes: true, validationLevel: "strict" });
} catch (error) {
return {
ok: false,
errors: [
{
code: "mjml.parse",
message: error instanceof Error ? error.message : "Invalid MJML.",
severity: "error",
},
],
};
}
const errors: MjmlDiagnostic[] = [];
const convert = (node: MjmlAst): BlockNode[] => {
if (!supported.has(node.tagName)) {
errors.push({
code: "mjml.component",
message: `Unsupported MJML component ${node.tagName}.`,
severity: "error",
element: node.tagName,
});
return [];
}
const children = (node.children ?? []).flatMap(convert);
if (node.tagName === "mjml" || node.tagName === "mj-head" || node.tagName === "mj-body")
return children;
if (
node.tagName === "mj-title" ||
node.tagName === "mj-preview" ||
node.tagName === "mj-attributes" ||
node.tagName === "mj-breakpoint"
)
return [];
const id = options.id();
const a = node.attributes ?? {};
if (["mj-wrapper", "mj-section", "mj-group"].includes(node.tagName))
return [
{
id,
type: "layout.section",
version: 1,
props: { background: a["background-color"] ?? "#ffffff", padding: a.padding ?? "24px" },
styles: {},
slots: { content: children },
},
];
if (node.tagName === "mj-column")
return [
{
id,
type: "layout.column",
version: 1,
props: { width: a.width ?? "100%" },
styles: {},
slots: { content: children },
},
];
if (node.tagName === "mj-text")
return [
{
id,
type: "content.text",
version: 1,
props: { text: node.content ?? "" },
styles: {},
slots: {},
},
];
if (node.tagName === "mj-image")
return [
{
id,
type: "content.image",
version: 1,
props: {
src: a.src ?? "",
alt: a.alt ?? "",
width: (a.width ?? "600").replace(/px$/, ""),
height: (a.height ?? "300").replace(/px$/, ""),
decorative: false,
},
styles: {},
slots: {},
},
];
if (node.tagName === "mj-button")
return [
{
id,
type: "content.button",
version: 1,
props: { label: node.content ?? "Button", href: a.href ?? "" },
styles: {},
slots: {},
},
];
if (node.tagName === "mj-divider")
return [{ id, type: "content.divider", version: 1, props: {}, styles: {}, slots: {} }];
if (node.tagName === "mj-spacer")
return [
{
id,
type: "content.spacer",
version: 1,
props: { height: (a.height ?? "24").replace(/px$/, "") },
styles: {},
slots: {},
},
];
return containers.has(node.tagName) ? children : [];
};
const content = convert(ast);
if (errors.length) return { ok: false, errors };
const root: BlockNode = {
id: options.id(),
type: "layout.email-document",
version: 1,
props: { width: "600", background: "#f3f4f6" },
styles: {},
slots: {
content: content.map((node) =>
node.type === "layout.column"
? {
id: options.id(),
type: "layout.columns",
version: 1,
props: {},
styles: {},
slots: { columns: [node] },
}
: node,
),
},
};
return {
ok: true,
value: {
schemaVersion: 1,
kind: "email",
meta: { subject: options.subject, preheader: options.preheader ?? "", lang: "en" },
theme: { tokens: {} },
root,
},
};
}
export async function validateMjml(source: string): Promise<MjmlDiagnostic[]> {
try {
const result = await compileMjml(source, {
validationLevel: "strict",
ignoreIncludes: true,
minify: false,
});
return (result.errors ?? []).map((error) => ({
code: "mjml.validation",
message: error.message,
severity: "error",
line: error.line,
element: error.tagName,
}));
} catch (error) {
const details =
error && typeof error === "object" && "errors" in error
? ((
error as {
errors?: Array<{
line?: number;
message?: string;
formattedMessage?: string;
tagName?: string;
}>;
}
).errors ?? [])
: [];
if (details.length)
return details.map((detail) => ({
code: "mjml.validation",
message: detail.message ?? detail.formattedMessage ?? "Invalid MJML.",
severity: "error",
line: detail.line,
element: detail.tagName,
}));
return [
{
code: "mjml.validation",
message: error instanceof Error ? error.message : "Invalid MJML.",
severity: "error",
},
];
}
}