import { z } from "zod";
import { childCodeFenceFields, serializeChildCodeFenceFields } from "../mdx.js";
import type { BlockMdxConfig, BlockVisualFrame } from "../types.js";
/**
* Pure (React-free) part of the shared `diagram` block: its data schema and MDX
* round-trip config. Lives in core so BOTH apps' server/shared registries
* (`plan-block-registry.ts`, `nfm-registry.ts`) and the client spec
* (`diagram.tsx`) consume one definition. Keeping it React-free means importing
* it into a server module never pulls React into the Nitro/SSR bundle.
*
* The MDX `tag` keeps backward compatibility with legacy
* `` files while the current authoring form stores
* maintainable `html`/`css` as child code fences.
*/
export interface DiagramNode {
id: string;
label: string;
detail?: string;
x?: number;
y?: number;
}
export interface DiagramEdge {
from: string;
to: string;
label?: string;
}
export interface DiagramNote {
id: string;
text: string;
x?: number;
y?: number;
}
export interface DiagramData {
/**
* Preferred authoring path for architecture/code diagrams: a scoped, inert
* HTML/SVG fragment. Use .diagram-* primitives and --wf-* tokens; the
* renderer supplies theme-token-backed styling plus sketch/clean style hooks.
*/
html?: string;
css?: string;
/** `design` forces clean HTML/CSS rendering without the sketch overlay. */
renderMode?: "wireframe" | "design";
caption?: string;
/** Outer surface frame. `auto` lets the host choose the right default. */
frame?: BlockVisualFrame;
/**
* Legacy compatibility path for older/simple node graphs. New plans should use
* `html`/`css` when layout quality matters.
*/
nodes?: DiagramNode[];
edges?: DiagramEdge[];
notes?: DiagramNote[];
}
const idSchema = z.string().trim().min(1).max(120);
const unsafeDiagramHtmlPattern =
/(?:/]|@(?:import|font-face|keyframes|page|namespace|charset)\b|\b(?:java\s*script|vb\s*script|data\s*:\s*(?:text\/html|image\/svg\+xml))\s*:?\s*|\bsrcdoc\s*=|(?:^|\s)(?:on[a-z][\w:-]*|@[\w:.-]+|x-on:[\w:.-]+|:on[a-z][\w:-]*|x-bind:on[a-z][\w:-]*|:style|x-bind:style)\s*=|expression\s*\(|url\s*\(\s*['"]?\s*(?:java\s*script|vb\s*script|data\s*:\s*(?:text\/html|image\/svg\+xml)))/i;
const unsafeViewportCssPattern =
/(?:^|[;{\s])position\s*:\s*(?:fixed|sticky)\b|(?:^|[;{\s])z-index\s*:\s*[1-9]\d{4,}\b/i;
function decodeSafetyEntities(value: string): string {
return value
.replace(/(x[0-9a-f]+|\d+);?/gi, (_, code: string) => {
const point = code.toLowerCase().startsWith("x")
? Number.parseInt(code.slice(1), 16)
: Number.parseInt(code, 10);
return Number.isFinite(point) ? String.fromCodePoint(point) : "";
})
.replace(/&(colon|tab|newline);/gi, (_, name: string) => {
if (name.toLowerCase() === "colon") return ":";
if (name.toLowerCase() === "tab") return "\t";
return "\n";
});
}
function decodeCssSafetyEscapes(value: string): string {
return value.replace(/\\([0-9a-fA-F]{1,6}\s?|.)/g, (_match, escaped) => {
const hex = String(escaped).match(/^[0-9a-fA-F]{1,6}/)?.[0];
if (hex) {
const point = Number.parseInt(hex, 16);
return Number.isFinite(point) ? String.fromCodePoint(point) : "";
}
return String(escaped)[0] ?? "";
});
}
function noActiveDiagramText(value: string) {
const decoded = decodeCssSafetyEscapes(decodeSafetyEntities(value));
const compact = decoded.toLowerCase().replace(/[\u0000-\u0020]+/g, "");
return (
!unsafeDiagramHtmlPattern.test(value) &&
!unsafeDiagramHtmlPattern.test(decoded) &&
!unsafeViewportCssPattern.test(decoded) &&
!/(?:javascript|vbscript):|data:(?:text\/html|image\/svg\+xml)|expression\(|url\(['"]?(?:javascript|vbscript|data:(?:text\/html|image\/svg\+xml))/.test(
compact,
)
);
}
const diagramNodeSchema = z.object({
id: idSchema,
label: z.string().trim().min(1).max(160),
detail: z.string().trim().max(500).optional(),
x: z.number().min(0).max(100).optional(),
y: z.number().min(0).max(100).optional(),
}) as z.ZodType;
const diagramEdgeSchema = z.object({
from: idSchema,
to: idSchema,
label: z.string().trim().max(100).optional(),
}) as z.ZodType;
const diagramNoteSchema = z.object({
id: idSchema,
text: z.string().trim().min(1).max(500),
x: z.number().min(0).max(100).optional(),
y: z.number().min(0).max(100).optional(),
}) as z.ZodType;
const visualFrameSchema = z.enum(["auto", "show", "hide"]);
/**
* The block can be a flexible HTML/SVG fragment or a legacy positional
* node/edge/note graph, so it ships a custom `Edit` rather than relying on the
* schema auto-editor. Editing stays comment/patch-driven.
*/
export const diagramSchema = z
.object({
html: z
.string()
.trim()
.max(100_000)
.refine(noActiveDiagramText, {
message:
"Diagram html must be an inert fragment; SVG is allowed, scripts/events are not.",
})
.optional(),
css: z
.string()
.max(50_000)
.refine(noActiveDiagramText, {
message: "Diagram css must not include document or script tags.",
})
.optional(),
renderMode: z.enum(["wireframe", "design"]).optional(),
caption: z.string().trim().max(600).optional(),
frame: visualFrameSchema.optional(),
nodes: z.array(diagramNodeSchema).max(80).optional(),
edges: z.array(diagramEdgeSchema).max(120).optional(),
notes: z.array(diagramNoteSchema).max(40).optional(),
})
.superRefine((data, ctx) => {
if (data.html?.trim() || (data.nodes?.length ?? 0) > 0) return;
ctx.addIssue({
code: "custom",
path: ["html"],
message: "Diagram block requires html or at least one node.",
});
}) as unknown as z.ZodType;
function hasChildFenceData(data: DiagramData) {
return Boolean(data.html || data.css);
}
function graphDataForAttr(data: DiagramData): DiagramData | undefined {
const graph: DiagramData = {};
if (data.nodes?.length) graph.nodes = data.nodes;
if (data.edges?.length) graph.edges = data.edges;
if (data.notes?.length) graph.notes = data.notes;
if (Object.keys(graph).length > 0) return graph;
if (hasChildFenceData(data)) return undefined;
const { frame: _frame, renderMode: _renderMode, ...dataForAttr } = data;
return Object.keys(dataForAttr).length > 0 ? dataForAttr : undefined;
}
/**
* MDX config: new source uses normal fenced-code children:
*
* `` plus child `html` / `css` fences. Legacy
* `` remains accepted by `fromAttrs`; `toAttrs` keeps the
* `data` key present-but-undefined when using child fences so docs validation
* still recognizes old `data={...}` as a supported compatibility attribute.
*/
export const diagramMdx: BlockMdxConfig = {
tag: "Diagram",
toAttrs: (data) => ({
data: graphDataForAttr(data) as unknown as
| Record
| undefined,
caption: data.caption,
frame: data.frame,
renderMode: data.renderMode,
}),
fromAttrs: (attrs) => ({
...(attrs.object("data") ?? {}),
...(attrs.string("caption") !== undefined
? { caption: attrs.string("caption") }
: {}),
...(attrs.string("frame") !== undefined
? { frame: attrs.string("frame") as BlockVisualFrame }
: {}),
...(attrs.string("renderMode") !== undefined
? {
renderMode: attrs.string("renderMode") as DiagramData["renderMode"],
}
: {}),
}),
serializeChildren: (data) =>
serializeChildCodeFenceFields(data, { html: "html", css: "css" }),
parseChildren: (childNodes) =>
childCodeFenceFields(childNodes, {
html: "html",
css: "css",
}),
};