diff --git a/packages/block-editor/README.md b/packages/block-editor/README.md index 016ff865..d1d1553a 100644 --- a/packages/block-editor/README.md +++ b/packages/block-editor/README.md @@ -1,4 +1,42 @@ # @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. +Reusable editor state, DOM surfaces, inspector drafts, clipboard-download UI, +selection actions, and explicit block/preset composition. + +## Configure a builder + +```ts +import { createBuilderKit, createBlockEditorState, mountBlockCanvas } from "@wrnexus/block-editor"; +import { emailEditorBlocks } from "@wrnexus/email-renderer/create"; +import { emailPresets } from "@wrnexus/email-renderer/presets"; + +const builder = createBuilderKit({ + blocks: [...emailEditorBlocks, myTrustedBlockDefinition], + presets: [...emailPresets, myTrustedSectionPreset], +}); + +const palette = builder.blocks(); // manifests: name, icon, category, schemas +const presets = builder.presets(); +const node = builder.createBlock("custom.my-block", { id: () => crypto.randomUUID() }); +``` + +Definitions use `defineBlock` from `@wrnexus/blocks`: manifest, create, +renderers, textRenderer, inspector controls, and migrations. Presets have +`id`, localized `name`, `icon`, and `create({ id })`. Registration is +explicit and frozen; duplicates and invalid created trees throw before insertion. +Factories must return schema-valid instances. Placement still goes through the +document-kind command validator; kit validation is not publish authorization. + +Use the same trusted definitions in your document kind and server renderer. +Adding a block to a palette alone does not enable its rendering or publishing. +The built-in email production adapter currently uses its core email kind; custom +email target composition and a complete high-level editor component remain work +in progress. + +The package exports `InspectorDrafts`, `builderOutlineLabel`, +`builderDragAnnouncement`, `updateBuilderCanvas`, `showBuilderBlockActions`, +`editBuilderText`, `showImageFallbacks`, and `offerBuilderDownload`. +These are low-level reusable surfaces, not a drop-in complete studio yet. +The host owns authenticated load/save/render/asset service callbacks. +`createBlockEditorState` uses validated commands; host applications must actually +wire it rather than assuming all application mutations already use that state. diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json index c57eb759..a3536ec3 100644 --- a/packages/block-editor/package.json +++ b/packages/block-editor/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/block-editor", - "version": "0.1.8", + "version": "0.1.9", "type": "module", "description": "Hydrated accessible editor runtime for WRNexus block documents.", "exports": { diff --git a/packages/block-editor/src/announcements.ts b/packages/block-editor/src/announcements.ts new file mode 100644 index 00000000..e2f4c9f3 --- /dev/null +++ b/packages/block-editor/src/announcements.ts @@ -0,0 +1,31 @@ +type DragEvent = "pickedUp" | "movedOver" | "invalid" | "dropped" | "cancelled"; + +const messages: Record> = { + en: { + pickedUp: "Block picked up. Use arrow keys to move, Space to drop, or Escape to cancel.", + movedOver: "Block moved over a drop position.", + invalid: "This location does not accept the block.", + dropped: "Block moved.", + cancelled: "Move cancelled. Original block order restored.", + }, + hi: { + pickedUp: + "ब्लॉक चुन लिया गया है। स्थान बदलने के लिए तीर कुंजियाँ, रखने के लिए स्पेस और रद्द करने के लिए एस्केप दबाएँ।", + movedOver: "ब्लॉक रखने की जगह बदल गई है।", + invalid: "इस स्थान पर यह ब्लॉक नहीं रखा जा सकता।", + dropped: "ब्लॉक का स्थान बदल दिया गया है।", + cancelled: "स्थान बदलना रद्द किया गया। ब्लॉक का मूल क्रम बहाल कर दिया गया है।", + }, + ar: { + pickedUp: "تم التقاط الكتلة. استخدم مفاتيح الأسهم للتحريك، والمسافة للإفلات، وEscape للإلغاء.", + movedOver: "تم تحريك الكتلة فوق موضع إفلات.", + invalid: "هذا الموضع لا يقبل هذه الكتلة.", + dropped: "تم نقل الكتلة.", + cancelled: "تم إلغاء النقل واستعادة ترتيب الكتل الأصلي.", + }, +}; + +export function builderDragAnnouncement(type: string, locale = "en"): string { + const catalog = messages[locale.toLowerCase().split(/[-_]/)[0]!] ?? messages.en!; + return Object.hasOwn(catalog, type) ? catalog[type as DragEvent] : ""; +} diff --git a/packages/block-editor/src/download.ts b/packages/block-editor/src/download.ts new file mode 100644 index 00000000..08757704 --- /dev/null +++ b/packages/block-editor/src/download.ts @@ -0,0 +1,33 @@ +const downloads = new WeakMap void>(); + +/** Keep a direct user-gesture fallback until replaced or the editor is removed. */ +export function offerBuilderDownload(host: HTMLElement, content: string, type: string, filename: string): void { + const outlet = host.querySelector("[data-builder-download]"); + if (!outlet) throw new Error("Download outlet is unavailable"); + const url = URL.createObjectURL(new Blob([content], { type })); + const link = host.ownerDocument.createElement("a"); + link.href = url; + link.download = filename; + link.textContent = `Download prepared file: ${filename}`; + let disposed = false; + const observer = new MutationObserver(() => { if (!host.isConnected) dispose(); }); + const dispose = () => { + if (disposed) return; + disposed = true; + observer.disconnect(); + URL.revokeObjectURL(url); + link.remove(); + if (downloads.get(host) === dispose) downloads.delete(host); + }; + try { + downloads.get(host)?.(); + outlet.replaceChildren(link); + observer.observe(host.ownerDocument.documentElement, { childList: true, subtree: true }); + downloads.set(host, dispose); + } catch (error) { + dispose(); + throw error; + } + // A blocked automatic click must not destroy the direct-click fallback. + try { link.click(); } catch { /* The visible link remains available. */ } +} diff --git a/packages/block-editor/src/image-preview.ts b/packages/block-editor/src/image-preview.ts new file mode 100644 index 00000000..7106a808 --- /dev/null +++ b/packages/block-editor/src/image-preview.ts @@ -0,0 +1,26 @@ +const placeholders = new WeakMap(); + +/** Preview-only fallbacks; never writes to the block document or export source. */ +export function showImageFallbacks(canvas: Document, enabled: boolean): void { + for (const previous of placeholders.get(canvas) ?? []) previous.remove(); + placeholders.delete(canvas); + if (!enabled) return; + const created: HTMLElement[] = []; + for (const image of canvas.querySelectorAll("img,amp-img")) { + const fallback = canvas.createElement("span"); + fallback.dataset.imageFallback = ""; + const alt = image.getAttribute("alt") ?? ""; + fallback.textContent = alt; + if (alt) { fallback.setAttribute("role", "img"); fallback.setAttribute("aria-label", alt); } + else fallback.setAttribute("aria-hidden", "true"); + const width = Number(image.getAttribute("width")), height = Number(image.getAttribute("height")); + fallback.style.cssText = "display:flex;align-items:center;justify-content:center;box-sizing:border-box;border:1px dashed #64748b;color:#334155;background:#f1f5f9;font:14px Arial;padding:8px;overflow-wrap:anywhere;overflow:hidden;width:100%;min-height:32px;"; + if (Number.isFinite(width) && width > 0 && width <= 4000) { + fallback.style.maxWidth = `${width}px`; + if (Number.isFinite(height) && height > 0 && height <= 4000) fallback.style.aspectRatio = `${width}/${height}`; + } + image.after(fallback); + created.push(fallback); + } + placeholders.set(canvas, created); +} diff --git a/packages/block-editor/src/index.ts b/packages/block-editor/src/index.ts index 5b490bea..7946d4bc 100644 --- a/packages/block-editor/src/index.ts +++ b/packages/block-editor/src/index.ts @@ -2,3 +2,10 @@ export * from "./state.ts"; export * from "./runtime.ts"; export * from "./unsaved.ts"; export * from "./canvas.ts"; +export * from "./inspector-drafts.ts"; +export * from "./outline.ts"; +export * from "./live-canvas.ts"; +export * from "./download.ts"; +export * from "./image-preview.ts"; +export * from "./announcements.ts"; +export * from "./kit.ts"; diff --git a/packages/block-editor/src/inspector-drafts.ts b/packages/block-editor/src/inspector-drafts.ts new file mode 100644 index 00000000..b35edbfd --- /dev/null +++ b/packages/block-editor/src/inspector-drafts.ts @@ -0,0 +1,64 @@ +type InspectorNode = { id: string; props: unknown; styles: unknown }; +export type InspectorValues = { + props: Record; + styles: Record; +}; +type Group = keyof InspectorValues; +type Draft = { baseline: string; values: Record }; + +/** Session-only form drafts. Never included in documents, history, or exports. */ +export class InspectorDrafts { + private drafts = new Map>>(); + + capture(node: InspectorNode, baseline: InspectorValues, current: InspectorValues) { + const draft: Partial> = {}; + for (const group of ["props", "styles"] as const) { + const values = Object.fromEntries( + Object.entries(current[group]).filter( + ([key, value]) => JSON.stringify(value) !== JSON.stringify(baseline[group][key]), + ), + ); + if (Object.keys(values).length) + draft[group] = { + baseline: JSON.stringify(node[group] ?? {}), + values: structuredClone(values), + }; + } + if (Object.keys(draft).length) this.drafts.set(node.id, draft); + else this.drafts.delete(node.id); + } + + restore(node: InspectorNode, baseline: InspectorValues): InspectorValues { + const result = structuredClone(baseline), + draft = this.drafts.get(node.id); + if (!draft) return result; + for (const group of ["props", "styles"] as const) { + const saved = draft[group]; + if (!saved) continue; + // An applied edit, undo, or server replacement takes precedence for that group. + if (saved.baseline !== JSON.stringify(node[group] ?? {})) { + delete draft[group]; + continue; + } + for (const [key, value] of Object.entries(saved.values)) { + if (Object.hasOwn(result[group], key)) + Object.assign(result[group], { [key]: structuredClone(value) }); + } + } + if (!Object.keys(draft).length) this.drafts.delete(node.id); + return result; + } + + discard(id: string) { + this.drafts.delete(id); + } + has(id: string) { + return this.drafts.has(id); + } + retain(ids: Set) { + for (const id of this.drafts.keys()) if (!ids.has(id)) this.drafts.delete(id); + } + get size() { + return this.drafts.size; + } +} diff --git a/packages/block-editor/src/kit.ts b/packages/block-editor/src/kit.ts new file mode 100644 index 00000000..95a81367 --- /dev/null +++ b/packages/block-editor/src/kit.ts @@ -0,0 +1,79 @@ +import { createRegistry, type BlockDefinition, type BlockNode, type LocalizedLabel } from "@wrnexus/blocks"; + +export interface BuilderCreateContext { id(): string } +export interface BuilderPreset { + id: string; + name: LocalizedLabel; + icon: string; + create(context: BuilderCreateContext): BlockNode; +} + +/** Trusted explicit configuration, not tenant-supplied executable code. */ +export function createBuilderKit(options: { + blocks: readonly BlockDefinition[]; + presets?: readonly BuilderPreset[]; +}) { + const registry = createRegistry(options.blocks).freeze(); + const presets = new Map(); + for (const preset of options.presets ?? []) { + if (!preset.id || !preset.name?.key || !preset.name.fallback || !preset.icon) + throw new Error("Preset id, localized name and icon are required"); + if (presets.has(preset.id)) throw new Error("Duplicate preset: " + preset.id); + presets.set(preset.id, Object.freeze({ ...preset })); + } + const validateProps = (type: string, props: Record): void => { + const definition = registry.get(type); + if (!definition) throw new Error("Unavailable block: " + type + ". Existing data must be preserved."); + const parsed = definition.manifest.props.parse(props, "props"); + if (!parsed.ok) throw new Error(parsed.errors.map(error => (error.path ?? "props") + ": " + error.message).join("\n")); + }; + const validateCreated = (root: BlockNode): BlockNode => { + const ids = new Set(); + const visit = (node: BlockNode, depth: number) => { + if (depth > 30 || ids.size >= 500) throw new Error("Created tree exceeds builder limits"); + if (!node.id || ids.has(node.id)) throw new Error("Created node IDs must be nonempty and unique"); + ids.add(node.id); + const definition = registry.get(node.type); + if (!definition || node.version !== definition.manifest.version) throw new Error("Unsupported created block version: " + node.type); + validateProps(node.type, node.props); + const styles = definition.manifest.styles.parse(node.styles, "styles"); + if (!styles.ok) throw new Error(styles.errors.map(error => error.message).join("\n")); + for (const [name, children] of Object.entries(node.slots)) { + const slot = definition.manifest.slots[name]; + if (!slot || !Array.isArray(children)) throw new Error("Unknown or invalid slot: " + name); + if (children.length < (slot.minChildren ?? 0) || children.length > (slot.maxChildren ?? 500)) throw new Error("Invalid slot size: " + name); + for (const child of children) { + const childDefinition = registry.get(child.type); + if (slot.accepts?.types && !slot.accepts.types.includes(child.type)) throw new Error("Slot rejects block type: " + child.type); + if (slot.accepts?.capabilities && !slot.accepts.capabilities.every(value => childDefinition?.manifest.capabilities?.includes(value))) throw new Error("Slot rejects block capabilities: " + child.type); + visit(child, depth + 1); + } + } + for (const [name, slot] of Object.entries(definition.manifest.slots)) + if ((slot.minChildren ?? 0) > 0 && !node.slots[name]) throw new Error("Required slot missing: " + name); + const errors = definition.validate?.(node)?.filter(item => item.severity === "error"); + if (errors?.length) throw new Error(errors.map(item => item.message).join("\n")); + }; + const copy = structuredClone(root); + visit(copy, 0); + return copy; + }; + return Object.freeze({ + registry, + blocks: () => registry.list(), + presets: () => Object.freeze([...presets.values()]), + validateProps, + createBlock(type: string, context: BuilderCreateContext) { + const definition = registry.get(type); + if (!definition) throw new Error("Unavailable block: " + type); + const node = definition.create(context); + if (node.type !== type) throw new Error("Block factory returned a different type"); + return validateCreated(node); + }, + createPreset(id: string, context: BuilderCreateContext) { + const preset = presets.get(id); + if (!preset) throw new Error("Unavailable preset: " + id); + return validateCreated(preset.create(context)); + }, + }); +} diff --git a/packages/block-editor/src/live-canvas.ts b/packages/block-editor/src/live-canvas.ts new file mode 100644 index 00000000..cf7c209f --- /dev/null +++ b/packages/block-editor/src/live-canvas.ts @@ -0,0 +1,66 @@ +/** Updates the existing sandbox document without navigating/reloading its iframe. */ +export function updateBuilderCanvas(frame: HTMLIFrameElement, html: string): void { + const canvas = frame.contentDocument; + if (!canvas?.body) return; + const parsed = new DOMParser().parseFromString(html, "text/html"); + const scrolling = canvas.scrollingElement; + const top = scrolling?.scrollTop ?? 0, left = scrolling?.scrollLeft ?? 0; + canvas.head.replaceChildren(...Array.from(parsed.head.childNodes).map(node => canvas.importNode(node, true))); + for (const attr of Array.from(canvas.body.attributes)) canvas.body.removeAttribute(attr.name); + for (const attr of Array.from(parsed.body.attributes)) canvas.body.setAttribute(attr.name, attr.value); + canvas.body.replaceChildren(...Array.from(parsed.body.childNodes).map(node => canvas.importNode(node, true))); + if (scrolling) { scrolling.scrollTop = top; scrolling.scrollLeft = left; } +} + +/** Selection controls are editor decorations, never part of saved/exported HTML. */ +export function showBuilderBlockActions(canvas: Document, id: string, label: string, actions: Record void>): void { + canvas.querySelector("[data-builder-block-actions]")?.remove(); + const block = Array.from(canvas.querySelectorAll("[data-block-id]")).find(item => item.dataset.blockId === id); + if (!block) return; + const toolbar = canvas.createElement("div"); + toolbar.dataset.builderBlockActions = ""; + toolbar.setAttribute("role", "toolbar"); + toolbar.setAttribute("aria-label", label + " actions"); + toolbar.style.cssText = "position:absolute;z-index:100;display:flex;flex-wrap:wrap;gap:4px;align-items:center;padding:6px;box-sizing:border-box;max-width:100%;background:#111827;color:white;font:12px Arial;box-shadow:0 2px 8px #0003"; + const name = canvas.createElement("strong"); + name.textContent = label; + name.style.cssText = "margin-inline-end:auto"; + toolbar.append(name); + for (const [name, action] of Object.entries(actions)) { + const button = canvas.createElement("button"); + button.type = "button"; + button.textContent = name; + button.style.cssText = "border:1px solid #64748b;border-radius:4px;background:#1f2937;color:white;padding:5px 8px;cursor:pointer"; + button.addEventListener("click", event => { event.preventDefault(); event.stopPropagation(); action(); }); + toolbar.append(button); + } + // Keep controls outside presentation tables so browser table repair cannot move them. + canvas.body.prepend(toolbar); + const bounds = block.getBoundingClientRect(); + toolbar.style.top = Math.max(0, bounds.top + (canvas.defaultView?.scrollY ?? 0) - toolbar.offsetHeight) + "px"; + toolbar.style.left = Math.max(0, Math.min(bounds.left, canvas.documentElement.clientWidth - toolbar.offsetWidth)) + "px"; + block.setAttribute("aria-label", label + " selected"); +} + +/** Text remains a string, not pasted HTML. The owner validates every committed value. */ +export function editBuilderText(canvas: Document, id: string, value: string, update: (text: string) => void, finish: () => void): boolean { + const block = Array.from(canvas.querySelectorAll("[data-block-id]")).find(item => item.dataset.blockId === id); + const element = block?.querySelector("h1,h2,h3,h4,p"); + if (!element) return false; + element.textContent = value; + element.contentEditable = "plaintext-only"; + element.setAttribute("role", "textbox"); + element.setAttribute("aria-label", "Edit block text"); + element.style.whiteSpace = "pre-wrap"; + element.addEventListener("input", () => update(element.innerText)); + element.addEventListener("keydown", event => { + event.stopPropagation(); + if (event.key === "Escape") { event.preventDefault(); element.blur(); } + }); + element.addEventListener("blur", () => { + element.contentEditable = "false"; + finish(); + }, { once: true }); + element.focus({ preventScroll: true }); + return true; +} diff --git a/packages/block-editor/src/outline.ts b/packages/block-editor/src/outline.ts new file mode 100644 index 00000000..52ac8537 --- /dev/null +++ b/packages/block-editor/src/outline.ts @@ -0,0 +1,17 @@ +import type { BlockNode } from "@wrnexus/blocks"; + +/** Plain text only; callers must assign this to textContent, never innerHTML. */ +export function builderOutlineLabel( + node: BlockNode, + definition?: { name?: { fallback?: string } }, +): string { + const name = definition?.name?.fallback || node.type; + const props = node.props ?? {}; + const candidate = [props.text, props.label, props.title, props.question, props.alt] + .find(value => typeof value === "string" && value.trim()); + const detail = typeof candidate === "string" + ? candidate.replace(/\s+/g, " ").trim() + : node.type === "layout.column" && typeof props.width === "string" ? props.width : ""; + const bounded = Array.from(detail); + return detail ? `${name} — ${bounded.slice(0, 64).join("")}${bounded.length > 64 ? "…" : ""}` : name; +} diff --git a/packages/block-editor/test/announcements.test.ts b/packages/block-editor/test/announcements.test.ts new file mode 100644 index 00000000..375a9c30 --- /dev/null +++ b/packages/block-editor/test/announcements.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; +import { builderDragAnnouncement } from "../src/announcements.ts"; + +test("drag messages cover every lifecycle event in supported locales", () => { + for (const event of ["pickedUp", "movedOver", "invalid", "dropped", "cancelled"]) { + for (const locale of ["en", "hi", "ar"]) { + const message = builderDragAnnouncement(event, locale); + expect(message.length).toBeGreaterThan(10); + expect(message).not.toBe(event); + expect(builderDragAnnouncement(event, `${locale}-IN`)).toBe(message); + } + } + expect(builderDragAnnouncement("dropped", "unknown")).toBe( + builderDragAnnouncement("dropped", "en"), + ); + expect(builderDragAnnouncement("toString")).toBe(""); +}); diff --git a/packages/block-editor/test/download.test.ts b/packages/block-editor/test/download.test.ts new file mode 100644 index 00000000..df13eeb4 --- /dev/null +++ b/packages/block-editor/test/download.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; + +const source = new Bun.Transpiler({ loader: "ts" }).transformSync(readFileSync(new URL("../src/download.ts", import.meta.url), "utf8")).replace("export function", "function"); + +test("download keeps direct link after blocked automatic click and releases replaced/detached blobs", () => { + const revoked: string[] = [], observers: Array<{ callback(): void; disconnect(): void }> = []; + let sequence = 0, clicks = 0, disconnected = 0; + const links: any[] = []; + const outlet = { replaceChildren: (link: unknown) => { links.splice(0, links.length, link); } }; + const host = { isConnected: true, querySelector: () => outlet, ownerDocument: { documentElement: {}, createElement: () => ({ href: "", download: "", textContent: "", click() { clicks++; throw new Error("blocked"); }, remove() { const index = links.indexOf(this); if (index >= 0) links.splice(index, 1); } }) } }; + const offer = new Function("URL", "Blob", "MutationObserver", source + ";return offerBuilderDownload;")( + { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: (url: string) => revoked.push(url) }, Blob, + class { constructor(public callback: () => void) { observers.push(this); } observe() {} disconnect() { disconnected++; } }, + ); + offer(host, "html", "text/html", "draft.html"); + expect(links[0].href).toBe("blob:1"); expect(links[0].download).toBe("draft.html"); + expect(revoked).toEqual([]); expect(clicks).toBe(1); + offer(host, "text", "text/plain", "draft.txt"); + expect(revoked).toEqual(["blob:1"]); expect(links[0].download).toBe("draft.txt"); + host.isConnected = false; observers[1]!.callback(); observers[1]!.callback(); + expect(revoked).toEqual(["blob:1", "blob:2"]); expect(links).toEqual([]); expect(disconnected).toBe(2); +}); diff --git a/packages/block-editor/test/image-preview.test.ts b/packages/block-editor/test/image-preview.test.ts new file mode 100644 index 00000000..93aaeff2 --- /dev/null +++ b/packages/block-editor/test/image-preview.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { showImageFallbacks } from "../src/image-preview.ts"; + +test("image-disabled mode exposes safe alt text, proportions and reversible placeholders", () => { + const displayed: any[] = []; + const images = [{ alt: 'Logo & name', width: '320', height: '180' }, { alt: '', width: 'not a size', height: '99999' }].map(attributes => ({ + getAttribute: (key: string) => (attributes as Record)[key] ?? null, + after: (element: unknown) => displayed.push(element), + })); + const canvas = { + querySelectorAll: () => images, + createElement: () => ({ dataset: {}, style: {} as Record, textContent: "", attributes: {} as Record, setAttribute(key: string, value: string) { this.attributes[key] = value; }, remove() { const index = displayed.indexOf(this); if (index >= 0) displayed.splice(index, 1); } }), + } as unknown as Document; + showImageFallbacks(canvas, true); + expect(displayed).toHaveLength(2); + expect(displayed[0].textContent).toBe('Logo & name'); + expect(displayed[0].attributes['aria-label']).toBe('Logo & name'); + expect(displayed[0].style.aspectRatio).toBe('320/180'); + expect(displayed[1].attributes['aria-hidden']).toBe('true'); + expect(displayed[1].style.maxWidth).toBeUndefined(); + showImageFallbacks(canvas, true); + expect(displayed).toHaveLength(2); + showImageFallbacks(canvas, false); + expect(displayed).toHaveLength(0); + expect(images[0]!.getAttribute('alt')).toBe('Logo & name'); +}); diff --git a/packages/block-editor/test/inspector-drafts.test.ts b/packages/block-editor/test/inspector-drafts.test.ts new file mode 100644 index 00000000..739f8300 --- /dev/null +++ b/packages/block-editor/test/inspector-drafts.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "bun:test"; +import { InspectorDrafts, type InspectorValues } from "../src/inspector-drafts.ts"; + +const node = { id: "text", props: { text: "Saved" }, styles: {} }; +const baseline: InspectorValues = { + props: { text: "Saved", decorative: false }, + styles: { fontSize: { value: "", empty: "true" } }, +}; +const edited: InspectorValues = { + props: { text: "Typing", decorative: true }, + styles: { fontSize: { value: "20px", empty: "" } }, +}; + +test("selection and preview remount preserve independent props, checkbox, and style drafts", () => { + const store = new InspectorDrafts(); + store.capture(node, baseline, edited); + expect(store.restore({ ...node, id: "other" }, baseline)).toEqual(baseline); + expect(store.restore(node, baseline)).toEqual(edited); + expect(store.restore(node, baseline)).toEqual(edited); + expect(node.props.text).toBe("Saved"); + expect(store.size).toBe(1); +}); + +test("applying content does not discard pending style changes or resurrect applied content", () => { + const store = new InspectorDrafts(); + store.capture(node, baseline, edited); + const applied = { ...node, props: { text: "Typing" } }; + const next = { ...baseline, props: { ...baseline.props, text: "Typing" } }; + expect(store.restore(applied, next)).toEqual({ ...next, styles: edited.styles }); + expect(store.restore(node, baseline).props).toEqual(baseline.props); +}); + +test("applying styles does not erase content drafts", () => { + const store = new InspectorDrafts(); + store.capture(node, baseline, edited); + const applied = { ...node, styles: { fontSize: { base: "20px" } } }; + const next = { ...baseline, styles: edited.styles }; + expect(store.restore(applied, next)).toEqual({ props: edited.props, styles: edited.styles }); +}); + +test("reverting fields, discarding, and deleting blocks release their drafts", () => { + const store = new InspectorDrafts(); + store.capture(node, baseline, edited); + store.capture(node, baseline, baseline); + expect(store.size).toBe(0); + store.capture(node, baseline, edited); + store.discard(node.id); + expect(store.restore(node, baseline)).toEqual(baseline); + store.capture(node, baseline, edited); + store.retain(new Set(["other"])); + expect(store.size).toBe(0); +}); + +test("capture and restore do not share mutable control snapshots", () => { + const store = new InspectorDrafts(), + draft = structuredClone(edited); + store.capture(node, baseline, draft); + draft.styles.fontSize!.value = "99px"; + const restored = store.restore(node, baseline); + restored.styles.fontSize!.value = "88px"; + expect(store.restore(node, baseline).styles.fontSize!.value).toBe("20px"); +}); diff --git a/packages/block-editor/test/kit.test.ts b/packages/block-editor/test/kit.test.ts new file mode 100644 index 00000000..308d2218 --- /dev/null +++ b/packages/block-editor/test/kit.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "bun:test"; +import { defineBlock, object, string } from "@wrnexus/blocks"; +import { createBuilderKit } from "../src/kit.ts"; + +const block = defineBlock({ + manifest: { type: "custom.note", version: 1, name: { key: "note", fallback: "Note" }, icon: "note", category: "content", + props: object({ text: string({ min: 1 }) }), styles: object({}), slots: {} }, + create: ({ id }: { id(): string }) => ({ id: id(), type: "custom.note", version: 1, props: { text: "Hello" }, styles: {}, slots: {} }), +}); +test("trusted custom blocks and presets expose independent instances and explicit metadata", () => { + let next = 0; const context = { id: () => String(++next) }; + const kit = createBuilderKit({ blocks: [block], presets: [{ id: "note", name: { key: "preset.note", fallback: "Note" }, icon: "note", create: block.create }] }); + expect(kit.blocks()[0].manifest.name.fallback).toBe("Note"); + expect(kit.presets()[0].id).toBe("note"); + const first = kit.createBlock("custom.note", context), second = kit.createPreset("note", context); + first.props.text = "Changed"; + expect(second.props.text).toBe("Hello"); + expect(first.id).not.toBe(second.id); + expect(Object.isFrozen(kit)).toBe(true); + expect(() => kit.validateProps("custom.note", { text: "" })).toThrow(); + expect(() => kit.createBlock("missing.block", context)).toThrow(); +}); +test("duplicate registration and invalid factories fail before insertion", () => { + expect(() => createBuilderKit({ blocks: [block, block] })).toThrow(); + const preset = { id: "note", name: { key: "note", fallback: "Note" }, icon: "note", create: block.create }; + expect(() => createBuilderKit({ blocks: [block], presets: [preset, preset] })).toThrow(); + const invalid = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), props: { text: "" } }) }] }); + expect(() => invalid.createBlock("custom.note", { id: () => "id" })).toThrow(); + const wrong = createBuilderKit({ blocks: [{ ...block, create: () => ({ ...block.create({ id: () => "id" }), type: "custom.other" }) }] }); + expect(() => wrong.createBlock("custom.note", { id: () => "id" })).toThrow(); +}); diff --git a/packages/block-editor/test/outline.test.ts b/packages/block-editor/test/outline.test.ts new file mode 100644 index 00000000..0bbf625a --- /dev/null +++ b/packages/block-editor/test/outline.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import type { BlockNode } from "@wrnexus/blocks"; +import { builderOutlineLabel } from "../src/outline.ts"; + +const node = (props: BlockNode["props"], type = "content.button"): BlockNode => ({ id: "n", type, version: 1, props, styles: {}, slots: {} }); + +test("outline uses registered names and distinguishes content without mutating it", () => { + const block = node({ label: " Buy\n now " }); + expect(builderOutlineLabel(block, { name: { fallback: "Button" } })).toBe("Button — Buy now"); + expect(block.props.label).toBe(" Buy\n now "); + expect(builderOutlineLabel(node({ width: "35%" }, "layout.column"), { name: { fallback: "Column" } })).toBe("Column — 35%"); + expect(builderOutlineLabel(node({}, "custom.unknown"))).toBe("custom.unknown"); +}); + +test("outline bounds Unicode snippets and treats content as plain text", () => { + const label = builderOutlineLabel(node({ label: "😀".repeat(65) }), { name: { fallback: "Button" } }); + expect(label).toBe("Button — " + "😀".repeat(64) + "…"); + expect(builderOutlineLabel(node({ label: '' }))).toContain(''); + expect(builderOutlineLabel(node({ text: {}, label: "", title: "Product title" }))).toBe("content.button — Product title"); +}); diff --git a/packages/email-renderer/package.json b/packages/email-renderer/package.json index 38ae52f0..b787e6fc 100644 --- a/packages/email-renderer/package.json +++ b/packages/email-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/email-renderer", - "version": "0.1.44", + "version": "0.1.45", "type": "module", "description": "WRNexus block email renderers with HTML, text, AMP Email, and MJML interoperability.", "exports": { @@ -8,6 +8,7 @@ "./blocks": "./src/blocks.ts", "./browser": "./src/browser.ts", "./create": "./src/create.ts", + "./presets": "./src/presets.ts", "./style-values": "./src/style-values.ts" }, "files": [ diff --git a/packages/email-renderer/src/create.ts b/packages/email-renderer/src/create.ts index a52b3e83..8f16bdfe 100644 --- a/packages/email-renderer/src/create.ts +++ b/packages/email-renderer/src/create.ts @@ -1,6 +1,13 @@ import type { BlockNode } from "@wrnexus/blocks"; import { emailCoreBlocks } from "./blocks.ts"; +/** Complete trusted editor pack, including schema-valid content factories. */ +export const emailEditorBlocks = emailCoreBlocks.map(definition => + definition.manifest.category === "content" || definition.manifest.type.startsWith("commerce.") + ? { ...definition, create: (context: { id(): string }) => createEmailBlock(definition.manifest.type, context) } + : definition, +); + /** Canonical initial content for palette blocks; time and instance IDs are injectable. */ export function createEmailBlock(type: string, context: { id(): string; now?: number }): BlockNode { const now = context.now ?? Date.now(); diff --git a/packages/email-renderer/src/presets.ts b/packages/email-renderer/src/presets.ts new file mode 100644 index 00000000..5c1ad7ab --- /dev/null +++ b/packages/email-renderer/src/presets.ts @@ -0,0 +1,58 @@ +import type { BlockNode, JsonObject } from "@wrnexus/blocks"; + +export function createSectionPreset(name: string, id: () => string): BlockNode { + const node = ( + type: string, + props: JsonObject, + slots: Record = {}, + ): BlockNode => ({ + id: id(), + type, + version: 1, + props, + styles: {}, + 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"), + ]; + 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 = [ + 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 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] }); +} + +/** Native, editable section presets; no HTML snapshots. */ +export const emailPresets = ["welcome", "feature", "footer"].map(id => ({ + id, + 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), +})); diff --git a/packages/email-renderer/test/editor-pack.test.ts b/packages/email-renderer/test/editor-pack.test.ts new file mode 100644 index 00000000..c5d0807a --- /dev/null +++ b/packages/email-renderer/test/editor-pack.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { emailEditorBlocks } from "../src/create.ts"; +import { emailPresets } from "../src/presets.ts"; + +test("published email editor content factories return valid independent instances", () => { + let id = 0; + for (const definition of emailEditorBlocks.filter(block => block.manifest.category === "content")) { + const first = definition.create({ id: () => String(++id) }); + const second = definition.create({ id: () => String(++id) }); + expect(first.id).not.toBe(second.id); + expect(definition.manifest.props.parse(first.props).ok).toBe(true); + expect(first.type).toBe(definition.manifest.type); + } +}); +test("published presets expose metadata and remap all instance IDs on creation", () => { + let id = 0; + for (const preset of emailPresets) { + const first = preset.create({ id: () => String(++id) }); + const second = preset.create({ id: () => String(++id) }); + expect(preset.name.key).toBeTruthy(); + expect(preset.icon).toBeTruthy(); + expect(first.type).toBe("layout.section"); + expect(first.id).not.toBe(second.id); + } +});