fix(email-renderer): validate brand settings and apply them to AMP
This commit is contained in:
1 parent
fd777605e1
commit
371c23db46
5 files changed
+74
-9
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@wrnexus/email-renderer",
|
"name": "@wrnexus/email-renderer",
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type BlockDefinition,
|
type BlockDefinition,
|
||||||
type BlockNode,
|
type BlockNode,
|
||||||
} from "@wrnexus/blocks";
|
} from "@wrnexus/blocks";
|
||||||
|
import { brandColor, brandFont, brandWidth } from "./brand.ts";
|
||||||
|
|
||||||
const styles = record(json, { maxKeys: 50 }) as never;
|
const styles = record(json, { maxKeys: 50 }) as never;
|
||||||
const label = (key: string, fallback: string) => ({ key, fallback });
|
const label = (key: string, fallback: string) => ({ key, fallback });
|
||||||
@@ -72,12 +73,12 @@ export const emailDocumentBlock = defineBlock({
|
|||||||
category: "layout",
|
category: "layout",
|
||||||
capabilities: ["root"],
|
capabilities: ["root"],
|
||||||
props: object({
|
props: object({
|
||||||
width: string({ default: "600" }),
|
width: brandWidth,
|
||||||
background: string({ default: "#f3f4f6" }),
|
background: brandColor("#f3f4f6"),
|
||||||
contentBackground: string({ default: "#ffffff" }),
|
contentBackground: brandColor("#ffffff"),
|
||||||
fontFamily: string({ default: "Arial, sans-serif" }),
|
fontFamily: brandFont,
|
||||||
textColor: string({ default: "#111827" }),
|
textColor: brandColor("#111827"),
|
||||||
linkColor: string({ default: "#2563eb" }),
|
linkColor: brandColor("#2563eb"),
|
||||||
logoUrl: safeUrl({ schemes: ["https"], optional: true }),
|
logoUrl: safeUrl({ schemes: ["https"], optional: true }),
|
||||||
logoAlt: string({ max: 300, default: "" }),
|
logoAlt: string({ max: 300, default: "" }),
|
||||||
}) as never,
|
}) as never,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { string, type ValueSchema } from "@wrnexus/blocks";
|
||||||
|
|
||||||
|
// Brand fields are semantic values, never arbitrary CSS declarations.
|
||||||
|
export function brandValue(
|
||||||
|
defaultValue: string,
|
||||||
|
accepts: (value: string) => boolean,
|
||||||
|
): ValueSchema<string> {
|
||||||
|
const base = string({ default: defaultValue, max: 160 });
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
parse(value, path = "value") {
|
||||||
|
const result = base.parse(value, path);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
return accepts(result.value)
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
ok: false,
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
code: "email.brand",
|
||||||
|
message: "Unsupported brand style value.",
|
||||||
|
severity: "error",
|
||||||
|
path,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const brandColor = (fallback: string) =>
|
||||||
|
brandValue(fallback, (value) =>
|
||||||
|
/^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|transparent)$/i.test(value),
|
||||||
|
);
|
||||||
|
export const brandFont = brandValue(
|
||||||
|
"Arial, sans-serif",
|
||||||
|
(value) => /^[a-z0-9 ,'"-]+$/i.test(value) && /[a-z]/i.test(value),
|
||||||
|
);
|
||||||
|
export const brandWidth = brandValue(
|
||||||
|
"600",
|
||||||
|
(value) => /^\d{2,4}$/.test(value) && Number(value) >= 240 && Number(value) <= 1200,
|
||||||
|
);
|
||||||
@@ -79,8 +79,11 @@ const head = (document: BlockDocument, body: string, direction: "ltr" | "rtl") =
|
|||||||
const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif");
|
const fontFamily = escapeHtml(props.fontFamily ?? "Arial, sans-serif");
|
||||||
return `<!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>a{color:${linkColor}}@media only screen and (max-width:480px){.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;color:${textColor};font-family:${fontFamily};">${document.meta.preheader ? `<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${escapeHtml(document.meta.preheader)}</div>` : ""}${body}</body></html>`;
|
return `<!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>a{color:${linkColor}}@media only screen and (max-width:480px){.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;color:${textColor};font-family:${fontFamily};">${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") =>
|
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>`;
|
const props = document.root.props as Record<string, unknown>;
|
||||||
|
const css = `body{margin:0;color:${props.textColor ?? "#111827"};font-family:${props.fontFamily ?? "Arial, sans-serif"}}a{color:${props.linkColor ?? "#2563eb"}}.email-bg{background:${props.background ?? "#f3f4f6"}}.email-body{max-width:${props.width ?? "600"}px;width:100%;background:${props.contentBackground ?? "#ffffff"}}`;
|
||||||
|
return `<!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>${css}.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> {
|
export async function validateAmpEmail(content: string): Promise<ValidationReport> {
|
||||||
const validator = await amphtmlValidator.getInstance();
|
const validator = await amphtmlValidator.getInstance();
|
||||||
|
|||||||
@@ -57,6 +57,25 @@ const document: BlockDocument = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("email renderer", () => {
|
describe("email renderer", () => {
|
||||||
|
test("AMP inherits the saved brand font, colors and body width", async () => {
|
||||||
|
const draft = structuredClone(document);
|
||||||
|
Object.assign(draft.root.props, {width:"720",fontFamily:"Georgia, serif",textColor:"#112233",linkColor:"#445566",background:"#eeeeee",contentBackground:"#fafafa"});
|
||||||
|
const rendered = await renderEmail(draft, {amp:true});
|
||||||
|
expect(rendered.ok).toBe(true);
|
||||||
|
if (!rendered.ok) return;
|
||||||
|
const amp = rendered.value.parts.amp;
|
||||||
|
expect(amp?.validation.valid).toBe(true);
|
||||||
|
for (const style of ["font-family:Georgia, serif", "color:#112233", "a{color:#445566}", "background:#eeeeee", "background:#fafafa", "max-width:720px"]) expect(amp?.content).toContain(style);
|
||||||
|
});
|
||||||
|
test("brand fields reject arbitrary CSS and invalid widths before rendering", async () => {
|
||||||
|
for (const [key,value] of [["fontFamily","Arial;position:fixed"],["fontFamily","</style><script>alert(1)</script>"],["textColor","red;background:url(https://example.com)"],["background","#fff;display:none"],["width","600;position:absolute"],["width","9999"]]) {
|
||||||
|
const draft = structuredClone(document);
|
||||||
|
draft.root.props[key!] = value!;
|
||||||
|
const rendered = await renderEmail(draft, {amp:true});
|
||||||
|
expect(rendered.ok).toBe(false);
|
||||||
|
if (!rendered.ok) expect(rendered.errors.some(error => error.code === "email.brand")).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
test("responsive stacking begins below the desktop email width", async () => {
|
test("responsive stacking begins below the desktop email width", async () => {
|
||||||
const rendered = await renderEmail(document);
|
const rendered = await renderEmail(document);
|
||||||
expect(rendered.ok).toBe(true);
|
expect(rendered.ok).toBe(true);
|
||||||
|
|||||||
Reference in new issue
Block a user