169 lines
5.6 KiB
TypeScript
169 lines
5.6 KiB
TypeScript
import type { BlockRegistry, ReadonlyBlockRegistry } from "./registry.ts";
|
|
import type { DocumentKindDefinition } from "./kinds.ts";
|
|
import { resolveDocumentVariables } from "./variables.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 variables = resolveDocumentVariables(document, kind.variables);
|
|
if (!variables.ok) errors.push(...variables.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";
|