import { z } from "zod";
import { prop, attributeValue } from "../mdx.js";
import type { BlockMdxConfig, BlockVisualFrame } from "../types.js";
/**
* Pure (React-free) part of the shared `wireframe` block: its data shape, zod
* schema, the stable node-id helper, and the nested-MDX round-trip config. Lives
* in core so BOTH apps' server/shared registries and the client spec
* (`wireframe.tsx`) consume one definition; importing it into a server module
* never pulls React into the Nitro/SSR bundle.
*
* The wireframe block originated in the plan template. The vocabulary
* (`PlanWireframe*` names, the `el` set, the `--wf-*` token contract) and the
* MDX encoding (`……` with kit
* component names `FrameScreen`/`Row`/`Col`/…) are preserved EXACTLY so stored
* plans round-trip byte-compatibly and existing node ids never change.
*
* The wireframe is NESTED MDX, not a flat-attribute or prose block: its body is
* a `…kit-tree…` subtree. So the config uses
* `serializeChildren`/`parseChildren` (the registry's nested-MDX path) rather
* than `toAttrs`/`childrenField`.
*/
/* -------------------------------------------------------------------------- */
/* Vocabulary (decoupled copy of the plan-content wireframe types) */
/* -------------------------------------------------------------------------- */
export type WireframeSurface =
| "desktop"
| "mobile"
| "popover"
| "panel"
| "browser";
export type WireframeRenderMode = "wireframe" | "design";
/** Tone keyword reused across screen primitives. The renderer maps to color. */
export type WireframeTone = "default" | "accent" | "warn" | "ok" | "muted";
/**
* Names of the kit primitives. Component-like (MDX-friendly). The renderer maps
* each to a flex kit component. Layout is ALWAYS flex; row/col/sidebar/main set
* the flex direction.
*/
export type WireframeElName =
| "screen"
| "browserBar"
| "statusBar"
| "toolbar"
| "row"
| "col"
| "sidebar"
| "navItem"
| "main"
| "title"
| "text"
| "lines"
| "section"
| "taskRow"
| "chips"
| "chip"
| "pill"
| "check"
| "field"
| "btn"
| "fab"
| "card"
| "column"
| "avatar"
| "iconSquare"
| "kv"
| "searchBar"
| "box"
| "divider";
/**
* A single node in the wireframe kit tree. `el` is the primitive name; the
* remaining props are the union of every primitive's props (kept permissive so
* the model can compose freely). `children` nests other nodes. `id` is a stable
* node id used by node-addressable patch ops (auto-assigned on create).
*
* There is intentionally NO x/y/width/height here — wireframe internals are
* geometry-free and laid out by the renderer with flex.
*/
export type WireframeNode = {
/** Stable id for node-addressable patches; auto-assigned when absent. */
id?: string;
el: WireframeElName;
children?: WireframeNode[];
// Generic content props
text?: string;
value?: string;
label?: string;
placeholder?: string;
title?: string;
// Styling-by-intent (semantic only; renderer owns actual color/size)
tone?: WireframeTone;
color?: WireframeTone;
weight?: "normal" | "medium" | "bold";
active?: boolean;
done?: boolean;
emphasis?: boolean;
full?: boolean;
solid?: boolean;
dashed?: boolean;
dot?: boolean;
script?: boolean;
area?: boolean;
shape?: "square" | "circle";
// Numeric / structured props
count?: number;
prio?: number;
n?: number;
widths?: number[];
icon?: string;
// taskRow specifics
note?: string;
due?: string;
dueTone?: WireframeTone;
// Collection props (chips, kv)
items?: Array<{
label: string;
active?: boolean;
count?: number;
dot?: boolean;
}>;
rows?: Array<{ k: string; v: string }>;
};
export interface WireframeData {
surface: WireframeSurface;
/** `design` renders full-fidelity branded HTML/CSS instead of a sketch. */
renderMode?: WireframeRenderMode;
caption?: string;
/** Outer surface frame. `auto` lets the host choose the right default. */
frame?: BlockVisualFrame;
/**
* Neutral, textless loading register. The renderer drops borders, the sketch
* outline, and color, rendering soft placeholder geometry only.
*/
skeleton?: boolean;
/**
* PRIMARY content: a self-contained HTML mockup of the screen (sanitized
* fragment — no document/script/style tags). The renderer owns the surface
* aspect, the dark/light theme, the hand-drawn font, and the rough overlay.
* When `html` is set, `screen` is ignored.
*/
html?: string;
/** Optional scoped CSS for the html mockup (sanitized fragment). */
css?: string;
/** Kit-tree screen. Used when `html` is absent. */
screen?: WireframeNode[];
}
export const WIREFRAME_SURFACES: WireframeSurface[] = [
"desktop",
"mobile",
"popover",
"panel",
"browser",
];
export const WIREFRAME_EL_NAMES: WireframeElName[] = [
"screen",
"browserBar",
"statusBar",
"toolbar",
"row",
"col",
"sidebar",
"navItem",
"main",
"title",
"text",
"lines",
"section",
"taskRow",
"chips",
"chip",
"pill",
"check",
"field",
"btn",
"fab",
"card",
"column",
"avatar",
"iconSquare",
"kv",
"searchBar",
"box",
"divider",
];
/* -------------------------------------------------------------------------- */
/* Schema (mirrors the plan-content wireframeDataSchema) */
/* -------------------------------------------------------------------------- */
const toneSchema = z.enum(["default", "accent", "warn", "ok", "muted"]);
const elNameSchema = z.enum(
WIREFRAME_EL_NAMES as [WireframeElName, ...WireframeElName[]],
);
const idSchema = z.string().trim().min(1).max(120);
const visualFrameSchema = z.enum(["auto", "show", "hide"]);
/**
* Reject full-document HTML (html/head/body/script/style tags) in the `html` /
* `css` fields. Wireframe content must be a bounded fragment; document or script
* tags are a stored-XSS and layout-escape hazard.
*/
function noFullHtmlDocument(value: string): boolean {
return !/<\s*(?:!doctype|html|head|body|script|style)\b/i.test(value);
}
const wireframeNodeSchema: z.ZodType = z.lazy(() =>
z
.object({
id: idSchema.optional(),
el: elNameSchema,
children: z.array(wireframeNodeSchema).max(60).optional(),
text: z.string().trim().max(400).optional(),
value: z.string().trim().max(400).optional(),
label: z.string().trim().max(200).optional(),
placeholder: z.string().trim().max(200).optional(),
title: z.string().trim().max(200).optional(),
tone: toneSchema.optional(),
color: toneSchema.optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
active: z.boolean().optional(),
done: z.boolean().optional(),
emphasis: z.boolean().optional(),
full: z.boolean().optional(),
solid: z.boolean().optional(),
dashed: z.boolean().optional(),
dot: z.boolean().optional(),
script: z.boolean().optional(),
area: z.boolean().optional(),
shape: z.enum(["square", "circle"]).optional(),
count: z.number().int().min(0).max(9_999).optional(),
prio: z.number().int().min(0).max(9).optional(),
n: z.number().int().min(0).max(20).optional(),
widths: z.array(z.number().min(0).max(100)).max(20).optional(),
icon: z.string().trim().max(40).optional(),
note: z.string().trim().max(400).optional(),
due: z.string().trim().max(120).optional(),
dueTone: toneSchema.optional(),
items: z
.array(
z.object({
label: z.string().trim().min(1).max(200),
active: z.boolean().optional(),
count: z.number().int().min(0).max(9_999).optional(),
dot: z.boolean().optional(),
}),
)
.max(40)
.optional(),
rows: z
.array(
z.object({
k: z.string().trim().min(1).max(200),
v: z.string().trim().max(400),
}),
)
.max(40)
.optional(),
})
.passthrough(),
) as z.ZodType;
export const wireframeSchema = z
.object({
surface: z.enum(["desktop", "mobile", "popover", "panel", "browser"]),
renderMode: z.enum(["wireframe", "design"]).optional(),
caption: z.string().trim().max(400).optional(),
frame: visualFrameSchema.optional(),
skeleton: z.boolean().optional(),
html: z
.string()
.max(40_000)
.refine(noFullHtmlDocument, {
message:
"Wireframe html must be a bounded fragment without html/head/body/script/style tags.",
})
.optional(),
css: z
.string()
.max(20_000)
.refine(noFullHtmlDocument, {
message: "Wireframe css must not include document or script tags.",
})
.optional(),
screen: z.array(wireframeNodeSchema).max(200).optional(),
})
.passthrough() as unknown as z.ZodType;
/* -------------------------------------------------------------------------- */
/* Stable node-id derivation (verbatim from plan-mdx.ts) */
/* -------------------------------------------------------------------------- */
/**
* Derive a stable node id from the element name and tree path. Re-runs identically
* to the legacy plan derivation so stored plans round-trip without changing ids.
*/
export function createStableWireframeNodeId(
el: WireframeElName,
path: string,
): string {
return `node-${el}-${path}`
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
/* -------------------------------------------------------------------------- */
/* Kit node <-> MDX component name maps (mirrors plan-mdx.ts NODE_TO_COMPONENT) */
/* -------------------------------------------------------------------------- */
const NODE_TO_COMPONENT: Record = {
screen: "FrameScreen",
browserBar: "BrowserBar",
statusBar: "StatusBar",
toolbar: "Toolbar",
row: "Row",
col: "Col",
sidebar: "Sidebar",
navItem: "NavItem",
main: "Main",
title: "Title",
text: "Text",
lines: "Lines",
section: "SectionLabel",
taskRow: "TaskRow",
chips: "Chips",
chip: "Chip",
pill: "Pill",
check: "Check",
field: "Field",
btn: "Btn",
fab: "Fab",
card: "Card",
column: "Column",
avatar: "Avatar",
iconSquare: "IconSquare",
kv: "KV",
searchBar: "SearchBar",
box: "Box",
divider: "Divider",
};
const COMPONENT_TO_NODE = Object.fromEntries(
Object.entries(NODE_TO_COMPONENT).map(([el, component]) => [component, el]),
) as Record;
/* -------------------------------------------------------------------------- */
/* Serialize (verbatim port of plan-mdx.ts serializeNode/serializeScreen) */
/* -------------------------------------------------------------------------- */
function serializeNode(node: WireframeNode, indent = ""): string {
const name = NODE_TO_COMPONENT[node.el] ?? "Box";
const attrs = Object.entries(node)
.filter(([key]) => key !== "children" && key !== "el")
.map(([key, value]) => prop(key, value))
.join("");
if (!node.children?.length) return `${indent}<${name}${attrs} />`;
const children = node.children
.map((child) => serializeNode(child, `${indent} `))
.join("\n");
return `${indent}<${name}${attrs}>\n${children}\n${indent}${name}>`;
}
/**
* Serialize the wireframe data to its inner `` MDX subtree. The registry
* serializer wraps this between the `` open/close tags, so the
* total output equals the legacy `serializeBlock` wireframe branch exactly.
*/
function serializeScreen(data: WireframeData): string {
const attrs = [
prop("surface", data.surface),
prop("renderMode", data.renderMode),
prop("caption", data.caption),
prop("frame", data.frame),
prop("html", data.html),
prop("css", data.css),
prop("skeleton", data.skeleton),
].join("");
const children = (data.screen ?? [])
.map((node) => serializeNode(node, " "))
.join("\n");
if (!children) return ``;
return `\n${children}\n`;
}
/* -------------------------------------------------------------------------- */
/* Parse (verbatim port of plan-mdx.ts parseScreen/parseWireframeNode) */
/* -------------------------------------------------------------------------- */
/**
* Minimal MDX AST node shape used while walking the wireframe subtree. Declared
* standalone (not an intersection with `MdxJsxNode`, whose `children` is
* `unknown[]`) so recursive `children` stays narrowed to `WireframeMdxNode`.
*/
type WireframeMdxNode = {
type: string;
name?: string;
attributes?: Array<{
type: string;
name?: string;
value?: string | null | { type: string; value: string; data?: unknown };
}>;
children?: WireframeMdxNode[];
};
function elementName(node: WireframeMdxNode | undefined): string | undefined {
return node?.type === "mdxJsxFlowElement" ||
node?.type === "mdxJsxTextElement"
? node.name
: undefined;
}
function findAttribute(node: WireframeMdxNode, name: string) {
return node.attributes?.find(
(attr) => attr.type === "mdxJsxAttribute" && attr.name === name,
);
}
function stringAttr(node: WireframeMdxNode, name: string): string | undefined {
const value = attributeValue(findAttribute(node, name));
return typeof value === "string" ? value : undefined;
}
/**
* Resolve a required-when-present string attribute on a ``. If the
* attribute is absent we return undefined, but if it is present yet cannot be
* resolved to a string (e.g. a number, an object, or an unevaluable expression)
* we THROW so a malformed wireframe fails the import instead of silently
* dropping the value and rendering an empty wireframe.
*/
function requiredStringAttr(
node: WireframeMdxNode,
name: string,
): string | undefined {
const attr = findAttribute(node, name);
if (!attr) return undefined;
const value = attributeValue(attr);
if (typeof value !== "string") {
throw new Error(
`Wireframe attribute "${name}" must resolve to a string, got ${typeof value}. Use a quoted string or a static template literal.`,
);
}
return value;
}
function boolAttr(node: WireframeMdxNode, name: string): boolean | undefined {
const value = attributeValue(findAttribute(node, name));
return typeof value === "boolean" ? value : undefined;
}
function parseWireframeNode(
node: WireframeMdxNode,
path = "node",
): WireframeNode | null {
const component = elementName(node);
if (!component) return null;
const el = COMPONENT_TO_NODE[component];
if (!el) return null;
const attrs = node.attributes ?? [];
const parsed: WireframeNode = { el };
for (const attr of attrs) {
if (attr.type !== "mdxJsxAttribute") continue;
const value = attributeValue(attr);
if (value !== undefined)
(parsed as Record)[attr.name as string] = value;
}
parsed.el = el;
parsed.id ??= createStableWireframeNodeId(el, path);
const children = (node.children ?? [])
.map((child, index) => parseWireframeNode(child, `${path}-${index}`))
.filter(Boolean) as WireframeNode[];
if (children.length > 0) parsed.children = children;
return parsed;
}
function parseScreen(node: WireframeMdxNode, idContext: string): WireframeData {
return {
surface:
(stringAttr(node, "surface") as WireframeData["surface"]) ?? "desktop",
renderMode: stringAttr(node, "renderMode") as WireframeData["renderMode"],
caption: stringAttr(node, "caption"),
frame: stringAttr(node, "frame") as WireframeData["frame"],
html: requiredStringAttr(node, "html"),
css: requiredStringAttr(node, "css"),
skeleton: boolAttr(node, "skeleton"),
screen: (node.children ?? [])
.map((child, index) =>
parseWireframeNode(child, `${idContext}-screen-${index}`),
)
.filter(Boolean) as WireframeNode[],
};
}
/* -------------------------------------------------------------------------- */
/* MDX config */
/* -------------------------------------------------------------------------- */
/**
* Registry MDX config for the wireframe block. `tag` matches the legacy
* `WireframeBlock`. The block has no flat attributes (`toAttrs` → `{}`); all data
* lives in the nested `` subtree handled by `serializeChildren` /
* `parseChildren`. The registry serializer wraps `serializeChildren` between the
* `` open/close, producing the exact legacy bytes. On parse, the
* registry passes the already-extended `${idContext}-${blockId}` to
* `parseChildren` — the same id base the legacy `parseScreen` receives — so node
* ids are reproduced identically.
*/
export const wireframeMdx: BlockMdxConfig = {
tag: "WireframeBlock",
toAttrs: () => ({}),
fromAttrs: () => ({ surface: "desktop", screen: [] }),
serializeChildren: (data) => serializeScreen(data),
parseChildren: (childNodes, idContext) => {
const nodes = childNodes as WireframeMdxNode[];
const screen = nodes.find((child) => elementName(child) === "Screen");
if (!screen) return { surface: "desktop", screen: [] };
return parseScreen(screen, idContext);
},
};