2026-05-11 00:35:14 -07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import {
|
2026-05-14 01:43:06 +02:00
|
|
|
type ComponentPropsWithoutRef,
|
2026-05-11 00:35:14 -07:00
|
|
|
type ReactNode,
|
|
|
|
|
type ReactElement,
|
|
|
|
|
isValidElement,
|
|
|
|
|
} from "react";
|
|
|
|
|
import { CopyButton } from "./copy-button";
|
|
|
|
|
|
2026-05-14 01:43:06 +02:00
|
|
|
type Props = ComponentPropsWithoutRef<"pre"> & {
|
2026-05-11 00:35:14 -07:00
|
|
|
title?: string;
|
2026-05-14 01:43:06 +02:00
|
|
|
"data-language"?: string;
|
2026-05-11 00:35:14 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const TERMINAL_LANGS = new Set(["bash", "sh", "shell", "zsh"]);
|
|
|
|
|
const WIZARD_GLYPHS = /^\s*[◆◇◯◐○●]/;
|
|
|
|
|
|
|
|
|
|
function extractText(node: ReactNode): string {
|
|
|
|
|
if (typeof node === "string") return node;
|
|
|
|
|
if (typeof node === "number") return String(node);
|
|
|
|
|
if (Array.isArray(node)) return node.map(extractText).join("");
|
|
|
|
|
if (isValidElement(node)) {
|
|
|
|
|
const props = (node as ReactElement<{ children?: ReactNode }>).props;
|
|
|
|
|
return extractText(props.children);
|
|
|
|
|
}
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detectLanguage(props: Props, children: ReactNode): string | null {
|
|
|
|
|
const dataLang = props["data-language"];
|
|
|
|
|
if (typeof dataLang === "string" && dataLang) return dataLang;
|
|
|
|
|
|
|
|
|
|
const className = typeof props.className === "string" ? props.className : "";
|
|
|
|
|
const m = className.match(/language-([\w-]+)/);
|
|
|
|
|
if (m) return m[1];
|
|
|
|
|
|
|
|
|
|
if (isValidElement(children)) {
|
|
|
|
|
const childProps = (children as ReactElement<{ className?: string }>).props;
|
|
|
|
|
const childClass = typeof childProps.className === "string" ? childProps.className : "";
|
|
|
|
|
const cm = childClass.match(/language-([\w-]+)/);
|
|
|
|
|
if (cm) return cm[1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function CodeBlock(props: Props) {
|
|
|
|
|
const { children, title, className: _ignored, ...rest } = props;
|
|
|
|
|
const language = detectLanguage(props, children);
|
|
|
|
|
const codeText = extractText(children);
|
|
|
|
|
|
2026-05-11 23:32:16 -07:00
|
|
|
const isTerminal = language !== null && TERMINAL_LANGS.has(language);
|
|
|
|
|
const isOutput = !isTerminal && WIZARD_GLYPHS.test(codeText);
|
2026-05-11 00:35:14 -07:00
|
|
|
const hasTitle = typeof title === "string" && title.length > 0;
|
|
|
|
|
|
2026-05-14 12:43:14 -04:00
|
|
|
// Mode A - Terminal (commands the user types)
|
2026-05-11 00:35:14 -07:00
|
|
|
if (isTerminal) {
|
|
|
|
|
return (
|
2026-05-11 16:54:56 -07:00
|
|
|
<div className="not-prose ktx-code ktx-code-terminal group">
|
2026-05-11 00:35:14 -07:00
|
|
|
<div className="ktx-code-terminal-head">
|
|
|
|
|
<span className="ktx-tl-dot" style={{ background: "#ff5f57" }} />
|
|
|
|
|
<span className="ktx-tl-dot" style={{ background: "#febc2e" }} />
|
|
|
|
|
<span className="ktx-tl-dot" style={{ background: "#28c840" }} />
|
|
|
|
|
<span className="ktx-code-terminal-label">
|
|
|
|
|
{hasTitle ? title : "zsh"}
|
|
|
|
|
</span>
|
|
|
|
|
<CopyButton
|
|
|
|
|
text={codeText}
|
|
|
|
|
className="ml-auto text-white/80"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<pre {...rest} className="ktx-code-body ktx-code-body-terminal">
|
|
|
|
|
{children}
|
|
|
|
|
</pre>
|
2026-05-11 23:32:16 -07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 12:43:14 -04:00
|
|
|
// Mode D - Output preview (wizard prompts, terminal output)
|
2026-05-11 23:32:16 -07:00
|
|
|
if (isOutput) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="not-prose ktx-code ktx-code-output group relative">
|
|
|
|
|
<span className="ktx-code-output-label">output</span>
|
|
|
|
|
<CopyButton text={codeText} className="ktx-code-output-copy" />
|
|
|
|
|
<pre {...rest} className="ktx-code-body ktx-code-body-output">
|
|
|
|
|
{children}
|
|
|
|
|
</pre>
|
2026-05-11 00:35:14 -07:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 12:43:14 -04:00
|
|
|
// Mode B - VS Code tab (filename present)
|
2026-05-11 00:35:14 -07:00
|
|
|
if (hasTitle) {
|
|
|
|
|
return (
|
2026-05-11 16:54:56 -07:00
|
|
|
<div className="not-prose ktx-code ktx-code-tab group">
|
2026-05-11 00:35:14 -07:00
|
|
|
<div className="ktx-code-tab-head">
|
|
|
|
|
<span className="ktx-file-glyph" data-lang={language ?? ""} />
|
|
|
|
|
<span className="ktx-code-tab-filename">{title}</span>
|
|
|
|
|
{language && <span className="ktx-lang-pill">{language}</span>}
|
|
|
|
|
<CopyButton text={codeText} className="ml-auto" />
|
|
|
|
|
</div>
|
|
|
|
|
<pre {...rest} className="ktx-code-body ktx-code-body-tab">
|
|
|
|
|
{children}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 12:43:14 -04:00
|
|
|
// Mode C - Minimal default
|
2026-05-11 00:35:14 -07:00
|
|
|
return (
|
2026-05-11 16:54:56 -07:00
|
|
|
<div className="not-prose ktx-code ktx-code-minimal group relative">
|
2026-05-11 00:35:14 -07:00
|
|
|
{language && <span className="ktx-code-minimal-lang">{language}</span>}
|
|
|
|
|
<CopyButton text={codeText} className="ktx-code-minimal-copy" />
|
|
|
|
|
<pre {...rest} className="ktx-code-body ktx-code-body-minimal">
|
|
|
|
|
{children}
|
|
|
|
|
</pre>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|