Ship file viewer, editor improvements, and private package releases

This commit is contained in:
Clintchiz committed 2026-09-09 11:20:11 +05:30
1 parent 645914955c
commit a4a31032b9
158 files changed
+10523 -137

No files matched your search

+1 -1
View File
@@ -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": {
+9 -7
View File
@@ -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`,
);
+89 -54
View File
@@ -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),
}));
+26
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;").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>&lt;hello&gt;</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");
});