Ship file viewer, editor improvements, and private package releases
This commit is contained in:
1 parent
645914955c
commit
a4a31032b9
158 files changed
+10523
-137
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/email-renderer",
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.50",
|
||||
"type": "module",
|
||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||
"exports": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ValueSchema,
|
||||
} from "@wrnexus/blocks";
|
||||
import { brandColor, brandFont, brandWidth } from "./brand.ts";
|
||||
import { richTextSchema, renderRichText } from "./rich-text.ts";
|
||||
import { emailStyleValue } from "./style-values.ts";
|
||||
|
||||
const styles = record(json, { maxKeys: 50 }) as never;
|
||||
@@ -332,19 +333,20 @@ export const headingBlock = leaf(
|
||||
"Heading",
|
||||
"icon-[lucide--heading]",
|
||||
object({
|
||||
richText: richTextSchema,
|
||||
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:inherit;margin:0 0 16px;">${html(attrs(node).text)}</h${attrs(node).level}>`,
|
||||
`<h${attrs(node).level} style="font-family:inherit;margin:0 0 16px;">${renderRichText(attrs(node))}</h${attrs(node).level}>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node }) =>
|
||||
emailCell(`<h${attrs(node).level}>${html(attrs(node).text)}</h${attrs(node).level}>`, node),
|
||||
emailCell(`<h${attrs(node).level}>${renderRichText(attrs(node))}</h${attrs(node).level}>`, node),
|
||||
mjml: ({ node }) =>
|
||||
`<mj-text font-size="24px" font-weight="700">${html(attrs(node).text)}</mj-text>`,
|
||||
`<mj-text font-size="24px" font-weight="700">${renderRichText(attrs(node))}</mj-text>`,
|
||||
},
|
||||
({ node }) => `${String(attrs(node).text)}\n\n`,
|
||||
);
|
||||
@@ -353,15 +355,15 @@ export const textBlock = leaf(
|
||||
"content.text",
|
||||
"Text",
|
||||
"icon-[lucide--text]",
|
||||
object({ text: string({ max: 200_000, default: "" }) }) as never,
|
||||
object({ richText: richTextSchema, text: string({ max: 200_000, default: "" }) }) as never,
|
||||
{
|
||||
emailHtml: ({ node }) =>
|
||||
emailCell(
|
||||
`<p style="font-family:inherit;line-height:1.5;margin:0 0 16px;">${multilineText(attrs(node).text)}</p>`,
|
||||
`<p style="font-family:inherit;line-height:1.5;margin:0 0 16px;">${renderRichText(attrs(node))}</p>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node }) => emailCell(`<p>${multilineText(attrs(node).text)}</p>`, node),
|
||||
mjml: ({ node }) => `<mj-text>${multilineText(attrs(node).text)}</mj-text>`,
|
||||
ampEmail: ({ node }) => emailCell(`<p>${renderRichText(attrs(node))}</p>`, node),
|
||||
mjml: ({ node }) => `<mj-text>${renderRichText(attrs(node))}</mj-text>`,
|
||||
},
|
||||
({ node }) => `${String(attrs(node).text)}\n\n`,
|
||||
);
|
||||
|
||||
@@ -1,66 +1,101 @@
|
||||
import type { BlockNode, JsonObject } from "@wrnexus/blocks";
|
||||
|
||||
/** Designed compositions made entirely from native, editable email blocks. */
|
||||
export function createSectionPreset(name: string, id: () => string): BlockNode {
|
||||
const node = (
|
||||
type: string,
|
||||
props: JsonObject,
|
||||
slots: Record<string, BlockNode[]> = {},
|
||||
): BlockNode => ({
|
||||
id: id(),
|
||||
type,
|
||||
version: 1,
|
||||
props,
|
||||
styles: {},
|
||||
slots,
|
||||
const node = (type: string, props: JsonObject, slots: Record<string, BlockNode[]> = {}, appearance: Record<string, string> = {}): BlockNode => ({
|
||||
id: id(), type, version: 1, props,
|
||||
styles: Object.fromEntries(Object.entries(appearance).map(([key, base]) => [key, {base}])), slots,
|
||||
});
|
||||
const text = (value: string) => node("content.text", { text: value });
|
||||
const heading = (value: string) => node("content.heading", { text: value, level: "2" });
|
||||
const button = (label: string, href: string) => node("content.button", { label, href });
|
||||
let content: BlockNode[];
|
||||
if (name === "welcome")
|
||||
content = [
|
||||
heading("Welcome, {{recipientName|friend}}!"),
|
||||
text("We’re glad you’re here. Discover what’s new and find your next favorite."),
|
||||
button("Explore now", "https://example.com"),
|
||||
const text = (value: string, appearance: Record<string, string> = {}) =>
|
||||
node("content.text", {text: value}, {}, {fontSize: "16px", color: "#475569", ...appearance});
|
||||
const heading = (value: string, appearance: Record<string, string> = {}) =>
|
||||
node("content.heading", {text: value, level: "2"}, {}, {fontSize: "22px", color: "#14213d", ...appearance});
|
||||
const eyebrow = (value: string, color = "#0f766e") => text(value, {fontSize: "11px", fontWeight: "700", color});
|
||||
const button = (label: string, background = "#14213d", color = "#ffffff", href = "https://example.com") =>
|
||||
node("content.button", {label, href}, {}, {backgroundColor: background, color, padding: "14px 24px", borderRadius: "8px", fontSize: "14px", fontWeight: "700"});
|
||||
const space = (height = "16") => node("content.spacer", {height});
|
||||
const column = (content: BlockNode[], width = "100%") => node("layout.column", {width}, {content});
|
||||
const row = (columns: BlockNode[]) => node("layout.columns", {}, {columns});
|
||||
let rows: BlockNode[], background = "#ffffff", padding = "40px";
|
||||
if (name === "welcome") {
|
||||
background = "#edf7f3";
|
||||
rows = [row([column([
|
||||
eyebrow("A FRESH START"),
|
||||
heading("Good things start here.", {fontSize: "26px"}),
|
||||
text("Welcome, {{recipientName|friend}}. A little inspiration, thoughtful discoveries, and more of what you love — all in one place."),
|
||||
space("8"), button("Make yourself at home", "#0f766e"),
|
||||
space("24"), text("YOUR NEXT CHAPTER / STARTS TODAY", {fontSize: "10px", color: "#0f766e", fontWeight: "700"}),
|
||||
])])];
|
||||
} else if (name === "feature") {
|
||||
background = "#f4f1eb";
|
||||
rows = [row([
|
||||
column([
|
||||
text("THE EDIT", {fontSize: "11px", fontWeight: "700", color: "#9a3412"}),
|
||||
heading("Less noise.\nMore possibility.", {fontSize: "23px"}),
|
||||
text("Meet your next everyday favorite. Thoughtfully made, beautifully simple, and ready for whatever comes next.", {fontSize: "14px"}),
|
||||
space("8"), button("Discover the collection", "#9a3412"),
|
||||
], "65%"),
|
||||
column([
|
||||
text("01", {fontSize: "64px", fontWeight: "700", color: "#9a3412", textAlign: "center", backgroundColor: "#eadfd1", padding: "24px 8px"}),
|
||||
text("THOUGHTFULLY\nCURATED", {fontSize: "11px", fontWeight: "700", textAlign: "center", color: "#9a3412", padding: "16px 8px"}),
|
||||
], "35%"),
|
||||
])];
|
||||
} else if (name === "announcement") {
|
||||
background = "#14213d";
|
||||
rows = [row([column([
|
||||
eyebrow("JUST RELEASED", "#a7f3d0"),
|
||||
heading("Something new.\nMade for you.", {fontSize: "28px", color: "#ffffff"}),
|
||||
text("A fresh perspective. A better experience. Discover the latest chapter and see what we’ve been working on.", {color: "#cbd5e1"}),
|
||||
space("12"), button("Take a first look", "#a7f3d0", "#14213d"),
|
||||
space("20"), text("BE THE FIRST TO EXPLORE", {fontSize: "10px", color: "#a7f3d0", fontWeight: "700"}),
|
||||
])])];
|
||||
} else if (name === "testimonial") {
|
||||
background = "#fff7ed";
|
||||
rows = [row([column([
|
||||
eyebrow("FROM OUR COMMUNITY", "#9a3412"),
|
||||
text("“", {fontSize: "64px", color: "#c2410c", fontWeight: "700"}),
|
||||
heading("The details make\nall the difference.", {fontSize: "23px", color: "#431407"}),
|
||||
text("“A thoughtful experience from start to finish. Everything felt effortless — and the result was even better than I imagined.”", {color: "#7c2d12"}),
|
||||
space("12"), text("CUSTOMER NAME", {fontSize: "12px", fontWeight: "700", color: "#431407"}),
|
||||
text("Customer story · Replace with an approved quote", {fontSize: "11px", color: "#9a3412"}),
|
||||
])])];
|
||||
} else if (name === "two-column") {
|
||||
rows = [
|
||||
row([column([eyebrow("A LITTLE INSPIRATION"), heading("Two ways to find your next favorite."), space("12")])]),
|
||||
row([
|
||||
column([
|
||||
text("01 / DISCOVER", {fontSize: "11px", fontWeight: "700", color: "#0f766e", backgroundColor: "#edf7f3", padding: "20px 16px"}),
|
||||
heading("Fresh perspectives", {fontSize: "16px", padding: "16px 16px 0"}),
|
||||
text("Ideas and inspiration to make every day feel a little different.", {fontSize: "14px", padding: "0 16px"}),
|
||||
button("Explore ideas", "#0f766e"),
|
||||
], "50%"),
|
||||
column([
|
||||
text("02 / EXPLORE", {fontSize: "11px", fontWeight: "700", color: "#9a3412", backgroundColor: "#fff7ed", padding: "20px 16px"}),
|
||||
heading("Thoughtful essentials", {fontSize: "16px", padding: "16px 16px 0"}),
|
||||
text("Considered details. Everyday favorites. Find something that fits.", {fontSize: "14px", padding: "0 16px"}),
|
||||
button("Find your favorite", "#9a3412"),
|
||||
], "50%"),
|
||||
]),
|
||||
];
|
||||
else if (name === "feature")
|
||||
content = [
|
||||
node("content.image", {
|
||||
src: "https://placehold.co/600x300",
|
||||
alt: "Replace with your featured image",
|
||||
width: "600",
|
||||
height: "300",
|
||||
decorative: false,
|
||||
}),
|
||||
heading("Something worth sharing"),
|
||||
text("Introduce your product, tell your story, or share your latest announcement."),
|
||||
button("Learn more", "https://example.com"),
|
||||
];
|
||||
else if (name === "footer")
|
||||
content = [
|
||||
} else if (name === "footer") {
|
||||
background = "#f1f5f9";
|
||||
padding = "32px 40px";
|
||||
rows = [row([column([
|
||||
eyebrow("LET’S STAY CONNECTED", "#334155"),
|
||||
heading("Thanks for being here.", {fontSize: "18px"}),
|
||||
text("Good things are better shared. We’re glad you’re part of our community.", {fontSize: "13px"}),
|
||||
node("content.divider", {}),
|
||||
text("Thank you for being part of our community."),
|
||||
text("{{organizationAddress|Add your organization address}}"),
|
||||
button("Unsubscribe", "{{unsubscribeUrl|https://example.com/unsubscribe}}"),
|
||||
];
|
||||
else if (name === "announcement")
|
||||
content = [heading("Big news, just for you"), text("Share your latest launch, update, or upcoming event with your community."), button("See what’s new", "https://example.com")];
|
||||
else if (name === "testimonial")
|
||||
content = [heading("What our customers say"), text("“A thoughtful experience from start to finish. We couldn’t be happier.”"), text("— Customer name, Company")];
|
||||
else if (name === "two-column") {
|
||||
const columns = ["First highlight", "Second highlight"].map(title => node("layout.column", { width: "50%" }, { content: [heading(title), text("Describe a benefit, share an update, or introduce a service."), button("Learn more", "https://example.com")] }));
|
||||
return node("layout.section", { background: "#ffffff", padding: "24px" }, { content: [node("layout.columns", {}, { columns })] });
|
||||
}
|
||||
else throw new Error("Unknown section preset.");
|
||||
const column = node("layout.column", { width: "100%" }, { content });
|
||||
const columns = node("layout.columns", {}, { columns: [column] });
|
||||
return node("layout.section", { background: "#ffffff", padding: "24px" }, { content: [columns] });
|
||||
text("{{organizationAddress|Add your organization address}}", {fontSize: "12px", color: "#64748b"}),
|
||||
text("You’re receiving this email because you signed up for our updates.", {fontSize: "11px", color: "#64748b"}),
|
||||
button("Unsubscribe", "#e2e8f0", "#334155", "{{unsubscribeUrl|https://example.com/unsubscribe}}"),
|
||||
])])];
|
||||
} else throw new Error("Unknown section preset.");
|
||||
return node("layout.section", {background, padding}, {content: rows});
|
||||
}
|
||||
|
||||
/** Native, editable section presets; no HTML snapshots. */
|
||||
export const emailPresets = ["welcome", "feature", "announcement", "testimonial", "two-column", "footer"].map(id => ({
|
||||
id,
|
||||
name: { key: "email.presets." + id, fallback: id[0].toUpperCase() + id.slice(1) },
|
||||
name: {key: "email.presets." + id, fallback: id[0].toUpperCase() + id.slice(1)},
|
||||
icon: "icon-[lucide--layout-template]",
|
||||
create: (context: { id(): string }) => createSectionPreset(id, context.id),
|
||||
create: (context: {id(): string}) => createSectionPreset(id, context.id),
|
||||
}));
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ValueSchema } from "@wrnexus/blocks";
|
||||
|
||||
export interface TextRun { text: string; bold?: boolean; italic?: boolean; underline?: boolean }
|
||||
export const richTextSchema: ValueSchema<TextRun[]> = {
|
||||
kind: "rich-text",
|
||||
parse(value = [], path = "richText") {
|
||||
if (!Array.isArray(value) || value.length > 4000 || value.some(run => !run || typeof run !== "object" || typeof run.text !== "string" || Object.keys(run).some(key => !["text", "bold", "italic", "underline"].includes(key)) || ["bold", "italic", "underline"].some(key => run[key] !== undefined && typeof run[key] !== "boolean")) || value.reduce((size, run) => size + run.text.length, 0) > 200000)
|
||||
return { ok: false, errors: [{code: "email.rich-text", message: "Invalid or oversized rich text.", severity: "error", path}] };
|
||||
return { ok: true, value };
|
||||
},
|
||||
};
|
||||
|
||||
const escape = (value: string) => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'").replace(/\r\n?|\n/g, "<br />");
|
||||
/** Ignore stale formatting if a plain-text inspector or importer changed the text. */
|
||||
export function renderRichText(props: Record<string, unknown>): string {
|
||||
const parsed = richTextSchema.parse(props.richText);
|
||||
const text = String(props.text ?? "");
|
||||
if (!parsed.ok || !parsed.value.length || parsed.value.map(run => run.text).join("") !== text) return escape(text);
|
||||
return parsed.value.map(run => {
|
||||
let result = escape(run.text);
|
||||
if (run.underline) result = `<u>${result}</u>`;
|
||||
if (run.italic) result = `<em>${result}</em>`;
|
||||
if (run.bold) result = `<strong>${result}</strong>`;
|
||||
return result;
|
||||
}).join("");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createSectionPreset, emailPresets } from "../src/presets.ts";
|
||||
import { renderEmailPreview } from "../src/browser.ts";
|
||||
import type { BlockDocument } from "@wrnexus/blocks";
|
||||
|
||||
test("every designed preset renders natively without remote placeholder assets", async () => {
|
||||
for (const preset of emailPresets) {
|
||||
let id = 0;
|
||||
const section = createSectionPreset(preset.id, () => `preset-${++id}`);
|
||||
const document: BlockDocument = {schemaVersion:1,kind:"email",meta:{subject:"Preset QA"},theme:{tokens:{}},root:{id:"root",type:"layout.email-document",version:1,props:{width:"600"},styles:{},slots:{content:[section]}}};
|
||||
const before = JSON.stringify(document);
|
||||
const result = await renderEmailPreview(document, {variables:{recipientName:"Alex",organizationAddress:"Company address",unsubscribeUrl:"https://example.com/unsubscribe"}});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.html).not.toContain("placehold.co");
|
||||
expect(result.value.html).toContain(String(section.props.background));
|
||||
expect(result.value.text.length).toBeGreaterThan(80);
|
||||
}
|
||||
expect(JSON.stringify(document)).toBe(before);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderRichText, richTextSchema } from "../src/rich-text.ts";
|
||||
|
||||
test("semantic text is escaped, bounded, and rejects arbitrary markup properties", () => {
|
||||
expect(renderRichText({text:"<hello>\nworld",richText:[{text:"<hello>",bold:true},{text:"\nworld",italic:true,underline:true}]})).toBe("<strong><hello></strong><em><u><br />world</u></em>");
|
||||
expect(richTextSchema.parse([{text:"hello",html:"<script>"}]).ok).toBe(false);
|
||||
expect(richTextSchema.parse([{text:"hello",bold:"yes"}]).ok).toBe(false);
|
||||
expect(richTextSchema.parse([{text:"a".repeat(200001)}]).ok).toBe(false);
|
||||
expect(renderRichText({text:"Changed",richText:[{text:"Old",bold:true}]})).toBe("Changed");
|
||||
});
|
||||
Reference in new issue
Block a user