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,4 @@
|
||||
# @wrnexus/block-editor
|
||||
|
||||
Hydrated editor state and DOM runtime for portable block documents. All changes
|
||||
flow through validated `@wrnexus/blocks` commands; DnD produces move intents.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@wrnexus/block-editor",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Hydrated accessible editor runtime for WRNexus block documents.",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./runtime": "./src/runtime.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/blocks": "workspace:*",
|
||||
"@wrnexus/dnd": "workspace:*",
|
||||
"@wrnexus/reactive": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"happy-dom": "^20.11.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./state.ts";
|
||||
export * from "./runtime.ts";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createDndManager, type DndAnnouncement, type DndManager } from "@wrnexus/dnd";
|
||||
|
||||
export interface MountedEditor {
|
||||
destroy(): void;
|
||||
refresh(): void;
|
||||
}
|
||||
export interface MountEditorOptions {
|
||||
direction?: "ltr" | "rtl";
|
||||
announce?(announcement: DndAnnouncement): string;
|
||||
onMove(intent: {
|
||||
nodeIds: string[];
|
||||
to: { parentId: string; slot: string; index: number };
|
||||
}): boolean | void | Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export function mountBlockEditor(root: HTMLElement, options: MountEditorOptions): MountedEditor {
|
||||
let manager: DndManager | undefined;
|
||||
let cleanups: Array<() => void> = [];
|
||||
const live = document.createElement("div");
|
||||
live.setAttribute("role", "status");
|
||||
live.setAttribute("aria-live", "polite");
|
||||
live.setAttribute("aria-atomic", "true");
|
||||
live.style.cssText =
|
||||
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0";
|
||||
root.append(live);
|
||||
const refresh = () => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
cleanups = [];
|
||||
manager?.destroy();
|
||||
manager = createDndManager({
|
||||
direction: options.direction ?? (getComputedStyle(root).direction === "rtl" ? "rtl" : "ltr"),
|
||||
reducedMotion: matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
announce(announcement) {
|
||||
live.textContent = options.announce?.(announcement) ?? announcement.type;
|
||||
},
|
||||
async onMoveIntent(intent) {
|
||||
const [parentId, slot] = String(intent.to.group).split(":", 2);
|
||||
if (!parentId || !slot) return false;
|
||||
return options.onMove({
|
||||
nodeIds: intent.ids.map(String),
|
||||
to: { parentId, slot, index: intent.to.index },
|
||||
});
|
||||
},
|
||||
});
|
||||
for (const element of root.querySelectorAll<HTMLElement>(
|
||||
"[data-block-id][data-block-parent][data-block-slot]",
|
||||
)) {
|
||||
const id = element.dataset.blockId!;
|
||||
const parent = element.dataset.blockParent!;
|
||||
const slot = element.dataset.blockSlot!;
|
||||
const siblings = [
|
||||
...element.parentElement!.querySelectorAll<HTMLElement>(":scope > [data-block-id]"),
|
||||
];
|
||||
const handle = element.querySelector<HTMLElement>("[data-block-handle]") ?? undefined;
|
||||
cleanups.push(
|
||||
manager.registerSortable({
|
||||
id,
|
||||
group: `${parent}:${slot}`,
|
||||
index: siblings.indexOf(element),
|
||||
element,
|
||||
handle,
|
||||
type: element.dataset.blockType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
refresh();
|
||||
return {
|
||||
refresh,
|
||||
destroy() {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
manager?.destroy();
|
||||
live.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { signal, type Signal } from "@wrnexus/reactive";
|
||||
import {
|
||||
applyCommand,
|
||||
type BlockCommand,
|
||||
type BlockDocument,
|
||||
type CommandResult,
|
||||
type DocumentKindDefinition,
|
||||
type ReadonlyBlockRegistry,
|
||||
type Result,
|
||||
type SelectionState,
|
||||
} from "@wrnexus/blocks";
|
||||
|
||||
export interface EditorSnapshot {
|
||||
document: BlockDocument;
|
||||
revision: number;
|
||||
selection: SelectionState;
|
||||
dirty: boolean;
|
||||
status: "idle" | "saving" | "saved" | "conflict" | "error";
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
forward: BlockCommand;
|
||||
inverse: BlockCommand;
|
||||
}
|
||||
|
||||
export interface BlockEditorState extends Signal<EditorSnapshot> {
|
||||
execute(command: BlockCommand): Result<CommandResult>;
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
select(ids: string[], primary?: string | null): void;
|
||||
markSaving(): void;
|
||||
markSaved(revision: number): void;
|
||||
markConflict(): void;
|
||||
replaceFromServer(document: BlockDocument, revision: number): void;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
}
|
||||
|
||||
export function createBlockEditorState(options: {
|
||||
document: BlockDocument;
|
||||
revision: number;
|
||||
kind: DocumentKindDefinition;
|
||||
registry: ReadonlyBlockRegistry;
|
||||
historyLimit?: number;
|
||||
}): BlockEditorState {
|
||||
const current = signal<EditorSnapshot>({
|
||||
document: options.document,
|
||||
revision: options.revision,
|
||||
selection: { primary: null, ordered: [] },
|
||||
dirty: false,
|
||||
status: "idle",
|
||||
});
|
||||
const past: HistoryEntry[] = [];
|
||||
const future: HistoryEntry[] = [];
|
||||
const limit = Math.max(1, options.historyLimit ?? 100);
|
||||
const apply = (command: BlockCommand, remember: boolean): Result<CommandResult> => {
|
||||
const result = applyCommand(current.get().document, command, options.kind, options.registry);
|
||||
if (!result.ok) return result;
|
||||
if (remember) {
|
||||
past.push({ forward: command, inverse: result.value.inverse });
|
||||
if (past.length > limit) past.shift();
|
||||
future.length = 0;
|
||||
}
|
||||
current.set({
|
||||
...current.get(),
|
||||
document: result.value.document,
|
||||
selection: result.value.selection,
|
||||
dirty: true,
|
||||
status: "idle",
|
||||
});
|
||||
return result;
|
||||
};
|
||||
return Object.assign(current, {
|
||||
execute: (command: BlockCommand) => apply(command, true),
|
||||
undo() {
|
||||
const entry = past.pop();
|
||||
if (!entry) return false;
|
||||
const result = apply(entry.inverse, false);
|
||||
if (!result.ok) {
|
||||
past.push(entry);
|
||||
return false;
|
||||
}
|
||||
future.push(entry);
|
||||
return true;
|
||||
},
|
||||
redo() {
|
||||
const entry = future.pop();
|
||||
if (!entry) return false;
|
||||
const result = apply(entry.forward, false);
|
||||
if (!result.ok) {
|
||||
future.push(entry);
|
||||
return false;
|
||||
}
|
||||
past.push(entry);
|
||||
return true;
|
||||
},
|
||||
select(ids: string[], primary: string | null = ids[0] ?? null) {
|
||||
current.set({ ...current.get(), selection: { primary, ordered: [...new Set(ids)] } });
|
||||
},
|
||||
markSaving() {
|
||||
current.set({ ...current.get(), status: "saving" });
|
||||
},
|
||||
markSaved(revision: number) {
|
||||
current.set({ ...current.get(), revision, dirty: false, status: "saved" });
|
||||
},
|
||||
markConflict() {
|
||||
current.set({ ...current.get(), status: "conflict" });
|
||||
},
|
||||
replaceFromServer(document: BlockDocument, revision: number) {
|
||||
past.length = 0;
|
||||
future.length = 0;
|
||||
current.set({
|
||||
document,
|
||||
revision,
|
||||
selection: { primary: null, ordered: [] },
|
||||
dirty: false,
|
||||
status: "idle",
|
||||
});
|
||||
},
|
||||
canUndo: () => past.length > 0,
|
||||
canRedo: () => future.length > 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createRegistry,
|
||||
defineBlock,
|
||||
defineDocumentKind,
|
||||
json,
|
||||
object,
|
||||
record,
|
||||
string,
|
||||
type BlockDocument,
|
||||
} from "@wrnexus/blocks";
|
||||
import { createBlockEditorState } from "../src/index.ts";
|
||||
|
||||
const styles = record(json) as never;
|
||||
const definitions = [
|
||||
defineBlock({
|
||||
manifest: {
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
name: { key: "root", fallback: "Root" },
|
||||
icon: "root",
|
||||
category: "layout",
|
||||
props: object({}) as never,
|
||||
styles,
|
||||
slots: { content: { accepts: { capabilities: ["content"] } } },
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
props: {},
|
||||
styles: {},
|
||||
slots: { content: [] },
|
||||
}),
|
||||
}),
|
||||
defineBlock({
|
||||
manifest: {
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
name: { key: "text", fallback: "Text" },
|
||||
icon: "text",
|
||||
category: "content",
|
||||
capabilities: ["content"],
|
||||
props: object({ text: string() }) as never,
|
||||
styles,
|
||||
slots: {},
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
props: { text: "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
}),
|
||||
}),
|
||||
];
|
||||
const kind = defineDocumentKind({
|
||||
kind: "test",
|
||||
version: 1,
|
||||
metadata: object({ title: string() }) as never,
|
||||
rootTypes: ["layout.root"],
|
||||
blocks: definitions,
|
||||
variables: {},
|
||||
breakpoints: {},
|
||||
capabilities: [],
|
||||
targets: [],
|
||||
limits: {
|
||||
maxBytes: 10000,
|
||||
maxNodes: 20,
|
||||
maxDepth: 5,
|
||||
maxChildrenPerSlot: 10,
|
||||
maxStringLength: 100,
|
||||
maxOutputBytes: 1000,
|
||||
},
|
||||
urlSchemes: ["https"],
|
||||
});
|
||||
const registry = createRegistry(definitions).freeze();
|
||||
const document: BlockDocument = {
|
||||
schemaVersion: 1,
|
||||
kind: "test",
|
||||
meta: { title: "x" },
|
||||
theme: { tokens: {} },
|
||||
root: definitions[0]!.create({ id: () => "root" }),
|
||||
};
|
||||
|
||||
test("editor state executes undo and redo", () => {
|
||||
const state = createBlockEditorState({ document, revision: 1, kind, registry });
|
||||
const node = definitions[1]!.create({ id: () => "text" });
|
||||
expect(
|
||||
state.execute({
|
||||
type: "insert",
|
||||
location: { parentId: "root", slot: "content", index: 0 },
|
||||
nodes: [node],
|
||||
}).ok,
|
||||
).toBe(true);
|
||||
expect(state.get().dirty).toBe(true);
|
||||
expect(state.undo()).toBe(true);
|
||||
expect(state.get().document.root.slots.content).toEqual([]);
|
||||
expect(state.redo()).toBe(true);
|
||||
expect(state.get().document.root.slots.content[0]?.id).toBe("text");
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# @wrnexus/blocks
|
||||
|
||||
Pure, dependency-free block document engine. Documents contain portable JSON;
|
||||
trusted packages register schemas, editor metadata, migrations, and target
|
||||
renderers explicitly. Named slots are the only containment model.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@wrnexus/blocks",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Pure dynamic block document engine for WRNexus builders.",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { duplicateNodes } from "./commands.ts";
|
||||
import type { BlockNode, Result } from "./types.ts";
|
||||
|
||||
export const BLOCK_CLIPBOARD_MIME = "application/x-wrnexus-blocks+json";
|
||||
|
||||
export interface BlockClipboardEnvelope {
|
||||
format: typeof BLOCK_CLIPBOARD_MIME;
|
||||
version: 1;
|
||||
sourceKind: string;
|
||||
roots: BlockNode[];
|
||||
requiredTypes: Array<{ type: string; version: number }>;
|
||||
}
|
||||
|
||||
export function encodeClipboard(sourceKind: string, roots: readonly BlockNode[]): string {
|
||||
const required = new Map<string, number>();
|
||||
const visit = (node: BlockNode) => {
|
||||
required.set(node.type, Math.max(required.get(node.type) ?? 0, node.version));
|
||||
for (const children of Object.values(node.slots)) children.forEach(visit);
|
||||
};
|
||||
roots.forEach(visit);
|
||||
return JSON.stringify({
|
||||
format: BLOCK_CLIPBOARD_MIME,
|
||||
version: 1,
|
||||
sourceKind,
|
||||
roots,
|
||||
requiredTypes: [...required]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([type, version]) => ({ type, version })),
|
||||
});
|
||||
}
|
||||
|
||||
export function decodeClipboard(
|
||||
value: string,
|
||||
id: () => string,
|
||||
maxBytes = 250_000,
|
||||
): Result<BlockClipboardEnvelope> {
|
||||
if (new TextEncoder().encode(value).byteLength > maxBytes)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{ code: "clipboard.size", message: "Clipboard content is too large.", severity: "error" },
|
||||
],
|
||||
};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as BlockClipboardEnvelope;
|
||||
if (
|
||||
parsed.format !== BLOCK_CLIPBOARD_MIME ||
|
||||
parsed.version !== 1 ||
|
||||
!Array.isArray(parsed.roots)
|
||||
)
|
||||
throw new Error();
|
||||
return { ok: true, value: { ...parsed, roots: duplicateNodes(parsed.roots, id) } };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "clipboard.format",
|
||||
message: "Clipboard does not contain WRNexus blocks.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { findBlock, validateDocument } from "./document.ts";
|
||||
import type { DocumentKindDefinition } from "./kinds.ts";
|
||||
import type { ReadonlyBlockRegistry } from "./registry.ts";
|
||||
import type {
|
||||
BlockDocument,
|
||||
BlockLocation,
|
||||
BlockNode,
|
||||
BlockStyles,
|
||||
Diagnostic,
|
||||
JsonObject,
|
||||
MoveIntent,
|
||||
Result,
|
||||
SelectionState,
|
||||
} from "./types.ts";
|
||||
|
||||
export type BlockCommand =
|
||||
| { type: "insert"; location: BlockLocation; nodes: BlockNode[] }
|
||||
| { type: "remove"; nodeIds: string[] }
|
||||
| { type: "move"; intent: MoveIntent }
|
||||
| { type: "updateProps"; nodeId: string; patch: JsonObject }
|
||||
| { type: "updateStyles"; nodeId: string; patch: BlockStyles }
|
||||
| { type: "replace"; nodeId: string; node: BlockNode }
|
||||
| { type: "batch"; commands: BlockCommand[] };
|
||||
|
||||
export interface CommandResult {
|
||||
document: BlockDocument;
|
||||
inverse: BlockCommand;
|
||||
affectedIds: string[];
|
||||
selection: SelectionState;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
const clone = <T>(value: T): T => structuredClone(value);
|
||||
|
||||
function updateNode(
|
||||
root: BlockNode,
|
||||
id: string,
|
||||
update: (node: BlockNode) => BlockNode,
|
||||
): BlockNode {
|
||||
if (root.id === id) return update(root);
|
||||
const slots: Record<string, BlockNode[]> = {};
|
||||
for (const [name, children] of Object.entries(root.slots))
|
||||
slots[name] = children.map((child) => updateNode(child, id, update));
|
||||
return { ...root, slots };
|
||||
}
|
||||
|
||||
function locationOf(root: BlockNode, id: string): BlockLocation | undefined {
|
||||
for (const [slot, children] of Object.entries(root.slots)) {
|
||||
const index = children.findIndex((child) => child.id === id);
|
||||
if (index >= 0) return { parentId: root.id, slot, index };
|
||||
for (const child of children) {
|
||||
const nested = locationOf(child, id);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function insert(root: BlockNode, location: BlockLocation, nodes: BlockNode[]): BlockNode {
|
||||
return updateNode(root, location.parentId, (parent) => {
|
||||
const children = [...(parent.slots[location.slot] ?? [])];
|
||||
children.splice(location.index, 0, ...nodes);
|
||||
return { ...parent, slots: { ...parent.slots, [location.slot]: children } };
|
||||
});
|
||||
}
|
||||
|
||||
function remove(root: BlockNode, ids: Set<string>): BlockNode {
|
||||
const slots: Record<string, BlockNode[]> = {};
|
||||
for (const [name, children] of Object.entries(root.slots))
|
||||
slots[name] = children.filter((child) => !ids.has(child.id)).map((child) => remove(child, ids));
|
||||
return { ...root, slots };
|
||||
}
|
||||
|
||||
export function applyCommand(
|
||||
document: BlockDocument,
|
||||
command: BlockCommand,
|
||||
kind: DocumentKindDefinition,
|
||||
registry: ReadonlyBlockRegistry,
|
||||
): Result<CommandResult> {
|
||||
if (command.type === "batch") {
|
||||
let current = document;
|
||||
const inverses: BlockCommand[] = [];
|
||||
const affected: string[] = [];
|
||||
for (const child of command.commands) {
|
||||
const result = applyCommand(current, child, kind, registry);
|
||||
if (!result.ok) return result;
|
||||
current = result.value.document;
|
||||
inverses.unshift(result.value.inverse);
|
||||
affected.push(...result.value.affectedIds);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
document: current,
|
||||
inverse: { type: "batch", commands: inverses },
|
||||
affectedIds: [...new Set(affected)],
|
||||
selection: { primary: affected.at(-1) ?? null, ordered: [...new Set(affected)] },
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
let root = document.root;
|
||||
let inverse: BlockCommand;
|
||||
let affected: string[];
|
||||
if (command.type === "insert") {
|
||||
root = insert(root, command.location, clone(command.nodes));
|
||||
affected = command.nodes.map((node) => node.id);
|
||||
inverse = { type: "remove", nodeIds: affected };
|
||||
} else if (command.type === "remove") {
|
||||
const captures = command.nodeIds.map((id) => ({
|
||||
node: findBlock(root, id),
|
||||
location: locationOf(root, id),
|
||||
}));
|
||||
if (captures.some((item) => !item.node || !item.location))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "command.missing",
|
||||
message: "Cannot remove the root or a missing block.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
root = remove(root, new Set(command.nodeIds));
|
||||
affected = command.nodeIds;
|
||||
inverse = {
|
||||
type: "batch",
|
||||
commands: captures.map((item) => ({
|
||||
type: "insert",
|
||||
location: item.location!,
|
||||
nodes: [item.node!],
|
||||
})),
|
||||
};
|
||||
} else if (command.type === "move") {
|
||||
const moving = command.intent.nodeIds.map((id) => findBlock(root, id));
|
||||
if (moving.some((node) => !node))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{ code: "command.missing", message: "A moved block does not exist.", severity: "error" },
|
||||
],
|
||||
};
|
||||
for (const node of moving)
|
||||
if (node && findBlock(node, command.intent.to.parentId))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "command.cycle",
|
||||
message: "A block cannot move into its descendant.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
root = insert(
|
||||
remove(root, new Set(command.intent.nodeIds)),
|
||||
command.intent.to,
|
||||
moving as BlockNode[],
|
||||
);
|
||||
affected = command.intent.nodeIds;
|
||||
inverse = {
|
||||
type: "batch",
|
||||
commands: command.intent.from.map((location, index) => ({
|
||||
type: "move",
|
||||
intent: {
|
||||
nodeIds: [command.intent.nodeIds[index]!],
|
||||
from: [command.intent.to],
|
||||
to: location,
|
||||
},
|
||||
})),
|
||||
};
|
||||
} else if (command.type === "updateProps") {
|
||||
const previous = findBlock(root, command.nodeId);
|
||||
if (!previous)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }],
|
||||
};
|
||||
root = updateNode(root, command.nodeId, (node) => ({
|
||||
...node,
|
||||
props: { ...node.props, ...command.patch },
|
||||
}));
|
||||
affected = [command.nodeId];
|
||||
inverse = { type: "replace", nodeId: command.nodeId, node: clone(previous) };
|
||||
} else if (command.type === "updateStyles") {
|
||||
const previous = findBlock(root, command.nodeId);
|
||||
if (!previous)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }],
|
||||
};
|
||||
root = updateNode(root, command.nodeId, (node) => ({
|
||||
...node,
|
||||
styles: { ...node.styles, ...command.patch },
|
||||
}));
|
||||
affected = [command.nodeId];
|
||||
inverse = { type: "replace", nodeId: command.nodeId, node: clone(previous) };
|
||||
} else {
|
||||
const previous = findBlock(root, command.nodeId);
|
||||
if (!previous)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "command.missing", message: "Block does not exist.", severity: "error" }],
|
||||
};
|
||||
root = updateNode(root, command.nodeId, () => clone(command.node));
|
||||
affected = [command.node.id];
|
||||
inverse = { type: "replace", nodeId: command.node.id, node: clone(previous) };
|
||||
}
|
||||
const next = { ...document, root };
|
||||
const valid = validateDocument(next, kind, registry);
|
||||
if (!valid.ok) return valid;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
document: next,
|
||||
inverse,
|
||||
affectedIds: affected,
|
||||
selection: { primary: affected[0] ?? null, ordered: affected },
|
||||
diagnostics: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateNodes(nodes: readonly BlockNode[], id: () => string): BlockNode[] {
|
||||
const copy = (node: BlockNode): BlockNode => ({
|
||||
...clone(node),
|
||||
id: id(),
|
||||
slots: Object.fromEntries(
|
||||
Object.entries(node.slots).map(([name, children]) => [name, children.map(copy)]),
|
||||
),
|
||||
});
|
||||
return nodes.map(copy);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { BlockRegistry, ReadonlyBlockRegistry } from "./registry.ts";
|
||||
import type { DocumentKindDefinition } from "./kinds.ts";
|
||||
import {
|
||||
DEFAULT_LIMITS,
|
||||
type BlockDocument,
|
||||
type BlockNode,
|
||||
type Diagnostic,
|
||||
type Result,
|
||||
} from "./types.ts";
|
||||
|
||||
const byteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
|
||||
|
||||
export function walkBlocks(
|
||||
root: BlockNode,
|
||||
visitor: (node: BlockNode, depth: number) => void,
|
||||
): void {
|
||||
const visit = (node: BlockNode, depth: number) => {
|
||||
visitor(node, depth);
|
||||
for (const slot of Object.keys(node.slots).sort())
|
||||
for (const child of node.slots[slot] ?? []) visit(child, depth + 1);
|
||||
};
|
||||
visit(root, 0);
|
||||
}
|
||||
|
||||
export function findBlock(root: BlockNode, id: string): BlockNode | undefined {
|
||||
let found: BlockNode | undefined;
|
||||
walkBlocks(root, (node) => {
|
||||
if (node.id === id) found = node;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
export function validateDocument(
|
||||
document: BlockDocument,
|
||||
kind: DocumentKindDefinition,
|
||||
registry: ReadonlyBlockRegistry,
|
||||
): Result<BlockDocument> {
|
||||
const errors: Diagnostic[] = [];
|
||||
const limits = kind.limits ?? DEFAULT_LIMITS;
|
||||
let encoded = "";
|
||||
try {
|
||||
encoded = JSON.stringify(document);
|
||||
} catch {
|
||||
errors.push({
|
||||
code: "document.json",
|
||||
message: "Document must be JSON-serializable.",
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
if (byteLength(encoded) > limits.maxBytes)
|
||||
errors.push({
|
||||
code: "document.size",
|
||||
message: `Document exceeds ${limits.maxBytes} bytes.`,
|
||||
severity: "error",
|
||||
});
|
||||
if (document.kind !== kind.kind)
|
||||
errors.push({
|
||||
code: "document.kind",
|
||||
message: `Expected document kind ${kind.kind}.`,
|
||||
severity: "error",
|
||||
});
|
||||
if (!kind.rootTypes.includes(document.root.type))
|
||||
errors.push({
|
||||
code: "document.root",
|
||||
message: `Block ${document.root.type} cannot be the root.`,
|
||||
severity: "error",
|
||||
nodeId: document.root.id,
|
||||
});
|
||||
const meta = kind.metadata.parse(document.meta, "meta");
|
||||
if (!meta.ok) errors.push(...meta.errors);
|
||||
const ids = new Set<string>();
|
||||
let count = 0;
|
||||
walkBlocks(document.root, (node, depth) => {
|
||||
count++;
|
||||
if (!node.id || ids.has(node.id))
|
||||
errors.push({
|
||||
code: "document.id",
|
||||
message: `Duplicate or empty block id ${node.id}.`,
|
||||
severity: "error",
|
||||
nodeId: node.id,
|
||||
});
|
||||
ids.add(node.id);
|
||||
if (depth > limits.maxDepth)
|
||||
errors.push({
|
||||
code: "document.depth",
|
||||
message: `Document exceeds depth ${limits.maxDepth}.`,
|
||||
severity: "error",
|
||||
nodeId: node.id,
|
||||
});
|
||||
const definition = registry.get(node.type);
|
||||
if (!definition) {
|
||||
errors.push({
|
||||
code: "document.unknown-block",
|
||||
message: `Unknown block type ${node.type}.`,
|
||||
severity: "error",
|
||||
nodeId: node.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.version !== definition.manifest.version)
|
||||
errors.push({
|
||||
code: "document.block-version",
|
||||
message: `${node.type} requires migration to version ${definition.manifest.version}.`,
|
||||
severity: "error",
|
||||
nodeId: node.id,
|
||||
});
|
||||
const props = definition.manifest.props.parse(node.props, `blocks.${node.id}.props`);
|
||||
if (!props.ok) errors.push(...props.errors.map((error) => ({ ...error, nodeId: node.id })));
|
||||
const styles = definition.manifest.styles.parse(node.styles, `blocks.${node.id}.styles`);
|
||||
if (!styles.ok) errors.push(...styles.errors.map((error) => ({ ...error, nodeId: node.id })));
|
||||
for (const slotName of Object.keys(node.slots))
|
||||
if (!(slotName in definition.manifest.slots))
|
||||
errors.push({
|
||||
code: "document.slot",
|
||||
message: `Unknown slot ${slotName} on ${node.type}.`,
|
||||
severity: "error",
|
||||
nodeId: node.id,
|
||||
});
|
||||
for (const [slotName, slot] of Object.entries(definition.manifest.slots)) {
|
||||
const children = node.slots[slotName] ?? [];
|
||||
const maximum = Math.min(
|
||||
slot.maxChildren ?? limits.maxChildrenPerSlot,
|
||||
limits.maxChildrenPerSlot,
|
||||
);
|
||||
if (children.length < (slot.minChildren ?? 0) || children.length > maximum)
|
||||
errors.push({
|
||||
code: "document.cardinality",
|
||||
message: `Slot ${slotName} has invalid child count.`,
|
||||
severity: "error",
|
||||
nodeId: node.id,
|
||||
});
|
||||
for (const child of children) {
|
||||
const childDefinition = registry.get(child.type);
|
||||
const acceptedType = !slot.accepts?.types || slot.accepts.types.includes(child.type);
|
||||
const acceptedCapabilities =
|
||||
!slot.accepts?.capabilities ||
|
||||
slot.accepts.capabilities.every((capability) =>
|
||||
childDefinition?.manifest.capabilities?.includes(capability),
|
||||
);
|
||||
if (!acceptedType || !acceptedCapabilities)
|
||||
errors.push({
|
||||
code: "document.slot-accept",
|
||||
message: `${child.type} is not accepted by ${node.type}.${slotName}.`,
|
||||
severity: "error",
|
||||
nodeId: child.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
errors.push(...(definition.validate?.(node) ?? []));
|
||||
});
|
||||
if (count > limits.maxNodes)
|
||||
errors.push({
|
||||
code: "document.nodes",
|
||||
message: `Document exceeds ${limits.maxNodes} nodes.`,
|
||||
severity: "error",
|
||||
});
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: document };
|
||||
}
|
||||
|
||||
export function registryForKind(kind: DocumentKindDefinition): BlockRegistry {
|
||||
const registry = createRegistry(kind.blocks);
|
||||
return registry;
|
||||
}
|
||||
|
||||
import { createRegistry } from "./registry.ts";
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./schema.ts";
|
||||
export * from "./registry.ts";
|
||||
export * from "./kinds.ts";
|
||||
export * from "./document.ts";
|
||||
export * from "./commands.ts";
|
||||
export * from "./clipboard.ts";
|
||||
export * from "./render.ts";
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { DocumentLimits, JsonObject } from "./types.ts";
|
||||
import type { ValueSchema } from "./schema.ts";
|
||||
import type { BlockDefinition } from "./registry.ts";
|
||||
|
||||
export interface DocumentKindDefinition {
|
||||
kind: string;
|
||||
version: number;
|
||||
metadata: ValueSchema<JsonObject>;
|
||||
rootTypes: string[];
|
||||
blocks: readonly BlockDefinition[];
|
||||
variables: Record<string, "string" | "number" | "date" | "boolean">;
|
||||
breakpoints: Record<string, number>;
|
||||
capabilities: string[];
|
||||
targets: string[];
|
||||
limits: DocumentLimits;
|
||||
urlSchemes: string[];
|
||||
}
|
||||
|
||||
export const defineDocumentKind = <T extends DocumentKindDefinition>(definition: T): T =>
|
||||
definition;
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { BlockNode, Diagnostic, JsonObject, Result } from "./types.ts";
|
||||
import type { ValueSchema } from "./schema.ts";
|
||||
|
||||
export interface LocalizedLabel {
|
||||
key: string;
|
||||
fallback: string;
|
||||
}
|
||||
export type BlockCapability = string;
|
||||
|
||||
export interface SlotDefinition {
|
||||
label?: LocalizedLabel;
|
||||
accepts?: { types?: string[]; capabilities?: string[] };
|
||||
minChildren?: number;
|
||||
maxChildren?: number;
|
||||
orientation?: "vertical" | "horizontal" | "grid";
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface InspectorControl {
|
||||
kind: string;
|
||||
path: `props.${string}` | `styles.${string}`;
|
||||
label?: LocalizedLabel;
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface BlockManifest {
|
||||
type: string;
|
||||
version: number;
|
||||
name: LocalizedLabel;
|
||||
description?: LocalizedLabel;
|
||||
icon: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
capabilities?: BlockCapability[];
|
||||
props: ValueSchema<JsonObject>;
|
||||
styles: ValueSchema<Record<string, unknown>>;
|
||||
slots: Record<string, SlotDefinition>;
|
||||
}
|
||||
|
||||
export interface BlockRenderContext {
|
||||
node: BlockNode;
|
||||
slots: Readonly<Record<string, string>>;
|
||||
context: unknown;
|
||||
}
|
||||
export type BlockRenderer = (input: BlockRenderContext) => string | Promise<string>;
|
||||
|
||||
export interface BlockDefinition {
|
||||
manifest: BlockManifest;
|
||||
create(context: { id(): string }): BlockNode;
|
||||
normalize?(node: BlockNode): BlockNode;
|
||||
validate?(node: BlockNode): Diagnostic[];
|
||||
resolveStyles?(
|
||||
styles: Record<string, unknown>,
|
||||
context: unknown,
|
||||
): Record<string, string | number>;
|
||||
renderers?: Record<string, BlockRenderer>;
|
||||
textRenderer?: BlockRenderer;
|
||||
editor?: { controls: InspectorControl[]; outlineLabel?(node: BlockNode): string };
|
||||
migrations?: Array<{ from: number; to: number; up(node: BlockNode): BlockNode }>;
|
||||
}
|
||||
|
||||
export const defineBlock = <T extends BlockDefinition>(definition: T): T => definition;
|
||||
|
||||
export interface BlockRegistry {
|
||||
get(type: string): BlockDefinition | undefined;
|
||||
has(type: string): boolean;
|
||||
list(): readonly BlockDefinition[];
|
||||
register(definition: BlockDefinition): () => void;
|
||||
freeze(): ReadonlyBlockRegistry;
|
||||
}
|
||||
|
||||
export type ReadonlyBlockRegistry = Pick<BlockRegistry, "get" | "has" | "list">;
|
||||
|
||||
const TYPE = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$/;
|
||||
|
||||
function validateDefinition(definition: BlockDefinition): Result<BlockDefinition> {
|
||||
const errors: Diagnostic[] = [];
|
||||
const { manifest } = definition;
|
||||
if (!TYPE.test(manifest.type))
|
||||
errors.push({
|
||||
code: "registry.type",
|
||||
message: `Invalid block type ${manifest.type}.`,
|
||||
severity: "error",
|
||||
});
|
||||
if (!Number.isInteger(manifest.version) || manifest.version < 1)
|
||||
errors.push({
|
||||
code: "registry.version",
|
||||
message: "Block version must be a positive integer.",
|
||||
severity: "error",
|
||||
});
|
||||
if (!manifest.name?.key || !manifest.name.fallback)
|
||||
errors.push({
|
||||
code: "registry.name",
|
||||
message: "Block name key and fallback are required.",
|
||||
severity: "error",
|
||||
});
|
||||
for (const control of definition.editor?.controls ?? []) {
|
||||
if (!/^(props|styles)\.[A-Za-z0-9_.-]+$/.test(control.path))
|
||||
errors.push({
|
||||
code: "registry.control",
|
||||
message: `Invalid inspector path ${control.path}.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
const migrations = definition.migrations ?? [];
|
||||
for (let version = 1; version < manifest.version; version++) {
|
||||
if (!migrations.some((migration) => migration.from === version && migration.to === version + 1))
|
||||
errors.push({
|
||||
code: "registry.migration",
|
||||
message: `Missing ${manifest.type} migration ${version} -> ${version + 1}.`,
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: definition };
|
||||
}
|
||||
|
||||
export function createRegistry(initial: readonly BlockDefinition[] = []): BlockRegistry {
|
||||
const entries = new Map<string, BlockDefinition>();
|
||||
let frozen = false;
|
||||
const registry: BlockRegistry = {
|
||||
get: (type) => entries.get(type),
|
||||
has: (type) => entries.has(type),
|
||||
list: () => [...entries.values()],
|
||||
register(definition) {
|
||||
if (frozen) throw new Error("Block registry is frozen.");
|
||||
const valid = validateDefinition(definition);
|
||||
if (!valid.ok)
|
||||
throw new Error(valid.errors.map((error) => `${error.code}: ${error.message}`).join("\n"));
|
||||
if (entries.has(definition.manifest.type))
|
||||
throw new Error(`Duplicate block type ${definition.manifest.type}.`);
|
||||
entries.set(definition.manifest.type, definition);
|
||||
return () => {
|
||||
if (!frozen) entries.delete(definition.manifest.type);
|
||||
};
|
||||
},
|
||||
freeze() {
|
||||
frozen = true;
|
||||
return registry;
|
||||
},
|
||||
};
|
||||
for (const definition of initial) registry.register(definition);
|
||||
return registry;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { validateDocument } from "./document.ts";
|
||||
import type { DocumentKindDefinition } from "./kinds.ts";
|
||||
import type { ReadonlyBlockRegistry } from "./registry.ts";
|
||||
import type { BlockDocument, BlockNode, Diagnostic, Result } from "./types.ts";
|
||||
|
||||
export interface RenderContext {
|
||||
target: string;
|
||||
locale: string;
|
||||
timezone: string;
|
||||
direction: "ltr" | "rtl";
|
||||
data?: Record<string, unknown>;
|
||||
resolveAsset?: (reference: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export interface RenderOutput {
|
||||
content: string;
|
||||
warnings: Diagnostic[];
|
||||
}
|
||||
|
||||
export async function renderTree(
|
||||
document: BlockDocument,
|
||||
kind: DocumentKindDefinition,
|
||||
registry: ReadonlyBlockRegistry,
|
||||
context: RenderContext,
|
||||
): Promise<Result<RenderOutput>> {
|
||||
const valid = validateDocument(document, kind, registry);
|
||||
if (!valid.ok) return valid;
|
||||
const warnings: Diagnostic[] = [];
|
||||
const renderNode = async (node: BlockNode): Promise<string> => {
|
||||
const definition = registry.get(node.type)!;
|
||||
const renderer =
|
||||
context.target === "plainText"
|
||||
? definition.textRenderer
|
||||
: definition.renderers?.[context.target];
|
||||
if (!renderer) throw new Error(`Block ${node.type} does not support ${context.target}.`);
|
||||
const slots: Record<string, string> = {};
|
||||
for (const name of Object.keys(definition.manifest.slots))
|
||||
slots[name] = (node.slots[name] ?? []).length
|
||||
? (await Promise.all((node.slots[name] ?? []).map(renderNode))).join("")
|
||||
: "";
|
||||
return renderer({ node, slots, context });
|
||||
};
|
||||
try {
|
||||
const content = await renderNode(document.root);
|
||||
if (new TextEncoder().encode(content).byteLength > kind.limits.maxOutputBytes)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{ code: "render.size", message: "Rendered output is too large.", severity: "error" },
|
||||
],
|
||||
};
|
||||
return { ok: true, value: { content, warnings } };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "render.failed",
|
||||
message: error instanceof Error ? error.message : "Rendering failed.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const escapeHtml = (value: unknown): string =>
|
||||
String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { Diagnostic, JsonValue, Result } from "./types.ts";
|
||||
|
||||
export interface ValueSchema<T = unknown> {
|
||||
readonly kind: string;
|
||||
parse(value: unknown, path?: string): Result<T>;
|
||||
readonly defaultValue?: T;
|
||||
}
|
||||
|
||||
const fail = (code: string, message: string, path: string): Result<never> => ({
|
||||
ok: false,
|
||||
errors: [{ code, message, severity: "error", path }],
|
||||
});
|
||||
|
||||
export function string(
|
||||
options: { min?: number; max?: number; default?: string } = {},
|
||||
): ValueSchema<string> {
|
||||
return {
|
||||
kind: "string",
|
||||
defaultValue: options.default,
|
||||
parse(value, path = "value") {
|
||||
if (value === undefined && options.default !== undefined)
|
||||
return { ok: true, value: options.default };
|
||||
if (typeof value !== "string") return fail("schema.string", "Expected a string.", path);
|
||||
if (options.min !== undefined && value.length < options.min)
|
||||
return fail("schema.string.min", `Must contain at least ${options.min} characters.`, path);
|
||||
if (options.max !== undefined && value.length > options.max)
|
||||
return fail("schema.string.max", `Must contain at most ${options.max} characters.`, path);
|
||||
return { ok: true, value };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function number(
|
||||
options: { min?: number; max?: number; default?: number } = {},
|
||||
): ValueSchema<number> {
|
||||
return {
|
||||
kind: "number",
|
||||
defaultValue: options.default,
|
||||
parse(value, path = "value") {
|
||||
if (value === undefined && options.default !== undefined)
|
||||
return { ok: true, value: options.default };
|
||||
if (typeof value !== "number" || !Number.isFinite(value))
|
||||
return fail("schema.number", "Expected a finite number.", path);
|
||||
if (options.min !== undefined && value < options.min)
|
||||
return fail("schema.number.min", `Must be at least ${options.min}.`, path);
|
||||
if (options.max !== undefined && value > options.max)
|
||||
return fail("schema.number.max", `Must be at most ${options.max}.`, path);
|
||||
return { ok: true, value };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function boolean(defaultValue?: boolean): ValueSchema<boolean> {
|
||||
return {
|
||||
kind: "boolean",
|
||||
defaultValue,
|
||||
parse(value, path = "value") {
|
||||
if (value === undefined && defaultValue !== undefined)
|
||||
return { ok: true, value: defaultValue };
|
||||
return typeof value === "boolean"
|
||||
? { ok: true, value }
|
||||
: fail("schema.boolean", "Expected a boolean.", path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function choice<const T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { default?: T[number] } = {},
|
||||
): ValueSchema<T[number]> {
|
||||
const allowed = new Set<string>(values);
|
||||
return {
|
||||
kind: "choice",
|
||||
defaultValue: options.default,
|
||||
parse(value, path = "value") {
|
||||
if (value === undefined && options.default !== undefined)
|
||||
return { ok: true, value: options.default };
|
||||
return typeof value === "string" && allowed.has(value)
|
||||
? { ok: true, value: value as T[number] }
|
||||
: fail("schema.choice", `Expected one of: ${values.join(", ")}.`, path);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function safeUrl(
|
||||
options: { schemes?: string[]; optional?: boolean } = {},
|
||||
): ValueSchema<string> {
|
||||
const schemes = new Set((options.schemes ?? ["https"]).map((value) => value.replace(/:$/, "")));
|
||||
return {
|
||||
kind: "url",
|
||||
parse(value, path = "value") {
|
||||
if ((value === undefined || value === "") && options.optional) return { ok: true, value: "" };
|
||||
if (typeof value !== "string" || value.length > 4_096)
|
||||
return fail("schema.url", "Expected a bounded URL.", path);
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return schemes.has(parsed.protocol.replace(/:$/, ""))
|
||||
? { ok: true, value: parsed.href }
|
||||
: fail("schema.url.scheme", `URL scheme ${parsed.protocol} is not allowed.`, path);
|
||||
} catch {
|
||||
return fail("schema.url", "Expected an absolute URL.", path);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function object<T extends Record<string, ValueSchema>>(
|
||||
shape: T,
|
||||
): ValueSchema<{ [K in keyof T]: ReturnType<T[K]["parse"]> extends Result<infer V> ? V : never }> {
|
||||
return {
|
||||
kind: "object",
|
||||
parse(value, path = "value") {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
return fail("schema.object", "Expected an object.", path);
|
||||
const source = value as Record<string, unknown>;
|
||||
const output: Record<string, unknown> = {};
|
||||
const errors: Diagnostic[] = [];
|
||||
for (const key of Object.keys(source)) {
|
||||
if (!(key in shape))
|
||||
errors.push({
|
||||
code: "schema.unknown",
|
||||
message: `Unknown field ${key}.`,
|
||||
severity: "error",
|
||||
path: `${path}.${key}`,
|
||||
});
|
||||
}
|
||||
for (const [key, schema] of Object.entries(shape)) {
|
||||
const result = schema.parse(source[key], `${path}.${key}`);
|
||||
if (result.ok) output[key] = result.value;
|
||||
else errors.push(...result.errors);
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: output as never };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const json: ValueSchema<JsonValue> = {
|
||||
kind: "json",
|
||||
parse(value, path = "value") {
|
||||
try {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (encoded === undefined)
|
||||
return fail("schema.json", "Expected JSON-serializable data.", path);
|
||||
return { ok: true, value: JSON.parse(encoded) as JsonValue };
|
||||
} catch {
|
||||
return fail("schema.json", "Expected JSON-serializable data.", path);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export function record<T>(
|
||||
valueSchema: ValueSchema<T>,
|
||||
options: { maxKeys?: number } = {},
|
||||
): ValueSchema<Record<string, T>> {
|
||||
return {
|
||||
kind: "record",
|
||||
parse(value, path = "value") {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value))
|
||||
return fail("schema.record", "Expected an object.", path);
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length > (options.maxKeys ?? 100))
|
||||
return fail("schema.record.size", "Object contains too many fields.", path);
|
||||
const output: Record<string, T> = {};
|
||||
const errors: Diagnostic[] = [];
|
||||
for (const [key, raw] of entries) {
|
||||
const result = valueSchema.parse(raw, `${path}.${key}`);
|
||||
if (result.ok) output[key] = result.value;
|
||||
else errors.push(...result.errors);
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: output };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
|
||||
export interface ResponsiveValue<T extends JsonValue = JsonValue> {
|
||||
base: T;
|
||||
overrides?: Record<string, T>;
|
||||
states?: Partial<Record<"hover" | "focus" | "active" | "dark", T>>;
|
||||
}
|
||||
|
||||
export type BlockStyles = Record<string, ResponsiveValue>;
|
||||
|
||||
export interface BlockNode<P extends JsonObject = JsonObject, S extends BlockStyles = BlockStyles> {
|
||||
id: string;
|
||||
type: string;
|
||||
version: number;
|
||||
props: P;
|
||||
styles: S;
|
||||
slots: Record<string, BlockNode[]>;
|
||||
}
|
||||
|
||||
export interface DocumentTheme {
|
||||
tokens: Record<string, string | number>;
|
||||
darkTokens?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface BlockDocument {
|
||||
schemaVersion: number;
|
||||
kind: string;
|
||||
meta: JsonObject;
|
||||
theme: DocumentTheme;
|
||||
root: BlockNode;
|
||||
}
|
||||
|
||||
export type Severity = "error" | "warning" | "info";
|
||||
|
||||
export interface Diagnostic {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: Severity;
|
||||
nodeId?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export type Result<T, E = Diagnostic[]> = { ok: true; value: T } | { ok: false; errors: E };
|
||||
|
||||
export interface SelectionState {
|
||||
primary: string | null;
|
||||
ordered: string[];
|
||||
}
|
||||
|
||||
export interface BlockLocation {
|
||||
parentId: string;
|
||||
slot: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface MoveIntent {
|
||||
nodeIds: string[];
|
||||
from: BlockLocation[];
|
||||
to: BlockLocation;
|
||||
}
|
||||
|
||||
export interface DocumentLimits {
|
||||
maxBytes: number;
|
||||
maxNodes: number;
|
||||
maxDepth: number;
|
||||
maxChildrenPerSlot: number;
|
||||
maxStringLength: number;
|
||||
maxOutputBytes: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LIMITS: DocumentLimits = {
|
||||
maxBytes: 1_000_000,
|
||||
maxNodes: 2_000,
|
||||
maxDepth: 32,
|
||||
maxChildrenPerSlot: 500,
|
||||
maxStringLength: 200_000,
|
||||
maxOutputBytes: 2_000_000,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
applyCommand,
|
||||
createRegistry,
|
||||
decodeClipboard,
|
||||
defineBlock,
|
||||
defineDocumentKind,
|
||||
duplicateNodes,
|
||||
encodeClipboard,
|
||||
json,
|
||||
object,
|
||||
record,
|
||||
string,
|
||||
validateDocument,
|
||||
type BlockDocument,
|
||||
} from "../src/index.ts";
|
||||
|
||||
let serial = 0;
|
||||
const id = () => `id-${++serial}`;
|
||||
const styles = record(json) as never;
|
||||
const text = defineBlock({
|
||||
manifest: {
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
name: { key: "text", fallback: "Text" },
|
||||
icon: "text",
|
||||
category: "content",
|
||||
capabilities: ["content"],
|
||||
props: object({ text: string({ max: 20 }) }) as never,
|
||||
styles,
|
||||
slots: {},
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
props: { text: "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
}),
|
||||
renderers: { html: ({ node }) => String(node.props.text) },
|
||||
textRenderer: ({ node }) => String(node.props.text),
|
||||
});
|
||||
const root = defineBlock({
|
||||
manifest: {
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
name: { key: "root", fallback: "Root" },
|
||||
icon: "root",
|
||||
category: "layout",
|
||||
props: object({}) as never,
|
||||
styles,
|
||||
slots: { content: { accepts: { capabilities: ["content"] }, maxChildren: 5 } },
|
||||
},
|
||||
create: ({ id }) => ({
|
||||
id: id(),
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
props: {},
|
||||
styles: {},
|
||||
slots: { content: [] },
|
||||
}),
|
||||
renderers: { html: ({ slots }) => slots.content },
|
||||
textRenderer: ({ slots }) => slots.content,
|
||||
});
|
||||
const kind = defineDocumentKind({
|
||||
kind: "test",
|
||||
version: 1,
|
||||
metadata: object({ title: string() }) as never,
|
||||
rootTypes: ["layout.root"],
|
||||
blocks: [root, text],
|
||||
variables: {},
|
||||
breakpoints: {},
|
||||
capabilities: [],
|
||||
targets: ["html"],
|
||||
limits: {
|
||||
maxBytes: 10_000,
|
||||
maxNodes: 20,
|
||||
maxDepth: 5,
|
||||
maxChildrenPerSlot: 5,
|
||||
maxStringLength: 100,
|
||||
maxOutputBytes: 1_000,
|
||||
},
|
||||
urlSchemes: ["https"],
|
||||
});
|
||||
const registry = createRegistry(kind.blocks).freeze();
|
||||
const doc = (): BlockDocument => ({
|
||||
schemaVersion: 1,
|
||||
kind: "test",
|
||||
meta: { title: "x" },
|
||||
theme: { tokens: {} },
|
||||
root: {
|
||||
id: "root",
|
||||
type: "layout.root",
|
||||
version: 1,
|
||||
props: {},
|
||||
styles: {},
|
||||
slots: { content: [] },
|
||||
},
|
||||
});
|
||||
|
||||
describe("dynamic blocks engine", () => {
|
||||
test("rejects duplicate registrations and freezes registries", () => {
|
||||
expect(() => createRegistry([text, text])).toThrow(/Duplicate block/);
|
||||
const frozen = createRegistry([text]).freeze();
|
||||
expect(() => (frozen as never as { register(value: unknown): void }).register(text)).toThrow();
|
||||
});
|
||||
|
||||
test("validates named slots and applies invertible commands", () => {
|
||||
const node = text.create({ id });
|
||||
node.props.text = "Hello";
|
||||
const inserted = applyCommand(
|
||||
doc(),
|
||||
{ type: "insert", location: { parentId: "root", slot: "content", index: 0 }, nodes: [node] },
|
||||
kind,
|
||||
registry,
|
||||
);
|
||||
expect(inserted.ok).toBe(true);
|
||||
if (!inserted.ok) return;
|
||||
expect(inserted.value.document.root.slots.content[0]?.props.text).toBe("Hello");
|
||||
const undone = applyCommand(inserted.value.document, inserted.value.inverse, kind, registry);
|
||||
expect(undone.ok && undone.value.document.root.slots.content).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects cycles and invalid slots", () => {
|
||||
const bad = doc();
|
||||
bad.root.slots.missing = [];
|
||||
expect(validateDocument(bad, kind, registry).ok).toBe(false);
|
||||
});
|
||||
|
||||
test("clipboard is deterministic and remaps ids", () => {
|
||||
const node = text.create({ id });
|
||||
node.props.text = "Hello";
|
||||
const encoded = encodeClipboard("test", [node]);
|
||||
expect(encoded).toBe(encodeClipboard("test", [node]));
|
||||
const decoded = decodeClipboard(encoded, id);
|
||||
expect(decoded.ok && decoded.value.roots[0]?.id).not.toBe(node.id);
|
||||
expect(duplicateNodes([node], id)[0]?.props).toEqual(node.props);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# @wrnexus/dnd
|
||||
|
||||
Stable WRNexus drag-and-drop API backed by `@dnd-kit/dom`. Applications consume
|
||||
move intents and never import dnd-kit directly.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@wrnexus/dnd",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Stable accessible drag-and-drop primitives for WRNexus.",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/dom": "0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"happy-dom": "^20.11.2",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./reorder.ts";
|
||||
export * from "./manager.ts";
|
||||
@@ -0,0 +1,108 @@
|
||||
import { DragDropManager } from "@dnd-kit/dom";
|
||||
import { Sortable } from "@dnd-kit/dom/sortable";
|
||||
import type {
|
||||
DndManager,
|
||||
DndManagerOptions,
|
||||
DndMoveIntent,
|
||||
SortableRegistration,
|
||||
} from "./types.ts";
|
||||
|
||||
export function createDndManager(options: DndManagerOptions): DndManager {
|
||||
const direction = options.direction ?? "ltr";
|
||||
const reducedMotion = options.reducedMotion ?? false;
|
||||
const entries = new Map<
|
||||
string | number,
|
||||
{
|
||||
sortable: Sortable;
|
||||
initial: { group: string | number; index: number };
|
||||
current: SortableRegistration;
|
||||
}
|
||||
>();
|
||||
const manager = new DragDropManager();
|
||||
let activeIds: Array<string | number> = [];
|
||||
|
||||
manager.monitor.addEventListener("dragstart", (event) => {
|
||||
const source = event.operation.source;
|
||||
if (!source) return;
|
||||
activeIds = [source.id];
|
||||
options.announce?.({ type: "pickedUp", ids: activeIds });
|
||||
});
|
||||
manager.monitor.addEventListener("dragover", (event) => {
|
||||
const source = event.operation.source as
|
||||
(typeof event.operation.source & { group?: string | number; index?: number }) | null;
|
||||
if (!source) return;
|
||||
const destination = { group: source.group ?? "default", index: source.index ?? 0 };
|
||||
if (options.accept && !options.accept(activeIds, destination)) {
|
||||
event.preventDefault();
|
||||
options.announce?.({ type: "invalid", ids: activeIds, location: destination });
|
||||
} else options.announce?.({ type: "movedOver", ids: activeIds, location: destination });
|
||||
});
|
||||
manager.monitor.addEventListener("dragend", async (event) => {
|
||||
const source = event.operation.source as
|
||||
| (typeof event.operation.source & {
|
||||
group?: string | number;
|
||||
index?: number;
|
||||
initialGroup?: string | number;
|
||||
initialIndex?: number;
|
||||
})
|
||||
| null;
|
||||
if (!source || event.operation.canceled) {
|
||||
options.announce?.({ type: "cancelled", ids: activeIds });
|
||||
activeIds = [];
|
||||
return;
|
||||
}
|
||||
const intent: DndMoveIntent = {
|
||||
ids: [...activeIds],
|
||||
from: [
|
||||
{
|
||||
group: source.initialGroup ?? source.group ?? "default",
|
||||
index: source.initialIndex ?? source.index ?? 0,
|
||||
},
|
||||
],
|
||||
to: { group: source.group ?? "default", index: source.index ?? 0 },
|
||||
};
|
||||
const accepted = await options.onMoveIntent(intent);
|
||||
options.announce?.({
|
||||
type: accepted === false ? "cancelled" : "dropped",
|
||||
ids: activeIds,
|
||||
location: intent.to,
|
||||
});
|
||||
activeIds = [];
|
||||
});
|
||||
|
||||
return {
|
||||
registerSortable(registration) {
|
||||
if (entries.has(registration.id))
|
||||
throw new Error(`Duplicate sortable id ${String(registration.id)}.`);
|
||||
const sortable = new Sortable(
|
||||
{
|
||||
id: registration.id,
|
||||
group: registration.group,
|
||||
index: registration.index,
|
||||
element: registration.element,
|
||||
handle: registration.handle,
|
||||
type: registration.type,
|
||||
accept: registration.accepts,
|
||||
disabled: registration.disabled,
|
||||
transition: reducedMotion ? null : undefined,
|
||||
data: { ...registration.data, direction },
|
||||
},
|
||||
manager,
|
||||
);
|
||||
entries.set(registration.id, {
|
||||
sortable,
|
||||
initial: { group: registration.group, index: registration.index },
|
||||
current: registration,
|
||||
});
|
||||
return () => {
|
||||
sortable.destroy();
|
||||
entries.delete(registration.id);
|
||||
};
|
||||
},
|
||||
destroy() {
|
||||
for (const entry of entries.values()) entry.sortable.destroy();
|
||||
entries.clear();
|
||||
manager.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Direction, DndMoveIntent, SortableLocation } from "./types.ts";
|
||||
|
||||
export function visualIndex(
|
||||
index: number,
|
||||
length: number,
|
||||
orientation: "vertical" | "horizontal",
|
||||
direction: Direction,
|
||||
): number {
|
||||
return orientation === "horizontal" && direction === "rtl"
|
||||
? Math.max(0, length - 1 - index)
|
||||
: index;
|
||||
}
|
||||
|
||||
export function reorderGroups<T extends string | number>(
|
||||
groups: ReadonlyMap<T, readonly T[]>,
|
||||
intent: DndMoveIntent,
|
||||
): Map<T, T[]> {
|
||||
const output = new Map<T, T[]>();
|
||||
for (const [group, ids] of groups) output.set(group, [...ids]);
|
||||
const moving = new Set(intent.ids as T[]);
|
||||
for (const [group, ids] of output)
|
||||
output.set(
|
||||
group,
|
||||
ids.filter((id) => !moving.has(id)),
|
||||
);
|
||||
const destination = output.get(intent.to.group as T);
|
||||
if (!destination) throw new Error(`Unknown destination group ${String(intent.to.group)}.`);
|
||||
destination.splice(
|
||||
Math.max(0, Math.min(intent.to.index, destination.length)),
|
||||
0,
|
||||
...(intent.ids as T[]),
|
||||
);
|
||||
return output;
|
||||
}
|
||||
|
||||
export function deepestValidTarget<
|
||||
T extends { depth: number; location: SortableLocation; disabled?: boolean },
|
||||
>(targets: readonly T[], accept: (location: SortableLocation) => boolean): T | undefined {
|
||||
return [...targets]
|
||||
.filter((target) => !target.disabled && accept(target.location))
|
||||
.sort((left, right) => right.depth - left.depth)[0];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type DndId = string | number;
|
||||
export type Direction = "ltr" | "rtl";
|
||||
export type Orientation = "vertical" | "horizontal" | "grid";
|
||||
|
||||
export interface SortableLocation {
|
||||
group: DndId;
|
||||
index: number;
|
||||
}
|
||||
export interface DndMoveIntent {
|
||||
ids: DndId[];
|
||||
from: SortableLocation[];
|
||||
to: SortableLocation;
|
||||
}
|
||||
export interface DndAnnouncement {
|
||||
type: "pickedUp" | "movedOver" | "invalid" | "dropped" | "cancelled";
|
||||
ids: DndId[];
|
||||
location?: SortableLocation;
|
||||
}
|
||||
|
||||
export interface SortableRegistration {
|
||||
id: DndId;
|
||||
group: DndId;
|
||||
index: number;
|
||||
element: HTMLElement;
|
||||
handle?: HTMLElement;
|
||||
type?: DndId;
|
||||
accepts?: DndId[];
|
||||
disabled?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DndManagerOptions {
|
||||
direction?: Direction;
|
||||
reducedMotion?: boolean;
|
||||
announce?: (announcement: DndAnnouncement) => void;
|
||||
accept?: (ids: DndId[], destination: SortableLocation) => boolean;
|
||||
onMoveIntent(intent: DndMoveIntent): boolean | void | Promise<boolean | void>;
|
||||
}
|
||||
|
||||
export interface DndManager {
|
||||
registerSortable(registration: SortableRegistration): () => void;
|
||||
destroy(): void;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { deepestValidTarget, reorderGroups, visualIndex } from "../src/index.ts";
|
||||
|
||||
describe("dnd pure behavior", () => {
|
||||
test("moves across groups without mutation", () => {
|
||||
const input = new Map([
|
||||
["a", ["1", "2"]],
|
||||
["b", ["3"]],
|
||||
]);
|
||||
const result = reorderGroups(input, {
|
||||
ids: ["2"],
|
||||
from: [{ group: "a", index: 1 }],
|
||||
to: { group: "b", index: 0 },
|
||||
});
|
||||
expect(result.get("a")).toEqual(["1"]);
|
||||
expect(result.get("b")).toEqual(["2", "3"]);
|
||||
expect(input.get("a")).toEqual(["1", "2"]);
|
||||
});
|
||||
test("mirrors horizontal RTL index", () =>
|
||||
expect(visualIndex(0, 4, "horizontal", "rtl")).toBe(3));
|
||||
test("selects deepest valid target", () =>
|
||||
expect(
|
||||
deepestValidTarget(
|
||||
[
|
||||
{ depth: 1, location: { group: "a", index: 0 } },
|
||||
{ depth: 3, location: { group: "b", index: 0 } },
|
||||
],
|
||||
(location) => location.group === "b",
|
||||
)?.depth,
|
||||
).toBe(3));
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# @wrnexus/email-renderer
|
||||
|
||||
Email target pack for `@wrnexus/blocks`. Standard HTML and plain text are
|
||||
required outputs; valid AMP Email is an optional MIME alternative. MJML is a
|
||||
strict import/export interoperability format, never the canonical document.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@wrnexus/email-renderer",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wrnexus/blocks": "workspace:*",
|
||||
"amphtml-validator": "1.0.38",
|
||||
"mjml": "5.4.0",
|
||||
"mjml-parser-xml": "5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.4.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
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;"> </div>`,
|
||||
node,
|
||||
),
|
||||
ampEmail: ({ node }) => emailCell(`<div class="spacer"> </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,
|
||||
];
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./blocks.ts";
|
||||
export * from "./email.ts";
|
||||
export * from "./mjml.ts";
|
||||
@@ -0,0 +1,278 @@
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
DEFAULT_LIMITS,
|
||||
type BlockDocument,
|
||||
type BlockNode,
|
||||
type Diagnostic,
|
||||
type Result,
|
||||
} from "@wrnexus/blocks";
|
||||
|
||||
interface MjmlAst {
|
||||
tagName: string;
|
||||
attributes?: Record<string, string>;
|
||||
children?: MjmlAst[];
|
||||
content?: string;
|
||||
}
|
||||
interface MjmlCompileError {
|
||||
line?: number;
|
||||
message: string;
|
||||
tagName?: string;
|
||||
}
|
||||
const require = createRequire(import.meta.url);
|
||||
const parseMjml = require("mjml-parser-xml") as (
|
||||
value: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => MjmlAst;
|
||||
const compileMjml = require("mjml") as (
|
||||
value: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => Promise<{ html: string; errors: MjmlCompileError[] }>;
|
||||
|
||||
const supported = new Set([
|
||||
"mjml",
|
||||
"mj-head",
|
||||
"mj-title",
|
||||
"mj-preview",
|
||||
"mj-attributes",
|
||||
"mj-breakpoint",
|
||||
"mj-body",
|
||||
"mj-wrapper",
|
||||
"mj-section",
|
||||
"mj-group",
|
||||
"mj-column",
|
||||
"mj-text",
|
||||
"mj-image",
|
||||
"mj-button",
|
||||
"mj-divider",
|
||||
"mj-spacer",
|
||||
]);
|
||||
const containers = new Set([
|
||||
"mjml",
|
||||
"mj-body",
|
||||
"mj-wrapper",
|
||||
"mj-section",
|
||||
"mj-group",
|
||||
"mj-column",
|
||||
]);
|
||||
|
||||
export interface MjmlDiagnostic extends Diagnostic {
|
||||
line?: number;
|
||||
column?: number;
|
||||
element?: string;
|
||||
attribute?: string;
|
||||
}
|
||||
|
||||
export function importMjml(
|
||||
source: string,
|
||||
options: { id(): string; subject: string; preheader?: string },
|
||||
): Result<BlockDocument, MjmlDiagnostic[]> {
|
||||
if (new TextEncoder().encode(source).byteLength > DEFAULT_LIMITS.maxBytes)
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ code: "mjml.size", message: "MJML source is too large.", severity: "error" }],
|
||||
};
|
||||
if (/<mj-(?:raw|include)\b|<script\b|\{[%{[]/i.test(source))
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "mjml.unsafe",
|
||||
message: "MJML raw/include, scripts, and template syntax are not supported.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
let ast: MjmlAst;
|
||||
try {
|
||||
ast = parseMjml(source, { ignoreIncludes: true, validationLevel: "strict" });
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "mjml.parse",
|
||||
message: error instanceof Error ? error.message : "Invalid MJML.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
const errors: MjmlDiagnostic[] = [];
|
||||
const convert = (node: MjmlAst): BlockNode[] => {
|
||||
if (!supported.has(node.tagName)) {
|
||||
errors.push({
|
||||
code: "mjml.component",
|
||||
message: `Unsupported MJML component ${node.tagName}.`,
|
||||
severity: "error",
|
||||
element: node.tagName,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
const children = (node.children ?? []).flatMap(convert);
|
||||
if (node.tagName === "mjml" || node.tagName === "mj-head" || node.tagName === "mj-body")
|
||||
return children;
|
||||
if (
|
||||
node.tagName === "mj-title" ||
|
||||
node.tagName === "mj-preview" ||
|
||||
node.tagName === "mj-attributes" ||
|
||||
node.tagName === "mj-breakpoint"
|
||||
)
|
||||
return [];
|
||||
const id = options.id();
|
||||
const a = node.attributes ?? {};
|
||||
if (["mj-wrapper", "mj-section", "mj-group"].includes(node.tagName))
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "layout.section",
|
||||
version: 1,
|
||||
props: { background: a["background-color"] ?? "#ffffff", padding: a.padding ?? "24px" },
|
||||
styles: {},
|
||||
slots: { content: children },
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-column")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "layout.column",
|
||||
version: 1,
|
||||
props: { width: a.width ?? "100%" },
|
||||
styles: {},
|
||||
slots: { content: children },
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-text")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.text",
|
||||
version: 1,
|
||||
props: { text: node.content ?? "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-image")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.image",
|
||||
version: 1,
|
||||
props: {
|
||||
src: a.src ?? "",
|
||||
alt: a.alt ?? "",
|
||||
width: (a.width ?? "600").replace(/px$/, ""),
|
||||
height: (a.height ?? "300").replace(/px$/, ""),
|
||||
decorative: false,
|
||||
},
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-button")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.button",
|
||||
version: 1,
|
||||
props: { label: node.content ?? "Button", href: a.href ?? "" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
if (node.tagName === "mj-divider")
|
||||
return [{ id, type: "content.divider", version: 1, props: {}, styles: {}, slots: {} }];
|
||||
if (node.tagName === "mj-spacer")
|
||||
return [
|
||||
{
|
||||
id,
|
||||
type: "content.spacer",
|
||||
version: 1,
|
||||
props: { height: (a.height ?? "24").replace(/px$/, "") },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
];
|
||||
return containers.has(node.tagName) ? children : [];
|
||||
};
|
||||
const content = convert(ast);
|
||||
if (errors.length) return { ok: false, errors };
|
||||
const root: BlockNode = {
|
||||
id: options.id(),
|
||||
type: "layout.email-document",
|
||||
version: 1,
|
||||
props: { width: "600", background: "#f3f4f6" },
|
||||
styles: {},
|
||||
slots: {
|
||||
content: content.map((node) =>
|
||||
node.type === "layout.column"
|
||||
? {
|
||||
id: options.id(),
|
||||
type: "layout.columns",
|
||||
version: 1,
|
||||
props: {},
|
||||
styles: {},
|
||||
slots: { columns: [node] },
|
||||
}
|
||||
: node,
|
||||
),
|
||||
},
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
kind: "email",
|
||||
meta: { subject: options.subject, preheader: options.preheader ?? "", lang: "en" },
|
||||
theme: { tokens: {} },
|
||||
root,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateMjml(source: string): Promise<MjmlDiagnostic[]> {
|
||||
try {
|
||||
const result = await compileMjml(source, {
|
||||
validationLevel: "strict",
|
||||
ignoreIncludes: true,
|
||||
minify: false,
|
||||
});
|
||||
return (result.errors ?? []).map((error) => ({
|
||||
code: "mjml.validation",
|
||||
message: error.message,
|
||||
severity: "error",
|
||||
line: error.line,
|
||||
element: error.tagName,
|
||||
}));
|
||||
} catch (error) {
|
||||
const details =
|
||||
error && typeof error === "object" && "errors" in error
|
||||
? ((
|
||||
error as {
|
||||
errors?: Array<{
|
||||
line?: number;
|
||||
message?: string;
|
||||
formattedMessage?: string;
|
||||
tagName?: string;
|
||||
}>;
|
||||
}
|
||||
).errors ?? [])
|
||||
: [];
|
||||
if (details.length)
|
||||
return details.map((detail) => ({
|
||||
code: "mjml.validation",
|
||||
message: detail.message ?? detail.formattedMessage ?? "Invalid MJML.",
|
||||
severity: "error",
|
||||
line: detail.line,
|
||||
element: detail.tagName,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
code: "mjml.validation",
|
||||
message: error instanceof Error ? error.message : "Invalid MJML.",
|
||||
severity: "error",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
renderEmail,
|
||||
validateAmpEmail,
|
||||
validateMjml,
|
||||
importMjml,
|
||||
type EmailRenderBundle,
|
||||
} from "../src/index.ts";
|
||||
import type { BlockDocument } from "@wrnexus/blocks";
|
||||
|
||||
const document: BlockDocument = {
|
||||
schemaVersion: 1,
|
||||
kind: "email",
|
||||
meta: { subject: "Welcome", preheader: "Preview", lang: "en" },
|
||||
theme: { tokens: {} },
|
||||
root: {
|
||||
id: "root",
|
||||
type: "layout.email-document",
|
||||
version: 1,
|
||||
props: { width: "600", background: "#f3f4f6" },
|
||||
styles: {},
|
||||
slots: {
|
||||
content: [
|
||||
{
|
||||
id: "section",
|
||||
type: "layout.section",
|
||||
version: 1,
|
||||
props: { background: "#ffffff", padding: "24px" },
|
||||
styles: {},
|
||||
slots: {
|
||||
content: [
|
||||
{
|
||||
id: "heading",
|
||||
type: "content.heading",
|
||||
version: 1,
|
||||
props: { text: "Hello <world>", level: "1" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
{
|
||||
id: "button",
|
||||
type: "content.button",
|
||||
version: 1,
|
||||
props: { label: "Open", href: "https://example.com/" },
|
||||
styles: {},
|
||||
slots: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("email renderer", () => {
|
||||
test("renders deterministic HTML, text, AMP, and MJML", async () => {
|
||||
const first = await renderEmail(document, { amp: true, mjml: true });
|
||||
const second = await renderEmail(document, { amp: true, mjml: true });
|
||||
expect(first).toEqual(second);
|
||||
expect(first.ok).toBe(true);
|
||||
if (!first.ok) return;
|
||||
const value: EmailRenderBundle = first.value;
|
||||
expect(value.parts.html.content).toContain("Hello <world>");
|
||||
expect(value.parts.text.content).toContain("Hello <world>");
|
||||
expect(value.parts.amp?.validation.valid).toBe(true);
|
||||
expect(value.exports?.mjml).toContain("<mjml>");
|
||||
});
|
||||
|
||||
test("validates AMP output", async () => {
|
||||
expect((await validateAmpEmail("<html></html>")).valid).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects unsafe MJML and imports supported content", () => {
|
||||
expect(
|
||||
importMjml("<mjml><mj-body><mj-raw>x</mj-raw></mj-body></mjml>", {
|
||||
id: () => crypto.randomUUID(),
|
||||
subject: "x",
|
||||
}).ok,
|
||||
).toBe(false);
|
||||
let n = 0;
|
||||
const imported = importMjml(
|
||||
"<mjml><mj-body><mj-section><mj-column><mj-text>Hello</mj-text></mj-column></mj-section></mj-body></mjml>",
|
||||
{ id: () => `n${++n}`, subject: "Imported" },
|
||||
);
|
||||
expect(imported.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("strict MJML validation catches invalid nesting", async () => {
|
||||
expect(
|
||||
(await validateMjml("<mjml><mj-body><mj-text>x</mj-text></mj-body></mjml>")).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user