feat(builder): add dynamic blocks email and dnd packages
This commit is contained in:
1 parent
06433e4ac4
commit
0bd2a6860d
37 files changed
+3060
-1
No files matched your search
@@ -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",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user