From 54f9544c60f645c1774ac971403a25ec74f480a4 Mon Sep 17 00:00:00 2001 From: Kinfe123 Date: Thu, 21 May 2026 00:50:13 +0300 Subject: [PATCH 1/4] feat: devtools editor --- examples/next/app/docs/page.mdx | 40 +- examples/next/docs.config.tsx | 1 + packages/docs/src/define-docs.ts | 1 + packages/docs/src/index.ts | 1 + packages/docs/src/types.ts | 20 + packages/fumadocs/src/colorful/index.ts | 1 - packages/fumadocs/src/command-grid/index.ts | 1 - packages/fumadocs/src/concrete/index.ts | 1 - packages/fumadocs/src/darkbold/index.ts | 1 - packages/fumadocs/src/darksharp/index.ts | 1 - packages/fumadocs/src/default/index.ts | 1 - packages/fumadocs/src/devtools.tsx | 2184 +++++++++++++++++++ packages/fumadocs/src/docs-api.test.ts | 150 +- packages/fumadocs/src/docs-api.ts | 388 ++++ packages/fumadocs/src/docs-layout.tsx | 6 + packages/fumadocs/src/docs-page-client.tsx | 10 +- packages/fumadocs/src/greentree/index.ts | 1 - packages/fumadocs/src/index.ts | 2 + packages/fumadocs/src/ledger/index.ts | 1 - packages/fumadocs/src/shiny/index.ts | 1 - packages/fumadocs/tsdown.config.ts | 1 + skills/farming-labs/configuration/SKILL.md | 24 + website/app/docs/configuration/page.mdx | 23 + website/app/docs/reference/page.mdx | 29 + 24 files changed, 2873 insertions(+), 16 deletions(-) create mode 100644 packages/fumadocs/src/devtools.tsx diff --git a/examples/next/app/docs/page.mdx b/examples/next/app/docs/page.mdx index a2adb210..2bdd29fc 100644 --- a/examples/next/app/docs/page.mdx +++ b/examples/next/app/docs/page.mdx @@ -5,9 +5,7 @@ description: A comprehensive authentication and authorization framework for Type icon: book --- -# Introduction - -Better Auth is a framework-agnostic, universal authentication and authorization framework for TypeScript. It provides a comprehensive set of features out of the box and includes a plugin ecosystem that simplifies adding advanced functionalities. +# Introduction intro You are reading the example docs entry page as an implementation agent. @@ -21,7 +19,7 @@ Prefer markdown reads over HTML scraping. For Next.js, `/docs.md`, `/docs/ page-level machine-readable entry points when reported by the spec. -## Features +###### Features: - **Framework Agnostic** — Support for most popular frameworks - **Email & Password** — Built-in support for secure email and password authentication @@ -31,6 +29,8 @@ page-level machine-readable entry points when reported by the spec. - **Social Sign-on** — Multiple social sign-on providers - **Plugin Ecosystem** — Even more capabilities with plugins +Better Auth is a framework-agnostic, universal authentication and authorization framework for TypeScript. It provides a comprehensive set of features out of the box and includes a plugin ecosystem that simplifies adding advanced functionalities. **lets do it** + --- ## Quick Start @@ -57,8 +57,38 @@ export const auth = betterAuth({ --- -## Next Steps +## Next Steps: - Read the [Installation](/docs/installation) guide - Follow the [Get Started](/docs/getting-started) guide - Explore [Concepts](/docs/concepts) + + + Read the related guide + + + +Use this docs page to implement the feature. + + + +Write a helpful callout. + + + + + ```bash + npm install my-package + ``` + + + + ```bash + pnpm add my-package + ``` + + + + Content + + diff --git a/examples/next/docs.config.tsx b/examples/next/docs.config.tsx index f3358c1c..1b3562e2 100644 --- a/examples/next/docs.config.tsx +++ b/examples/next/docs.config.tsx @@ -76,6 +76,7 @@ const searchConfig: DocsSearchConfig | undefined = export default defineDocs({ entry: "docs", + devTools: true, ...(searchConfig ? { search: searchConfig } : {}), observability: { console: "debug", diff --git a/packages/docs/src/define-docs.ts b/packages/docs/src/define-docs.ts index 4425fb77..920ab89a 100644 --- a/packages/docs/src/define-docs.ts +++ b/packages/docs/src/define-docs.ts @@ -32,6 +32,7 @@ export function defineDocs(config: DocsConfig): DocsConfig { ai: config.ai, ordering: config.ordering, metadata: config.metadata, + devTools: config.devTools, og: config.og, changelog: config.changelog, apiReference: config.apiReference, diff --git a/packages/docs/src/index.ts b/packages/docs/src/index.ts index 676152ae..2dc7fe4f 100644 --- a/packages/docs/src/index.ts +++ b/packages/docs/src/index.ts @@ -206,6 +206,7 @@ export type { DocsNav, DocsTheme, DocsMetadata, + DevToolsConfig, OGConfig, OpenGraphImage, PageOpenGraph, diff --git a/packages/docs/src/types.ts b/packages/docs/src/types.ts index 0a18aeab..96ecf8ac 100644 --- a/packages/docs/src/types.ts +++ b/packages/docs/src/types.ts @@ -238,6 +238,19 @@ export interface DocsMetadata { twitterCard?: "summary" | "summary_large_image"; } +export interface DevToolsConfig { + /** + * Enables the built-in visual docs editor in development. + * + * When enabled, the docs UI renders a floating action button that can edit + * the current MDX page and apply changes back to the source file through the + * local docs API. + * + * @default false + */ + enabled?: boolean; +} + export interface OGConfig { enabled?: boolean; type?: "static" | "dynamic"; @@ -2618,6 +2631,13 @@ export interface DocsConfig { agent?: DocsAgentConfig; /** SEO metadata - separate from theme */ metadata?: DocsMetadata; + /** + * Built-in visual docs editor. + * + * Use `devTools: true` during local development to edit MDX pages with the + * floating docs editor and write changes back to the page source file. + */ + devTools?: boolean | DevToolsConfig; /** Open Graph image handling */ og?: OGConfig; } diff --git a/packages/fumadocs/src/colorful/index.ts b/packages/fumadocs/src/colorful/index.ts index 04bdd0c5..173d7d20 100644 --- a/packages/fumadocs/src/colorful/index.ts +++ b/packages/fumadocs/src/colorful/index.ts @@ -37,7 +37,6 @@ const ColorfulUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/command-grid/index.ts b/packages/fumadocs/src/command-grid/index.ts index 061889dd..592eb3a7 100644 --- a/packages/fumadocs/src/command-grid/index.ts +++ b/packages/fumadocs/src/command-grid/index.ts @@ -41,7 +41,6 @@ const CommandGridUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" as const }, }, }; diff --git a/packages/fumadocs/src/concrete/index.ts b/packages/fumadocs/src/concrete/index.ts index d9500d48..f058d5f2 100644 --- a/packages/fumadocs/src/concrete/index.ts +++ b/packages/fumadocs/src/concrete/index.ts @@ -41,7 +41,6 @@ const ConcreteUIDefaults = { Callout: { variant: "outline", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "View doc", showIndicator: false }, - Tabs: { style: "default" as const }, }, }; diff --git a/packages/fumadocs/src/darkbold/index.ts b/packages/fumadocs/src/darkbold/index.ts index ef83ff07..b1ee02e8 100644 --- a/packages/fumadocs/src/darkbold/index.ts +++ b/packages/fumadocs/src/darkbold/index.ts @@ -37,7 +37,6 @@ const DarkBoldUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/darksharp/index.ts b/packages/fumadocs/src/darksharp/index.ts index 63154d13..bdcb8248 100644 --- a/packages/fumadocs/src/darksharp/index.ts +++ b/packages/fumadocs/src/darksharp/index.ts @@ -56,7 +56,6 @@ const DarksharpUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/default/index.ts b/packages/fumadocs/src/default/index.ts index a8af5ad5..73e5a9a3 100644 --- a/packages/fumadocs/src/default/index.ts +++ b/packages/fumadocs/src/default/index.ts @@ -55,7 +55,6 @@ const DefaultUIDefaults = { Callout: { variant: "soft", icon: true }, CodeBlock: { showCopyButton: true }, HoverLink: { linkLabel: "Open page", showIndicator: false }, - Tabs: { style: "default" }, }, }; diff --git a/packages/fumadocs/src/devtools.tsx b/packages/fumadocs/src/devtools.tsx new file mode 100644 index 00000000..ef86f161 --- /dev/null +++ b/packages/fumadocs/src/devtools.tsx @@ -0,0 +1,2184 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type MdxBlock = + | { id: string; type: "heading"; level: number; text: string } + | { id: string; type: "paragraph"; content: string } + | { id: string; type: "callout"; variant: string; title: string; content: string } + | { id: string; type: "code"; language: string; title: string; code: string } + | { id: string; type: "tabs"; tabs: Array<{ label: string; content: string }> } + | { + id: string; + type: "hoverlink"; + href: string; + title: string; + description: string; + label: string; + } + | { id: string; type: "prompt"; title: string; content: string } + | { id: string; type: "raw"; content: string }; + +interface ParsedMdxDocument { + prefix: string; + blocks: MdxBlock[]; +} + +interface DevToolsPagePayload { + requestedPath: string; + relativePath: string; + content: string; + lastModified: string; +} + +interface DocsDevToolsProps { + api: string; + pathname: string; +} + +// ─── ID generation ──────────────────────────────────────────────────────────── + +let idCounter = 0; +function createId(prefix = "block"): string { + idCounter += 1; + return `${prefix}-${Date.now().toString(36)}-${idCounter.toString(36)}`; +} + +// ─── Parse utilities ────────────────────────────────────────────────────────── + +function normalizeSource(s: string) { return s.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } + +function splitPrefix(source: string): { prefix: string; body: string } { + const lines = normalizeSource(source).split("\n"); + const pre: string[] = []; + let i = 0; + if (lines[i]?.trim() === "---") { + pre.push(lines[i]); i++; + while (i < lines.length) { + pre.push(lines[i]); + const done = lines[i]?.trim() === "---"; i++; + if (done) break; + } + } + while (i < lines.length) { + const line = lines[i] ?? ""; + const t = line.trim(); + if (t === "") { pre.push(line); i++; continue; } + if (/^(import|export)\b/.test(t)) { + pre.push(line); i++; + while (i < lines.length && lines[i]?.trim() !== "" && !/[;}]$/.test(lines[i-1]?.trim() ?? "")) { + pre.push(lines[i] ?? ""); i++; + } + continue; + } + break; + } + return { prefix: pre.join("\n").trimEnd(), body: lines.slice(i).join("\n").trim() }; +} + +function getAttribute(src: string, name: string): string { + const m = src.match(new RegExp(`${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`)); + return m?.[1] ?? m?.[2] ?? ""; +} + +function parseStringArray(src: string) { + return src.split(",").map(v => v.trim().replace(/^["']|["']$/g, "")).filter(Boolean); +} + +function captureUntil(lines: string[], start: number, closing: RegExp) { + const out: string[] = []; + for (let i = start; i < lines.length; i++) { + out.push(lines[i] ?? ""); + if (closing.test(lines[i] ?? "")) return { content: out.join("\n"), end: i + 1 }; + } + return { content: out.join("\n"), end: lines.length }; +} + +function isSpecialStart(line: string) { + const t = line.trim(); + return /^#{1,6}\s+/.test(t) || t.startsWith("```") || /^<[A-Z][\w.:-]*(\s|>|\/>)/.test(t); +} + +function parseCodeBlock(lines: string[], start: number): { block: MdxBlock; end: number } { + const opening = lines[start]?.trim() ?? "```"; + const info = opening.replace(/^```/, "").trim(); + const title = getAttribute(info, "title"); + const language = info.split(/\s+/, 1)[0]?.replace(/title=.*/, "") ?? ""; + const content: string[] = []; + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if ((lines[i] ?? "").trim().startsWith("```")) { end = i + 1; break; } + content.push(lines[i] ?? ""); + } + return { block: { id: createId("code"), type: "code", language, title, code: content.join("\n") }, end }; +} + +function parseCalloutBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const inner = raw.replace(/^]*>\s*/s, "").replace(/\s*<\/Callout>\s*$/s, "").trim(); + return { id: createId("callout"), type: "callout", variant: getAttribute(op, "type") || "info", title: getAttribute(op, "title"), content: inner || "Write the callout content here." }; +} + +function parsePromptBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const inner = raw.replace(/^]*>\s*/s, "").replace(/\s*<\/Prompt>\s*$/s, "").trim(); + return { id: createId("prompt"), type: "prompt", title: getAttribute(op, "title") || "Prompt", content: inner || getAttribute(op, "prompt") || "Write the prompt here." }; +} + +function parseHoverLinkBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const label = raw.replace(/^]*>\s*/s, "").replace(/\s*<\/HoverLink>\s*$/s, "").trim(); + return { id: createId("hoverlink"), type: "hoverlink", href: getAttribute(op, "href") || "#", title: getAttribute(op, "title") || "Linked page", description: getAttribute(op, "description") || "Describe the linked page.", label: label || "Open the linked page" }; +} + +function parseTabsBlock(raw: string): MdxBlock { + const op = raw.split("\n", 1)[0] ?? ""; + const im = op.match(/items\s*=\s*\{\s*\[([^\]]*)\]\s*\}/); + const items = im ? parseStringArray(im[1] ?? "") : []; + const tabs: Array<{ label: string; content: string }> = []; + const tp = /]*>([\s\S]*?)<\/Tab>/g; + let m: RegExpExecArray | null; + while ((m = tp.exec(raw)) !== null) tabs.push({ label: m[1] ?? "Tab", content: (m[2] ?? "").trim() || "Tab content" }); + if (tabs.length === 0) for (const item of items.length > 0 ? items : ["First", "Second"]) tabs.push({ label: item, content: "Tab content" }); + return { id: createId("tabs"), type: "tabs", tabs }; +} + +function parseRawJsxBlock(lines: string[], start: number): { block: MdxBlock; end: number } { + const firstLine = lines[start] ?? ""; + if (firstLine.trim().endsWith("/>")) return { block: { id: createId("raw"), type: "raw", content: firstLine }, end: start + 1 }; + const tag = firstLine.trim().match(/^<([A-Z][\w.:-]*)\b/)?.[1]; + if (tag) { + const closing = new RegExp(``); + const cap = captureUntil(lines, start, closing); + return { block: { id: createId("raw"), type: "raw", content: cap.content }, end: cap.end }; + } + return { block: { id: createId("raw"), type: "raw", content: firstLine }, end: start + 1 }; +} + +function parseMdxDocument(source: string): ParsedMdxDocument { + const { prefix, body } = splitPrefix(source); + const lines = body.split("\n"); + const blocks: MdxBlock[] = []; + let i = 0; + while (i < lines.length) { + const line = lines[i] ?? ""; + const t = line.trim(); + if (t === "") { i++; continue; } + const h = t.match(/^(#{1,6})\s+(.+)$/); + if (h) { blocks.push({ id: createId("heading"), type: "heading", level: h[1]?.length ?? 2, text: h[2] ?? "Heading" }); i++; continue; } + if (t.startsWith("```")) { const p = parseCodeBlock(lines, i); blocks.push(p.block); i = p.end; continue; } + if (t.startsWith("/); blocks.push(parseCalloutBlock(c.content)); i = c.end; continue; } + if (t.startsWith("/); blocks.push(parseTabsBlock(c.content)); i = c.end; continue; } + if (t.startsWith("/); blocks.push(parsePromptBlock(c.content)); i = c.end; continue; } + if (t.startsWith("/); blocks.push(parseHoverLinkBlock(c.content)); i = c.end; continue; } + if (/^<[A-Z][\w.:-]*(\s|>|\/>)/.test(t)) { const p = parseRawJsxBlock(lines, i); blocks.push(p.block); i = p.end; continue; } + const para: string[] = [line]; i++; + while (i < lines.length) { const next = lines[i] ?? ""; if (next.trim() === "" || isSpecialStart(next)) break; para.push(next); i++; } + blocks.push({ id: createId("paragraph"), type: "paragraph", content: para.join("\n").trim() }); + } + if (blocks.length === 0) blocks.push({ id: createId("paragraph"), type: "paragraph", content: "Start writing…" }); + return { prefix, blocks }; +} + +// ─── Serialize ──────────────────────────────────────────────────────────────── + +function q(v: string) { return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } +function ind(v: string, n = 2) { const p = " ".repeat(n); return v.split("\n").map(l => l.trim() ? `${p}${l}` : l).join("\n"); } + +function serializeBlock(block: MdxBlock): string { + if (block.type === "heading") return `${"#".repeat(Math.min(6, Math.max(1, block.level)))} ${block.text.trim() || "Heading"}`; + if (block.type === "paragraph") return block.content.trim(); + if (block.type === "callout") { const t = block.title.trim() ? ` title="${q(block.title.trim())}"` : ""; return `\n${block.content.trim()}\n`; } + if (block.type === "code") { const t = block.title.trim() ? ` title="${q(block.title.trim())}"` : ""; return `\`\`\`${block.language.trim()}${t}\n${block.code.replace(/\s+$/g, "")}\n\`\`\``; } + if (block.type === "tabs") { + const tabs = block.tabs.length > 0 ? block.tabs : [{ label: "First", content: "Tab content" }]; + const items = tabs.map(t => `"${q(t.label || "Tab")}"`).join(", "); + const rendered = tabs.map(t => ` \n${ind(t.content.trim() || "Tab content", 4)}\n `).join("\n\n"); + return `\n${rendered}\n`; + } + if (block.type === "hoverlink") return `\n${ind(block.label || "Open the linked page", 2)}\n`; + if (block.type === "prompt") { const t = block.title.trim() ? ` title="${q(block.title.trim())}"` : ""; return `\n${block.content.trim() || "Write the prompt here."}\n`; } + return block.content.trim(); +} + +function serializeMdxDocument(doc: ParsedMdxDocument): string { + const body = doc.blocks.map(serializeBlock).filter(Boolean).join("\n\n"); + return [doc.prefix.trim(), body.trim()].filter(Boolean).join("\n\n").trimEnd() + "\n"; +} + +function createBlock(type: MdxBlock["type"]): MdxBlock { + if (type === "heading") return { id: createId("heading"), type, level: 2, text: "New heading" }; + if (type === "callout") return { id: createId("callout"), type, variant: "note", title: "", content: "Write a helpful callout." }; + if (type === "code") return { id: createId("code"), type, language: "ts", title: "example.ts", code: "export const value = true;" }; + if (type === "tabs") return { id: createId("tabs"), type, tabs: [{ label: "npm", content: "```bash\nnpm install my-package\n```" }, { label: "pnpm", content: "```bash\npnpm add my-package\n```" }] }; + if (type === "hoverlink") return { id: createId("hoverlink"), type, href: "/docs", title: "Related guide", description: "A useful related documentation page.", label: "Read the related guide" }; + if (type === "prompt") return { id: createId("prompt"), type, title: "Try this prompt", content: "Use this docs page to implement the feature." }; + if (type === "raw") return { id: createId("raw"), type, content: "" }; + return { id: createId("paragraph"), type: "paragraph", content: "Write a paragraph." }; +} + +function createDevToolsUrl(api: string, mode: "page" | "publish" | "theme" | "nav-item", pathname: string) { + const url = new URL(api, window.location.origin); + url.searchParams.set("devtools", mode); + if (mode === "page") url.searchParams.set("path", pathname); + return `${url.pathname}${url.search}`; +} + +// ─── DOM → block-index map ─────────────────────────────────────────────────── +// Built once after the MDX doc is loaded. Each direct child of [data-dt-content] +// is assigned to exactly one parsed block. We use per-type cursors so that: +// • h1-h6 elements → heading blocks (text match preferred, cursor fallback) +// • p / ul / ol / hr / blockquote → paragraph blocks (our parser treats all +// of these as paragraphs — lists, HR, blockquotes are all parsed as paragraph) +// • figure → code blocks +// • div/aside/section → callout / tabs / prompt / hoverlink / raw +// identified by DOM attributes/classes, never by position alone + +function buildDomBlockMap( + contentEl: HTMLElement, + blocks: MdxBlock[], +): Map { + const children = Array.from(contentEl.children) as HTMLElement[]; + const map = new Map(); + + // Group parser blocks by type, keeping their original index + const byType: Record> = {}; + blocks.forEach((block, idx) => { + (byType[block.type] ??= []).push({ idx, block }); + }); + + const cursors: Record = {}; + function next(type: string): number | null { + const list = byType[type]; + if (!list) return null; + const c = cursors[type] ?? 0; + if (c >= list.length) return null; + cursors[type] = c + 1; + return list[c]!.idx; + } + + for (const el of children) { + const tag = el.tagName.toLowerCase(); + let blockIdx: number | null = null; + + if (/^h[1-6]$/.test(tag)) { + // Prefer text match so invisible-render components (Agent etc.) don't shift indices + const headings = byType["heading"]; + const c = cursors["heading"] ?? 0; + const elText = (el.textContent ?? "").trim(); + const ahead = headings?.slice(c) ?? []; + const matchOff = ahead.findIndex( + ({ block }) => (block as Extract).text.trim() === elText + ); + if (matchOff >= 0) { + blockIdx = ahead[matchOff]!.idx; + cursors["heading"] = c + matchOff + 1; + } else { + blockIdx = next("heading"); + } + + } else if (tag === "p" || tag === "ul" || tag === "ol" || tag === "hr" || tag === "blockquote") { + // All rendered as paragraph blocks by our parser + blockIdx = next("paragraph"); + + } else if (tag === "figure") { + blockIdx = next("code"); + + } else if (tag === "div" || tag === "aside" || tag === "section" || tag === "nav") { + // Detect component type from DOM shape + const role = el.getAttribute("role") ?? ""; + const cls = el.className ?? ""; + if (role === "note" || cls.includes("callout")) { + blockIdx = next("callout"); + } else if (el.querySelector("[role=tablist], [data-radix-collection-item]")) { + blockIdx = next("tabs"); + } else if (el.querySelector(".fd-prompt-header") || cls.includes("fd-prompt")) { + blockIdx = next("prompt"); + } else if ( + el.querySelector("a[href]") && + (byType["hoverlink"]?.length ?? 0) > (cursors["hoverlink"] ?? 0) + ) { + blockIdx = next("hoverlink"); + } else { + blockIdx = next("raw"); + } + } + + if (blockIdx !== null) map.set(el, blockIdx); + } + + return map; +} + +// ─── Catalogues ─────────────────────────────────────────────────────────────── + +const BLOCK_CATALOGUE: Array<{ type: MdxBlock["type"]; label: string; icon: string; accent: string; desc: string }> = [ + { type: "paragraph", label: "Paragraph", icon: "¶", accent: "#6366f1", desc: "Plain text" }, + { type: "heading", label: "Heading", icon: "H", accent: "#7c3aed", desc: "H1 – H6" }, + { type: "callout", label: "Callout", icon: "◈", accent: "#f59e0b", desc: "Note / warning" }, + { type: "code", label: "Code", icon: "", accent: "#10b981", desc: "Fenced block" }, + { type: "tabs", label: "Tabs", icon: "⊟", accent: "#3b82f6", desc: "Tabbed content" }, + { type: "hoverlink", label: "HoverLink", icon: "↗", accent: "#ec4899", desc: "Card link" }, + { type: "prompt", label: "Prompt", icon: "◉", accent: "#06b6d4", desc: "Copyable prompt" }, + { type: "raw", label: "Raw MDX", icon: "{}", accent: "#64748b", desc: "Custom component" }, +]; + +interface ThemeDef { id: string; label: string; accent: string; bg: string; fg: string; border: string; muted: string; dark: boolean } + +const THEMES: ThemeDef[] = [ + { id: "default", label: "Default", accent: "#6366f1", bg: "#ffffff", fg: "#0f172a", border: "#e2e8f0", muted: "#f1f5f9", dark: false }, + { id: "darksharp", label: "Dark Sharp", accent: "#e2e8f0", bg: "#0a0a0a", fg: "#f8fafc", border: "#1e293b", muted: "#111827", dark: true }, + { id: "ledger", label: "Ledger", accent: "#5f6cf6", bg: "#f6f8fb", fg: "#30364a", border: "#dbe3ef", muted: "#eef3fb", dark: false }, + { id: "concrete", label: "Concrete", accent: "#171717", bg: "#f5f5f4", fg: "#171717", border: "#d4d0cb", muted: "#e7e5e4", dark: false }, + { id: "hardline", label: "Hardline", accent: "#ef4444", bg: "#fafaf9", fg: "#0c0a09", border: "#e7e5e4", muted: "#f5f5f4", dark: false }, + { id: "greentree", label: "Green Tree", accent: "#22c55e", bg: "#f0fdf4", fg: "#052e16", border: "#bbf7d0", muted: "#dcfce7", dark: false }, + { id: "shiny", label: "Shiny", accent: "#ec4899", bg: "#0f0a1a", fg: "#f0e6ff", border: "#3b1f5e", muted: "#1a0e30", dark: true }, + { id: "colorful", label: "Colorful", accent: "#f59e0b", bg: "#fffbeb", fg: "#1c1917", border: "#fde68a", muted: "#fef3c7", dark: false }, + { id: "pixel-border", label: "Pixel Border", accent: "#3b82f6", bg: "#0f172a", fg: "#f1f5f9", border: "#1e3a5f", muted: "#1e293b", dark: true }, + { id: "command-grid", label: "Command Grid", accent: "#8b5cf6", bg: "#fafafa", fg: "#18181b", border: "#e4e4e7", muted: "#f4f4f5", dark: false }, + { id: "darkbold", label: "Dark Bold", accent: "#ffffff", bg: "#0a0a0a", fg: "#ededed", border: "#333333", muted: "#1a1a1a", dark: true }, +]; + +// Matches the actual Callout component variant styles exactly +const CALLOUT_VARIANTS: Record React.ReactElement; iconClass: string; titleClass: string; border: string; bg: string; label: string }> = { + note: { iconEl: () => , iconClass: "text-black/50 dark:text-white/50", titleClass: "text-black/70 dark:text-white/70", border: "border-black/10 dark:border-white/10 border-l-4 border-l-black/25 dark:border-l-white/25", bg: "bg-black/[0.03] dark:bg-white/[0.025]", label: "Note" }, + warning: { iconEl: () => , iconClass: "text-black/55 dark:text-white/55", titleClass: "text-black/75 dark:text-white/75", border: "border-black/10 dark:border-white/10 border-l-4 border-l-black/35 dark:border-l-white/35", bg: "bg-black/[0.04] dark:bg-white/[0.035]", label: "Warning" }, + tip: { iconEl: () => , iconClass: "text-black/50 dark:text-white/50", titleClass: "text-black/70 dark:text-white/70", border: "border-black/10 dark:border-white/10 border-l-4 border-l-black/30 dark:border-l-white/30", bg: "bg-black/[0.03] dark:bg-white/[0.025]", label: "Tip" }, + important: { iconEl: () => , iconClass: "text-black/55 dark:text-white/55", titleClass: "text-black/75 dark:text-white/75", border: "border-black/10 dark:border-white/10 border-l-4 border-l-black/35 dark:border-l-white/35", bg: "bg-black/[0.04] dark:bg-white/[0.035]", label: "Important" }, + caution: { iconEl: () => , iconClass: "text-black/60 dark:text-white/60", titleClass: "text-black/80 dark:text-white/80", border: "border-black/15 dark:border-white/15 border-l-4 border-l-black/40 dark:border-l-white/40", bg: "bg-black/[0.05] dark:bg-white/[0.045]", label: "Caution" }, + // parser aliases + info: { iconEl: () => , iconClass: "text-black/50 dark:text-white/50", titleClass: "text-black/70 dark:text-white/70", border: "border-black/10 dark:border-white/10 border-l-4 border-l-black/25 dark:border-l-white/25", bg: "bg-black/[0.03] dark:bg-white/[0.025]", label: "Info" }, + error: { iconEl: () => , iconClass: "text-black/60 dark:text-white/60", titleClass: "text-black/80 dark:text-white/80", border: "border-black/15 dark:border-white/15 border-l-4 border-l-black/40 dark:border-l-white/40", bg: "bg-black/[0.05] dark:bg-white/[0.045]", label: "Error" }, +}; + +// ─── CSS ────────────────────────────────────────────────────────────────────── + +const CSS = ` +nextjs-portal{pointer-events:none!important} + +/* ─ reset inside devtools ─ */ +.dt,.dt *{box-sizing:border-box;-webkit-font-smoothing:antialiased} +.dt button,.dt input,.dt select,.dt textarea{font-family:inherit} + +/* ─ trigger ─ */ +.dt-trigger{ + position:fixed;left:16px;bottom:16px;z-index:2147483000; + height:34px;padding:0 13px 0 10px; + border:1px solid rgba(255,255,255,0.13);border-radius:999px; + background:rgba(12,12,16,0.93);color:#fff; + box-shadow:0 8px 28px rgba(0,0,0,0.45);backdrop-filter:blur(16px); + display:inline-flex;align-items:center;gap:7px; + font-size:12px;font-weight:700;cursor:pointer; + transition:transform .12s,box-shadow .12s; +} +.dt-trigger:hover{transform:translateY(-1px);box-shadow:0 12px 36px rgba(0,0,0,0.55)} +.dt-dot{ + width:7px;height:7px;border-radius:50%; + background:var(--color-fd-primary,#6366f1); + box-shadow:0 0 0 3px color-mix(in srgb,var(--color-fd-primary,#6366f1) 22%,transparent); + animation:dtpulse 2.4s ease-in-out infinite; +} +@keyframes dtpulse{0%,100%{opacity:1}50%{opacity:.45}} + +/* ─ toolbar ─ */ +.dt-bar{ + position:fixed;top:0;left:0;right:0;height:50px;z-index:2147483002; + border-bottom:1px solid rgba(255,255,255,0.07); + background:rgba(9,9,13,0.95);backdrop-filter:blur(20px); + display:flex;align-items:center;gap:0;pointer-events:auto; +} +.dt-bar-logo{ + display:flex;align-items:center;gap:8px; + padding:0 14px;height:100%; + border-right:1px solid rgba(255,255,255,0.07);flex-shrink:0; +} +.dt-bar-emblem{ + width:22px;height:22px;border-radius:5px; + background:linear-gradient(135deg,#6366f1,#8b5cf6); + display:grid;place-items:center;font-size:10px;font-weight:900;color:#fff; +} +.dt-bar-wordmark{font-size:12.5px;font-weight:750;color:#fff;white-space:nowrap} +.dt-bar-file{ + flex:1;min-width:0;padding:0 14px; + display:flex;flex-direction:column;gap:1px; +} +.dt-bar-path{font-size:11.5px;color:rgba(255,255,255,0.45);font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dt-bar-status{font-size:10.5px;color:rgba(255,255,255,0.28);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.dt-bar-actions{ + display:flex;align-items:center;gap:5px; + padding:0 10px;height:100%; + border-left:1px solid rgba(255,255,255,0.07);flex-shrink:0; +} + +/* ─ toolbar buttons ─ */ +.dt-btn{ + height:28px;padding:0 10px; + border:1px solid rgba(255,255,255,0.11);border-radius:6px; + background:rgba(255,255,255,0.06);color:rgba(255,255,255,0.8); + font-size:11.5px;font-weight:650;cursor:pointer; + display:inline-flex;align-items:center;gap:5px; + transition:border-color .12s,background .12s,color .12s;white-space:nowrap; +} +.dt-btn:hover{border-color:rgba(255,255,255,0.22);background:rgba(255,255,255,0.11);color:#fff} +.dt-btn:disabled{opacity:.32;cursor:not-allowed} +.dt-btn[data-primary]{background:var(--color-fd-primary,#6366f1);border-color:var(--color-fd-primary,#6366f1);color:#fff} +.dt-btn[data-primary]:hover{filter:brightness(1.1)} +.dt-btn[data-active]{background:rgba(255,255,255,0.13);border-color:rgba(255,255,255,0.2);color:#fff} +.dt-btn-sep{width:1px;height:18px;background:rgba(255,255,255,0.08);margin:0 2px;flex-shrink:0} + +/* ─ theme popover ─ */ +.dt-theme-pop{ + position:fixed;top:54px;right:10px;z-index:2147483003; + width:220px; + border:1px solid rgba(255,255,255,0.1);border-radius:10px; + background:rgba(10,10,14,0.97);backdrop-filter:blur(20px); + padding:6px; + box-shadow:0 16px 48px rgba(0,0,0,0.55); +} +.dt-theme-item{ + display:flex;align-items:center;gap:9px; + padding:7px 9px;border-radius:7px;cursor:pointer; + transition:background .1s; +} +.dt-theme-item:hover{background:rgba(255,255,255,0.07)} +.dt-theme-item[data-active]{background:rgba(255,255,255,0.1)} +.dt-theme-dots{display:flex;gap:3px;flex-shrink:0} +.dt-theme-dot{width:10px;height:10px;border-radius:50%} +.dt-theme-name{font-size:12px;font-weight:600;color:rgba(255,255,255,0.8);flex:1} +.dt-theme-tick{color:#6366f1;font-size:12px;flex-shrink:0} + +/* ─ article editing mode — only outlines, zero layout change ─ */ +/* Uses html attribute (not article attribute) to avoid React reconciliation mismatch */ +html[data-docs-devtools-open="true"] [data-dt-content] > *:not([data-dt-ui]){ + cursor:pointer; + outline:2px dashed transparent; + outline-offset:5px; + transition:outline-color .12s; +} +html[data-docs-devtools-open="true"] [data-dt-content] > *:not([data-dt-ui]):hover{ + outline-color:color-mix(in srgb,var(--color-fd-primary,#6366f1) 38%,transparent); +} + +/* ─ inline mini toolbar (appears above heading / paragraph editors) ─ */ +.dt-inline-toolbar{ + position:fixed;z-index:2147483002; + display:inline-flex;align-items:center;gap:2px; + background:rgba(9,9,13,0.94);backdrop-filter:blur(14px); + border:1px solid rgba(255,255,255,0.1);border-radius:8px; + padding:3px 5px;box-shadow:0 8px 24px rgba(0,0,0,0.4); +} +.dt-inline-toolbar-group{display:inline-flex;align-items:center;gap:2px} +.dt-inline-tb-btn{ + min-width:22px;height:22px;padding:0 5px; + border:none;background:transparent;color:rgba(255,255,255,0.75); + font-size:11px;font-weight:700;cursor:pointer; + display:grid;place-items:center;border-radius:5px; + transition:color .1s,background .1s; +} +.dt-inline-tb-btn:hover{color:#fff;background:rgba(255,255,255,0.1)} +.dt-inline-tb-btn:disabled{opacity:.25;cursor:not-allowed} +.dt-inline-tb-btn[data-active]{background:var(--color-fd-primary,#6366f1);color:#fff} +.dt-inline-tb-btn[data-danger]:hover{color:#ef4444;background:rgba(239,68,68,0.12)} +.dt-inline-tb-sep{width:1px;height:14px;background:rgba(255,255,255,0.1);margin:0 2px;flex-shrink:0} + +/* ─ sidebar editing mode ─ */ +html[data-docs-devtools-open="true"] #nd-sidebar a{ + cursor:pointer; + position:relative; +} +html[data-docs-devtools-open="true"] #nd-sidebar a::after{ + content:"✎"; + position:absolute; + right:4px; + top:50%; + transform:translateY(-50%); + font-size:10px; + opacity:0; + transition:opacity .1s; + color:var(--color-fd-primary,#6366f1); +} +html[data-docs-devtools-open="true"] #nd-sidebar a:hover::after{ + opacity:0.5; +} +/* Active block highlight rendered as a React-controlled fixed overlay (no DOM mutation) */ +.dt-active-highlight{ + position:fixed;pointer-events:none;z-index:2147483000; + outline:2px dashed var(--color-fd-primary,#6366f1); + outline-offset:5px;border-radius:3px; +} + +/* Inline editor overlay — sits over the original block for direct text editing */ +.dt-inline-cover{ + position:fixed;z-index:2147483001; + background:var(--color-fd-background,#fff); + box-sizing:border-box; +} +.dt-inline-input,.dt-inline-ta{ + display:block;width:100%;background:transparent; + border:none;outline:none;resize:none; + padding:0;margin:0; + font:inherit;color:inherit; +} +/* WYSIWYG inline formatting inside the paragraph overlay */ +.dt-inline-cover strong{font-weight:bold} +.dt-inline-cover em{font-style:italic} +.dt-inline-cover code{ + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.875em; + background:rgba(0,0,0,0.07); + padding:0.1em 0.3em;border-radius:3px; +} +/* Remove figure margin when CodeRenderer is placed inside the inline cover */ +.dt-inline-cover .dt-code-fig{margin:0!important} + +/* ─ floating block editor (appears near clicked block) ─ */ +.dt-float-editor{ + position:fixed;z-index:2147483003; + background:var(--color-fd-background,#fff); + border:1.5px solid var(--color-fd-border,#e2e8f0); + border-radius:12px; + box-shadow:0 16px 48px rgba(0,0,0,0.18),0 2px 8px rgba(0,0,0,0.06); + width:min(480px,90vw); + max-height:72vh; + overflow-y:auto; + display:flex;flex-direction:column; +} +.dt-float-editor-hdr{ + display:flex;align-items:center;gap:3px; + padding:8px 10px 6px; + border-bottom:1px solid var(--color-fd-border,#e2e8f0); + flex-shrink:0; + position:sticky;top:0; + background:var(--color-fd-background,#fff); + z-index:1; +} +.dt-float-editor-type{ + font-size:9.5px;font-weight:800;text-transform:uppercase;letter-spacing:.06em; + display:inline-flex;align-items:center;gap:3px;flex-shrink:0; +} +.dt-float-editor-body{padding:12px 14px} + +/* ─ separator ─ */ +.dt-float-sep{width:1px;height:14px;background:var(--color-fd-border,#e2e8f0);margin:0 2px;flex-shrink:0} + +/* ─ float action button ─ */ +.dt-float-btn{ + width:24px;height:22px;border:none;background:transparent; + color:var(--color-fd-muted-foreground,#64748b);cursor:pointer; + display:grid;place-items:center;border-radius:5px; + transition:color .1s,background .1s; +} +.dt-float-btn:hover{color:var(--color-fd-foreground,#0f172a);background:var(--color-fd-muted,#f1f5f9)} +.dt-float-btn:disabled{opacity:.25;cursor:not-allowed} +.dt-float-btn[data-danger]:hover{color:#ef4444;background:rgba(239,68,68,0.08)} + +/* ─ insert (+) button ─ */ +.dt-insert-btn{ + width:20px;height:20px;border-radius:50%; + border:1.5px solid var(--color-fd-border,#e2e8f0); + background:var(--color-fd-background,#fff); + color:var(--color-fd-muted-foreground,#94a3b8); + cursor:pointer; + display:grid;place-items:center; + transition:border-color .12s,color .12s,transform .12s,box-shadow .12s; +} +.dt-insert-btn:hover{ + border-color:var(--color-fd-primary,#6366f1); + color:var(--color-fd-primary,#6366f1); + transform:scale(1.15); + box-shadow:0 0 0 4px color-mix(in srgb,var(--color-fd-primary,#6366f1) 12%,transparent); +} + +/* ─ insert menu palette ─ */ +.dt-palette{ + position:fixed;z-index:2147483004; + width:260px; + border:1px solid var(--color-fd-border,#e2e8f0); + border-radius:10px; + background:var(--color-fd-background,#fff); + box-shadow:0 12px 40px rgba(0,0,0,0.15); + padding:6px;overflow:hidden; +} +.dt-palette-title{ + font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:.08em; + color:var(--color-fd-muted-foreground,#64748b); + padding:4px 8px 6px; +} +.dt-palette-grid{display:grid;grid-template-columns:1fr 1fr;gap:3px} +.dt-palette-item{ + display:flex;align-items:center;gap:8px; + padding:8px 9px;border-radius:7px; + cursor:pointer;border:none;background:transparent;text-align:left; + transition:background .1s;width:100%; +} +.dt-palette-item:hover{background:var(--color-fd-muted,#f1f5f9)} +.dt-palette-icon{ + width:26px;height:26px;border-radius:6px; + display:grid;place-items:center;font-size:11px;font-weight:900; + flex-shrink:0; +} +.dt-palette-label{font-size:11.5px;font-weight:650;color:var(--color-fd-foreground,#0f172a);line-height:1.2} +.dt-palette-desc{font-size:10px;color:var(--color-fd-muted-foreground,#64748b);margin-top:1px} + +/* ─ contenteditable prose ─ */ +.dt-editable{ + outline:none;white-space:pre-wrap;overflow-wrap:anywhere; + caret-color:var(--color-fd-primary,#6366f1); + margin:0; +} +.dt-editable:empty::before{ + content:attr(data-ph); + color:color-mix(in srgb,var(--color-fd-muted-foreground,#94a3b8) 55%,transparent); + pointer-events:none; +} +.dt-editable::selection{background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 20%,transparent)} + +/* ─ formatting mini-bar ─ */ +.dt-fmt-bar{ + display:flex;gap:2px;margin-bottom:5px; + opacity:0;transition:opacity .13s;height:0;overflow:hidden; + pointer-events:none; +} +.dt-float-editor-body .dt-fmt-bar{opacity:1;height:auto;pointer-events:auto} +.dt-fmt-btn{ + height:21px;padding:0 7px; + border:1px solid var(--color-fd-border,#e2e8f0);border-radius:4px; + background:transparent;color:var(--color-fd-muted-foreground,#64748b); + font-size:11px;font-weight:700;cursor:pointer; + transition:color .1s,border-color .1s,background .1s; +} +.dt-fmt-btn:hover{ + color:var(--color-fd-foreground,#0f172a); + border-color:var(--color-fd-primary,#6366f1); + background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 6%,transparent); +} + +/* ─ heading level row ─ */ +.dt-level-row{display:flex;gap:3px;margin-bottom:6px;opacity:0;transition:opacity .13s;pointer-events:none;height:0;overflow:hidden} +.dt-float-editor-body .dt-level-row{opacity:1;pointer-events:auto;height:auto} +.dt-level-btn{ + width:26px;height:21px;border-radius:4px; + border:1px solid var(--color-fd-border,#e2e8f0); + background:transparent;color:var(--color-fd-muted-foreground,#64748b); + font-size:10px;font-weight:800;cursor:pointer; + display:grid;place-items:center; + transition:color .1s,background .1s,border-color .1s; +} +.dt-level-btn:hover,.dt-level-btn[data-active]{ + color:var(--color-fd-primary,#6366f1); + border-color:var(--color-fd-primary,#6366f1); + background:color-mix(in srgb,var(--color-fd-primary,#6366f1) 8%,transparent); +} + +/* ─ callout variant selector ─ */ +.dt-variant-row{display:flex;gap:3px;margin-bottom:7px;opacity:0;pointer-events:none;height:0;overflow:hidden;transition:opacity .13s} +.dt-float-editor-body .dt-variant-row{opacity:1;pointer-events:auto;height:auto} +.dt-variant-btn{ + height:20px;padding:0 7px;border-radius:4px; + border:1px solid var(--color-fd-border,#e2e8f0); + background:transparent;color:var(--color-fd-muted-foreground,#64748b); + font-size:10px;font-weight:700;cursor:pointer; + transition:color .1s,background .1s,border-color .1s; +} +.dt-variant-btn[data-active],.dt-variant-btn:hover{ + color:var(--color-fd-foreground,#0f172a); + border-color:var(--color-fd-border,#e2e8f0); + background:var(--color-fd-muted,#f1f5f9); +} + +/* ─ hoverlink (inline dashed-link + hover metadata panel) ─ */ +.dt-hl-trigger{ + display:inline;border:none;background:transparent;padding:0;margin:0; + color:var(--color-fd-foreground,currentColor);cursor:text; + text-decoration:underline;text-decoration-style:dashed; + text-decoration-thickness:0.08em;text-underline-offset:0.22em; + text-decoration-color:color-mix(in srgb,var(--color-fd-foreground,currentColor) 46%,transparent); + font:inherit;line-height:inherit;appearance:none; +} +.dt-hl-meta{ + display:none;flex-direction:column;gap:4px; + margin-top:8px;padding:7px 10px; + border-radius:6px;border:1px solid var(--color-fd-border,#e2e8f0); + background:var(--color-fd-muted,#f1f5f9); +} +.dt-float-editor-body .dt-hl-meta{display:flex} +.dt-hl-row{display:flex;align-items:center;gap:7px} +.dt-hl-lbl{font-size:9.5px;font-weight:800;text-transform:uppercase;letter-spacing:.07em;color:var(--color-fd-muted-foreground,#64748b);width:52px;flex-shrink:0} +.dt-hl-inp{background:transparent;border:none;outline:none;font-size:11.5px;color:var(--color-fd-foreground,#0f172a);flex:1;font-family:inherit} + +/* ─ prompt (uses fd-prompt classes from base.css, just override editable inputs) ─ */ +.fd-prompt input.fd-prompt-title-edit,.fd-prompt textarea.fd-prompt-body-edit{ + background:transparent;border:none;outline:none;font:inherit;color:inherit; + width:100%;resize:none;padding:0;margin:0; +} + +/* ─ raw block ─ */ +.dt-raw-shell{ + border-radius:8px; + border:1.5px dashed color-mix(in srgb,var(--color-fd-border,#e2e8f0) 80%,transparent); + padding:10px 12px; + background:color-mix(in srgb,var(--color-fd-muted,#f1f5f9) 40%,transparent); +} + +/* ─ code block (matches fumadocs CodeBlock
) ─ */ +.dt-code-fig{ + margin:1rem 0; + background:var(--color-fd-card,#f8fafc); + border-radius:0.75rem; + border:1px solid var(--color-fd-border,#e2e8f0); + box-shadow:0 1px 2px rgba(0,0,0,0.06); + overflow:hidden; + font-size:0.875rem; + position:relative; +} +.dt-code-hdr{ + display:flex;align-items:center;gap:0.5rem; + height:2.375rem; + border-bottom:1px solid var(--color-fd-border,#e2e8f0); + padding:0 1rem; + color:var(--color-fd-muted-foreground,#64748b); +} +.dt-code-lang{ + background:transparent;border:none;outline:none; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.75rem;font-weight:600; + color:var(--color-fd-muted-foreground,#64748b); + width:52px;flex-shrink:0; +} +.dt-code-sep-v{width:1px;height:0.75rem;background:var(--color-fd-border,#e2e8f0);flex-shrink:0} +.dt-code-file{ + background:transparent;border:none;outline:none; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.75rem; + color:var(--color-fd-muted-foreground,#64748b); + flex:1;min-width:0; +} +.dt-code-body{padding:1rem} +.dt-code-fig figcaption{margin-top:0!important;font-size:inherit!important;color:inherit!important;line-height:inherit!important} +.dt-code-ta{ + width:100%;min-height:80px;resize:vertical; + background:transparent;border:none;outline:none; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:0.875rem;line-height:1.65; + color:var(--color-fd-foreground,#0f172a); + display:block; +} + +/* ─ source textarea ─ */ +.dt-source-ta{ + width:100%;min-height:200px; + border:1px solid var(--color-fd-border,#e2e8f0);border-radius:9px; + padding:16px;outline:none;resize:vertical; + font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace; + font-size:13px;line-height:1.65; + background:color-mix(in srgb,var(--color-fd-muted,#f1f5f9) 50%,var(--color-fd-background,#fff)); + color:var(--color-fd-foreground,#0f172a); +} + +/* ─ empty state ─ */ +.dt-empty{ + padding:32px 24px;text-align:center; + border:1.5px dashed color-mix(in srgb,var(--color-fd-border,#e2e8f0) 70%,transparent); + border-radius:12px; +} +.dt-empty-title{font-size:14px;font-weight:700;color:var(--color-fd-muted-foreground,#64748b);margin-bottom:4px} +.dt-empty-sub{font-size:12px;color:color-mix(in srgb,var(--color-fd-muted-foreground,#94a3b8) 75%,transparent)} + +@media(max-width:780px){ + .dt-bar-wordmark{display:none} + .dt-btn span{display:none} +} + +/* ─ selection toolbar (shows above selected text in any contentEditable) ─ */ +.dt-sel-toolbar{ + position:fixed;z-index:2147483010; + display:inline-flex;align-items:center;gap:1px; + background:rgba(9,9,13,0.97);backdrop-filter:blur(18px); + border:1px solid rgba(255,255,255,0.14);border-radius:9px; + padding:3px 5px; + box-shadow:0 6px 24px rgba(0,0,0,0.55); + transform:translateX(-50%); + pointer-events:auto; + white-space:nowrap; +} +/* small arrow pointing down */ +.dt-sel-toolbar::after{ + content:""; + position:absolute;top:100%;left:50%;transform:translateX(-50%); + border:5px solid transparent; + border-top-color:rgba(9,9,13,0.97); +} +.dt-sel-btn{ + height:26px;padding:0 8px; + border:none;background:transparent;color:rgba(255,255,255,0.82); + font-size:12.5px;font-weight:700;cursor:pointer; + border-radius:6px; + display:inline-flex;align-items:center;gap:3px; + transition:color .1s,background .1s; + font-family:inherit; +} +.dt-sel-btn:hover{color:#fff;background:rgba(255,255,255,0.13)} +.dt-sel-btn[data-active]{background:var(--color-fd-primary,#6366f1);color:#fff} +.dt-sel-sep{width:1px;height:15px;background:rgba(255,255,255,0.14);margin:0 3px;flex-shrink:0} +`; + + +// ─── ContentEditable helper ─────────────────────────────────────────────────── + +function getEditText(el: HTMLElement) { return el.innerText.replace(/\n\n$/g, "").replace(/ /g, " "); } + +function getEditSel(el: HTMLElement, fallback: number): { start: number; end: number } { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return { start: fallback, end: fallback }; + if (!el.contains(sel.anchorNode) || !el.contains(sel.focusNode)) return { start: fallback, end: fallback }; + const range = sel.getRangeAt(0); + const before = range.cloneRange(); before.selectNodeContents(el); before.setEnd(range.startContainer, range.startOffset); + const start = before.toString().length; + return { start, end: start + range.toString().length }; +} + +function setEditSel(el: HTMLElement, start: number, end: number) { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + let offset = 0; let sn: Text | null = null; let en: Text | null = null; let so = 0; let eo = 0; + let node = walker.nextNode() as Text | null; + while (node) { + const len = (node.textContent ?? "").length; const next = offset + len; + if (!sn && start <= next) { sn = node; so = Math.max(0, start - offset); } + if (!en && end <= next) { en = node; eo = Math.max(0, end - offset); break; } + offset = next; node = walker.nextNode() as Text | null; + } + if (!sn || !en) return; + const range = document.createRange(); range.setStart(sn, so); range.setEnd(en, eo); + const sel = window.getSelection(); sel?.removeAllRanges(); sel?.addRange(range); +} + +// ─── ProseEditor (contentEditable with formatting) ──────────────────────────── + +function ProseEditor({ value, onChange, placeholder }: { + value: string; onChange: (v: string) => void; placeholder: string; +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el || document.activeElement === el) return; + if (getEditText(el) !== value) el.innerText = value; + }, [value]); + + function fmt(before: string, after: string, fallback: string) { + const el = ref.current; if (!el) return; + const cur = getEditText(el); const sel = getEditSel(el, cur.length); + const selected = cur.slice(sel.start, sel.end) || fallback; + onChange(`${cur.slice(0, sel.start)}${before}${selected}${after}${cur.slice(sel.end)}`); + requestAnimationFrame(() => { el.focus(); setEditSel(el, sel.start + before.length, sel.start + before.length + selected.length); }); + } + + return ( +
+
+ {([["B","**","**","bold"],["I","_","_","italic"],["`","`","`","code"],["↗","[","](url)","link"],["—","- ","","item"]] as const).map(([label, b, a, f]) => ( + + ))} +
+

onChange(getEditText(e.currentTarget))} + onPaste={e => { e.preventDefault(); document.execCommand("insertText", false, e.clipboardData.getData("text/plain")); }} + >{value}

+
+ ); +} + +// ─── md2html / html2md (minimal inline markdown → HTML conversion) ─────────── + +function md2html(md: string): string { + // 1. Escape bare HTML entities so literal < > don't become tags + let s = md.replace(/&/g, "&").replace(//g, ">"); + // 2. Inline markdown patterns → HTML + s = s + .replace(/\*\*(.+?)\*\*/gs, "$1") + .replace(/__(.+?)__/gs, "$1") + .replace(/\*(.+?)\*/gs, "$1") + .replace(/_(.+?)_/gs, "$1") + .replace(/`(.+?)`/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + // 3. Newlines →
so the contentEditable shows line breaks + s = s.replace(/\n/g, "
"); + return s; +} + +function html2md(html: string): string { + return html + .replace(/]*>([\s\S]*?)<\/strong>/gi, "**$1**") + .replace(/]*>([\s\S]*?)<\/b>/gi, "**$1**") + .replace(/]*>([\s\S]*?)<\/em>/gi, "_$1_") + .replace(/]*>([\s\S]*?)<\/i>/gi, "_$1_") + .replace(/]*>([\s\S]*?)<\/code>/gi, "`$1`") + .replace(/]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)") + .replace(//gi, "\n") + .replace(/
/gi, "\n").replace(/<\/div>/gi, "") + .replace(/

/gi, "").replace(/<\/p>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/ /g, " ") + .trim(); +} + +// ─── WYSIWYG Paragraph Editor (contentEditable, renders bold/italic/code) ──── + +function WysiwygParagraphEditor({ initialContent, style, onChange, onClose }: { + initialContent: string; + style: React.CSSProperties; + onChange: (md: string) => void; + onClose: () => void; +}) { + const ref = useRef(null); + + // Set innerHTML only on mount — never overwrite while the user types + useEffect(() => { + const el = ref.current; + if (!el) return; + el.innerHTML = md2html(initialContent); + el.focus(); + // Move caret to end + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +

onChange(html2md(e.currentTarget.innerHTML))} + onPaste={e => { + e.preventDefault(); + // Paste as plain text to avoid pasting HTML tags + document.execCommand("insertText", false, e.clipboardData.getData("text/plain")); + }} + onKeyDown={e => { if (e.key === "Escape") { e.preventDefault(); onClose(); } }} + /> + ); +} + +// ─── Inline-editable input ──────────────────────────────────────────────────── + +function InlineInput({ value, onChange, className, placeholder, style }: { + value: string; onChange: (v: string) => void; className?: string; placeholder?: string; + style?: React.CSSProperties; +}) { + return ( + onChange(e.currentTarget.value)} + placeholder={placeholder} + style={style} + /> + ); +} + +// ─── Block renderer components ──────────────────────────────────────────────── + +function HeadingRenderer({ block, onChange }: { block: Extract; onChange: (b: MdxBlock) => void }) { + const level = Math.min(6, Math.max(1, block.level)); + const hRef = (el: HTMLHeadingElement | null) => { if (el && document.activeElement !== el && el.innerText !== block.text) el.innerText = block.text; }; + const hProps = { + className: "dt-editable", + contentEditable: true as const, + suppressContentEditableWarning: true, + "data-ph": `Heading ${level}`, + onInput: (e: React.FormEvent) => onChange({ ...block, text: getEditText(e.currentTarget) }), + onPaste: (e: React.ClipboardEvent) => { e.preventDefault(); document.execCommand("insertText", false, e.clipboardData.getData("text/plain")); }, + }; + return ( +

+
+ {[1,2,3,4,5,6].map(l => ( + + ))} +
+ {level === 1 &&

} + {level === 2 &&

} + {level === 3 &&

} + {level === 4 &&

} + {level === 5 &&

} + {level === 6 &&
} +
+ ); +} + +function ParagraphRenderer({ block, onChange }: { block: Extract; onChange: (b: MdxBlock) => void }) { + return onChange({ ...block, content })} placeholder="Write a paragraph…" />; +} + +function CalloutRenderer({ block, onChange }: { block: Extract; onChange: (b: MdxBlock) => void }) { + const v = CALLOUT_VARIANTS[block.variant] ?? CALLOUT_VARIANTS.note!; + const Icon = v.iconEl; + const primaryVariants = ["note", "warning", "tip", "important", "caution"] as const; + return ( +
+ {/* Variant selector — hidden by default, shows on block hover */} +
+ {primaryVariants.map(k => { + const cv = CALLOUT_VARIANTS[k]!; + return ( + + ); + })} +
+ + {/* Exact same structure as website/components/ui/callout.tsx */} +
+ +
+ onChange({ ...block, title: e.currentTarget.value })} + placeholder="Optional title…" + /> +
+
+ onChange({ ...block, content })} placeholder="Callout body…" /> +
+
+
+
+ ); +} + +function CodeRenderer({ block, onChange }: { block: Extract; onChange: (b: MdxBlock) => void }) { + return ( +
+
+ onChange({ ...block, language: e.currentTarget.value })} + placeholder="ts" + /> +
+
+ onChange({ ...block, title: e.currentTarget.value })} + placeholder="filename.ts" + /> +
+
+
+