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

+340
View File
@@ -0,0 +1,340 @@
import {
boolean,
choice,
defineBlock,
json,
object,
record,
safeUrl,
string,
type BlockDefinition,
type BlockNode,
} from "@wrnexus/blocks";
const styles = record(json, { maxKeys: 50 }) as never;
const label = (key: string, fallback: string) => ({ key, fallback });
const empty = object({}) as never;
const contentSlot = {
label: label("blocks.slot.content", "Content"),
accepts: { capabilities: ["content"] },
maxChildren: 500,
orientation: "vertical" as const,
sortable: true,
};
const html = (value: unknown) =>
String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
const attrs = (node: BlockNode) => node.props as Record<string, unknown>;
const emailCell = (body: string, node: BlockNode) =>
`<tr><td data-block-id="${html(node.id)}" style="padding:0;">${body}</td></tr>`;
export const emailDocumentBlock = defineBlock({
manifest: {
type: "layout.email-document",
version: 1,
name: label("blocks.document.name", "Document"),
icon: "icon-[lucide--mail]",
category: "layout",
capabilities: ["root"],
props: object({
width: string({ default: "600" }),
background: string({ default: "#f3f4f6" }),
}) as never,
styles,
slots: { content: contentSlot },
},
create: ({ id }) => ({
id: id(),
type: "layout.email-document",
version: 1,
props: { width: "600", background: "#f3f4f6" },
styles: {},
slots: { content: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
`<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background:${html(attrs(node).background)};"><tr><td align="center"><table role="presentation" class="wrn-email" width="${html(attrs(node).width)}" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;max-width:100%;width:${html(attrs(node).width)}px;">${slots.content}</table></td></tr></table>`,
ampEmail: ({ node, slots }) =>
`<table role="presentation" width="100%" class="email-bg"><tr><td align="center"><table role="presentation" width="${html(attrs(node).width)}" class="email-body">${slots.content}</table></td></tr></table>`,
mjml: ({ node, slots }) =>
`<mjml><mj-body width="${html(attrs(node).width)}px" background-color="${html(attrs(node).background)}">${slots.content}</mj-body></mjml>`,
},
textRenderer: ({ slots }) => slots.content,
} satisfies BlockDefinition);
export const sectionBlock = defineBlock({
manifest: {
type: "layout.section",
version: 1,
name: label("blocks.section.name", "Section"),
icon: "icon-[lucide--panel-top]",
category: "layout",
capabilities: ["content"],
props: object({
background: string({ default: "#ffffff" }),
padding: string({ default: "24px" }),
}) as never,
styles,
slots: { content: contentSlot },
},
create: ({ id }) => ({
id: id(),
type: "layout.section",
version: 1,
props: { background: "#ffffff", padding: "24px" },
styles: {},
slots: { content: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
emailCell(
`<table role="presentation" width="100%"><tr><td style="background:${html(attrs(node).background)};padding:${html(attrs(node).padding)};">${slots.content}</td></tr></table>`,
node,
),
ampEmail: ({ node, slots }) =>
emailCell(
`<table role="presentation" width="100%"><tr><td class="section">${slots.content}</td></tr></table>`,
node,
),
mjml: ({ node, slots }) =>
`<mj-section background-color="${html(attrs(node).background)}" padding="${html(attrs(node).padding)}"><mj-column>${slots.content}</mj-column></mj-section>`,
},
textRenderer: ({ slots }) => `${slots.content}\n`,
} satisfies BlockDefinition);
export const columnsBlock = defineBlock({
manifest: {
type: "layout.columns",
version: 1,
name: label("blocks.columns.name", "Columns"),
icon: "icon-[lucide--columns-3]",
category: "layout",
capabilities: ["content"],
props: empty,
styles,
slots: {
columns: {
accepts: { types: ["layout.column"] },
minChildren: 1,
maxChildren: 4,
orientation: "horizontal",
sortable: true,
},
},
},
create: ({ id }) => ({
id: id(),
type: "layout.columns",
version: 1,
props: {},
styles: {},
slots: { columns: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
emailCell(
`<table role="presentation" width="100%"><tr class="wrn-columns">${slots.columns}</tr></table>`,
node,
),
ampEmail: ({ node, slots }) =>
emailCell(`<table role="presentation" width="100%"><tr>${slots.columns}</tr></table>`, node),
mjml: ({ slots }) => slots.columns,
},
textRenderer: ({ slots }) => slots.columns,
} satisfies BlockDefinition);
export const columnBlock = defineBlock({
manifest: {
type: "layout.column",
version: 1,
name: label("blocks.column.name", "Column"),
icon: "icon-[lucide--panel-left]",
category: "layout",
capabilities: ["column"],
props: object({ width: string({ default: "100%" }) }) as never,
styles,
slots: { content: contentSlot },
},
create: ({ id }) => ({
id: id(),
type: "layout.column",
version: 1,
props: { width: "100%" },
styles: {},
slots: { content: [] },
}),
renderers: {
emailHtml: ({ node, slots }) =>
`<td class="wrn-column" width="${html(attrs(node).width)}" valign="top">${slots.content}</td>`,
ampEmail: ({ node, slots }) =>
`<td width="${html(attrs(node).width)}" valign="top">${slots.content}</td>`,
mjml: ({ node, slots }) =>
`<mj-column width="${html(attrs(node).width)}">${slots.content}</mj-column>`,
},
textRenderer: ({ slots }) => slots.content,
} satisfies BlockDefinition);
const leaf = (
type: string,
name: string,
icon: string,
props: never,
renderers: BlockDefinition["renderers"],
textRenderer: BlockDefinition["textRenderer"],
): BlockDefinition =>
defineBlock({
manifest: {
type,
version: 1,
name: label(`blocks.${type}.name`, name),
icon,
category: "content",
capabilities: ["content"],
props,
styles,
slots: {},
},
create: ({ id }) => ({ id: id(), type, version: 1, props: {}, styles: {}, slots: {} }),
renderers,
textRenderer,
});
export const headingBlock = leaf(
"content.heading",
"Heading",
"icon-[lucide--heading]",
object({
text: string({ min: 1, max: 500, default: "Heading" }),
level: choice(["1", "2", "3", "4"] as const, { default: "2" }),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<h${attrs(node).level} style="font-family:Arial,sans-serif;margin:0 0 16px;">${html(attrs(node).text)}</h${attrs(node).level}>`,
node,
),
ampEmail: ({ node }) =>
emailCell(`<h${attrs(node).level}>${html(attrs(node).text)}</h${attrs(node).level}>`, node),
mjml: ({ node }) =>
`<mj-text font-size="24px" font-weight="700">${html(attrs(node).text)}</mj-text>`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
export const textBlock = leaf(
"content.text",
"Text",
"icon-[lucide--text]",
object({ text: string({ max: 200_000, default: "" }) }) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<p style="font-family:Arial,sans-serif;line-height:1.5;margin:0 0 16px;white-space:pre-line;">${html(attrs(node).text)}</p>`,
node,
),
ampEmail: ({ node }) => emailCell(`<p>${html(attrs(node).text)}</p>`, node),
mjml: ({ node }) => `<mj-text>${html(attrs(node).text)}</mj-text>`,
},
({ node }) => `${String(attrs(node).text)}\n\n`,
);
export const imageBlock = leaf(
"content.image",
"Image",
"icon-[lucide--image]",
object({
src: safeUrl({ schemes: ["https"] }),
alt: string({ max: 1_000, default: "" }),
width: string({ default: "600" }),
height: string({ default: "300" }),
decorative: boolean(false),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<img src="${html(attrs(node).src)}" alt="${html(attrs(node).decorative ? "" : attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" style="border:0;display:block;height:auto;max-width:100%;" />`,
node,
),
ampEmail: ({ node }) =>
emailCell(
`<amp-img src="${html(attrs(node).src)}" alt="${html(attrs(node).decorative ? "" : attrs(node).alt)}" width="${html(attrs(node).width)}" height="${html(attrs(node).height)}" layout="responsive"></amp-img>`,
node,
),
mjml: ({ node }) =>
`<mj-image src="${html(attrs(node).src)}" alt="${html(attrs(node).alt)}" width="${html(attrs(node).width)}px" />`,
},
({ node }) => (attrs(node).decorative ? "" : `[${String(attrs(node).alt)}]\n\n`),
);
export const buttonBlock = leaf(
"content.button",
"Button",
"icon-[lucide--mouse-pointer-click]",
object({
label: string({ min: 1, max: 200, default: "Button" }),
href: safeUrl({ schemes: ["https", "mailto"] }),
}) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<table role="presentation"><tr><td bgcolor="#2563eb" style="border-radius:6px;"><a href="${html(attrs(node).href)}" style="color:#ffffff;display:inline-block;font-family:Arial,sans-serif;font-weight:700;padding:12px 20px;text-decoration:none;">${html(attrs(node).label)}</a></td></tr></table>`,
node,
),
ampEmail: ({ node }) =>
emailCell(
`<a class="button" href="${html(attrs(node).href)}">${html(attrs(node).label)}</a>`,
node,
),
mjml: ({ node }) =>
`<mj-button href="${html(attrs(node).href)}">${html(attrs(node).label)}</mj-button>`,
},
({ node }) => `${String(attrs(node).label)}: ${String(attrs(node).href)}\n\n`,
);
export const dividerBlock = leaf(
"content.divider",
"Divider",
"icon-[lucide--minus]",
empty,
{
emailHtml: ({ node }) =>
emailCell(`<hr style="border:0;border-top:1px solid #d1d5db;margin:16px 0;" />`, node),
ampEmail: ({ node }) => emailCell(`<hr />`, node),
mjml: () => `<mj-divider />`,
},
() => "----------------\n",
);
export const spacerBlock = leaf(
"content.spacer",
"Spacer",
"icon-[lucide--move-vertical]",
object({ height: string({ default: "24" }) }) as never,
{
emailHtml: ({ node }) =>
emailCell(
`<div style="font-size:1px;height:${html(attrs(node).height)}px;line-height:${html(attrs(node).height)}px;">&nbsp;</div>`,
node,
),
ampEmail: ({ node }) => emailCell(`<div class="spacer">&nbsp;</div>`, node),
mjml: ({ node }) => `<mj-spacer height="${html(attrs(node).height)}px" />`,
},
() => "\n",
);
export const emailCoreBlocks: readonly BlockDefinition[] = [
emailDocumentBlock,
sectionBlock,
columnsBlock,
columnBlock,
headingBlock,
textBlock,
imageBlock,
buttonBlock,
dividerBlock,
spacerBlock,
];