+ )}
+ >
+ );
+}
diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockContent.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockContent.tsx
new file mode 100644
index 000000000..9aabd3d38
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/components/CodeBlockContent.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import type {
+ ComponentPropsWithRef,
+ ReactNode,
+ Ref,
+ UIEventHandler,
+} from 'react';
+
+import { mergeProps, mergeRefs } from '@koobiq/react-core';
+
+import s from '../CodeBlock.module.css';
+
+export type CodeBlockContentProps = {
+ contentRef: Ref;
+ /** Accessible name of the region, taken from the file name. */
+ 'aria-label': string;
+ /** Whether the content overflows and therefore has to be reachable by keyboard. */
+ isFocusable: boolean;
+ maxHeight?: number;
+ onScroll: UIEventHandler;
+ children?: ReactNode;
+ slotProps?: Omit, 'children'>;
+};
+
+/**
+ * The scrollable region with the code of a `CodeBlock` without tabs. With tabs, that region is the
+ * tab panel rendered by `CodeBlockTabs`.
+ */
+export function CodeBlockContent(props: CodeBlockContentProps) {
+ const {
+ contentRef,
+ 'aria-label': ariaLabel,
+ isFocusable,
+ maxHeight,
+ onScroll,
+ children,
+ slotProps,
+ } = props;
+
+ const { ref: slotRef, style: slotStyle, ...restSlotProps } = slotProps ?? {};
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockHeader.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockHeader.tsx
new file mode 100644
index 000000000..f9f9aedce
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/components/CodeBlockHeader.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import type { ComponentPropsWithRef, ReactNode } from 'react';
+
+import { mergeProps } from '@koobiq/react-core';
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+import s from '../CodeBlock.module.css';
+
+export type CodeBlockHeaderSlotProps = Omit<
+ ComponentPropsWithRef<'div'>,
+ 'children'
+>;
+
+export type CodeBlockHeaderProps = {
+ /** Whether the code content is scrolled away from its top, which casts a shadow under the header. */
+ isScrolled: boolean;
+ children?: ReactNode;
+ slotProps?: CodeBlockHeaderSlotProps;
+};
+
+/**
+ * Props of the header band, shared by both headers: the plain one below and the one `Tabs` renders
+ * for `CodeBlockTabs`.
+ */
+export function getCodeBlockHeaderProps(
+ isScrolled: boolean,
+ slotProps?: CodeBlockHeaderSlotProps
+) {
+ return mergeProps(
+ {
+ className: s.header,
+ 'data-testid': 'code-block-header',
+ 'data-scrolled': isScrolled || undefined,
+ } satisfies ComponentPropsWithRef<'div'> & DataAttributeProps,
+ slotProps
+ );
+}
+
+/** The header band of a `CodeBlock` without tabs — with them it comes from `CodeBlockTabs`. */
+export function CodeBlockHeader(props: CodeBlockHeaderProps) {
+ const { isScrolled, children, slotProps } = props;
+
+ return (
+
{children}
+ );
+}
diff --git a/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx
new file mode 100644
index 000000000..4c6639499
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/components/CodeBlockTabs.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import type { ReactNode, Ref, UIEventHandler } from 'react';
+
+import { mergeProps, mergeRefs } from '@koobiq/react-core';
+
+import { Tab, Tabs } from '../../Tabs';
+import s from '../CodeBlock.module.css';
+import type { CodeBlockFile, CodeBlockProps } from '../types';
+
+import { getCodeBlockHeaderProps } from './CodeBlockHeader';
+
+export type CodeBlockTabsProps = {
+ files: CodeBlockFile[];
+ activeFileIndex: number;
+ onActiveFileIndexChange: (index: number) => void;
+ fallbackFileName: string;
+ renderTabLabel?: (file: CodeBlockFile, fallbackFileName: string) => ReactNode;
+ panelRef: Ref;
+ panelContent: ReactNode;
+ panelMaxHeight?: number;
+ onPanelScroll: UIEventHandler;
+ isScrolled: boolean;
+ 'aria-label': string;
+ slotProps?: CodeBlockProps['slotProps'];
+};
+
+/**
+ * The header of a `CodeBlock` with tabs, along with the tab panel holding the code: `Tabs` renders
+ * both of them, and the code block lays them out next to the action bar.
+ */
+export function CodeBlockTabs(props: CodeBlockTabsProps) {
+ const {
+ files,
+ activeFileIndex,
+ onActiveFileIndexChange,
+ fallbackFileName,
+ renderTabLabel,
+ panelRef,
+ panelContent,
+ panelMaxHeight,
+ onPanelScroll,
+ isScrolled,
+ 'aria-label': ariaLabel,
+ slotProps,
+ } = props;
+
+ const {
+ ref: contentRef,
+ style: contentStyle,
+ ...contentProps
+ } = slotProps?.content ?? {};
+
+ return (
+ {
+ if (files.length > 1) onActiveFileIndexChange(Number(key));
+ }}
+ slotProps={{
+ tabs: getCodeBlockHeaderProps(isScrolled, slotProps?.header),
+ tabPanel: {
+ ...mergeProps(
+ { className: s.main, onScroll: onPanelScroll },
+ contentProps
+ ),
+ ref: mergeRefs(panelRef, contentRef),
+ style: { maxHeight: panelMaxHeight, ...contentStyle },
+ },
+ }}
+ >
+ {files.map((file, index) => (
+
+ {/* `Tabs` renders the children of the selected tab only, so the others need no copy. */}
+ {index === activeFileIndex ? panelContent : null}
+
+ ))}
+
+ );
+}
diff --git a/packages/components/src/components/CodeBlock/components/index.ts b/packages/components/src/components/CodeBlock/components/index.ts
new file mode 100644
index 000000000..1b6d56ce5
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/components/index.ts
@@ -0,0 +1,5 @@
+export * from './CodeBlockActionBar';
+export * from './CodeBlockCode';
+export * from './CodeBlockContent';
+export * from './CodeBlockHeader';
+export * from './CodeBlockTabs';
diff --git a/packages/components/src/components/CodeBlock/context.tsx b/packages/components/src/components/CodeBlock/context.tsx
new file mode 100644
index 000000000..55a32de93
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/context.tsx
@@ -0,0 +1,65 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+import type { ReactNode } from 'react';
+
+import type { HLJSApi, LanguageFn } from 'highlight.js';
+
+/** `highlight.js` loading configuration for `CodeBlock`. */
+export type CodeBlockHighlightConfig = Partial<{
+ /** Lazy loader for the highlight.js core (no bundled languages). When omitted, the full bundle is loaded. */
+ core: () => Promise<{ default: HLJSApi }>;
+ /** Map of language name to a lazy loader for that language's `LanguageFn`. */
+ languages: Record Promise<{ default: LanguageFn }>>;
+ /** Language used when a file language is missing or unsupported. */
+ fallbackLanguage: string;
+}>;
+
+const CodeBlockHighlightConfigContext =
+ createContext(null);
+
+// A stable reference so `useCodeBlockHighlightConfig` doesn't hand back a new object identity on every
+// call when no provider is present — `useHighlightedCode` keys its load cache off that identity.
+const EMPTY_CONFIG: CodeBlockHighlightConfig = {};
+
+export type CodeBlockProviderProps = {
+ /** The `highlight.js` loading configuration applied to every `CodeBlock` inside. */
+ highlightConfig: CodeBlockHighlightConfig;
+ children?: ReactNode;
+};
+
+/**
+ * Configures every `CodeBlock` inside it.
+ *
+ * By default, `CodeBlock` lazily loads the full `highlight.js` bundle with all languages (~1 MB).
+ * To reduce bundle size, wrap the app (or a part of it) with this provider and specify only the languages you need.
+ * @example
+ * ```tsx
+ * const highlightConfig = {
+ * core: () => import('highlight.js/lib/core'),
+ * languages: {
+ * typescript: () => import('highlight.js/lib/languages/typescript'),
+ * css: () => import('highlight.js/lib/languages/css'),
+ * xml: () => import('highlight.js/lib/languages/xml')
+ * },
+ * fallbackLanguage: 'plaintext'
+ * };
+ *
+ *
+ *
+ *
+ * ```
+ */
+export function CodeBlockProvider(props: CodeBlockProviderProps) {
+ const { highlightConfig, children } = props;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCodeBlockHighlightConfig(): CodeBlockHighlightConfig {
+ return useContext(CodeBlockHighlightConfigContext) ?? EMPTY_CONFIG;
+}
diff --git a/packages/components/src/components/CodeBlock/hooks/index.ts b/packages/components/src/components/CodeBlock/hooks/index.ts
new file mode 100644
index 000000000..3b42780c7
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useHighlightedCode';
+export * from './useOverflowShadow';
diff --git a/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts
new file mode 100644
index 000000000..3f4d5ba45
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/hooks/useHighlightedCode.ts
@@ -0,0 +1,240 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
+import { once } from '@koobiq/logger';
+import type { HLJSApi } from 'highlight.js';
+
+import { useCodeBlockHighlightConfig } from '../context';
+import type { CodeBlockHighlightConfig } from '../context';
+import type { CodeBlockFile } from '../types';
+import { addLineNumbers } from '../utils/lineNumbers';
+
+const FALLBACK_LANGUAGE = 'plaintext';
+
+const hljsPromises = new WeakMap>();
+
+async function loadHljs(config: CodeBlockHighlightConfig): Promise {
+ const loadCore = config.core ?? (() => import('highlight.js'));
+ const { default: instance } = await loadCore();
+
+ if (config.languages) {
+ await Promise.all(
+ Object.entries(config.languages).map(async ([name, loadLanguage]) => {
+ const { default: language } = await loadLanguage();
+
+ instance.registerLanguage(name, language);
+ })
+ );
+ }
+
+ return instance;
+}
+
+/**
+ * Resolves the shared `highlight.js` instance for the app.
+ *
+ * Cached at module scope so every `CodeBlock` on the page reuses the same load, keyed by the
+ * `CodeBlockHighlightConfig` reference — pass a stable (e.g. module-level or memoized) `highlightConfig` to
+ * `CodeBlockProvider` to avoid redundant reloads.
+ */
+function getHljsInstance(config: CodeBlockHighlightConfig): Promise {
+ const cachedPromise = hljsPromises.get(config);
+
+ if (cachedPromise) return cachedPromise;
+
+ const promise = loadHljs(config).catch((error: unknown) => {
+ // A transient chunk/network failure should be retryable by a later mount.
+ hljsPromises.delete(config);
+
+ throw error;
+ });
+
+ hljsPromises.set(config, promise);
+
+ return promise;
+}
+
+export type UseHighlightedCodeOptions = {
+ /** Whether to wrap the output in a line-numbered table. */
+ hasLineNumbers?: boolean;
+ /** The starting line number. */
+ startFrom?: number;
+};
+
+export type UseHighlightedCodeResult = {
+ /** Highlighted HTML, ready for `dangerouslySetInnerHTML`. Empty while `pending`. */
+ html: string;
+ /** Whether `highlight.js` is still loading. */
+ pending: boolean;
+ /** Whether loading or highlighting failed. */
+ failed: boolean;
+ /** The language `highlight.js` actually used (may differ from `file.language` if unsupported). */
+ language: string;
+};
+
+type HighlightedCodeState = UseHighlightedCodeResult & {
+ config: CodeBlockHighlightConfig;
+ content: string;
+ fileLanguage: string | undefined;
+ hasLineNumbers: boolean;
+ startFrom: number;
+};
+
+function escapeHTML(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
+
+/**
+ * Loads `highlight.js` (honoring `CodeBlockProvider`) and highlights `file`.
+ *
+ * `highlight.js` escapes the raw source text before wrapping it in `` tokens, so the returned
+ * `html` is safe to render with `dangerouslySetInnerHTML` — the same trust model other React syntax
+ * highlighters rely on.
+ */
+export function useHighlightedCode(
+ file: CodeBlockFile,
+ options: UseHighlightedCodeOptions = {}
+): UseHighlightedCodeResult {
+ const { hasLineNumbers = false, startFrom = 1 } = options;
+ const config = useCodeBlockHighlightConfig();
+ const fallbackLanguage = config.fallbackLanguage ?? FALLBACK_LANGUAGE;
+
+ const [result, setResult] = useState({
+ html: '',
+ pending: true,
+ failed: false,
+ language: file.language ?? fallbackLanguage,
+ config,
+ content: file.content,
+ fileLanguage: file.language,
+ hasLineNumbers,
+ startFrom,
+ });
+
+ const isCurrentResult =
+ result.config === config &&
+ result.content === file.content &&
+ result.fileLanguage === file.language &&
+ result.hasLineNumbers === hasLineNumbers &&
+ result.startFrom === startFrom;
+
+ useEffect(() => {
+ let cancelled = false;
+
+ // The inputs `isCurrentResult` compares the stored result against.
+ const inputs = {
+ config,
+ content: file.content,
+ fileLanguage: file.language,
+ hasLineNumbers,
+ startFrom,
+ };
+
+ getHljsInstance(config)
+ .then((hljs) => {
+ if (cancelled) return;
+
+ let { language } = file;
+
+ if (!language || !hljs.getLanguage(language)) {
+ if (process.env.NODE_ENV !== 'production') {
+ once.warn(
+ language
+ ? `[CodeBlock] Unsupported file language: "${language}". Fall back to "${fallbackLanguage}".`
+ : `[CodeBlock] Missing file language. Fall back to "${fallbackLanguage}".`,
+ file
+ );
+ }
+
+ language = fallbackLanguage;
+ }
+
+ let value: string;
+ let resolvedLanguage = language;
+
+ if (hljs.getLanguage(language)) {
+ const highlighted = hljs.highlight(file.content, { language });
+
+ value = highlighted.value;
+ resolvedLanguage = highlighted.language ?? language;
+
+ if (process.env.NODE_ENV !== 'production' && highlighted.illegal) {
+ once.warn(
+ '[CodeBlock] File content contains illegal characters.',
+ file
+ );
+ }
+
+ if (
+ process.env.NODE_ENV !== 'production' &&
+ highlighted.relevance === 0
+ ) {
+ once.warn(
+ '[CodeBlock] File content does not match the specified programming language.',
+ file
+ );
+ }
+ } else {
+ value = escapeHTML(file.content);
+ }
+
+ const html = hasLineNumbers
+ ? addLineNumbers(value, { startFrom })
+ : value;
+
+ setResult({
+ ...inputs,
+ html,
+ pending: false,
+ failed: false,
+ language: resolvedLanguage,
+ });
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+
+ if (process.env.NODE_ENV !== 'production') {
+ once.warn('[CodeBlock] Failed to highlight the file.', error);
+ }
+
+ setResult({
+ ...inputs,
+ html: '',
+ pending: false,
+ failed: true,
+ language: file.language ?? fallbackLanguage,
+ });
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ // `file.content`/`file.language` (not `file`) so a fresh `file` object with the same values
+ // (e.g. an inline object literal from the caller) doesn't re-trigger highlighting.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ config,
+ fallbackLanguage,
+ file.content,
+ file.language,
+ hasLineNumbers,
+ startFrom,
+ ]);
+
+ if (!isCurrentResult) {
+ return {
+ html: '',
+ pending: true,
+ failed: false,
+ language: file.language ?? fallbackLanguage,
+ };
+ }
+
+ return result;
+}
diff --git a/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts
new file mode 100644
index 000000000..8787833ab
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/hooks/useOverflowShadow.ts
@@ -0,0 +1,19 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import type { UIEvent } from 'react';
+
+/** Tracks whether a scrollable element is scrolled away from its top, to drive a header shadow. */
+export function useOverflowShadow() {
+ const [isScrolled, setIsScrolled] = useState(false);
+
+ const onScroll = useCallback((event: UIEvent) => {
+ setIsScrolled(event.currentTarget.scrollTop > 0);
+ }, []);
+
+ // A remounted scroll container (`Tabs` re-creates the panel on tab change) starts at the top
+ // without firing a scroll event, so the shadow has to be dropped explicitly.
+ const resetShadow = useCallback(() => setIsScrolled(false), []);
+
+ return { isScrolled, onScroll, resetShadow };
+}
diff --git a/packages/components/src/components/CodeBlock/index.ts b/packages/components/src/components/CodeBlock/index.ts
new file mode 100644
index 000000000..66e4c91c5
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/index.ts
@@ -0,0 +1,7 @@
+export * from './CodeBlock';
+export {
+ CodeBlockProvider,
+ type CodeBlockHighlightConfig,
+ type CodeBlockProviderProps,
+} from './context';
+export * from './types';
diff --git a/packages/components/src/components/CodeBlock/intl.json b/packages/components/src/components/CodeBlock/intl.json
new file mode 100644
index 000000000..3324891f1
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/intl.json
@@ -0,0 +1,24 @@
+{
+ "ru-RU": {
+ "softWrapOnTooltip": "Включить перенос строк",
+ "softWrapOffTooltip": "Выключить перенос строк",
+ "downloadTooltip": "Скачать",
+ "copyTooltip": "Копировать",
+ "copiedTooltip": "✓ Скопировано",
+ "viewAllText": "Показать всё",
+ "viewLessText": "Свернуть",
+ "filesLabel": "Файлы",
+ "openExternalSystemTooltip": "Открыть во внешней системе"
+ },
+ "en-US": {
+ "softWrapOnTooltip": "Enable word wrap",
+ "softWrapOffTooltip": "Disable word wrap",
+ "downloadTooltip": "Download",
+ "copyTooltip": "Copy",
+ "copiedTooltip": "✓ Copied",
+ "viewAllText": "Show all",
+ "viewLessText": "Show less",
+ "filesLabel": "Files",
+ "openExternalSystemTooltip": "Open in the external system"
+ }
+}
diff --git a/packages/components/src/components/CodeBlock/types.ts b/packages/components/src/components/CodeBlock/types.ts
new file mode 100644
index 000000000..00cb9e610
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/types.ts
@@ -0,0 +1,166 @@
+import type {
+ ComponentPropsWithRef,
+ CSSProperties,
+ ReactNode,
+ Ref,
+} from 'react';
+
+import type { DataAttributeProps } from '@koobiq/react-core';
+
+/** A single file displayed inside a `CodeBlock`. */
+export type CodeBlockFile = {
+ /** Code content. */
+ content: string;
+ /**
+ * File name, displayed in the tab header and used when downloading.
+ * If not provided, `fallbackFileName` is used instead.
+ */
+ filename?: string;
+ /**
+ * File language, required for correct syntax highlighting.
+ * If not provided or unsupported, falls back to `plaintext`.
+ *
+ * List of supported languages: {@link https://highlightjs.readthedocs.io/en/stable/supported-languages.html}
+ */
+ language?: string;
+ /**
+ * Link to the file, opened in a new tab.
+ * Adds the "open in external system" action.
+ */
+ link?: string;
+};
+
+/** Options to scroll the code content to a given position. */
+export type CodeBlockScrollToOptions = ScrollOptions & {
+ /** Offset from the top edge. */
+ top?: number;
+ /** Offset from the bottom edge. */
+ bottom?: number;
+ /** Offset from the left edge. */
+ left?: number;
+ /** Offset from the right edge. */
+ right?: number;
+ /** Offset from the inline-start edge. */
+ start?: number;
+ /** Offset from the inline-end edge. */
+ end?: number;
+};
+
+/** Imperative handle exposed on the `CodeBlock` ref. */
+export type CodeBlockRef = {
+ /** The root DOM element. */
+ element: HTMLDivElement | null;
+ /** Scrolls the code content to the specified position. */
+ scrollTo: (options: CodeBlockScrollToOptions) => void;
+};
+
+export type CodeBlockProps = {
+ /** Files to display. */
+ files: CodeBlockFile[];
+ /**
+ * Whether to display line numbers.
+ * @default false
+ */
+ hasLineNumbers?: boolean;
+ /**
+ * Whether the code block should be filled instead of outlined.
+ * @default false
+ */
+ isFilled?: boolean;
+ /**
+ * Whether to hide the border.
+ * @default false
+ */
+ hideBorder?: boolean;
+ /**
+ * Adds a soft-wrap toggle button to the action bar.
+ * @default false
+ */
+ canToggleSoftWrap?: boolean;
+ /**
+ * Adds a download-file button to the action bar.
+ * @default false
+ */
+ canDownload?: boolean;
+ /**
+ * Whether to hide the copy-to-clipboard button.
+ * @default false
+ */
+ hideCopyButton?: boolean;
+ /**
+ * Whether the action bar should remain visible when tabs are hidden.
+ * @default false
+ */
+ alwaysShowActionBar?: boolean;
+ /**
+ * Whether sequences of whitespace are preserved instead of wrapping.
+ * @default false
+ */
+ softWrap?: boolean;
+ /** The uncontrolled default value for `softWrap`. */
+ defaultSoftWrap?: boolean;
+ /** Handler called when the soft-wrap mode changes. */
+ onSoftWrapChange?: (softWrap: boolean) => void;
+ /**
+ * Whether the full content is shown regardless of `maxHeight`.
+ * @default false
+ */
+ viewAll?: boolean;
+ /** The uncontrolled default value for `viewAll`. */
+ defaultViewAll?: boolean;
+ /** Handler called when `viewAll` changes. */
+ onViewAllChange?: (viewAll: boolean) => void;
+ /**
+ * Maximum height (in pixels) of the code block content, in which case the rest is hidden.
+ * Can be toggled open with `viewAll`.
+ */
+ maxHeight?: number;
+ /**
+ * Whether to hide the header tabs, which also makes the action bar floating and shown on hover only.
+ *
+ * When the prop is omitted, the component decides on its own: the header is hidden for a single file
+ * without a `filename` and shown otherwise. Passing `false` takes that decision over and keeps the
+ * header visible even for such a file.
+ */
+ hideTabs?: boolean;
+ /** The uncontrolled default value for `hideTabs`. */
+ defaultHideTabs?: boolean;
+ /** Handler called when `hideTabs` changes. */
+ onHideTabsChange?: (hideTabs: boolean) => void;
+ /**
+ * The index of the active file.
+ * @default 0
+ */
+ activeFileIndex?: number;
+ /** The uncontrolled default value for `activeFileIndex`. */
+ defaultActiveFileIndex?: number;
+ /** Handler called when the active file index changes. */
+ onActiveFileIndexChange?: (activeFileIndex: number) => void;
+ /** Renders custom tab label content instead of the plain file name. */
+ renderTabLabel?: (file: CodeBlockFile, fallbackFileName: string) => ReactNode;
+ /**
+ * Fallback file name used when a file has no `filename`, both for the tab label and for downloads.
+ * @default 'code'
+ */
+ fallbackFileName?: string;
+ /**
+ * The starting line number.
+ * @default 1
+ */
+ startFrom?: number;
+ /** The props used for each slot inside. */
+ slotProps?: {
+ /** Props of the header holding the tabs. */
+ header?: Omit, 'children'>;
+ /** Props of the scrollable region holding the code. */
+ content?: Omit, 'children'>;
+ };
+ /** Additional CSS-classes. */
+ className?: string;
+ /** Inline styles. */
+ style?: CSSProperties;
+ /** Ref to the root element, also exposing the `scrollTo` method. */
+ ref?: Ref;
+ /** Unique identifier for testing purposes. */
+ 'data-testid'?: string | number;
+} & DataAttributeProps;
diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts
new file mode 100644
index 000000000..e5f200fb9
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from 'vitest';
+
+import { addLineNumbers } from './lineNumbers';
+
+function parse(html: string): HTMLElement {
+ const container = document.createElement('div');
+
+ container.innerHTML = html;
+
+ return container;
+}
+
+describe('addLineNumbers', () => {
+ it('adds a numbered row for each line and honors startFrom', () => {
+ const result = parse(
+ addLineNumbers('const first = 1;\n\nconst third = 3;', { startFrom: 7 })
+ );
+
+ const numberCells = result.querySelectorAll('.hljs-ln-numbers');
+ const codeCells = result.querySelectorAll('.hljs-ln-code');
+
+ expect(numberCells).toHaveLength(3);
+
+ expect(
+ Array.from(numberCells).map((cell) =>
+ cell.getAttribute('data-line-number')
+ )
+ ).toEqual(['7', '8', '9']);
+
+ expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([
+ 'const first = 1;',
+ ' ',
+ 'const third = 3;',
+ ]);
+ });
+
+ it('leaves single-line markup unchanged unless singleLine is enabled', () => {
+ const html = 'const value = 1;';
+
+ expect(addLineNumbers(html)).toBe(html);
+
+ expect(parse(addLineNumbers(html, { singleLine: true }))).toHaveTextContent(
+ 'const value = 1;'
+ );
+
+ expect(
+ parse(addLineNumbers(html, { singleLine: true })).querySelectorAll('tr')
+ ).toHaveLength(1);
+ });
+
+ it('duplicates multiline highlight spans into the corresponding rows', () => {
+ const result = parse(
+ addLineNumbers('first\nsecond')
+ );
+
+ const codeCells = result.querySelectorAll('.hljs-ln-code');
+
+ expect(codeCells).toHaveLength(2);
+
+ expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([
+ 'first',
+ 'second',
+ ]);
+
+ expect(codeCells[0]?.querySelector('.hljs-string')).not.toBeNull();
+ expect(codeCells[1]?.querySelector('.hljs-string')).not.toBeNull();
+ });
+
+ it('duplicates a highlight span that ends with a line break', () => {
+ const result = parse(
+ addLineNumbers('first\nsecond')
+ );
+
+ const codeCells = result.querySelectorAll('.hljs-ln-code');
+
+ expect(codeCells).toHaveLength(2);
+
+ expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([
+ 'first',
+ ' second',
+ ]);
+
+ expect(codeCells[0]?.querySelector('.hljs-string')).toHaveTextContent(
+ 'first'
+ );
+
+ expect(codeCells[1]?.querySelector('.hljs-string')?.textContent).toBe(' ');
+ });
+
+ it('keeps the outer token on every row when the line break sits in a nested one', () => {
+ const result = parse(
+ addLineNumbers(
+ 'first\nsecond'
+ )
+ );
+
+ const codeCells = result.querySelectorAll('.hljs-ln-code');
+
+ expect(codeCells).toHaveLength(2);
+
+ expect(Array.from(codeCells).map((cell) => cell.textContent)).toEqual([
+ 'first',
+ 'second',
+ ]);
+
+ // Splitting the nested token alone would leave the outer tags torn across the two rows.
+ expect(
+ Array.from(codeCells).map((cell) =>
+ Boolean(cell.querySelector('.hljs-string'))
+ )
+ ).toEqual([true, true]);
+ });
+
+ it('handles empty content and ignores a trailing blank line', () => {
+ expect(addLineNumbers('')).toBe('');
+
+ const result = parse(addLineNumbers('first\nsecond\n'));
+
+ expect(result.querySelectorAll('tr')).toHaveLength(2);
+ });
+});
diff --git a/packages/components/src/components/CodeBlock/utils/lineNumbers.ts b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts
new file mode 100644
index 000000000..92f94bde8
--- /dev/null
+++ b/packages/components/src/components/CodeBlock/utils/lineNumbers.ts
@@ -0,0 +1,107 @@
+const TABLE_NAME = 'hljs-ln';
+const LINE_NAME = 'hljs-ln-line';
+const CODE_BLOCK_NAME = 'hljs-ln-code';
+const NUMBERS_BLOCK_NAME = 'hljs-ln-numbers';
+const NUMBER_LINE_NAME = 'hljs-ln-n';
+const DATA_ATTR_NAME = 'data-line-number';
+const BREAK_LINE_REGEXP = /\r\n|\r|\n/g;
+
+export type AddLineNumbersOptions = {
+ /** The starting line number. */
+ startFrom?: number;
+ /** Whether to display line numbers for single line code. */
+ singleLine?: boolean;
+};
+
+function getLines(text: string): string[] {
+ if (text.length === 0) return [];
+
+ return text.split(BREAK_LINE_REGEXP);
+}
+
+function getLinesCount(text: string): number {
+ return (text.match(BREAK_LINE_REGEXP) || []).length;
+}
+
+/** Splits a multiline `hljs-*` span into one span per line, so each line can be wrapped in its own table row. */
+function duplicateMultilineNode(element: Element): void {
+ const { className } = element;
+
+ if (!/hljs-/.test(className)) return;
+
+ const lines = getLines(element.innerHTML);
+
+ element.innerHTML = lines
+ .map(
+ (line) =>
+ `${line.length > 0 ? line : ' '}`
+ )
+ .join('\n')
+ .trim();
+}
+
+/** Recursively fixes multi-line token spans produced by `highlight.js`. */
+function duplicateMultilineNodes(element: Element): void {
+ // Depth first, and over a snapshot: `duplicateMultilineNode` rewrites the element's `innerHTML`,
+ // which would swap the entries of the live child list mid-iteration. A nested token has to be
+ // split before its parent — otherwise the parent keeps a single pair of tags around a line break
+ // and `addLineNumbersBlockFor` tears them apart across two rows.
+ Array.from(element.children).forEach(duplicateMultilineNodes);
+
+ if (getLinesCount(element.textContent ?? '') > 0) {
+ duplicateMultilineNode(element);
+ }
+}
+
+function addLineNumbersBlockFor(
+ inputHtml: string,
+ options: Required
+): string {
+ const lines = getLines(inputHtml);
+
+ // If the last line contains only a line break, remove it.
+ if (lines[lines.length - 1]?.trim() === '') {
+ lines.pop();
+ }
+
+ if (lines.length <= 1 && !options.singleLine) return inputHtml;
+
+ const rows = lines
+ .map((line, index) => {
+ const lineNumber = index + options.startFrom;
+ const codeLine = line.length > 0 ? line : ' ';
+
+ return (
+ `
` +
+ `
` +
+ `` +
+ `
` +
+ `
${codeLine}
` +
+ `
`
+ );
+ })
+ .join('');
+
+ return `
${rows}
`;
+}
+
+/**
+ * Wraps highlighted `highlight.js` HTML output into a line-numbered `