diff --git a/.changeset/docs-rich-blocks.md b/.changeset/docs-rich-blocks.md new file mode 100644 index 0000000..d00441d --- /dev/null +++ b/.changeset/docs-rich-blocks.md @@ -0,0 +1,5 @@ +--- +"@frontlit/text-editor": minor +--- + +Add editable documentation blocks for callouts, tabs, steps, cards, accordions, code groups, and type tables, with Markdown import and export. diff --git a/apps/api/src/documentation/authoring.ts b/apps/api/src/documentation/authoring.ts index 4f9b037..c0ea455 100644 --- a/apps/api/src/documentation/authoring.ts +++ b/apps/api/src/documentation/authoring.ts @@ -272,6 +272,8 @@ export async function draftReport( } for (const mark of node.marks ?? []) if (mark.type === "link") links.push({ href: mark.attrs.href, path }); + if (node.type === "card" && node.attrs?.href) + links.push({ href: node.attrs.href, path: `${path}.attrs.href` }); for (const [i, child] of (node.content ?? []).entries()) walk(child, `${path}.content[${i}]`); } diff --git a/apps/api/src/documentation/chunking.test.ts b/apps/api/src/documentation/chunking.test.ts index caf80cd..62e5871 100644 --- a/apps/api/src/documentation/chunking.test.ts +++ b/apps/api/src/documentation/chunking.test.ts @@ -149,4 +149,46 @@ describe("extractChunks", () => { ]); expect(chunks[1].content).toBe("Setup works."); }); + + it("indexes rich blocks: step headings split chunks and attribute text is searchable", () => { + const chunks = extractChunks({ + type: "doc", + content: [ + { + type: "steps", + content: [ + { type: "step", content: [heading("Install"), paragraph("Run it.")] }, + { type: "step", content: [heading("Configure"), paragraph("Set it.")] }, + ], + }, + { + type: "callout", + attrs: { type: "warning", title: "Heads up" }, + content: [paragraph("Keep keys secret.")], + }, + { + type: "cards", + content: [ + { + type: "card", + attrs: { title: "Quick start" }, + content: [paragraph("Begin.")], + }, + ], + }, + { + type: "typeTable", + attrs: { + rows: [ + { name: "apiKey", type: "string", default: null, description: "Key." }, + ], + }, + }, + ], + }); + expect(chunks.map((chunk) => chunk.anchor)).toEqual(["install", "configure"]); + expect(chunks[1].content).toBe( + "Set it.\n\nHeads up\n\nKeep keys secret.\n\nQuick start\nBegin.\n\napiKey string Key.", + ); + }); }); diff --git a/apps/api/src/documentation/chunking.ts b/apps/api/src/documentation/chunking.ts index 6ca5a87..aa69717 100644 --- a/apps/api/src/documentation/chunking.ts +++ b/apps/api/src/documentation/chunking.ts @@ -11,7 +11,12 @@ export interface DocChunk { const CHUNK_TARGET_WORDS = 300; const MIN_CHUNK_WORDS = 20; -type TipTapNode = { type?: string; text?: string; content?: TipTapNode[] }; +type TipTapNode = { + type?: string; + text?: string; + attrs?: Record; + content?: TipTapNode[]; +}; /** Plain text of one TipTap node, its marks/attrs ignored \u2014 chunking only * needs the words, not formatting. */ @@ -24,26 +29,53 @@ function extractText(node: TipTapNode): string { function extractDocsText(node: TipTapNode): string { if (node.text) return node.text; if (node.type === "hardBreak") return "\n"; - return (node.content ?? []) - .map(extractDocsText) - .join(node.type === "paragraph" || node.type === "heading" ? "" : "\n"); + if (node.type === "typeTable") + return (node.attrs?.rows ?? []) + .map((row: Record) => + [row.name, row.type, row.default, row.description].filter(Boolean).join(" "), + ) + .join("\n"); + const children = (node.content ?? []).map(extractDocsText); + // A card's title is an attribute; its children are the description. + if (node.type === "card") children.unshift(node.attrs?.title ?? ""); + return children.join( + node.type === "paragraph" || node.type === "heading" ? "" : "\n", + ); } +/** Blocks whose children are read as top-level blocks, so their headings start chunks. */ +const CONTAINERS = [ + "blockquote", + "bulletList", + "orderedList", + "listItem", + "table", + "tableRow", + "tableHeader", + "tableCell", + "callout", + "tabs", + "tab", + "steps", + "step", + "cards", + "accordions", + "accordion", + "codeGroup", +]; + function docsBlocks(nodes: TipTapNode[]): TipTapNode[] { - return nodes.flatMap((node) => - [ - "blockquote", - "bulletList", - "orderedList", - "listItem", - "table", - "tableRow", - "tableHeader", - "tableCell", - ].includes(node.type ?? "") - ? docsBlocks(node.content ?? []) - : [node], - ); + return nodes.flatMap((node) => { + if (!CONTAINERS.includes(node.type ?? "")) return [node]; + // Callout and accordion titles and tab labels are attributes; keep them searchable. + const label = node.attrs?.title ?? node.attrs?.label; + return [ + ...(typeof label === "string" && label + ? [{ type: "paragraph", content: [{ text: label }] }] + : []), + ...docsBlocks(node.content ?? []), + ]; + }); } function wordCount(text: string): number { diff --git a/bun.lock b/bun.lock index ce7e355..ad1c19e 100644 --- a/bun.lock +++ b/bun.lock @@ -236,7 +236,7 @@ }, "packages/page-builder": { "name": "@frontlit/page-builder", - "version": "0.1.0-alpha.6", + "version": "0.1.0-alpha.7", "dependencies": { "@frontlit/media-uploader": "workspace:*", "@frontlit/text-editor": "workspace:*", @@ -282,7 +282,7 @@ }, "packages/preview": { "name": "@frontlit/preview", - "version": "0.1.0-alpha.4", + "version": "0.1.0-alpha.5", "bin": { "frontlit-preview": "cli.mjs", }, @@ -300,6 +300,8 @@ "@floating-ui/dom": "^1.6.0", "@tiptap/core": "^3.0.0", "@tiptap/extension-bubble-menu": "^3.10.4", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", "@tiptap/extension-code-block-lowlight": "^3.0.0", "@tiptap/extension-dropcursor": "^3.0.0", "@tiptap/extension-gapcursor": "^3.0.0", @@ -309,14 +311,18 @@ "@tiptap/extension-link": "^3.0.0", "@tiptap/extension-placeholder": "^3.0.0", "@tiptap/extension-table": "^3.10.7", + "@tiptap/extensions": "3.31.3", "@tiptap/pm": "^3.0.0", "@tiptap/react": "^3.0.0", "@tiptap/starter-kit": "^3.0.0", "@tiptap/static-renderer": "^3.10.7", + "acorn": "^8.19.0", "clsx": "^2.1.1", "highlight.js": "^11.10.0", "lowlight": "^2.9.0", "lucide-react": "^0.553.0", + "mdast-util-mdx-jsx": "^3.2.0", + "micromark-extension-mdx-jsx": "^3.0.2", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", @@ -1427,7 +1433,7 @@ "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -3169,6 +3175,8 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@mdx-js/mdx/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "@modelcontextprotocol/client/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "@modelcontextprotocol/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], @@ -3279,6 +3287,8 @@ "dot-prop/is-obj": ["is-obj@2.0.0", "", {}, "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w=="], + "esast-util-from-js/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "eslint-config-next/globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], @@ -3293,6 +3303,8 @@ "eslint-plugin-react-hooks/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "espree/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -3335,8 +3347,12 @@ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + "micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "mlly/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], "node-exports-info/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], diff --git a/packages/text-editor/package.json b/packages/text-editor/package.json index 3fb4347..6b0c66a 100644 --- a/packages/text-editor/package.json +++ b/packages/text-editor/package.json @@ -76,6 +76,8 @@ "@floating-ui/dom": "^1.6.0", "@tiptap/core": "^3.0.0", "@tiptap/extension-bubble-menu": "^3.10.4", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", "@tiptap/extension-code-block-lowlight": "^3.0.0", "@tiptap/extension-dropcursor": "^3.0.0", "@tiptap/extension-gapcursor": "^3.0.0", @@ -85,14 +87,18 @@ "@tiptap/extension-link": "^3.0.0", "@tiptap/extension-placeholder": "^3.0.0", "@tiptap/extension-table": "^3.10.7", + "@tiptap/extensions": "3.31.3", "@tiptap/pm": "^3.0.0", "@tiptap/react": "^3.0.0", "@tiptap/starter-kit": "^3.0.0", "@tiptap/static-renderer": "^3.10.7", + "acorn": "^8.19.0", "clsx": "^2.1.1", "highlight.js": "^11.10.0", "lowlight": "^2.9.0", "lucide-react": "^0.553.0", + "mdast-util-mdx-jsx": "^3.2.0", + "micromark-extension-mdx-jsx": "^3.0.2", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", diff --git a/packages/text-editor/src/components/docs-block-views.tsx b/packages/text-editor/src/components/docs-block-views.tsx new file mode 100644 index 0000000..4cc155b --- /dev/null +++ b/packages/text-editor/src/components/docs-block-views.tsx @@ -0,0 +1,472 @@ +import React from "react"; +import type { Extensions, JSONContent } from "@tiptap/core"; +import { Node as TiptapNode } from "@tiptap/core"; +import { + NodeViewContent, + NodeViewWrapper, + ReactNodeViewRenderer, + type ReactNodeViewProps, +} from "@tiptap/react"; +import { Fragment } from "@tiptap/pm/model"; +import { Plus, Trash2, X } from "lucide-react"; +import { CALLOUT_TYPES, type TypeTableRow } from "../docs/blocks"; + +const input = + "h-7 min-w-0 rounded border border-input bg-card px-2 text-xs text-foreground placeholder:text-muted-foreground"; +const smallButton = + "inline-flex h-7 items-center gap-1 rounded px-2 text-xs text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40"; + +const paragraph = (text = ""): JSONContent => + text + ? { type: "paragraph", content: [{ type: "text", text }] } + : { type: "paragraph" }; + +/** Content inserted from the toolbar's Insert menu. */ +export const docsBlockTemplates: Record< + string, + { label: string; content: JSONContent } +> = { + callout: { + label: "Callout", + content: { type: "callout", attrs: { type: "info" }, content: [paragraph()] }, + }, + tabs: { + label: "Tabs", + content: { + type: "tabs", + content: ["Tab 1", "Tab 2"].map((label) => ({ + type: "tab", + attrs: { label }, + content: [paragraph()], + })), + }, + }, + steps: { + label: "Steps", + content: { + type: "steps", + content: [ + { + type: "step", + content: [{ type: "heading", attrs: { level: 3 } }, paragraph()], + }, + ], + }, + }, + cards: { + label: "Cards", + content: { type: "cards", content: [{ type: "card", attrs: { title: "Card" } }] }, + }, + accordions: { + label: "Accordion", + content: { + type: "accordions", + content: [ + { type: "accordion", attrs: { title: "Question" }, content: [paragraph()] }, + ], + }, + }, + codeGroup: { + label: "Code group", + content: { + type: "codeGroup", + content: [ + { type: "codeBlock", attrs: { language: "bash", title: "Terminal" } }, + { type: "codeBlock", attrs: { language: "ts", title: "index.ts" } }, + ], + }, + }, + typeTable: { + label: "Type table", + content: { + type: "typeTable", + attrs: { + rows: [ + { + name: "name", + type: "string", + default: null, + description: null, + required: false, + }, + ], + }, + }, + }, +}; + +/** New child appended by a container's "Add" button. */ +const childTemplates: Record JSONContent> = { + tabs: (count) => ({ + type: "tab", + attrs: { label: `Tab ${count + 1}` }, + content: [paragraph()], + }), + steps: () => ({ + type: "step", + content: [{ type: "heading", attrs: { level: 3 } }, paragraph()], + }), + cards: () => ({ type: "card", attrs: { title: "Card" } }), + accordions: () => ({ + type: "accordion", + attrs: { title: "Question" }, + content: [paragraph()], + }), + codeGroup: () => ({ type: "codeBlock", attrs: { language: null, title: "Code" } }), +}; +const childLabels: Record = { + tabs: "Add tab", + steps: "Add step", + cards: "Add card", + accordions: "Add item", + codeGroup: "Add code block", +}; + +function BlockFrame({ + label, + props, + children, + fields, +}: { + label: string; + props: ReactNodeViewProps; + children?: React.ReactNode; + fields?: React.ReactNode; +}) { + const { node, editor, getPos, deleteNode } = props; + const add = childTemplates[node.type.name]; + return ( + +
+ {label} + {fields} + + {add && editor.isEditable && ( + + )} + {editor.isEditable && ( + + )} + +
+ {children ?? } +
+ ); +} + +/** A tab, step, card or accordion item: optional fields, removable unless it is the last one. */ +function ItemFrame({ + props, + fields, +}: { + props: ReactNodeViewProps; + fields?: React.ReactNode; +}) { + const { editor, getPos, deleteNode, node } = props; + const pos = getPos(); + const parent = typeof pos === "number" ? editor.state.doc.resolve(pos).parent : null; + return ( + + {(fields || (parent?.childCount ?? 0) > 1) && ( +
+ {fields} + {(parent?.childCount ?? 0) > 1 && editor.isEditable && ( + + )} +
+ )} + +
+ ); +} + +function TextField({ + props, + name, + placeholder, + nullable, + className = "w-40", +}: { + props: ReactNodeViewProps; + name: string; + placeholder: string; + nullable?: boolean; + className?: string; +}) { + const value = (props.node.attrs[name] as string | null) ?? ""; + return ( + + props.updateAttributes({ + [name]: nullable && !event.target.value ? null : event.target.value, + }) + } + /> + ); +} + +function CalloutView(props: ReactNodeViewProps) { + return ( + + + + + } + /> + ); +} + +function CodeBlockView(props: ReactNodeViewProps) { + return ( + +
+ + +
+
+         as="code" />
+      
+
+ ); +} + +function TypeTableView(props: ReactNodeViewProps) { + const rows = (props.node.attrs.rows ?? []) as TypeTableRow[]; + const editable = props.editor.isEditable; + const update = (index: number, patch: Partial) => + props.updateAttributes({ + rows: rows.map((row, i) => (i === index ? { ...row, ...patch } : row)), + }); + const text = (index: number, key: "name" | "type" | "default" | "description") => ( + + update(index, { + [key]: + (key === "default" || key === "description") && !event.target.value + ? null + : event.target.value, + }) + } + /> + ); + return ( + +
+ + + + + + + + + + + + {rows.map((row, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: row names are edited in place. + + + + + + + + + ))} + +
PropTypeDefaultDescriptionRequired +
{text(index, "name")}{text(index, "type")}{text(index, "default")}{text(index, "description")} + + update(index, { required: event.target.checked }) + } + /> + + +
+ {editable && ( + + )} +
+
+ ); +} + +const views: Record> = { + callout: CalloutView, + tabs: (props) => , + tab: (props) => ( + } + /> + ), + steps: (props) => , + step: (props) => , + cards: (props) => , + card: (props) => ( + + + + + + } + /> + ), + accordions: (props) => , + accordion: (props) => ( + } + /> + ), + codeGroup: (props) => , + typeTable: TypeTableView, +}; + +/** + * Gives the schema-only docs blocks editing UI in the browser editor. Code + * blocks get a file-name field only in the documentation profile, so the + * default editor keeps its syntax-highlighted code blocks. + */ +export function withDocsBlockViews( + extensions: Extensions, + profile: "default" | "documentation", +): Extensions { + return extensions.map((extension) => { + const view = + views[extension.name] ?? + (profile === "documentation" && extension.name === "codeBlock" + ? CodeBlockView + : undefined); + if (!view || !(extension instanceof TiptapNode)) return extension; + return extension.extend({ + addNodeView() { + return ReactNodeViewRenderer(view); + }, + }); + }); +} diff --git a/packages/text-editor/src/components/docs-blocks.tsx b/packages/text-editor/src/components/docs-blocks.tsx new file mode 100644 index 0000000..c3fc4a7 --- /dev/null +++ b/packages/text-editor/src/components/docs-blocks.tsx @@ -0,0 +1,159 @@ +import React from "react"; +import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { CircleAlert, Info, Lightbulb, OctagonAlert, StickyNote } from "lucide-react"; +import { DynamicIcon, type IconName } from "lucide-react/dynamic"; +import type { NodeProps } from "@tiptap/static-renderer"; +import type { TypeTableRow } from "../docs/blocks"; +import { DocsTabs } from "./docs-tabs"; + +type BlockProps = NodeProps; + +const childAttrs = (node: ProseMirrorNode) => { + const attrs: Record[] = []; + node.forEach((child) => { + attrs.push(child.attrs); + }); + return attrs; +}; + +const CALLOUT_ICONS = { + note: StickyNote, + tip: Lightbulb, + info: Info, + warning: CircleAlert, + danger: OctagonAlert, +}; + +export function Callout({ node, children }: BlockProps) { + const type = (node.attrs.type ?? "info") as keyof typeof CALLOUT_ICONS; + const Icon = CALLOUT_ICONS[type] ?? Info; + return ( +
+ +
+ {node.attrs.title &&

{node.attrs.title}

} + {children} +
+
+ ); +} + +export function Tabs({ node, children }: BlockProps) { + return ( + attrs.label)}> + {children} + + ); +} + +export function CodeGroup({ node, children }: BlockProps) { + const labels = childAttrs(node).map( + (attrs, index) => attrs.title ?? attrs.language ?? `Code ${index + 1}`, + ); + return ( + + {children} + + ); +} + +export function Steps({ children }: BlockProps) { + return
    {children}
; +} + +export function Step({ children }: BlockProps) { + return
  • {children}
  • ; +} + +export function Cards({ children }: BlockProps) { + return
    {children}
    ; +} + +/** Card icons are lucide names, written in Fumadocs as ``. */ +const iconName = (name: string) => + name + .replace(/([a-z0-9])([A-Z])/g, "$1-$2") + .replace(/([A-Za-z])([0-9])/g, "$1-$2") + .toLowerCase() as IconName; + +export function Card({ node, children }: BlockProps) { + const { title, href, icon } = node.attrs; + const body = ( + <> + {icon && ( + + )} + {title} + {node.childCount > 0 &&
    {children}
    } + + ); + return href ? ( + + {body} + + ) : ( +
    {body}
    + ); +} + +export function Accordions({ children }: BlockProps) { + return
    {children}
    ; +} + +export function Accordion({ node, children }: BlockProps) { + return ( +
    + {node.attrs.title} +
    {children}
    +
    + ); +} + +export function TypeTable({ node }: BlockProps) { + const rows = (node.attrs.rows ?? []) as TypeTableRow[]; + return ( +
    + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
    PropTypeDefaultDescription
    + {row.name} + {row.required && ( + (required) + )} + + {row.type} + {row.default === null ? "—" : {row.default}}{row.description ?? ""}
    +
    + ); +} + +export const docsBlockMapping = { + callout: Callout, + tabs: Tabs, + tab: ({ children }: BlockProps) => <>{children}, + codeGroup: CodeGroup, + steps: Steps, + step: Step, + cards: Cards, + card: Card, + accordions: Accordions, + accordion: Accordion, + typeTable: TypeTable, +}; diff --git a/packages/text-editor/src/components/docs-tabs.tsx b/packages/text-editor/src/components/docs-tabs.tsx new file mode 100644 index 0000000..1533339 --- /dev/null +++ b/packages/text-editor/src/components/docs-tabs.tsx @@ -0,0 +1,67 @@ +"use client"; + +import React, { useId, useState } from "react"; + +/** + * Tab switching for docs Tabs and Code groups; the only client part of them. + * Every panel is server-rendered, so hidden tabs stay in the page for search. + */ +export function DocsTabs({ + labels, + children, + className, +}: { + labels: string[]; + children: React.ReactNode; + className?: string; +}) { + const id = useId(); + const [active, setActive] = useState(0); + const panels = React.Children.toArray(children); + + function onKeyDown(event: React.KeyboardEvent, index: number) { + const step = event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0; + if (!step) return; + event.preventDefault(); + const next = (index + step + labels.length) % labels.length; + setActive(next); + document.getElementById(`${id}-tab-${next}`)?.focus(); + } + + return ( +
    +
    + {labels.map((label, index) => ( + + ))} +
    + {panels.map((panel, index) => ( + + ))} +
    + ); +} diff --git a/packages/text-editor/src/components/rendered-code-block.tsx b/packages/text-editor/src/components/rendered-code-block.tsx index 6fb5294..b583bc9 100644 --- a/packages/text-editor/src/components/rendered-code-block.tsx +++ b/packages/text-editor/src/components/rendered-code-block.tsx @@ -18,15 +18,18 @@ export function RenderedCodeBlock({ node, children, language, + title, }: { node?: { textContent?: string; content?: unknown }; children?: React.ReactNode; language?: string; + title?: string | null; }): React.JSX.Element { const source = extractTextFromNode(node) || textFromChildren(children); return (
    + {title &&
    {title}
    }
             
               {children}
    diff --git a/packages/text-editor/src/docs/blocks.test.ts b/packages/text-editor/src/docs/blocks.test.ts
    new file mode 100644
    index 0000000..772fada
    --- /dev/null
    +++ b/packages/text-editor/src/docs/blocks.test.ts
    @@ -0,0 +1,275 @@
    +import { readFileSync } from "node:fs";
    +import { describe, expect, it } from "vitest";
    +import {
    +  DocsContentError,
    +  extractDocsHeadings,
    +  parseDocsMarkdown,
    +  serializeDocsMarkdown,
    +  validateDocsContent,
    +} from "../docs";
    +
    +const fixture = readFileSync(
    +  new URL("./fixtures/rich-blocks.md", import.meta.url),
    +  "utf8",
    +);
    +const parse = (markdown: string) =>
    +  parseDocsMarkdown(markdown, { frontmatter: false }).content;
    +const diagnostics = (markdown: string) => {
    +  try {
    +    parseDocsMarkdown(`---\ntitle: Example\n---\n${markdown}`, { file: "guide.md" });
    +  } catch (error) {
    +    expect(error).toBeInstanceOf(DocsContentError);
    +    return (error as DocsContentError).diagnostics;
    +  }
    +  throw new Error("Expected failure");
    +};
    +
    +describe("rich docs blocks", () => {
    +  it("serialize(parse(md)) === md for every block", () => {
    +    const { content, frontmatter } = parseDocsMarkdown(fixture);
    +    expect(serializeDocsMarkdown(content, frontmatter)).toBe(fixture);
    +    expect(content.content?.map((node) => node.type)).toEqual([
    +      "heading",
    +      "callout",
    +      "callout",
    +      "codeBlock",
    +      "tabs",
    +      "steps",
    +      "cards",
    +      "accordions",
    +      "codeGroup",
    +      "typeTable",
    +    ]);
    +  });
    +
    +  it("maps props onto node attributes", () => {
    +    const [, plain, callout, code, tabs, , cards, , , table] =
    +      parseDocsMarkdown(fixture).content.content!;
    +    expect(plain.attrs).toEqual({ type: null, title: null });
    +    expect(callout.attrs).toEqual({ type: "warning", title: "Heads up" });
    +    expect(code.attrs).toEqual({ language: "ts", title: "app/api/route.ts" });
    +    expect(tabs.content?.map((tab) => tab.attrs?.label)).toEqual([
    +      "npm",
    +      'Plain "quoted" tab',
    +    ]);
    +    expect(cards.content?.[0].attrs).toEqual({
    +      title: "Quick start",
    +      href: "/docs/quick-start",
    +      icon: "Rocket",
    +    });
    +    expect(table.attrs?.rows).toEqual([
    +      {
    +        name: "apiKey",
    +        type: "string",
    +        default: "MEDIALIT_API_KEY",
    +        description: "Your app's key.",
    +        required: false,
    +      },
    +      {
    +        name: "access",
    +        type: '"public" | "private"',
    +        default: null,
    +        description: null,
    +        required: true,
    +      },
    +      {
    +        name: "data-id",
    +        type: "number",
    +        default: null,
    +        description: null,
    +        required: false,
    +      },
    +    ]);
    +  });
    +
    +  it("accepts Fumadocs layouts: indented children, tabs named by items, one-line callouts", () => {
    +    const content = parse(
    +      [
    +        '',
    +        "",
    +        "",
    +        "First",
    +        "",
    +        "",
    +        "",
    +        "",
    +        "Second",
    +        "",
    +        "",
    +        "",
    +        "",
    +        "",
    +        '  ',
    +        "    Indented text is a paragraph, not code.",
    +        "  ",
    +        "",
    +        "",
    +        'Short.',
    +      ].join("\n"),
    +    );
    +    expect(content.content?.[0].content?.map((tab) => tab.attrs?.label)).toEqual([
    +      "One",
    +      "Two",
    +    ]);
    +    expect(content.content?.[1].content?.[0].content?.[0].type).toBe("paragraph");
    +    expect(content.content?.[2]).toEqual({
    +      type: "callout",
    +      attrs: { type: "tip", title: null },
    +      content: [{ type: "paragraph", content: [{ type: "text", text: "Short." }] }],
    +    });
    +  });
    +
    +  it("headings inside blocks get anchors", () => {
    +    expect(
    +      extractDocsHeadings(parseDocsMarkdown(fixture).content).map((h) => h.id),
    +    ).toEqual(["rich-blocks", "install", "configure"]);
    +  });
    +
    +  it("keeps plain CommonMark meaning in files without block tags", () => {
    +    expect(parse("    indented code").content?.[0].type).toBe("codeBlock");
    +    expect(parse("1 <2 and {braces}").content?.[0].content?.[0]).toEqual({
    +      type: "text",
    +      text: "1 <2 and {braces}",
    +    });
    +  });
    +
    +  it.each([
    +    ["", "UNSUPPORTED_NODE", "Unknown component "],
    +    ['\n\nText\n\n', "UNSUPPORTED_NODE", "must be inside "],
    +    ["\n\nText\n\n", "UNSUPPORTED_NODE", "can only contain "],
    +    [
    +      '\n\nText\n\n',
    +      "UNSUPPORTED_ATTRIBUTE",
    +      "Callout type",
    +    ],
    +    [
    +      '\n\nText\n\n',
    +      "UNSUPPORTED_ATTRIBUTE",
    +      "prop: color",
    +    ],
    +    ["\n\nText\n\n", "UNSUPPORTED_ATTRIBUTE", "Spread"],
    +    [
    +      "\n\n\nA\n\n\n",
    +      "UNSUPPORTED_ATTRIBUTE",
    +      "literal",
    +    ],
    +    [
    +      '\n\n\nA\n\n\n',
    +      "INVALID_DOCUMENT",
    +      "not listed",
    +    ],
    +    [
    +      "",
    +      "UNSUPPORTED_ATTRIBUTE",
    +      "literal",
    +    ],
    +    [
    +      '',
    +      "UNSUPPORTED_ATTRIBUTE",
    +      "field a",
    +    ],
    +    [
    +      '\n\n',
    +      "UNSAFE_URL",
    +      "unsafe",
    +    ],
    +    ["\n\nText", "UNSUPPORTED_NODE", "Invalid component syntax"],
    +    ["Inline x text", "UNSUPPORTED_NODE", "Inline "],
    +    ['```js title="a" open\n1\n```', "UNSUPPORTED_ATTRIBUTE", "title="],
    +  ])("rejects %j with a located diagnostic", (markdown, code, message) => {
    +    const [first] = diagnostics(markdown);
    +    expect(first).toMatchObject({ code, file: "guide.md" });
    +    expect(first.message).toContain(message);
    +    expect(first.line).toBeGreaterThanOrEqual(4);
    +  });
    +
    +  it("validates block attributes in editor JSON", () => {
    +    const doc = (node: unknown) => ({ type: "doc", content: [node] });
    +    const paragraph = { type: "paragraph", content: [{ type: "text", text: "x" }] };
    +    expect(
    +      validateDocsContent(
    +        doc({ type: "callout", attrs: { type: "loud" }, content: [paragraph] }),
    +      ).diagnostics[0],
    +    ).toMatchObject({
    +      code: "INVALID_DOCUMENT",
    +      fieldPath: "content.content[0].attrs.type",
    +    });
    +    expect(
    +      validateDocsContent(
    +        doc({
    +          type: "tabs",
    +          content: [{ type: "tab", attrs: { label: 1 }, content: [paragraph] }],
    +        }),
    +      ).diagnostics[0],
    +    ).toMatchObject({ fieldPath: "content.content[0].content[0].attrs.label" });
    +    expect(
    +      validateDocsContent(
    +        doc({
    +          type: "typeTable",
    +          attrs: { rows: [{ name: "a", type: "x", extra: 1 }] },
    +        }),
    +      ).diagnostics[0],
    +    ).toMatchObject({ fieldPath: "content.content[0].attrs.rows" });
    +    expect(
    +      validateDocsContent(
    +        doc({ type: "tab", attrs: { label: "x" }, content: [paragraph] }),
    +      ).diagnostics[0],
    +    ).toMatchObject({ code: "INVALID_DOCUMENT" });
    +  });
    +
    +  it("every state the editor can autosave serializes losslessly", () => {
    +    const empty = { type: "paragraph" };
    +    const states = [
    +      { type: "callout", attrs: { type: "info", title: "" }, content: [empty] },
    +      {
    +        type: "tabs",
    +        content: [
    +          { type: "tab", attrs: { label: "" }, content: [empty] },
    +          { type: "tab", attrs: { label: "" }, content: [empty, empty] },
    +        ],
    +      },
    +      {
    +        type: "steps",
    +        content: [
    +          { type: "step", content: [{ type: "heading", attrs: { level: 3 } }, empty] },
    +        ],
    +      },
    +      {
    +        type: "cards",
    +        content: [
    +          { type: "card", attrs: { title: "", href: null, icon: null } },
    +          {
    +            type: "card",
    +            attrs: { title: "With text", href: "/docs/x", icon: null },
    +            content: [empty],
    +          },
    +        ],
    +      },
    +      {
    +        type: "accordions",
    +        content: [{ type: "accordion", attrs: { title: "" }, content: [empty] }],
    +      },
    +      {
    +        type: "codeGroup",
    +        content: [
    +          { type: "codeBlock", attrs: { language: null, title: null } },
    +          { type: "codeBlock", attrs: { language: null, title: "My file" } },
    +        ],
    +      },
    +      {
    +        type: "typeTable",
    +        attrs: {
    +          // Editor updates may reorder keys; validation canonicalizes them.
    +          rows: [
    +            { name: "", type: "", default: null, required: false, description: null },
    +          ],
    +        },
    +      },
    +    ];
    +    for (const state of states) {
    +      const content = { type: "doc", content: [state] };
    +      expect(validateDocsContent(content).diagnostics).toEqual([]);
    +      expect(() => serializeDocsMarkdown(content)).not.toThrow();
    +    }
    +  });
    +});
    diff --git a/packages/text-editor/src/docs/blocks.ts b/packages/text-editor/src/docs/blocks.ts
    new file mode 100644
    index 0000000..cfd0763
    --- /dev/null
    +++ b/packages/text-editor/src/docs/blocks.ts
    @@ -0,0 +1,140 @@
    +import { Extension, Node, type Extensions } from "@tiptap/core";
    +import Code from "@tiptap/extension-code";
    +
    +/**
    + * Rich documentation blocks (Callout, Tabs, Steps, Cards, Accordion, Code
    + * group, Type table). Schema-only: no React, so the server-side docs profile
    + * and the browser editor share them. Markdown form is MDX-style JSX with
    + * literal props; see `markdown.ts`.
    + */
    +export const CALLOUT_TYPES = ["note", "tip", "info", "warning", "danger"] as const;
    +export type CalloutType = (typeof CALLOUT_TYPES)[number];
    +
    +export interface TypeTableRow {
    +  name: string;
    +  type: string;
    +  default: string | null;
    +  description: string | null;
    +  required: boolean;
    +}
    +
    +function attr(name: string, fallback: unknown = null) {
    +  return {
    +    [name]: {
    +      default: fallback,
    +      parseHTML: (element: HTMLElement) => {
    +        const value = element.getAttribute(`data-${name}`);
    +        if (value === null) return fallback;
    +        try {
    +          return JSON.parse(value);
    +        } catch {
    +          return fallback;
    +        }
    +      },
    +      renderHTML: (attributes: Record) =>
    +        attributes[name] === null || attributes[name] === undefined
    +          ? {}
    +          : { [`data-${name}`]: JSON.stringify(attributes[name]) },
    +    },
    +  };
    +}
    +
    +function block(
    +  name: string,
    +  spec: {
    +    content?: string;
    +    group?: string;
    +    attrs?: Record;
    +    atom?: boolean;
    +  },
    +) {
    +  const tag = `div[data-docs-block="${name}"]`;
    +  return Node.create({
    +    name,
    +    group: spec.group,
    +    content: spec.content,
    +    atom: spec.atom,
    +    defining: true,
    +    isolating: true,
    +    addAttributes() {
    +      return Object.assign(
    +        {},
    +        ...Object.entries(spec.attrs ?? {}).map(([key, value]) => attr(key, value)),
    +      );
    +    },
    +    parseHTML() {
    +      return [{ tag }];
    +    },
    +    renderHTML({ HTMLAttributes }) {
    +      return spec.atom
    +        ? ["div", { ...HTMLAttributes, "data-docs-block": name }]
    +        : ["div", { ...HTMLAttributes, "data-docs-block": name }, 0];
    +    },
    +  });
    +}
    +
    +/** Inline code may sit inside links, bold, italic and strike (e.g. a linked `useUpload`). */
    +export const DocsCode = Code.extend({ excludes: "" });
    +
    +export function createDocsBlockExtensions(): Extensions {
    +  return [
    +    DocsCode,
    +    block("callout", {
    +      group: "block",
    +      content: "block+",
    +      attrs: { type: null, title: null },
    +    }),
    +    block("tabs", { group: "block", content: "tab+" }),
    +    block("tab", { content: "block+", attrs: { label: "" } }),
    +    block("steps", { group: "block", content: "step+" }),
    +    block("step", { content: "block+" }),
    +    block("cards", { group: "block", content: "card+" }),
    +    block("card", {
    +      content: "block*",
    +      attrs: { title: "", href: null, icon: null },
    +    }),
    +    block("accordions", {
    +      group: "block",
    +      content: "accordion+",
    +      attrs: { type: null },
    +    }),
    +    block("accordion", { content: "block+", attrs: { title: "" } }),
    +    block("codeGroup", { group: "block", content: "codeBlock+" }),
    +    block("typeTable", { group: "block", atom: true, attrs: { rows: [] } }),
    +  ];
    +}
    +
    +/** Node names owned by this module, in Markdown component order. */
    +export const DOCS_BLOCK_COMPONENTS = {
    +  Callout: "callout",
    +  Tabs: "tabs",
    +  Tab: "tab",
    +  Steps: "steps",
    +  Step: "step",
    +  Cards: "cards",
    +  Card: "card",
    +  Accordions: "accordions",
    +  Accordion: "accordion",
    +  CodeGroup: "codeGroup",
    +  TypeTable: "typeTable",
    +} as const;
    +
    +/** Code fence `title="…"` metadata, shown as the block's file name or tab label. */
    +export const CodeBlockTitle = Extension.create({
    +  name: "codeBlockTitle",
    +  addGlobalAttributes() {
    +    return [
    +      {
    +        types: ["codeBlock"],
    +        attributes: {
    +          title: {
    +            default: null,
    +            parseHTML: (element) => element.getAttribute("data-title"),
    +            renderHTML: (attributes) =>
    +              attributes.title ? { "data-title": attributes.title } : {},
    +          },
    +        },
    +      },
    +    ];
    +  },
    +});
    diff --git a/packages/text-editor/src/docs/extensions.ts b/packages/text-editor/src/docs/extensions.ts
    index bd20608..73cb57b 100644
    --- a/packages/text-editor/src/docs/extensions.ts
    +++ b/packages/text-editor/src/docs/extensions.ts
    @@ -1,15 +1,19 @@
     import { getSchema, Node, type Extensions } from "@tiptap/core";
     import StarterKit from "@tiptap/starter-kit";
    +import CodeBlock from "@tiptap/extension-code-block";
     import Image from "@tiptap/extension-image";
     import Link from "@tiptap/extension-link";
     import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
     import { isSafeDocsUrl } from "./urls";
    +import { CodeBlockTitle, createDocsBlockExtensions } from "./blocks";
     import type { Schema } from "@tiptap/pm/model";
     
     /** DM-VALIDATE: schema-only extensions; no React, upload controls, or node views. */
     export function createDocsExtensions(): Extensions {
       return [
         StarterKit.configure({
    +      code: false,
    +      codeBlock: false,
           document: false,
           underline: false,
           link: false,
    @@ -28,6 +32,9 @@ export function createDocsExtensions(): Extensions {
         TableRow,
         TableCell.extend({ content: "paragraph" }),
         TableHeader.extend({ content: "paragraph" }),
    +    CodeBlock,
    +    CodeBlockTitle,
    +    ...createDocsBlockExtensions(),
       ];
     }
     
    diff --git a/packages/text-editor/src/docs/fixtures/rich-blocks.md b/packages/text-editor/src/docs/fixtures/rich-blocks.md
    new file mode 100644
    index 0000000..787c4ab
    --- /dev/null
    +++ b/packages/text-editor/src/docs/fixtures/rich-blocks.md
    @@ -0,0 +1,105 @@
    +---
    +title: Rich blocks
    +---
    +
    +# Rich blocks
    +
    +
    +
    +A plain callout uses the default type.
    +
    +
    +
    +
    +
    +Callouts hold **any** blocks, including [`linked code`](/docs/setup).
    +
    +- One
    +
    +- Two
    +
    +
    +
    +```ts title="app/api/route.ts"
    +export const POST = handler;
    +```
    +
    +
    +
    +
    +
    +```bash
    +npm install medialit
    +```
    +
    +
    +
    +
    +
    +Text in a tab.
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +### Install
    +
    +Run the installer.
    +
    +
    +
    +
    +
    +### Configure
    +
    +Set the key.
    +
    +
    +
    +
    +
    +
    +
    +}>
    +
    +Upload your first file.
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +Yes.
    +
    +
    +
    +
    +
    +
    +
    +```ts title="client.ts"
    +upload();
    +```
    +
    +```bash
    +curl https://api.example.com
    +```
    +
    +
    +
    +
    diff --git a/packages/text-editor/src/docs/markdown.ts b/packages/text-editor/src/docs/markdown.ts
    index b01310a..b2bb7f5 100644
    --- a/packages/text-editor/src/docs/markdown.ts
    +++ b/packages/text-editor/src/docs/markdown.ts
    @@ -1,27 +1,234 @@
     import type { JSONContent } from "@tiptap/core";
     import type { Root, RootContent, PhrasingContent, Definition, Nodes } from "mdast";
    -import { unified } from "unified";
    +import { unified, type Processor } from "unified";
     import remarkParse from "remark-parse";
     import remarkGfm from "remark-gfm";
     import remarkStringify from "remark-stringify";
    +import { mdxJsx } from "micromark-extension-mdx-jsx";
    +import {
    +  mdxJsxFromMarkdown,
    +  mdxJsxToMarkdown,
    +  type MdxJsxAttribute,
    +  type MdxJsxExpressionAttribute,
    +  type MdxJsxFlowElement,
    +  type MdxJsxTextElement,
    +} from "mdast-util-mdx-jsx";
    +import { parseExpressionAt, type Expression } from "acorn";
     import { DocsContentError, diagnostic } from "./diagnostics";
     import { requireDocsContent, DOCS_LIMITS } from "./validate";
    +import { docsSchema } from "./extensions";
     import {
       splitDocsFrontmatter,
       serializeDocsFrontmatter,
       type DocsFrontmatter,
     } from "./frontmatter";
     import { isSafeDocsUrl } from "./urls";
    +import { CALLOUT_TYPES, DOCS_BLOCK_COMPONENTS, type TypeTableRow } from "./blocks";
    +
    +export const DOCS_PARSER_VERSION = 2;
     
    -export const DOCS_PARSER_VERSION = 1;
    +/** Rich blocks are MDX-style JSX tags. Only the tag syntax is enabled: braces in prose stay text. */
    +function jsxTags(this: Processor) {
    +  const data = this.data();
    +  data.micromarkExtensions = [...(data.micromarkExtensions ?? []), mdxJsx()];
    +  data.fromMarkdownExtensions = [
    +    ...(data.fromMarkdownExtensions ?? []),
    +    mdxJsxFromMarkdown(),
    +  ];
    +  data.toMarkdownExtensions = [
    +    ...(data.toMarkdownExtensions ?? []),
    +    mdxJsxToMarkdown(),
    +    {
    +      // The stock handler indents children, which turns nested fences into indented code.
    +      handlers: { mdxJsxFlowElement: writeComponent as never },
    +    },
    +  ];
    +}
    +/** Children stay unindented, between blank lines, so code fences inside keep their meaning. */
    +function writeComponent(
    +  node: MdxJsxFlowElement,
    +  _parent: unknown,
    +  state: { containerFlow(parent: MdxJsxFlowElement, info: unknown): string },
    +  info: unknown,
    +): string {
    +  const props = node.attributes.map((attribute) => {
    +    const { name, value } = attribute as MdxJsxAttribute;
    +    if (value === null || value === undefined) return name;
    +    if (typeof value === "string")
    +      return `${name}="${value.replace(/&/g, "&").replace(/"/g, """)}"`;
    +    return `${name}={${value.value}}`;
    +  });
    +  // Multi-line props (a type table) go one per line, as people write them by hand.
    +  const multiline = props.some((item) => item.includes("\n"));
    +  const open = multiline
    +    ? `<${node.name}\n${props.map((item) => `  ${item}`).join("\n")}\n`
    +    : `<${node.name}${props.map((item) => ` ${item}`).join("")}`;
    +  if (!node.children.length) return `${open}${multiline ? "" : " "}/>`;
    +  const body = state.containerFlow(node, info);
    +  return `${open}>\n\n${body}\n\n`;
    +}
    +
    +/** As in MDX, indentation inside components is layout, not an indented code block. */
    +function noIndentedCode(this: Processor) {
    +  const data = this.data();
    +  data.micromarkExtensions = [
    +    ...(data.micromarkExtensions ?? []),
    +    { disable: { null: ["codeIndented"] } },
    +  ];
    +}
     const parser = unified().use(remarkParse).use(remarkGfm);
    -const writer = unified().use(remarkGfm).use(remarkStringify, {
    +const componentParser = unified()
    +  .use(remarkParse)
    +  .use(remarkGfm)
    +  .use(jsxTags)
    +  .use(noIndentedCode);
    +const writer = unified().use(remarkGfm).use(jsxTags).use(remarkStringify, {
       bullet: "-",
       emphasis: "*",
       strong: "*",
       fences: true,
       incrementListMarker: true,
    +  resourceLink: true,
     });
    +const COMPONENT_TAG = new RegExp(
    +  `<\\/?(?:${Object.keys(DOCS_BLOCK_COMPONENTS).join("|")})\\b`,
    +);
    +
    +const CODE_TITLE = /^title=(?:"([^"\n]+)"|'([^'\n]+)')$/;
    +const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
    +const ICON = /^[A-Za-z][\w-]*$/;
    +/** Props each component accepts, and the only child a container may hold. */
    +const PROPS: Record = {
    +  Callout: ["type", "title"],
    +  Tabs: ["items"],
    +  Tab: ["value"],
    +  Steps: [],
    +  Step: [],
    +  Cards: [],
    +  Card: ["title", "href", "icon"],
    +  Accordions: ["type"],
    +  Accordion: ["title"],
    +  CodeGroup: [],
    +  TypeTable: ["type"],
    +};
    +const CHILD: Record = {
    +  Tabs: "Tab",
    +  Steps: "Step",
    +  Cards: "Card",
    +  Accordions: "Accordion",
    +};
    +const TYPE_TABLE_FIELDS = ["type", "default", "description", "required"];
    +
    +/** Files that use a rich-block tag follow MDX rules; all others stay plain CommonMark. */
    +function parseTree(
    +  body: string,
    +  fail: (place: { line: number; column: number } | undefined, message: string) => never,
    +): Root {
    +  if (!COMPONENT_TAG.test(body)) return parser.parse(body);
    +  try {
    +    return componentParser.parse(body);
    +  } catch (cause) {
    +    const {
    +      line,
    +      column,
    +      reason = String(cause),
    +    } = cause as {
    +      line?: number;
    +      column?: number;
    +      reason?: string;
    +    };
    +    // Unclosed-tag errors carry their position only in the message: "(1:1-1:10)".
    +    const place = /\((\d+):(\d+)-/.exec(reason);
    +    return fail(
    +      line
    +        ? { line, column: column ?? 1 }
    +        : place
    +          ? { line: Number(place[1]), column: Number(place[2]) }
    +          : undefined,
    +      `Invalid component syntax: ${reason}`,
    +    );
    +  }
    +}
    +
    +/** Evaluates a prop expression made only of literals: strings, numbers, booleans, null, arrays, objects. */
    +function literal(source: string): unknown {
    +  const expression = parseExpressionAt(source, 0, { ecmaVersion: "latest" });
    +  if (source.slice(expression.end).trim()) throw new Error("Unexpected input.");
    +  return evaluate(expression);
    +}
    +function evaluate(node: Expression): unknown {
    +  switch (node.type) {
    +    case "Literal":
    +      if (
    +        node.value === null ||
    +        ["string", "number", "boolean"].includes(typeof node.value)
    +      )
    +        return node.value;
    +      break;
    +    case "TemplateLiteral":
    +      if (!node.expressions.length) return node.quasis[0].value.cooked;
    +      break;
    +    case "UnaryExpression":
    +      if (
    +        node.operator === "-" &&
    +        node.argument.type === "Literal" &&
    +        typeof node.argument.value === "number"
    +      )
    +        return -node.argument.value;
    +      break;
    +    case "ArrayExpression":
    +      return node.elements.map((element) => {
    +        if (!element || element.type === "SpreadElement")
    +          throw new Error("Not literal.");
    +        return evaluate(element);
    +      });
    +    case "ObjectExpression": {
    +      const output: Record = {};
    +      for (const property of node.properties) {
    +        if (
    +          property.type !== "Property" ||
    +          property.kind !== "init" ||
    +          property.computed ||
    +          property.method ||
    +          property.shorthand
    +        )
    +          throw new Error("Not literal.");
    +        const key =
    +          property.key.type === "Identifier"
    +            ? property.key.name
    +            : property.key.type === "Literal" && typeof property.key.value === "string"
    +              ? property.key.value
    +              : undefined;
    +        if (key === undefined || key === "__proto__" || Object.hasOwn(output, key))
    +          throw new Error("Not literal.");
    +        output[key] = evaluate(property.value as Expression);
    +      }
    +      return output;
    +    }
    +  }
    +  throw new Error("Not literal.");
    +}
    +
    +/** A JS string literal, single-quoted when that avoids escaping double quotes. */
    +function quote(value: string): string {
    +  return value.includes('"') && !/['\\\n]/.test(value)
    +    ? `'${value}'`
    +    : JSON.stringify(value);
    +}
    +
    +function formatRows(rows: TypeTableRow[]): string {
    +  const lines = rows.map((row) => {
    +    const fields = [
    +      `type: ${quote(row.type)}`,
    +      ...(row.default === null ? [] : [`default: ${quote(row.default)}`]),
    +      ...(row.description === null ? [] : [`description: ${quote(row.description)}`]),
    +      ...(row.required ? ["required: true"] : []),
    +    ];
    +    const key = IDENTIFIER.test(row.name) ? row.name : JSON.stringify(row.name);
    +    return `    ${key}: { ${fields.join(", ")} },`;
    +  });
    +  return `{\n${lines.join("\n")}\n  }`;
    +}
     type Mark = NonNullable[number];
     
     /** DM-CONVERT: CommonMark/GFM is parsed as data. Unsupported constructs fail closed. */
    @@ -46,7 +253,15 @@ export function parseDocsMarkdown(
               frontmatter: undefined,
             }
           : splitDocsFrontmatter(markdown, options.file);
    -  const tree = parser.parse(source.body);
    +  const tree = parseTree(source.body, (place, message) => {
    +    throw new DocsContentError([
    +      diagnostic("UNSUPPORTED_NODE", message, {
    +        file: options.file,
    +        line: place ? place.line + source.lineOffset : undefined,
    +        column: place?.column,
    +      }),
    +    ]);
    +  });
       const definitions = new Map();
       function error(node: Nodes, message: string, code = "UNSUPPORTED_NODE"): never {
         throw new DocsContentError([
    @@ -101,8 +316,9 @@ export function parseDocsMarkdown(
             case "delete":
               return inline(node.children, [...marks, { type: "strike" }]);
             case "inlineCode":
    -          if (marks.length) error(node, "Inline code cannot carry other marks.");
    -          return [{ type: "text", text: node.value, marks: [{ type: "code" }] }];
    +          return [
    +            { type: "text", text: node.value, marks: [...marks, { type: "code" }] },
    +          ];
             case "break":
               if (marks.length) error(node, "Marked line breaks are unsupported.");
               return [{ type: "hardBreak" }];
    @@ -125,6 +341,11 @@ export function parseDocsMarkdown(
                 },
               ]);
             }
    +        case "mdxJsxTextElement":
    +          return error(
    +            node,
    +            `Inline <${node.name ?? ""}> is unsupported. Put the component on its own lines.`,
    +          );
             default:
               return error(node, `Unsupported inline construct: ${node.type}.`);
           }
    @@ -145,11 +366,228 @@ export function parseDocsMarkdown(
           },
         };
       }
    +  function props(node: MdxJsxFlowElement | MdxJsxTextElement) {
    +    const name = node.name ?? "";
    +    const output: Record = {};
    +    for (const attribute of node.attributes as (
    +      | MdxJsxAttribute
    +      | MdxJsxExpressionAttribute
    +    )[]) {
    +      if (attribute.type === "mdxJsxExpressionAttribute")
    +        error(node, "Spread props are unsupported.", "UNSUPPORTED_ATTRIBUTE");
    +      if (!PROPS[name].includes(attribute.name))
    +        error(
    +          node,
    +          `Unsupported <${name}> prop: ${attribute.name}.`,
    +          "UNSUPPORTED_ATTRIBUTE",
    +        );
    +      const value = attribute.value;
    +      const icon =
    +        name === "Card" &&
    +        attribute.name === "icon" &&
    +        typeof value === "object" &&
    +        value
    +          ? /^\s*<([A-Za-z][\w-]*)\s*\/>\s*$/.exec(value.value)
    +          : null;
    +      if (value === null || value === undefined) output[attribute.name] = true;
    +      else if (typeof value === "string") output[attribute.name] = value;
    +      else if (icon) output.icon = icon[1];
    +      else
    +        try {
    +          output[attribute.name] = literal(value.value);
    +        } catch {
    +          error(
    +            node,
    +            `<${name}> prop ${attribute.name} must be a literal value.`,
    +            "UNSUPPORTED_ATTRIBUTE",
    +          );
    +        }
    +    }
    +    return output;
    +  }
    +  function component(
    +    node: MdxJsxFlowElement | MdxJsxTextElement,
    +    children: RootContent[],
    +    parent?: string,
    +  ): JSONContent {
    +    const name = node.name ?? "";
    +    if (!Object.hasOwn(PROPS, name))
    +      error(
    +        node,
    +        `Unknown component <${name}>. Use a supported block or plain Markdown.`,
    +      );
    +    const container = Object.keys(CHILD).find((key) => CHILD[key] === name);
    +    if (container && parent !== container)
    +      error(node, `<${name}> must be inside <${container}>.`);
    +    const p = props(node);
    +    const label = (prop: string, required: boolean): string | null => {
    +      const value = p[prop];
    +      if (value === undefined && !required) return null;
    +      if (typeof value !== "string")
    +        error(node, `<${name}> needs a text ${prop} prop.`, "INVALID_DOCUMENT");
    +      return value;
    +    };
    +    // An empty block is an empty paragraph, as the editor inserts it.
    +    const content = (optional = false) => {
    +      const output = children.flatMap(block);
    +      return output.length || optional ? output : [{ type: "paragraph" }];
    +    };
    +    const items = () => {
    +      const output = children
    +        .filter((child) => child.type !== "definition")
    +        .map((child) => {
    +          if (child.type !== "mdxJsxFlowElement" || child.name !== CHILD[name])
    +            error(child, `<${name}> can only contain <${CHILD[name]}>.`);
    +          return child;
    +        });
    +      if (!output.length)
    +        error(
    +          node,
    +          `<${name}> needs at least one <${CHILD[name]}>.`,
    +          "INVALID_DOCUMENT",
    +        );
    +      return output;
    +    };
    +    const nested = () =>
    +      items().map((child) => component(child, child.children as RootContent[], name));
    +    switch (name) {
    +      case "Callout": {
    +        const type = p.type ?? null;
    +        if (type !== null && !CALLOUT_TYPES.includes(type as never))
    +          error(
    +            node,
    +            `Callout type must be one of: ${CALLOUT_TYPES.join(", ")}.`,
    +            "UNSUPPORTED_ATTRIBUTE",
    +          );
    +        return {
    +          type: "callout",
    +          attrs: { type, title: label("title", false) },
    +          content: content(),
    +        };
    +      }
    +      case "Tabs": {
    +        const listed = p.items;
    +        if (
    +          listed !== undefined &&
    +          !(Array.isArray(listed) && listed.every((item) => typeof item === "string"))
    +        )
    +          error(
    +            node,
    +            " items must be a list of strings.",
    +            "UNSUPPORTED_ATTRIBUTE",
    +          );
    +        const tabs = nested().map((tab, i) => {
    +          const value: string =
    +            tab.attrs!.label ?? (listed as string[] | undefined)?.[i] ?? "";
    +          if (listed && !(listed as string[]).includes(value))
    +            error(
    +              node,
    +              `Tab value "${value}" is not listed in items.`,
    +              "INVALID_DOCUMENT",
    +            );
    +          return { ...tab, attrs: { label: value } };
    +        });
    +        return { type: "tabs", content: tabs };
    +      }
    +      case "Tab":
    +        return {
    +          type: "tab",
    +          attrs: { label: label("value", false) },
    +          content: content(),
    +        };
    +      case "Steps":
    +        return { type: "steps", content: nested() };
    +      case "Step":
    +        return { type: "step", content: content() };
    +      case "Cards":
    +        return { type: "cards", content: nested() };
    +      case "Card": {
    +        const href = label("href", false);
    +        if (href !== null && !isSafeDocsUrl(href))
    +          error(node, "Card link URL is unsafe.", "UNSAFE_URL");
    +        const icon = label("icon", false);
    +        if (icon !== null && !ICON.test(icon))
    +          error(node, "Card icon must be an icon name.", "UNSUPPORTED_ATTRIBUTE");
    +        const body = content(true);
    +        return {
    +          type: "card",
    +          attrs: { title: label("title", true), href, icon },
    +          ...(body.length ? { content: body } : {}),
    +        };
    +      }
    +      case "Accordions": {
    +        const type = p.type ?? null;
    +        if (type !== null && type !== "single" && type !== "multiple")
    +          error(
    +            node,
    +            'Accordions type must be "single" or "multiple".',
    +            "UNSUPPORTED_ATTRIBUTE",
    +          );
    +        return { type: "accordions", attrs: { type }, content: nested() };
    +      }
    +      case "Accordion":
    +        return {
    +          type: "accordion",
    +          attrs: { title: label("title", true) },
    +          content: content(),
    +        };
    +      case "CodeGroup": {
    +        for (const child of children)
    +          if (child.type !== "code")
    +            error(child, " can only contain code blocks.");
    +        return { type: "codeGroup", content: content() };
    +      }
    +      default: {
    +        if (children.length) error(node, " cannot have children.");
    +        const type = p.type;
    +        if (!type || typeof type !== "object" || Array.isArray(type))
    +          error(node, " needs a type={{ … }} prop.", "INVALID_DOCUMENT");
    +        const rows = Object.entries(type as Record).map(
    +          ([field, entry]) => {
    +            const value = entry as Record;
    +            if (
    +              !value ||
    +              typeof value !== "object" ||
    +              Array.isArray(value) ||
    +              typeof value.type !== "string" ||
    +              Object.keys(value).some((key) => !TYPE_TABLE_FIELDS.includes(key)) ||
    +              !["string", "undefined"].includes(typeof value.default) ||
    +              !["string", "undefined"].includes(typeof value.description) ||
    +              !["boolean", "undefined"].includes(typeof value.required)
    +            )
    +              error(
    +                node,
    +                ` field ${field} needs a string type and only string default/description and boolean required.`,
    +                "UNSUPPORTED_ATTRIBUTE",
    +              );
    +            return {
    +              name: field,
    +              type: value.type as string,
    +              default: (value.default as string | undefined) ?? null,
    +              description: (value.description as string | undefined) ?? null,
    +              required: (value.required as boolean | undefined) ?? false,
    +            };
    +          },
    +        );
    +        return { type: "typeTable", attrs: { rows } };
    +      }
    +    }
    +  }
       function block(node: RootContent): JSONContent[] {
         switch (node.type) {
           case "definition":
             return [];
           case "paragraph": {
    +        const only = node.children.length === 1 ? node.children[0] : undefined;
    +        if (only?.type === "mdxJsxTextElement")
    +          return [
    +            component(
    +              only,
    +              only.children.length
    +                ? [{ type: "paragraph", children: only.children }]
    +                : [],
    +            ),
    +          ];
             if (
               node.children.length === 1 &&
               ["image", "imageReference"].includes(node.children[0].type)
    @@ -176,16 +614,42 @@ export function parseDocsMarkdown(
             return [{ type: "blockquote", content: node.children.flatMap(block) }];
           case "thematicBreak":
             return [{ type: "horizontalRule" }];
    -      case "code":
    -        if (node.meta)
    -          error(node, "Code fence metadata is unsupported.", "UNSUPPORTED_ATTRIBUTE");
    +      case "code": {
    +        // Without a language, remark reads `title="…"` itself as the language.
    +        const untitled = node.lang?.startsWith("title=");
    +        const meta = untitled
    +          ? [node.lang, node.meta].filter(Boolean).join(" ")
    +          : node.meta;
    +        const lang = untitled ? null : node.lang;
    +        const title = meta ? CODE_TITLE.exec(meta.trim()) : null;
    +        if (meta && !title)
    +          error(
    +            node,
    +            'Code fence metadata other than title="…" is unsupported.',
    +            "UNSUPPORTED_ATTRIBUTE",
    +          );
             return [
               {
                 type: "codeBlock",
    -            attrs: { language: node.lang ?? null },
    +            attrs: {
    +              language: lang ?? null,
    +              ...(title ? { title: title[1] ?? title[2] } : {}),
    +            },
                 content: node.value ? [{ type: "text", text: node.value }] : [],
               },
             ];
    +      }
    +      case "mdxJsxFlowElement":
    +        return [component(node, node.children as RootContent[])];
    +      case "html": {
    +        const tag = /^<([A-Z][\w.]*)/.exec(node.value);
    +        return error(
    +          node,
    +          tag
    +            ? `Unknown component <${tag[1]}>. Use a supported block or plain Markdown.`
    +            : "Raw HTML is unsupported.",
    +        );
    +      }
           case "list":
             return [
               {
    @@ -295,6 +759,33 @@ export function serializeDocsMarkdown(
       frontmatter?: DocsFrontmatter,
     ): string {
       const content = requireDocsContent(input);
    +  const prop = (
    +    name: string,
    +    value: string | null | undefined,
    +    expression = false,
    +  ): MdxJsxAttribute | undefined =>
    +    value === null || value === undefined
    +      ? undefined
    +      : {
    +          type: "mdxJsxAttribute",
    +          name,
    +          value: expression ? { type: "mdxJsxAttributeValueExpression", value } : value,
    +        };
    +  function element(
    +    name: string,
    +    attributes: (MdxJsxAttribute | undefined)[],
    +    content: JSONContent[],
    +    nested?: () => MdxJsxFlowElement[],
    +  ): MdxJsxFlowElement {
    +    return {
    +      type: "mdxJsxFlowElement",
    +      name,
    +      attributes: attributes.filter((item) => item !== undefined),
    +      children: (nested
    +        ? nested()
    +        : content.map(block)) as MdxJsxFlowElement["children"],
    +    };
    +  }
       function block(node: JSONContent): RootContent {
         const children = node.content ?? [];
         switch (node.type) {
    @@ -320,12 +811,76 @@ export function serializeDocsMarkdown(
                 },
               ],
             };
    -      case "codeBlock":
    +      case "codeBlock": {
    +        const title: string | null = node.attrs?.title ?? null;
    +        const language: string | null = node.attrs?.language ?? null;
    +        const meta = title
    +          ? title.includes('"')
    +            ? `title='${title}'`
    +            : `title="${title}"`
    +          : null;
    +        // remark drops meta when there is no language; write it as the info string.
             return {
               type: "code",
    -          lang: node.attrs?.language ?? null,
    +          lang: language ?? meta,
    +          meta: language ? meta : null,
               value: children.map((child) => child.text ?? "").join(""),
             };
    +      }
    +      case "callout":
    +        return element(
    +          "Callout",
    +          [prop("type", node.attrs?.type), prop("title", node.attrs?.title)],
    +          children,
    +        );
    +      case "tabs": {
    +        const labels = children.map((tab) => quote(tab.attrs!.label));
    +        return element(
    +          "Tabs",
    +          [prop("items", `[${labels.join(", ")}]`, true)],
    +          [],
    +          () =>
    +            children.map((tab) =>
    +              element("Tab", [prop("value", tab.attrs!.label)], tab.content ?? []),
    +            ),
    +        );
    +      }
    +      case "steps":
    +        return element("Steps", [], [], () =>
    +          children.map((step) => element("Step", [], step.content ?? [])),
    +        );
    +      case "cards":
    +        return element("Cards", [], [], () =>
    +          children.map((card) =>
    +            element(
    +              "Card",
    +              [
    +                prop("title", card.attrs!.title),
    +                prop("href", card.attrs?.href),
    +                prop("icon", card.attrs?.icon ? `<${card.attrs.icon} />` : null, true),
    +              ],
    +              card.content ?? [],
    +            ),
    +          ),
    +        );
    +      case "accordions":
    +        return element("Accordions", [prop("type", node.attrs?.type)], [], () =>
    +          children.map((item) =>
    +            element(
    +              "Accordion",
    +              [prop("title", item.attrs!.title)],
    +              item.content ?? [],
    +            ),
    +          ),
    +        );
    +      case "codeGroup":
    +        return element("CodeGroup", [], children);
    +      case "typeTable":
    +        return element(
    +          "TypeTable",
    +          [prop("type", formatRows(node.attrs?.rows ?? []), true)],
    +          [],
    +        );
           case "blockquote":
             return {
               type: "blockquote",
    @@ -371,15 +926,21 @@ export function serializeDocsMarkdown(
       const markdown = writer.stringify(tree);
       // A serializer must never silently change semantic structure (e.g. empty blocks).
       const roundtrip = parseDocsMarkdown(markdown, { frontmatter: false }).content;
    -  // Markdown does not encode blank paragraph spacing. Ignore empty top-level
    -  // paragraphs in semantic comparisons; retain them in canonical editor JSON.
    -  const semantic = (doc: JSONContent) => ({
    -    ...doc,
    -    content:
    -      doc.content?.filter(
    -        (node) => node.type !== "paragraph" || node.content?.length,
    -      ) ?? [],
    -  });
    +  // Markdown does not encode blank paragraph spacing. Ignore empty paragraphs
    +  // in semantic comparisons, keeping one where the node needs content (a new
    +  // callout or table cell); retain them all in canonical editor JSON.
    +  const semantic = (node: JSONContent): JSONContent => {
    +    if (!node.content) return node;
    +    const children = node.content
    +      .filter((child) => child.type !== "paragraph" || child.content?.length)
    +      .map(semantic);
    +    const required =
    +      node.type !== "doc" && !docsSchema.nodes[node.type!].contentMatch.validEnd;
    +    if (children.length) return { ...node, content: children };
    +    if (required) return { ...node, content: [{ type: "paragraph" }] };
    +    const { content: _empty, ...rest } = node;
    +    return node.type === "doc" ? { ...rest, content: [] } : rest;
    +  };
       if (JSON.stringify(semantic(roundtrip)) !== JSON.stringify(semantic(content)))
         throw new DocsContentError([
           diagnostic(
    diff --git a/packages/text-editor/src/docs/validate.ts b/packages/text-editor/src/docs/validate.ts
    index 9a6a740..757a80c 100644
    --- a/packages/text-editor/src/docs/validate.ts
    +++ b/packages/text-editor/src/docs/validate.ts
    @@ -7,6 +7,7 @@ import {
       type DocsDiagnostic,
     } from "./diagnostics";
     import { isSafeDocsUrl } from "./urls";
    +import { CALLOUT_TYPES, type TypeTableRow } from "./blocks";
     
     export const DOCS_LIMITS = {
       bytes: 1024 * 1024,
    @@ -20,6 +21,19 @@ const record = (value: unknown): value is Record =>
       !Array.isArray(value) &&
       [Object.prototype, null].includes(Object.getPrototypeOf(value));
     const nullableString = (v: unknown) => v === null || typeof v === "string";
    +// Titles and labels may be empty while an author is typing; autosave must not fail.
    +const label = (v: unknown) => typeof v === "string";
    +const nullableLabel = nullableString;
    +const typeTableRow = (v: unknown) =>
    +  record(v) &&
    +  Object.keys(v).every((key) =>
    +    ["name", "type", "default", "description", "required"].includes(key),
    +  ) &&
    +  label(v.name) &&
    +  typeof v.type === "string" &&
    +  nullableString(v.default) &&
    +  nullableString(v.description) &&
    +  typeof v.required === "boolean";
     const attributes: Record boolean>> = {
       heading: { level: (v) => Number.isInteger(v) && Number(v) >= 1 && Number(v) <= 6 },
       orderedList: {
    @@ -28,6 +42,25 @@ const attributes: Record boolean>> = {
       },
       codeBlock: {
         language: (v) => v === null || (typeof v === "string" && /^[\w+-]+$/.test(v)),
    +    title: nullableLabel,
    +  },
    +  callout: {
    +    type: (v) => v === null || CALLOUT_TYPES.includes(v as never),
    +    title: nullableLabel,
    +  },
    +  tab: { label },
    +  card: {
    +    title: label,
    +    href: nullableString,
    +    icon: (v) => v === null || (typeof v === "string" && /^[A-Za-z][\w-]*$/.test(v)),
    +  },
    +  accordions: { type: (v) => v === null || v === "single" || v === "multiple" },
    +  accordion: { title: label },
    +  typeTable: {
    +    rows: (v) =>
    +      Array.isArray(v) &&
    +      v.every(typeTableRow) &&
    +      new Set(v.map((row) => row.name)).size === v.length,
       },
       image: {
         src: (v) => typeof v === "string",
    @@ -149,12 +182,6 @@ export function validateDocsContent(input: unknown): {
               )
                 add("UNSAFE_URL", "Link URL is unsafe.", `${mp}.attrs.href`);
             });
    -        if (seen.has("code") && seen.size > 1)
    -          add(
    -            "INVALID_DOCUMENT",
    -            "Inline code cannot carry other marks.",
    -            `${path}.marks`,
    -          );
           }
         }
         if (
    @@ -162,6 +189,13 @@ export function validateDocsContent(input: unknown): {
           (!record(node.attrs) || !isSafeDocsUrl(node.attrs.src, true))
         )
           add("UNSAFE_URL", "Images require a stable HTTPS URL.", `${path}.attrs.src`);
    +    if (
    +      node.type === "card" &&
    +      record(node.attrs) &&
    +      typeof node.attrs.href === "string" &&
    +      !isSafeDocsUrl(node.attrs.href)
    +    )
    +      add("UNSAFE_URL", "Card link URL is unsafe.", `${path}.attrs.href`);
         if (
           node.type === "heading" &&
           (!record(node.attrs) || !Number.isInteger(node.attrs.level))
    @@ -215,6 +249,20 @@ export function validateDocsContent(input: unknown): {
         ...(node.type === "image"
           ? { attrs: { ...node.attrs, alt: node.attrs?.alt ?? "" } }
           : {}),
    +    // Type table rows are one attribute value; give them a fixed key order.
    +    ...(node.type === "typeTable"
    +      ? {
    +          attrs: {
    +            rows: (node.attrs?.rows ?? []).map((row: TypeTableRow) => ({
    +              name: row.name,
    +              type: row.type,
    +              default: row.default,
    +              description: row.description,
    +              required: row.required,
    +            })),
    +          },
    +        }
    +      : {}),
         ...(node.content ? { content: node.content.map(normalize) } : {}),
       });
       // Missing and empty alt text both lack a description; retain this as an
    diff --git a/packages/text-editor/src/editor.tsx b/packages/text-editor/src/editor.tsx
    index e8519a6..db5c8c4 100644
    --- a/packages/text-editor/src/editor.tsx
    +++ b/packages/text-editor/src/editor.tsx
    @@ -20,6 +20,8 @@ import type { ReactEditorProps } from "./types";
     import { cn } from "./lib/cn";
     import { useImagePicker } from "./image-picker";
     import { createDocsExtensions } from "./docs/extensions";
    +import { withDocsBlockViews } from "./components/docs-block-views";
    +import { Gapcursor, TrailingNode } from "@tiptap/extensions";
     import Placeholder from "@tiptap/extension-placeholder";
     import { isSafeDocsUrl } from "./docs/urls";
     
    @@ -79,12 +81,21 @@ const Editor: EditorType = Object.assign(
     
         const extensions = useMemo(
           () =>
    -        profile === "documentation"
    -          ? [
    -              ...createDocsExtensions(),
    -              Placeholder.configure({ placeholder: placeholder || "Write something…" }),
    -            ]
    -          : createExtensions({ placeholder }),
    +        withDocsBlockViews(
    +          profile === "documentation"
    +            ? [
    +                ...createDocsExtensions(),
    +                Placeholder.configure({
    +                  placeholder: placeholder || "Write something…",
    +                }),
    +                // Editing only: lets authors type before or after a block that
    +                // starts or ends the doc. Empty paragraphs are not saved to Markdown.
    +                Gapcursor,
    +                TrailingNode,
    +              ]
    +            : createExtensions({ placeholder }),
    +          profile ?? "default",
    +        ),
           [placeholder, profile],
         );
     
    diff --git a/packages/text-editor/src/extensions.ts b/packages/text-editor/src/extensions.ts
    index beae24f..6d67960 100644
    --- a/packages/text-editor/src/extensions.ts
    +++ b/packages/text-editor/src/extensions.ts
    @@ -17,6 +17,7 @@ import xml from "highlight.js/lib/languages/xml";
     import type { Extensions } from "@tiptap/core";
     import { createId } from "./create-id";
     import { CodeMirrorNode } from "./components/custom-code-mirror";
    +import { CodeBlockTitle, createDocsBlockExtensions } from "./docs/blocks";
     
     lowlight.registerLanguage("javascript", javascript);
     lowlight.registerLanguage("typescript", typescript);
    @@ -32,6 +33,7 @@ export const createExtensions = ({
       placeholder,
     }: ExtensionOptions = {}): Extensions => [
       StarterKit.configure({
    +    code: false,
         codeBlock: false,
         // Configured/replaced explicitly below (custom options or a
         // `.extend()`) — disabled here to avoid tiptap's duplicate-extension
    @@ -96,4 +98,6 @@ export const createExtensions = ({
       }),
       Highlight,
       CodeMirrorNode,
    +  CodeBlockTitle,
    +  ...createDocsBlockExtensions(),
     ];
    diff --git a/packages/text-editor/src/prose.css b/packages/text-editor/src/prose.css
    index a9064fb..63d856a 100644
    --- a/packages/text-editor/src/prose.css
    +++ b/packages/text-editor/src/prose.css
    @@ -399,3 +399,308 @@
       border-radius: 0.5rem;
       margin: 1rem auto;
     }
    +
    +/* Rich docs blocks. Colours come from the site theme tokens; callout accents
    +   mix a fixed hue into them so they read in light and dark themes. */
    +.tiptap-renderer .te-code-block__title {
    +  border: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +  border-bottom: none;
    +  border-radius: 0.5rem 0.5rem 0 0;
    +  color: var(--muted-foreground, hsl(215.4 16.3% 46.9%));
    +  font-family:
    +    ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
    +    monospace;
    +  font-size: 0.8125rem;
    +  padding: 0.5rem 1rem;
    +  padding-right: 2.75rem;
    +}
    +
    +.tiptap-renderer .te-code-block__title + pre {
    +  border-top-left-radius: 0;
    +  border-top-right-radius: 0;
    +}
    +
    +.tiptap-renderer .te-callout {
    +  --te-callout-accent: var(--primary, hsl(222.2 47.4% 11.2%));
    +  display: flex;
    +  gap: 0.75rem;
    +  margin: 1rem 0;
    +  padding: 0.75rem 1rem;
    +  border: 1px solid color-mix(in srgb, var(--te-callout-accent) 35%, transparent);
    +  border-radius: 0.5rem;
    +  background: color-mix(in srgb, var(--te-callout-accent) 7%, transparent);
    +}
    +
    +.tiptap-renderer .te-callout[data-type="tip"] {
    +  --te-callout-accent: hsl(142 71% 36%);
    +}
    +
    +.tiptap-renderer .te-callout[data-type="warning"] {
    +  --te-callout-accent: hsl(38 92% 45%);
    +}
    +
    +.tiptap-renderer .te-callout[data-type="danger"] {
    +  --te-callout-accent: hsl(0 72% 51%);
    +}
    +
    +.tiptap-renderer .te-callout[data-type="note"] {
    +  --te-callout-accent: var(--muted-foreground, hsl(215.4 16.3% 46.9%));
    +}
    +
    +.tiptap-renderer .te-callout__icon {
    +  flex-shrink: 0;
    +  width: 1.125rem;
    +  height: 1.125rem;
    +  margin-top: 0.2rem;
    +  color: var(--te-callout-accent);
    +}
    +
    +.tiptap-renderer .te-callout__body {
    +  min-width: 0;
    +  flex: 1;
    +}
    +
    +.tiptap-renderer .te-callout__body > :first-child {
    +  margin-top: 0;
    +}
    +
    +.tiptap-renderer .te-callout__body > :last-child {
    +  margin-bottom: 0;
    +}
    +
    +.tiptap-renderer .te-callout__title {
    +  font-weight: 600;
    +}
    +
    +.tiptap-renderer .te-tabs {
    +  margin: 1rem 0;
    +}
    +
    +.tiptap-renderer .te-tabs__list {
    +  display: flex;
    +  gap: 0.25rem;
    +  overflow-x: auto;
    +  border-bottom: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +}
    +
    +.tiptap-renderer .te-tabs__tab {
    +  appearance: none;
    +  background: none;
    +  border: none;
    +  border-bottom: 2px solid transparent;
    +  margin-bottom: -1px;
    +  padding: 0.5rem 0.75rem;
    +  color: var(--muted-foreground, hsl(215.4 16.3% 46.9%));
    +  font: inherit;
    +  font-size: 0.875rem;
    +  font-weight: 500;
    +  white-space: nowrap;
    +  cursor: pointer;
    +}
    +
    +.tiptap-renderer .te-tabs__tab[aria-selected="true"] {
    +  border-bottom-color: var(--primary, hsl(222.2 47.4% 11.2%));
    +  color: var(--foreground, hsl(222.2 84% 4.9%));
    +}
    +
    +.tiptap-renderer .te-tabs__panel > :first-child {
    +  margin-top: 0.75rem;
    +}
    +
    +.tiptap-renderer .te-code-group .te-code-block__title {
    +  display: none;
    +}
    +
    +.tiptap-renderer .te-steps {
    +  counter-reset: te-step;
    +  margin: 1rem 0;
    +  padding-left: 0;
    +}
    +
    +.tiptap-renderer .te-steps > .te-step {
    +  counter-increment: te-step;
    +  list-style: none;
    +  position: relative;
    +  padding-left: 2.75rem;
    +  padding-bottom: 0.5rem;
    +}
    +
    +.tiptap-renderer .te-steps > .te-step::before {
    +  content: counter(te-step);
    +  position: absolute;
    +  left: 0;
    +  top: 0;
    +  display: flex;
    +  align-items: center;
    +  justify-content: center;
    +  width: 1.75rem;
    +  height: 1.75rem;
    +  border: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +  border-radius: 9999px;
    +  background: var(--muted, hsl(210 40% 96.1%));
    +  font-size: 0.875rem;
    +  font-weight: 600;
    +}
    +
    +.tiptap-renderer .te-steps > .te-step::after {
    +  content: "";
    +  position: absolute;
    +  left: 0.875rem;
    +  top: 2.25rem;
    +  bottom: 0.25rem;
    +  border-left: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +}
    +
    +.tiptap-renderer .te-steps > .te-step > :first-child {
    +  margin-top: 0;
    +}
    +
    +.tiptap-renderer .te-cards {
    +  display: grid;
    +  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
    +  gap: 0.75rem;
    +  margin: 1rem 0;
    +}
    +
    +.tiptap-renderer .te-card {
    +  display: flex;
    +  flex-direction: column;
    +  gap: 0.25rem;
    +  padding: 1rem;
    +  border: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +  border-radius: 0.5rem;
    +  background: var(--card, transparent);
    +  color: inherit;
    +  text-decoration: none;
    +}
    +
    +.tiptap-renderer a.te-card:hover {
    +  background: var(--muted, hsl(210 40% 96.1%));
    +}
    +
    +.tiptap-renderer .te-card__icon {
    +  width: 1.25rem;
    +  height: 1.25rem;
    +  margin-bottom: 0.25rem;
    +  color: var(--muted-foreground, hsl(215.4 16.3% 46.9%));
    +}
    +
    +.tiptap-renderer .te-card__title {
    +  font-weight: 600;
    +}
    +
    +.tiptap-renderer .te-card__body {
    +  color: var(--muted-foreground, hsl(215.4 16.3% 46.9%));
    +  font-size: 0.875rem;
    +}
    +
    +.tiptap-renderer .te-card__body > * {
    +  margin: 0;
    +}
    +
    +.tiptap-renderer .te-accordions {
    +  margin: 1rem 0;
    +  border: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +  border-radius: 0.5rem;
    +}
    +
    +.tiptap-renderer .te-accordion + .te-accordion {
    +  border-top: 1px solid var(--border, hsl(214.3 31.8% 91.4%));
    +}
    +
    +.tiptap-renderer .te-accordion__title {
    +  padding: 0.75rem 1rem;
    +  font-weight: 600;
    +  cursor: pointer;
    +}
    +
    +.tiptap-renderer .te-accordion__body {
    +  padding: 0 1rem;
    +}
    +
    +.tiptap-renderer .te-type-table {
    +  overflow-x: auto;
    +}
    +
    +.tiptap-renderer .te-type-table table {
    +  table-layout: auto;
    +}
    +
    +.tiptap-renderer .te-type-table__required {
    +  color: var(--muted-foreground, hsl(215.4 16.3% 46.9%));
    +  font-size: 0.75rem;
    +}
    +
    +/* Rich docs blocks in the editor: a labelled frame per block with its props. */
    +.tiptap-editor .ProseMirror .te-block {
    +  margin: 0.75rem 0;
    +  border: 1px solid var(--border);
    +  border-radius: 0.5rem;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block__bar {
    +  display: flex;
    +  flex-wrap: wrap;
    +  align-items: center;
    +  gap: 0.375rem;
    +  padding: 0.25rem 0.5rem;
    +  border-bottom: 1px solid var(--border);
    +  background: var(--muted);
    +  border-radius: 0.5rem 0.5rem 0 0;
    +  font-size: 0.75rem;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block__label {
    +  font-weight: 600;
    +  color: var(--muted-foreground);
    +  text-transform: uppercase;
    +  letter-spacing: 0.04em;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block__content {
    +  padding: 0.25rem 0.75rem;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-item {
    +  margin: 0.5rem 0;
    +  border: 1px dashed var(--border);
    +  border-radius: 0.375rem;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-item > .te-block__bar {
    +  background: transparent;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block[data-block="steps"] > .te-block__content {
    +  counter-reset: te-step;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-item[data-block="step"]::before {
    +  counter-increment: te-step;
    +  content: "Step " counter(te-step);
    +  display: block;
    +  padding: 0.25rem 0.75rem 0;
    +  font-size: 0.75rem;
    +  font-weight: 600;
    +  color: var(--muted-foreground);
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-code {
    +  margin: 0.75rem 0;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-code pre {
    +  margin-top: 0;
    +  border-top-left-radius: 0;
    +  border-top-right-radius: 0;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-table {
    +  table-layout: auto;
    +  margin: 0.5rem 0;
    +}
    +
    +.tiptap-editor .ProseMirror .te-block-table th,
    +.tiptap-editor .ProseMirror .te-block-table td {
    +  padding: 0.25rem;
    +}
    diff --git a/packages/text-editor/src/text-renderer.tsx b/packages/text-editor/src/text-renderer.tsx
    index 2f78428..fc7c373 100644
    --- a/packages/text-editor/src/text-renderer.tsx
    +++ b/packages/text-editor/src/text-renderer.tsx
    @@ -11,6 +11,7 @@ import type { TextRendererTheme } from "./renderer-theme";
     import { cn } from "./lib/cn";
     import { RenderedCodeBlock } from "./components/rendered-code-block";
     import { extractDocsHeadings } from "./docs/headings";
    +import { docsBlockMapping } from "./components/docs-blocks";
     
     interface TextRendererProps {
       profile?: "default" | "documentation";
    @@ -157,8 +158,13 @@ export function TextRenderer({
                 
               );
             },
    +        ...docsBlockMapping,
             codeBlock: ({ node, children }) => (
    -          
    +          
                 {children}
               
             ),
    diff --git a/packages/text-editor/src/toolbar.tsx b/packages/text-editor/src/toolbar.tsx
    index c3720d5..451e519 100644
    --- a/packages/text-editor/src/toolbar.tsx
    +++ b/packages/text-editor/src/toolbar.tsx
    @@ -20,6 +20,7 @@ import {
       Undo2,
     } from "lucide-react";
     import LinkEditorPopover from "./components/link-editor-popover.js";
    +import { docsBlockTemplates } from "./components/docs-block-views";
     
     interface ToolbarProps {
       profile?: "default" | "documentation";
    @@ -336,6 +337,25 @@ const Toolbar = ({
                       {isUploadingImage ? "Uploading image…" : "Image"}
                     
     
    +                {profile === "documentation" && (
    +                  <>
    +                    
    + {Object.entries(docsBlockTemplates).map(([name, block]) => ( + + ))} + + )} + {editor.isActive("table") && ( <>
    diff --git a/packages/text-editor/tsup.renderer.config.ts b/packages/text-editor/tsup.renderer.config.ts index ac60a0c..48c7595 100644 --- a/packages/text-editor/tsup.renderer.config.ts +++ b/packages/text-editor/tsup.renderer.config.ts @@ -12,6 +12,7 @@ const srcDir = path.resolve("src"); // "use client" banner and imported (not bundled) by the renderer entry. const islands: Record = { "components/copy-code-button": "copy-code-button", + "components/docs-tabs": "docs-tabs", }; const shared = {