import { IconCode, IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; import { common, createLowlight } from "lowlight"; import { useMemo, useRef, useState, type ChangeEvent, type ReactNode, type UIEvent, } from "react"; import { Popover, PopoverContent, PopoverTrigger, } from "../../components/ui/popover.js"; import { cn } from "../../utils.js"; import { ltrCodeBlockProps } from "../code-block-direction.js"; import { defineBlock } from "../types.js"; import type { BlockReadProps, BlockEditProps } from "../types.js"; import { codeTabsSchema, codeTabsMdx, type CodeTabsData, type CodeTabsTab, } from "./code-tabs.config.js"; import { CodeSurface, DEFAULT_CODE_MAX_LINES } from "./HighlightedCode.js"; /** * Standard `code-tabs` block (STANDARD core library): a vertical file tab rail * with Shiki-highlighted code panes. Moved verbatim from the plan * `CodeTabsBlock` (`DocumentArea.tsx`) so its rendered output is unchanged, then * generalized to the registry `Read`/`Edit` contract. Shareable by any app that * registers the core block library. * * `Edit` is hybrid: each tab's `code` field renders as a code-style monospace * text area, while tab metadata (label/language/caption/add/remove) stays in a * settings popover so the document surface only exposes authored content. */ /* ── Syntax highlighting helpers ──────────────────────────────────────────── */ const lowlight = createLowlight(common); type LowlightNode = { type: string; value?: string; properties?: { className?: string[] | string; }; children?: LowlightNode[]; }; const LANGUAGE_ALIASES: Record = { cjs: "javascript", cts: "typescript", htm: "html", js: "javascript", jsonc: "json", jsx: "jsx", md: "markdown", mdx: "markdown", mjs: "javascript", mts: "typescript", py: "python", rb: "ruby", rs: "rust", sh: "bash", shell: "bash", ts: "typescript", tsx: "tsx", yml: "yaml", zsh: "bash", }; const TOKEN_CLASS_NAMES: Record = { "hljs-addition": "text-emerald-700 dark:text-emerald-300", "hljs-attr": "text-sky-700 dark:text-sky-300", "hljs-attribute": "text-sky-700 dark:text-sky-300", "hljs-built_in": "text-amber-700 dark:text-amber-300", "hljs-bullet": "text-primary", "hljs-comment": "text-muted-foreground italic", "hljs-deletion": "text-destructive", "hljs-doctag": "text-destructive", "hljs-emphasis": "italic", "hljs-formula": "text-destructive", "hljs-keyword": "text-rose-700 dark:text-rose-300", "hljs-link": "text-primary underline-offset-2", "hljs-literal": "text-violet-700 dark:text-violet-300", "hljs-meta": "text-primary", "hljs-meta-string": "text-emerald-700 dark:text-emerald-300", "hljs-name": "text-emerald-700 dark:text-emerald-300", "hljs-number": "text-sky-700 dark:text-sky-300", "hljs-params": "text-sky-700 dark:text-sky-300", "hljs-property": "text-sky-700 dark:text-sky-300", "hljs-quote": "text-muted-foreground italic", "hljs-regexp": "text-emerald-700 dark:text-emerald-300", "hljs-section": "text-violet-700 dark:text-violet-300", "hljs-selector-attr": "text-primary", "hljs-selector-class": "text-emerald-700 dark:text-emerald-300", "hljs-selector-id": "text-emerald-700 dark:text-emerald-300", "hljs-selector-pseudo": "text-primary", "hljs-selector-tag": "text-emerald-700 dark:text-emerald-300", "hljs-string": "text-emerald-700 dark:text-emerald-300", "hljs-strong": "font-semibold", "hljs-subst": "text-destructive", "hljs-symbol": "text-primary", "hljs-tag": "text-emerald-700 dark:text-emerald-300", "hljs-template-variable": "text-amber-700 dark:text-amber-300", "hljs-title": "text-violet-700 dark:text-violet-300", "hljs-type": "text-amber-700 dark:text-amber-300", "hljs-variable": "text-amber-700 dark:text-amber-300", language_: "text-amber-700 dark:text-amber-300", }; function normalizeCodeLanguage(value?: string | null): string | null { const raw = value ?.trim() .toLowerCase() .replace(/^language-/, ""); if (!raw) return null; const normalized = LANGUAGE_ALIASES[raw] ?? raw; return lowlight.registered(normalized) ? normalized : null; } function inferLanguageFromFilename(filename?: string | null): string | null { const basename = filename?.split("/").pop()?.toLowerCase(); if (!basename) return null; if (basename === "dockerfile") return normalizeCodeLanguage("bash"); const extension = basename.includes(".") ? basename.split(".").pop() : undefined; return normalizeCodeLanguage(extension); } function codeTabLanguage(tab?: CodeTabsTab): string | undefined { return ( normalizeCodeLanguage(tab?.language) ?? inferLanguageFromFilename(tab?.label) ?? undefined ); } function tokenClassName(value: string[] | string | undefined): string { const classes = Array.isArray(value) ? value : typeof value === "string" ? value.split(/\s+/) : []; return classes .map((className) => TOKEN_CLASS_NAMES[className] ?? "") .filter(Boolean) .join(" "); } function hastToReact(children: LowlightNode[], keyPrefix: string): ReactNode[] { return children.map((node, index) => { if (node.type === "text") return node.value ?? ""; if (node.type === "element") { const key = `${keyPrefix}${index}`; const renderedChildren = node.children?.length ? hastToReact(node.children, `${key}-`) : null; const className = tokenClassName(node.properties?.className); return ( {renderedChildren} ); } return null; }); } function highlightCode(code: string, language?: string): ReactNode { const normalized = normalizeCodeLanguage(language); if (!normalized || normalized === "plaintext" || normalized === "text") { return code; } try { const tree = lowlight.highlight(normalized, code) as LowlightNode; return hastToReact(tree.children ?? [], `${normalized}-`); } catch { return code; } } /* ── Read (vertical tab rail + Shiki) ──────────────────────────────────────── */ function CodeTabsRead({ data, blockId, title }: BlockReadProps) { const [activeId, setActiveId] = useState(data.tabs[0]?.id ?? ""); const active = data.tabs.find((tab) => tab.id === activeId) ?? data.tabs[0]; return (
{title &&
{title}
}
{data.tabs.map((tab) => ( ))}
{active && ( <>

{active.label}

{active.caption && (

{active.caption}

)} )}
); } /* ── Edit (code text areas per tab) ────────────────────────────────────────── */ const inputClass = "flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"; /** Language options for the per-tab picker; "" is the Auto-detect sentinel. */ const CODE_TAB_LANGUAGES: ReadonlyArray<{ value: string; label: string }> = [ { value: "", label: "Auto" }, { value: "typescript", label: "TypeScript" }, { value: "javascript", label: "JavaScript" }, { value: "tsx", label: "TSX" }, { value: "jsx", label: "JSX" }, { value: "json", label: "JSON" }, { value: "html", label: "HTML" }, { value: "css", label: "CSS" }, { value: "bash", label: "Bash" }, { value: "python", label: "Python" }, { value: "sql", label: "SQL" }, { value: "yaml", label: "YAML" }, { value: "markdown", label: "Markdown" }, { value: "graphql", label: "GraphQL" }, { value: "go", label: "Go" }, { value: "rust", label: "Rust" }, { value: "diff", label: "Diff" }, ]; /** Mint a reasonably-unique code-tab id without pulling a dep into core. */ function newCodeTabId(): string { return `code-tab-${Math.random().toString(36).slice(2, 10)}`; } function HighlightedCodeTextarea({ value, language, label, editable, onChange, }: { value: string; language?: string; label?: string; editable: boolean; onChange: (event: ChangeEvent) => void; }) { const highlightLayerRef = useRef(null); const resolvedLanguage = normalizeCodeLanguage(language) ?? inferLanguageFromFilename(label); const highlighted = useMemo( () => highlightCode(value, resolvedLanguage ?? undefined), [resolvedLanguage, value], ); const syncScroll = (event: UIEvent) => { const layer = highlightLayerRef.current; if (!layer) return; layer.scrollTop = event.currentTarget.scrollTop; layer.scrollLeft = event.currentTarget.scrollLeft; }; return (