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,149 @@
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
DEFAULT_LIMITS,
|
||||
defineDocumentKind,
|
||||
escapeHtml,
|
||||
object,
|
||||
registryForKind,
|
||||
renderTree,
|
||||
string,
|
||||
type BlockDocument,
|
||||
type Diagnostic,
|
||||
type Result,
|
||||
} from "@wrnexus/blocks";
|
||||
import { emailCoreBlocks } from "./blocks.ts";
|
||||
|
||||
interface AmpValidationError {
|
||||
severity: string;
|
||||
line: number;
|
||||
col: number;
|
||||
message: string;
|
||||
}
|
||||
const amphtmlValidator = createRequire(import.meta.url)("amphtml-validator") as {
|
||||
getInstance(): Promise<{
|
||||
validateString(
|
||||
value: string,
|
||||
format: string,
|
||||
): {
|
||||
status: string;
|
||||
errors: AmpValidationError[];
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export interface ValidationReport {
|
||||
valid: boolean;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
export interface EmailRenderBundle {
|
||||
kind: "email";
|
||||
subject: string;
|
||||
preheader?: string;
|
||||
parts: {
|
||||
text: { contentType: "text/plain; charset=utf-8"; content: string };
|
||||
amp?: {
|
||||
contentType: "text/x-amp-html; charset=utf-8";
|
||||
content: string;
|
||||
validation: ValidationReport;
|
||||
};
|
||||
html: { contentType: "text/html; charset=utf-8"; content: string };
|
||||
};
|
||||
exports?: { mjml?: string };
|
||||
warnings: Diagnostic[];
|
||||
assets: string[];
|
||||
}
|
||||
|
||||
export const emailKind = defineDocumentKind({
|
||||
kind: "email",
|
||||
version: 1,
|
||||
metadata: object({
|
||||
subject: string({ min: 1, max: 998 }),
|
||||
preheader: string({ max: 500, default: "" }),
|
||||
lang: string({ max: 35, default: "en" }),
|
||||
}) as never,
|
||||
rootTypes: ["layout.email-document"],
|
||||
blocks: emailCoreBlocks,
|
||||
variables: { recipientName: "string", unsubscribeUrl: "string", organizationAddress: "string" },
|
||||
breakpoints: { compact: 620 },
|
||||
capabilities: ["email-html", "plain-text", "amp-email", "mjml"],
|
||||
targets: ["emailHtml", "plainText", "ampEmail", "mjml"],
|
||||
limits: { ...DEFAULT_LIMITS, maxOutputBytes: 2_000_000 },
|
||||
urlSchemes: ["https", "mailto"],
|
||||
});
|
||||
|
||||
const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =>
|
||||
`<!doctype html><html lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="x-apple-disable-message-reformatting"><meta name="color-scheme" content="light dark"><title>${escapeHtml(document.meta.subject)}</title><style>@media only screen and (max-width:620px){.wrn-email{width:100%!important}.wrn-columns{display:block!important}.wrn-column{display:block!important;width:100%!important}}</style></head><body style="margin:0;padding:0;">${document.meta.preheader ? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(document.meta.preheader)}</div>` : ""}${body}</body></html>`;
|
||||
const ampHead = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =>
|
||||
`<!doctype html><html amp4email data-css-strict lang="${escapeHtml(document.meta.lang)}" dir="${direction}"><head><meta charset="utf-8"><script async src="https://cdn.ampproject.org/v0.js"></script><style amp4email-boilerplate>body{visibility:hidden}</style><style amp-custom>.email-body{max-width:600px;width:100%}.section{padding:24px}.button{background:#2563eb;border-radius:6px;color:#fff;display:inline-block;font-weight:700;padding:12px 20px;text-decoration:none}.spacer{height:24px}</style></head><body>${body}</body></html>`;
|
||||
|
||||
export async function validateAmpEmail(content: string): Promise<ValidationReport> {
|
||||
const validator = await amphtmlValidator.getInstance();
|
||||
const result = validator.validateString(content, "AMP4EMAIL");
|
||||
return {
|
||||
valid: result.status === "PASS",
|
||||
diagnostics: result.errors.map((error) => ({
|
||||
code: "amp.validation",
|
||||
message: error.message,
|
||||
severity: error.severity === "ERROR" ? "error" : "warning",
|
||||
path: `${error.line}:${error.col}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderEmail(
|
||||
document: BlockDocument,
|
||||
options: {
|
||||
amp?: boolean;
|
||||
mjml?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
direction?: "ltr" | "rtl";
|
||||
} = {},
|
||||
): Promise<Result<EmailRenderBundle>> {
|
||||
const registry = registryForKind(emailKind).freeze();
|
||||
const base = {
|
||||
locale: options.locale ?? "en",
|
||||
timezone: options.timezone ?? "UTC",
|
||||
direction: options.direction ?? "ltr",
|
||||
};
|
||||
const [htmlResult, textResult, mjmlResult] = await Promise.all([
|
||||
renderTree(document, emailKind, registry, { ...base, target: "emailHtml" }),
|
||||
renderTree(document, emailKind, registry, { ...base, target: "plainText" }),
|
||||
options.mjml
|
||||
? renderTree(document, emailKind, registry, { ...base, target: "mjml" })
|
||||
: Promise.resolve(undefined),
|
||||
]);
|
||||
if (!htmlResult.ok) return htmlResult;
|
||||
if (!textResult.ok) return textResult;
|
||||
const warnings: Diagnostic[] = [...htmlResult.value.warnings, ...textResult.value.warnings];
|
||||
const bundle: EmailRenderBundle = {
|
||||
kind: "email",
|
||||
subject: String(document.meta.subject),
|
||||
preheader: String(document.meta.preheader || "") || undefined,
|
||||
parts: {
|
||||
text: {
|
||||
contentType: "text/plain; charset=utf-8",
|
||||
content: textResult.value.content.trimEnd() + "\n",
|
||||
},
|
||||
html: {
|
||||
contentType: "text/html; charset=utf-8",
|
||||
content: head(document, htmlResult.value.content, base.direction),
|
||||
},
|
||||
},
|
||||
warnings,
|
||||
assets: [],
|
||||
};
|
||||
if (mjmlResult?.ok) bundle.exports = { mjml: mjmlResult.value.content };
|
||||
if (options.amp) {
|
||||
const amp = await renderTree(document, emailKind, registry, { ...base, target: "ampEmail" });
|
||||
if (amp.ok) {
|
||||
const content = ampHead(document, amp.value.content, base.direction);
|
||||
const validation = await validateAmpEmail(content);
|
||||
if (validation.valid)
|
||||
bundle.parts.amp = { contentType: "text/x-amp-html; charset=utf-8", content, validation };
|
||||
else warnings.push(...validation.diagnostics);
|
||||
} else
|
||||
warnings.push(...amp.errors.map((error) => ({ ...error, severity: "warning" as const })));
|
||||
}
|
||||
return { ok: true, value: bundle };
|
||||
}
|
||||
Reference in new issue
Block a user