Expose reusable builder kit surfaces and email preset pack
Quality / quality (ubuntu-latest) (push) Failing after 10m2s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-05 19:51:57 +05:30
1 parent e019ac34c8
commit eb1875f0bc
20 files changed
+635 -4

No files matched your search

@@ -0,0 +1,31 @@
type DragEvent = "pickedUp" | "movedOver" | "invalid" | "dropped" | "cancelled";
const messages: Record<string, Record<DragEvent, string>> = {
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] : "";
}
+33
View File
@@ -0,0 +1,33 @@
const downloads = new WeakMap<HTMLElement, () => 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<HTMLElement>("[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. */ }
}
@@ -0,0 +1,26 @@
const placeholders = new WeakMap<Document, HTMLElement[]>();
/** 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<HTMLElement>("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);
}
+7
View File
@@ -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";
@@ -0,0 +1,64 @@
type InspectorNode = { id: string; props: unknown; styles: unknown };
export type InspectorValues = {
props: Record<string, string | boolean>;
styles: Record<string, { value: string; empty: string }>;
};
type Group = keyof InspectorValues;
type Draft = { baseline: string; values: Record<string, unknown> };
/** Session-only form drafts. Never included in documents, history, or exports. */
export class InspectorDrafts {
private drafts = new Map<string, Partial<Record<Group, Draft>>>();
capture(node: InspectorNode, baseline: InspectorValues, current: InspectorValues) {
const draft: Partial<Record<Group, Draft>> = {};
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<string>) {
for (const id of this.drafts.keys()) if (!ids.has(id)) this.drafts.delete(id);
}
get size() {
return this.drafts.size;
}
}
+79
View File
@@ -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<string, BuilderPreset>();
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<string, unknown>): 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<string>();
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));
},
});
}
+66
View File
@@ -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<string, () => void>): void {
canvas.querySelector("[data-builder-block-actions]")?.remove();
const block = Array.from(canvas.querySelectorAll<HTMLElement>("[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<HTMLElement>("[data-block-id]")).find(item => item.dataset.blockId === id);
const element = block?.querySelector<HTMLElement>("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;
}
+17
View File
@@ -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;
}