cubby-ui
uicode-blocksource: ok · hit
Code-block
A code-block component.
npx shadcn add "https://www.cubby-ui.dev/r/code-block.json"free
Visual style
terminal-mono
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · light@icons-pack/react-simple-iconsshiki@hugeicons/react@hugeicons/core-free-iconshast-util-to-jsx-runtime
Registry dependencies
@cubby-ui/copy-button@cubby-ui/tabs@cubby-ui/scroll-area@cubby-ui/elevated
Files6 · source cached 10/14/2026, 3:20:00 PM
registry/default/code-block/code-block.tsx
"use client";
import {
useLayoutEffect,
useState,
useMemo,
createContext,
useContext,
} from "react";
import * as React from "react";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { highlight } from "@/registry/default/code-block/lib/shiki-shared";
import { stripDiffMarker } from "@/registry/default/code-block/lib/transformers/utils";
import { cn } from "@/lib/utils";
import { solidSurface } from "@/registry/default/lib/elevated";
import { CopyButton } from "@/registry/default/copy-button/copy-button";
import {
SiTypescript,
SiJavascript,
SiPython,
} from "@icons-pack/react-simple-icons";
import type { BundledLanguage } from "shiki/langs";
import { Tabs, TabsList, TabsTrigger } from "@/registry/default/tabs/tabs";
import {
ScrollArea,
type FadeEdges,
} from "@/registry/default/scroll-area/scroll-area";
import { HugeiconsIcon } from "@hugeicons/react";
import { ComputerTerminal01Icon } from "@hugeicons/core-free-icons";
interface CodeBlockContextValue {
code: string;
language: string;
nodes: React.ReactElement | undefined;
lines: string[];
hasFocus: boolean;
showDiff: boolean;
floatingCopy: boolean;
}
const CodeBlockContext = createContext<CodeBlockContextValue | null>(null);
function useCodeBlock() {
const context = useContext(CodeBlockContext);
if (!context) {
throw new Error(
"CodeBlock subcomponents must be used within a CodeBlock component",
);
}
return context;
}
type LanguageIconRenderer = React.ComponentType<{
size: number;
className: string;
}>;
function renderHugeicon(
icon: typeof ComputerTerminal01Icon,
): LanguageIconRenderer {
function HugeLanguageIcon({
size,
className,
}: {
size: number;
className: string;
}) {
return (
<HugeiconsIcon
icon={icon}
size={size}
className={className}
strokeWidth={2}
/>
);
}
return HugeLanguageIcon;
}
const LANGUAGE_ICONS: Record<string, LanguageIconRenderer> = {
typescript: SiTypescript,
ts: SiTypescript,
tsx: SiTypescript,
javascript: SiJavascript,
js: SiJavascript,
jsx: SiJavascript,
bash: renderHugeicon(ComputerTerminal01Icon),
sh: renderHugeicon(ComputerTerminal01Icon),
shell: renderHugeicon(ComputerTerminal01Icon),
python: SiPython,
py: SiPython,
};
function getLanguageIcon(language: string) {
const normalized = language.toLowerCase();
const Icon = LANGUAGE_ICONS[normalized];
if (Icon) {
return <Icon size={16} className="text-muted-foreground" />;
}
return <span className="text-muted-foreground text-sm">{language}</span>;
}
interface CodeBlockProps extends Omit<
useRender.ComponentProps<"div">,
"children"
> {
code: string;
language?: string;
initial?: React.ReactElement;
floatingCopy?: boolean;
highlightLines?: number[] | string;
showDiff?: boolean;
focusLines?: number[] | string;
children: React.ReactNode;
}
function CodeBlock({
code,
language = "javascript",
initial,
floatingCopy = false,
highlightLines,
showDiff,
focusLines,
className,
render,
children,
...props
}: CodeBlockProps) {
const [nodes, setNodes] = useState(initial);
// Update during render (React 18+ state-from-props pattern) to avoid a
// cascading effect render when the `initial` prop changes.
const [prevInitial, setPrevInitial] = useState(initial);
if (initial !== prevInitial) {
setPrevInitial(initial);
if (initial) {
setNodes(initial);
}
}
const lines = useMemo(() => code.split("\n"), [code]);
useLayoutEffect(() => {
if (!initial) {
const normalizedLanguage = (language as BundledLanguage) || "javascript";
void highlight(code, normalizedLanguage, {
highlightLines,
showDiff,
focusLines,
}).then(setNodes);
}
}, [code, language, initial, highlightLines, showDiff, focusLines]);
const contextValue = useMemo(
() => ({
language,
code,
nodes,
lines,
hasFocus: !!focusLines,
showDiff: !!showDiff,
floatingCopy,
}),
[language, code, nodes, lines, focusLines, showDiff, floatingCopy],
);
const content = (
<CodeBlockContext.Provider value={contextValue}>
{children}
</CodeBlockContext.Provider>
);
const defaultProps = {
"data-slot": "code-block",
className: cn(
"group max-w-full w-full rounded-2xl p-1 pt-0 relative",
// bg-muted follows solidSurface to override its bg-surface-3.
solidSurface(3, 1),
"bg-muted",
// No header: the tray has nothing to hold, so drop it and let the code
// card stand on its own.
// Its own child only: a code block nested inside (a card holding a
// peek) also opens with a pre, and stripped the tray around it.
"has-[>[data-slot='code-block-pre']:first-child]:bg-transparent has-[>[data-slot='code-block-pre']:first-child]:p-0 has-[>[data-slot='code-block-pre']:first-child]:shadow-none",
className,
),
children: content,
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
interface CodeBlockHeaderProps
extends useRender.ComponentProps<"div">, Partial<BaseTabsProps> {
filename?: string;
tabVariant?: React.ComponentProps<typeof TabsList>["variant"];
customIcon?: React.ReactNode;
showCopy?: boolean;
}
function CodeBlockHeader({
className,
render,
children,
filename,
tabs,
activeTab,
onTabChange,
tabVariant,
customIcon,
showCopy = true,
...props
}: CodeBlockHeaderProps) {
const context = useCodeBlock();
const language = context.language;
const code = context.code;
const startContent = (
<div className="flex min-w-0 items-center gap-2">
{language && (
<CodeBlockLanguage language={language} customIcon={customIcon} />
)}
{filename && <CodeBlockFilename>{filename}</CodeBlockFilename>}
{tabs && activeTab && onTabChange && (
<CodeBlockTabs
tabs={tabs}
activeTab={activeTab}
onTabChange={onTabChange}
variant={tabVariant}
/>
)}
</div>
);
const endContent = showCopy && code && (
<div className="flex items-center gap-2">
<CopyButton data-slot="code-block-copy-button" content={code} />
</div>
);
const content = children ?? (
<>
{startContent}
{endContent}
</>
);
const defaultProps = {
"data-slot": "code-block-header",
className: cn(
// min-h-9: a header with only a filename (no 28px tab pills) was
// 28px tall, crowding the name against the code card.
"flex min-h-9 items-center justify-between bg-transparent px-3 py-1",
className,
),
children: content,
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
interface CodeBlockLanguageProps extends useRender.ComponentProps<"div"> {
language: string;
customIcon?: React.ReactNode;
}
function CodeBlockLanguage({
language,
customIcon,
className,
render,
...props
}: CodeBlockLanguageProps) {
const icon = customIcon ?? getLanguageIcon(language);
const defaultProps = {
"data-slot": "code-block-language",
className: cn("flex items-center gap-1.5", className),
children: icon,
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
type CodeBlockFilenameProps = useRender.ComponentProps<"span">;
function CodeBlockFilename({
className,
render,
children,
...props
}: CodeBlockFilenameProps) {
const defaultProps = {
"data-slot": "code-block-filename",
className: cn("text-sm font-medium", className),
children,
};
const element = useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
});
return element;
}
interface HeaderTab {
value: string;
label: string;
}
interface BaseTabsProps {
tabs: HeaderTab[];
activeTab: React.ComponentProps<typeof Tabs>["value"];
onTabChange: React.ComponentProps<typeof Tabs>["onValueChange"];
}
interface CodeBlockTabsProps
extends useRender.ComponentProps<"div">, BaseTabsProps {
variant?: React.ComponentProps<typeof TabsList>["variant"];
}
function CodeBlockTabs({
tabs,
activeTab,
onTabChange,
variant = "capsule",
className,
render,
...props
}: CodeBlockTabsProps) {
const tabsElement = (
<Tabs value={activeTab} onValueChange={onTabChange} className="gap-1">
{/* Tabs that overflow scroll sideways without a scrollbar: the
project has no `scrollbar-hide` utility, so the native bar showed.
A scroller clips both axes, so it keeps 4px on every side (taken
back with negative margins) for the active tab's ring, first and
last tabs included, and scrolls a picked tab into view with the
same 4px (scroll padding), or it lands flush and the ring is cut. Its
width cap grows by the 8px its margins take back, or the right one
did nothing and the last tab ran 4px past the clip. */}
<div className="-m-1 flex max-w-[calc(100%+0.5rem)] scroll-px-1 items-center overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<TabsList
variant={variant}
size="small"
className="w-max bg-transparent p-0 shadow-none ring-0"
>
{tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
</Tabs>
);
const defaultProps = {
"data-slot": "code-block-tabs",
// The same 4px room inside this clip, so the scroller's fits (its 8px
// left margin split into 4px margin and 4px padding).
className: cn("-my-1 -mr-1 ml-1 min-w-0 overflow-hidden p-1", className),
children: tabsElement,
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
type CodeBlockFloatingCopyProps = useRender.ComponentProps<"div">;
function CodeBlockFloatingCopy({
className,
render,
...props
}: CodeBlockFloatingCopyProps) {
const context = useCodeBlock();
const code = context.code;
const defaultProps = {
"data-slot": "code-block-floating-copy",
className: cn("absolute pointer-events-none z-1 top-2 right-2", className),
children: (
<CopyButton
data-slot="code-block-floating-copy"
content={code}
className="pointer-events-auto backdrop-blur-sm"
/>
),
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
interface CodeBlockPreProps extends useRender.ComponentProps<"pre"> {
/** Show line numbers in the gutter */
lineNumbers?: boolean;
/** Configure fade edges to indicate scrollable content. Defaults to true. */
fadeEdges?: FadeEdges;
/** Hide scrollbars while keeping scroll functionality */
hideScrollbar?: boolean;
/** Use native browser scrolling instead of Base UI ScrollArea */
nativeScroll?: boolean;
}
function CodeBlockPre({
className,
render,
children,
lineNumbers = false,
fadeEdges = true,
hideScrollbar = false,
nativeScroll = false,
...props
}: CodeBlockPreProps) {
const context = useCodeBlock();
const lines = context.lines;
const hasFocus = context.hasFocus;
const floatingCopy = context.floatingCopy;
const content =
lineNumbers && lines.length > 0 ? (
<div className="flex">
<CodeBlockLineNumbers lines={lines} />
<div className="min-w-0 flex-1">{children}</div>
</div>
) : (
children
);
const defaultProps = {
"data-slot": "code-block-pre",
"data-has-focus": hasFocus ? "true" : undefined,
className: cn(
"relative rounded-lg whitespace-pre overflow-hidden max-h-96 flex flex-col",
// Inner code body card sitting in the gray tray.
solidSurface(3),
// Focus mode: blur non-focused lines
"[&[data-has-focus]_.line:not([data-focused])]:opacity-60 [&[data-has-focus]_.line:not([data-focused])]:blur-[1px] [&[data-has-focus]_.line:not([data-focused])]:transition-all",
className,
),
children: (
<>
{floatingCopy && <CodeBlockFloatingCopy />}
{/* Scroll chains to the page: code is in-flow content, and containing
it trapped the page's vertical scroll over any block whose long
lines only scroll sideways (wheel and touch alike). */}
<ScrollArea
fadeEdges={fadeEdges}
hideScrollbar={hideScrollbar}
nativeScroll={nativeScroll}
overscrollBehavior="auto"
viewportClassName="py-3"
className="min-h-0 flex-1"
>
{content}
</ScrollArea>
</>
),
};
const element = useRender({
defaultTagName: "pre",
render,
props: mergeProps<"pre">(defaultProps, props),
});
return element;
}
interface CodeBlockLineNumbersProps extends useRender.ComponentProps<"div"> {
lines: string[];
}
function CodeBlockLineNumbers({
className,
render,
lines,
...props
}: CodeBlockLineNumbersProps) {
const lineNumbers = lines.map((_, index) => (
<div
key={index}
className="text-right text-[.8125rem] leading-normal tabular-nums"
>
{index + 1}
</div>
));
const defaultProps = {
"data-slot": "code-block-line-numbers",
"data-line-numbers": "true",
className: cn("text-muted-foreground pl-3 select-none", className),
children: lineNumbers,
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
type CodeBlockCodeProps = useRender.ComponentProps<"div">;
function CodeBlockCode({ className, render, ...props }: CodeBlockCodeProps) {
const context = useCodeBlock();
const code = context.code;
const nodes = context.nodes;
const showDiff = context.showDiff;
const prehighlightedCode = useMemo(() => {
const originalLines = code.split("\n");
let lines = [...originalLines];
if (showDiff) {
lines = lines.map((line) => stripDiffMarker(line));
}
// Mirror Shiki's output structure: `.line` spans separated by newline text nodes.
const elements: React.ReactNode[] = [];
lines.forEach((line, index) => {
elements.push(
<span key={`line-${index}`} className="line">
{line || " "}
</span>,
);
if (index < lines.length - 1) {
elements.push("\n");
}
});
return <code>{elements}</code>;
}, [code, showDiff]);
const defaultProps = {
"data-slot": "code-block-code",
className: cn(
// font-mono on the wrapper plus the Shiki/fallback pre & code elements:
// the UA stylesheet sets `font-family: monospace` directly on pre/code,
// which beats inheritance, so Geist Mono must be applied to them too.
"block font-mono text-[.8125rem] leading-normal whitespace-pre w-fit min-w-full [&_pre]:font-mono [&_code]:font-mono",
// Uniform padding: raw text (no `.line`) vs. Shiki `.line` spans.
"[&:not(:has(.line))]:px-3 [&_.line]:!px-3 [&:not(:has(.line))]:pr-8 [&_.line]:!pr-8",
// Full-width lines so bg highlights stretch edge-to-edge.
"[&_.line]:inline-block [&_.line]:min-w-full",
// Left-border highlight: pl offset = px - border-width so text stays aligned.
"[&_.line[data-highlighted]]:bg-primary/10 [&_.line[data-highlighted]]:border-l-2 [&_.line[data-highlighted]]:border-primary/50 [&_.line[data-highlighted]]:!pl-[calc(0.75rem-2px)]",
// Diff: added lines
"[&_.line[data-diff='added']]:bg-green-500/10 [&_.line[data-diff='added']]:border-l-2 [&_.line[data-diff='added']]:border-green-500/70 [&_.line[data-diff='added']]:!pl-[calc(0.75rem-2px)]",
// Diff: removed lines
"[&_.line[data-diff='removed']]:bg-red-500/10 [&_.line[data-diff='removed']]:border-l-2 [&_.line[data-diff='removed']]:border-red-500/70 [&_.line[data-diff='removed']]:!pl-[calc(0.75rem-2px)]",
// Diff: modified lines
"[&_.line[data-diff='modified']]:bg-yellow-500/10 [&_.line[data-diff='modified']]:border-l-2 [&_.line[data-diff='modified']]:border-yellow-500/70 [&_.line[data-diff='modified']]:!pl-[calc(0.75rem-2px)]",
className,
),
children: nodes || prehighlightedCode,
};
const element = useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
});
return element;
}
export {
CodeBlock,
CodeBlockHeader,
CodeBlockFloatingCopy,
CodeBlockPre,
CodeBlockLineNumbers,
CodeBlockCode,
useCodeBlock,
};
export type { FadeEdges };
registry/default/code-block/lib/shiki-shared.ts
import type { JSX } from "react";
import type { ShikiTransformer } from "shiki/core";
import type { BundledLanguage } from "shiki/langs";
import { toJsxRuntime } from "hast-util-to-jsx-runtime";
import { Fragment } from "react";
import { jsx, jsxs } from "react/jsx-runtime";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { createHighlightLinesTransformer } from "./transformers/highlight-lines";
import { createDiffTransformer } from "./transformers/diff";
import { createFocusTransformer } from "./transformers/focus";
// Global highlighter promise (per Shiki Next.js docs pattern)
// Define without await as a global variable to reference from components
// https://shiki.style/packages/next
const highlighter = createHighlighterCore({
themes: [
import("shiki/themes/github-light.mjs"),
import("shiki/themes/github-dark.mjs"),
],
langs: [], // Load languages on-demand for optimal performance
engine: createJavaScriptRegexEngine(), // Smaller bundle, faster startup than WASM
});
// Track loaded languages to avoid redundant imports
const loadedLanguages = new Set<string>();
// Shiki's built-in no-op grammars. These need no on-demand `shiki/langs/*`
// import (there is no module for them) — `codeToHast` handles them directly.
// Used for fenced blocks with no language (file trees, plain output) so they
// still render in a real <pre> rather than collapsing into inline code.
const PLAINTEXT_LANGS = new Set(["text", "txt", "plaintext", "plain", "ansi"]);
const BASE_TRANSFORMERS: ShikiTransformer[] = [
{
name: "remove-background",
pre(node) {
delete node.properties.style;
},
code(node) {
delete node.properties.style;
},
},
{
name: "fix-empty-lines",
line(node) {
// Ensure empty lines have a space to maintain height
// Shiki creates empty .line spans for blank lines which collapse
if (!node.children || node.children.length === 0) {
node.children = [{ type: "text", value: " " }];
}
},
},
];
export interface HighlightOptions {
highlightLines?: number[] | string;
showDiff?: boolean;
focusLines?: number[] | string;
}
async function highlightWithLang(
code: string,
lang: BundledLanguage,
options?: HighlightOptions,
) {
// Await the global highlighter promise (per Shiki Next.js docs)
const instance = await highlighter;
// Load language dynamically if not already loaded (cached Set for performance).
// Plaintext grammars are built in, so skip the import that would 404.
if (!PLAINTEXT_LANGS.has(lang) && !loadedLanguages.has(lang)) {
// Dynamically import only the specific language module needed
const langModule = await import(`shiki/langs/${lang}.mjs`);
await instance.loadLanguage(langModule.default);
loadedLanguages.add(lang);
}
// Build transformers array based on options
const transformers: ShikiTransformer[] = [...BASE_TRANSFORMERS];
if (options?.highlightLines) {
transformers.push(createHighlightLinesTransformer(options.highlightLines));
}
if (options?.showDiff) {
transformers.push(createDiffTransformer());
}
if (options?.focusLines) {
transformers.push(createFocusTransformer(options.focusLines));
}
const out = instance.codeToHast(code, {
lang,
themes: {
light: "github-light",
dark: "github-dark",
},
defaultColor: "light-dark()",
transformers,
});
return toJsxRuntime(out, { Fragment, jsx, jsxs }) as JSX.Element;
}
export async function highlight(
code: string,
lang: BundledLanguage,
options?: HighlightOptions,
) {
try {
return await highlightWithLang(code, lang, options);
} catch {
// If language isn't supported, try with javascript as fallback
try {
return await highlightWithLang(code, "javascript", options);
} catch {
// Final fallback to plain text
return jsx("pre", { children: code }) as JSX.Element;
}
}
}
registry/default/code-block/lib/transformers/diff.ts
import type { ShikiTransformer } from "shiki";
import { detectDiffMarker, stripDiffMarker } from "./utils";
/**
* Shiki transformer that detects diff markers and adds data-diff attributes
* Supports:
* - "+" prefix for added lines
* - "-" prefix for removed lines
* - "!" prefix for modified lines
* - Comment variants: "// +", "// -", "// !"
*
* The markers are stripped from the displayed code for clean output
*
* @returns Shiki transformer
*/
export function createDiffTransformer(): ShikiTransformer {
// Store original code lines for diff detection
let codeLines: string[] = [];
return {
name: "diff",
preprocess(code) {
// Store original lines before Shiki processes them
codeLines = code.split("\n");
// Strip diff markers from the code that will be highlighted
return code
.split("\n")
.map((line) => stripDiffMarker(line))
.join("\n");
},
line(node, lineNumber) {
// lineNumber is 1-indexed by Shiki, convert to 0-indexed for array access
const originalLine = codeLines[lineNumber - 1];
if (!originalLine) return;
const diffType = detectDiffMarker(originalLine);
if (diffType) {
node.properties["data-diff"] = diffType;
}
},
};
}
registry/default/code-block/lib/transformers/focus.ts
import type { ShikiTransformer } from "shiki";
import { parseLineRange } from "./utils";
/**
* Shiki transformer that adds focus attributes to lines
* - Adds data-line-number to all lines for targeting
* - Adds data-focused to specified lines
*
* @param lines - Line numbers to focus (1-indexed) or range string like "1-3,5"
* @returns Shiki transformer
*/
export function createFocusTransformer(
lines: number[] | string | undefined,
): ShikiTransformer {
const focusedLines = parseLineRange(lines);
const focusSet = new Set(focusedLines);
return {
name: "focus",
line(node, lineNumber) {
// lineNumber is already 1-indexed by Shiki
// Add line number to all lines for CSS targeting
node.properties["data-line-number"] = String(lineNumber);
// Mark focused lines
if (focusSet.has(lineNumber)) {
node.properties["data-focused"] = "true";
}
},
};
}
registry/default/code-block/lib/transformers/highlight-lines.ts
import type { ShikiTransformer } from "shiki";
import { parseLineRange } from "./utils";
/**
* Shiki transformer that adds data-highlighted attribute to specified lines
*
* @param lines - Line numbers to highlight (1-indexed) or range string like "1-3,5,7-9"
* @returns Shiki transformer
*/
export function createHighlightLinesTransformer(
lines: number[] | string | undefined,
): ShikiTransformer {
const highlightedLines = parseLineRange(lines);
const highlightSet = new Set(highlightedLines);
return {
name: "highlight-lines",
line(node, lineNumber) {
// lineNumber is already 1-indexed by Shiki
if (highlightSet.has(lineNumber)) {
node.properties["data-highlighted"] = "true";
}
},
};
}
registry/default/code-block/lib/transformers/utils.ts
/**
* Parse line range syntax into an array of line numbers
* Supports formats:
* - Array: [1, 2, 3]
* - String: "1,3,5" or "1-3,5,7-9"
* - Mixed: "1-3,5" becomes [1, 2, 3, 5]
*
* @param input - Line numbers as array or range string
* @returns Array of line numbers (1-indexed)
*/
export function parseLineRange(
input: number[] | string | undefined,
): number[] {
if (!input) return [];
if (Array.isArray(input)) return input;
const lines = new Set<number>();
const parts = input.split(",").map((s) => s.trim());
for (const part of parts) {
// Range format: "1-3"
if (part.includes("-")) {
const [start, end] = part.split("-").map((s) => Number.parseInt(s, 10));
if (!Number.isNaN(start) && !Number.isNaN(end)) {
for (let i = start; i <= end; i++) {
lines.add(i);
}
}
} else {
// Single number
const num = Number.parseInt(part, 10);
if (!Number.isNaN(num)) {
lines.add(num);
}
}
}
return Array.from(lines).sort((a, b) => a - b);
}
/**
* Detect diff markers in code lines
* Supports:
* - Prefix markers: "+", "-", "!"
* - Comment markers: "// +", "// -", "// !"
*
* @param line - Code line to check
* @returns Diff type or null
*/
export function detectDiffMarker(
line: string,
): "added" | "removed" | "modified" | null {
const trimmed = line.trim();
// Direct prefix markers
if (trimmed.startsWith("+")) return "added";
if (trimmed.startsWith("-")) return "removed";
if (trimmed.startsWith("!")) return "modified";
// Comment markers (for languages that don't support bare +/-)
if (trimmed.startsWith("// +")) return "added";
if (trimmed.startsWith("// -")) return "removed";
if (trimmed.startsWith("// !")) return "modified";
return null;
}
/**
* Strip diff markers from a code line
*
* @param line - Code line with potential diff marker
* @returns Line with marker removed
*/
export function stripDiffMarker(line: string): string {
const trimmed = line.trim();
// Direct prefix markers
if (trimmed.startsWith("+") || trimmed.startsWith("-") || trimmed.startsWith("!")) {
return line.replace(/^(\s*)[-+!](\s?)/, "$1");
}
// Comment markers
if (trimmed.startsWith("// +") || trimmed.startsWith("// -") || trimmed.startsWith("// !")) {
return line.replace(/^(\s*)\/\/\s*[-+!](\s?)/, "$1");
}
return line;
}