feat(builder): add dynamic blocks email and dnd packages
Quality / quality (ubuntu-latest) (push) Failing after 11m26s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-09-03 14:00:41 +05:30
1 parent 06433e4ac4
commit 0bd2a6860d
37 files changed
+3060 -1

No files matched your search

+5
View File
@@ -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.
+21
View File
@@ -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"
}
}
+65
View File
@@ -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",
},
],
};
}
}
+233
View File
@@ -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);
}
+165
View File
@@ -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";
+8
View File
@@ -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";
+20
View File
@@ -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;
+143
View File
@@ -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;
}
+73
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
+173
View File
@@ -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 };
},
};
}
+80
View File
@@ -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,
};
+140
View File
@@ -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);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts", "test/**/*.ts"]
}