refactor(theme): CodeBlock, centralize metadata parsing + refactor theme component (#11059)

* fix import duplicated

* centralize parsing of code block metadata

* split logic into many subcomponents

* extract getCodeBlockClassName

* fix duplicate useCodeWordWrap() call

* simplify JSX

* move ensureLanguageClassName logic to theme-common

* fix line highlighting bug

* rename tokens to lines

* Extract Pre/Code subcomponents

* Add tests for metadata language

* Add tests for metadata className

* Add tests for metadata title

* Add tests for metadata line highlighting

* Add tests for metadata lineNumbersStart
This commit is contained in:
Sébastien Lorber 2025-04-04 18:15:34 +02:00 committed by GitHub
parent f6bdc3123b
commit d28210d35b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 489 additions and 146 deletions

View file

@ -5,18 +5,16 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {type ReactNode} from 'react';
import React, {type ComponentProps, type ReactNode} from 'react';
import clsx from 'clsx';
import {useThemeConfig, usePrismTheme} from '@docusaurus/theme-common';
import {
parseCodeBlockTitle,
parseLanguage,
parseLines,
getLineNumbersStart,
useCodeWordWrap,
createCodeBlockMetadata,
type CodeBlockMetadata,
} from '@docusaurus/theme-common/internal';
import useIsBrowser from '@docusaurus/useIsBrowser';
import {Highlight, type Language} from 'prism-react-renderer';
import {Highlight} from 'prism-react-renderer';
import Line from '@theme/CodeBlock/Line';
import CopyButton from '@theme/CodeBlock/CopyButton';
import WordWrapButton from '@theme/CodeBlock/WordWrapButton';
@ -25,81 +23,67 @@ import type {Props} from '@theme/CodeBlock/Content/String';
import styles from './styles.module.css';
// Prism languages are always lowercase
// We want to fail-safe and allow both "php" and "PHP"
// See https://github.com/facebook/docusaurus/issues/9012
function normalizeLanguage(language: string | undefined): string | undefined {
return language?.toLowerCase();
type WordWrap = ReturnType<typeof useCodeWordWrap>;
function CodeBlockTitle({children}: {children: ReactNode}): ReactNode {
// Just a pass-through for now
return children;
}
export default function CodeBlockString({
children,
className: blockClassName = '',
metastring,
title: titleProp,
showLineNumbers: showLineNumbersProp,
language: languageProp,
}: Props): ReactNode {
const {
prism: {defaultLanguage, magicComments},
} = useThemeConfig();
const language = normalizeLanguage(
languageProp ?? parseLanguage(blockClassName) ?? defaultLanguage,
);
const prismTheme = usePrismTheme();
const wordWrap = useCodeWordWrap();
const isBrowser = useIsBrowser();
// We still parse the metastring in case we want to support more syntax in the
// future. Note that MDX doesn't strip quotes when parsing metastring:
// "title=\"xyz\"" => title: "\"xyz\""
const title = parseCodeBlockTitle(metastring) || titleProp;
const {lineClassNames, code} = parseLines(children, {
metastring,
language,
magicComments,
});
const lineNumbersStart = getLineNumbersStart({
showLineNumbers: showLineNumbersProp,
metastring,
});
// TODO Docusaurus v4: remove useless forwardRef
const Pre = React.forwardRef<HTMLPreElement, ComponentProps<'pre'>>(
(props, ref) => {
return (
<Container
as="div"
className={clsx(
blockClassName,
language &&
!blockClassName.includes(`language-${language}`) &&
`language-${language}`,
)}>
{title && <div className={styles.codeBlockTitle}>{title}</div>}
<div className={styles.codeBlockContent}>
<Highlight
theme={prismTheme}
code={code}
language={(language ?? 'text') as Language}>
{({className, style, tokens, getLineProps, getTokenProps}) => (
<pre
ref={ref}
/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex */
tabIndex={0}
ref={wordWrap.codeBlockRef}
className={clsx(className, styles.codeBlock, 'thin-scrollbar')}
style={style}>
{...props}
className={clsx(props.className, styles.codeBlock, 'thin-scrollbar')}
/>
);
},
);
function Code({
metadata,
...props
}: {metadata: CodeBlockMetadata} & ComponentProps<'code'>) {
return (
<code
{...props}
className={clsx(
props.className,
styles.codeBlockLines,
lineNumbersStart !== undefined &&
metadata.lineNumbersStart !== undefined &&
styles.codeBlockLinesWithNumbering,
)}
style={
lineNumbersStart === undefined
style={{
...props.style,
counterReset:
metadata.lineNumbersStart === undefined
? undefined
: {counterReset: `line-count ${lineNumbersStart - 1}`}
}>
{tokens.map((line, i) => (
: `line-count ${metadata.lineNumbersStart - 1}`,
}}
/>
);
}
function CodeBlockContent({
metadata,
wordWrap,
}: {
metadata: CodeBlockMetadata;
wordWrap: WordWrap;
}): ReactNode {
const prismTheme = usePrismTheme();
const {code, language, lineNumbersStart, lineClassNames} = metadata;
return (
<Highlight theme={prismTheme} code={code} language={language}>
{({className, style, tokens: lines, getLineProps, getTokenProps}) => (
<Pre ref={wordWrap.codeBlockRef} className={className} style={style}>
<Code metadata={metadata}>
{lines.map((line, i) => (
<Line
key={i}
line={line}
@ -109,11 +93,21 @@ export default function CodeBlockString({
showLineNumbers={lineNumbersStart !== undefined}
/>
))}
</code>
</pre>
</Code>
</Pre>
)}
</Highlight>
{isBrowser ? (
);
}
function CodeBlockButtons({
metadata,
wordWrap,
}: {
metadata: CodeBlockMetadata;
wordWrap: WordWrap;
}): ReactNode {
return (
<div className={styles.buttonGroup}>
{(wordWrap.isEnabled || wordWrap.isCodeScrollable) && (
<WordWrapButton
@ -122,10 +116,46 @@ export default function CodeBlockString({
isEnabled={wordWrap.isEnabled}
/>
)}
<CopyButton className={styles.codeButton} code={code} />
<CopyButton className={styles.codeButton} code={metadata.code} />
</div>
) : null}
);
}
function CodeBlockLayout({metadata}: {metadata: CodeBlockMetadata}): ReactNode {
const isBrowser = useIsBrowser();
const wordWrap = useCodeWordWrap();
return (
<Container as="div" className={metadata.className}>
{metadata.title && (
<div className={styles.codeBlockTitle}>
<CodeBlockTitle>{metadata.title}</CodeBlockTitle>
</div>
)}
<div className={styles.codeBlockContent}>
<CodeBlockContent metadata={metadata} wordWrap={wordWrap} />
{isBrowser && (
<CodeBlockButtons metadata={metadata} wordWrap={wordWrap} />
)}
</div>
</Container>
);
}
function useCodeBlockMetadata(props: Props): CodeBlockMetadata {
const {prism} = useThemeConfig();
return createCodeBlockMetadata({
code: props.children,
className: props.className,
metastring: props.metastring,
magicComments: prism.magicComments,
defaultLanguage: prism.defaultLanguage,
language: props.language,
title: props.title,
showLineNumbers: props.showLineNumbers,
});
}
export default function CodeBlockString(props: Props): ReactNode {
const metadata = useCodeBlockMetadata(props);
return <CodeBlockLayout metadata={metadata} />;
}

View file

@ -34,10 +34,9 @@ export {ColorModeProvider} from './contexts/colorMode';
export {useAlternatePageUtils} from './utils/useAlternatePageUtils';
export {
parseCodeBlockTitle,
parseLanguage,
parseLines,
getLineNumbersStart,
type CodeBlockMetadata,
createCodeBlockMetadata,
getPrismCssVariables,
} from './utils/codeBlockUtils';
export {DEFAULT_SEARCH_TAG} from './utils/searchUtils';
@ -88,7 +87,6 @@ export {
} from './hooks/useKeyboardNavigation';
export {useLockBodyScroll} from './hooks/useLockBodyScroll';
export {useCodeWordWrap} from './hooks/useCodeWordWrap';
export {getPrismCssVariables} from './utils/codeBlockUtils';
export {useBackToTopButton} from './hooks/useBackToTopButton';
export {

View file

@ -9,10 +9,19 @@ import {
getLineNumbersStart,
type MagicCommentConfig,
parseCodeBlockTitle,
parseLanguage,
parseClassNameLanguage,
parseLines,
createCodeBlockMetadata,
} from '../codeBlockUtils';
const defaultMagicComments: MagicCommentConfig[] = [
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
];
describe('parseCodeBlockTitle', () => {
it('parses double quote delimited title', () => {
expect(parseCodeBlockTitle(`title="index.js"`)).toBe(`index.js`);
@ -59,24 +68,16 @@ describe('parseCodeBlockTitle', () => {
});
});
describe('parseLanguage', () => {
describe('parseClassNameLanguage', () => {
it('works', () => {
expect(parseLanguage('language-foo xxx yyy')).toBe('foo');
expect(parseLanguage('xxxxx language-foo yyy')).toBe('foo');
expect(parseLanguage('xx-language-foo yyyy')).toBeUndefined();
expect(parseLanguage('xxx yyy zzz')).toBeUndefined();
expect(parseClassNameLanguage('language-foo xxx yyy')).toBe('foo');
expect(parseClassNameLanguage('xxxxx language-foo yyy')).toBe('foo');
expect(parseClassNameLanguage('xx-language-foo yyyy')).toBeUndefined();
expect(parseClassNameLanguage('xxx yyy zzz')).toBeUndefined();
});
});
describe('parseLines', () => {
const defaultMagicComments: MagicCommentConfig[] = [
{
className: 'theme-code-block-highlighted-line',
line: 'highlight-next-line',
block: {start: 'highlight-start', end: 'highlight-end'},
},
];
it('does not parse content with metastring', () => {
expect(
parseLines('aaaaa\nnnnnn', {
@ -810,3 +811,214 @@ describe('getLineNumbersStart', () => {
});
});
});
describe('createCodeBlockMetadata', () => {
type Params = Parameters<typeof createCodeBlockMetadata>[0];
const defaultParams: Params = {
code: '',
className: undefined,
metastring: '',
language: undefined,
defaultLanguage: undefined,
magicComments: defaultMagicComments,
title: undefined,
showLineNumbers: undefined,
};
function create(params?: Partial<Params>) {
return createCodeBlockMetadata({...defaultParams, ...params});
}
it('creates basic metadata', () => {
const meta = create();
expect(meta).toMatchInlineSnapshot(`
{
"className": "language-text",
"code": "",
"codeInput": "",
"language": "text",
"lineClassNames": {},
"lineNumbersStart": undefined,
"title": undefined,
}
`);
});
describe('language', () => {
it('returns input language', () => {
const meta = create({language: 'js'});
expect(meta.language).toBe('js');
});
it('returns className language', () => {
const meta = create({className: 'x language-ts y z'});
expect(meta.language).toBe('ts');
});
it('returns default language', () => {
const meta = create({defaultLanguage: 'jsx'});
expect(meta.language).toBe('jsx');
});
it('returns fallback language', () => {
const meta = create();
expect(meta.language).toBe('text');
});
it('returns language with expected precedence', () => {
expect(
create({
language: 'js',
className: 'x language-ts y z',
defaultLanguage: 'jsx',
}).language,
).toBe('js');
expect(
create({
language: undefined,
className: 'x language-ts y z',
defaultLanguage: 'jsx',
}).language,
).toBe('ts');
expect(
create({
language: undefined,
className: 'x y z',
defaultLanguage: 'jsx',
}).language,
).toBe('jsx');
expect(
create({
language: undefined,
className: 'x y z',
defaultLanguage: undefined,
}).language,
).toBe('text');
});
});
describe('code highlighting', () => {
it('returns code with no highlighting', () => {
const code = 'const x = 42;';
const meta = create({code});
expect(meta.codeInput).toBe(code);
expect(meta.code).toBe(code);
expect(meta.lineClassNames).toMatchInlineSnapshot(`{}`);
});
it('returns code with metastring highlighting', () => {
const code = 'const x = 42;';
const meta = create({code, metastring: '{1}'});
expect(meta.codeInput).toBe(code);
expect(meta.code).toBe(code);
expect(meta.lineClassNames).toMatchInlineSnapshot(
`
{
"0": [
"theme-code-block-highlighted-line",
],
}
`,
);
});
it('returns code with magic comment highlighting', () => {
const code = 'const x = 42;';
const inputCode = `// highlight-next-line\n${code}`;
const meta = create({code: inputCode});
expect(meta.codeInput).toBe(inputCode);
expect(meta.code).toBe(code);
expect(meta.lineClassNames).toMatchInlineSnapshot(
`
{
"0": [
"theme-code-block-highlighted-line",
],
}
`,
);
});
});
describe('className', () => {
it('returns provided className with current language', () => {
const meta = create({language: 'js', className: 'some-class'});
expect(meta.className).toBe('some-class language-js');
});
it('returns provided className with fallback language', () => {
const meta = create({className: 'some-class'});
expect(meta.className).toBe('some-class language-text');
});
it('returns provided className without duplicating className language', () => {
const meta = create({
language: 'js',
className: 'some-class language-js',
});
expect(meta.className).toBe('some-class language-js');
});
});
describe('title', () => {
it('returns no title', () => {
const meta = create();
expect(meta.title).toBeUndefined();
});
it('returns title from metastring', () => {
const meta = create({metastring: "title='my title meta'"});
expect(meta.title).toBe('my title meta');
});
it('returns title from param', () => {
const meta = create({title: 'my title param'});
expect(meta.title).toBe('my title param');
});
it('returns title from meta over params', () => {
const meta = create({
metastring: "title='my title meta'",
title: 'my title param',
});
expect(meta.title).toBe('my title meta');
});
});
describe('showLineNumbers', () => {
it('returns no lineNumbersStart', () => {
const meta = create();
expect(meta.lineNumbersStart).toBeUndefined();
});
it('returns lineNumbersStart - params.showLineNumbers=true', () => {
const meta = create({showLineNumbers: true});
expect(meta.lineNumbersStart).toBe(1);
});
it('returns lineNumbersStart - params.showLineNumbers=3', () => {
const meta = create({showLineNumbers: 3});
expect(meta.lineNumbersStart).toBe(3);
});
it('returns lineNumbersStart - meta showLineNumbers', () => {
const meta = create({metastring: 'showLineNumbers'});
expect(meta.lineNumbersStart).toBe(1);
});
it('returns lineNumbersStart - meta showLineNumbers=2', () => {
const meta = create({metastring: 'showLineNumbers=2'});
expect(meta.lineNumbersStart).toBe(2);
});
it('returns lineNumbersStart - params.showLineNumbers=3 + meta showLineNumbers=2', () => {
const meta = create({
showLineNumbers: 3,
metastring: 'showLineNumbers=2',
});
expect(meta.lineNumbersStart).toBe(3);
});
});
});

View file

@ -5,7 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
import type {CSSProperties} from 'react';
import type {CSSProperties, ReactNode} from 'react';
import clsx from 'clsx';
import rangeParser from 'parse-numeric-range';
import type {PrismTheme, PrismThemeEntry} from 'prism-react-renderer';
@ -184,18 +185,6 @@ export function getLineNumbersStart({
return getMetaLineNumbersStart(metastring);
}
/**
* Gets the language name from the class name (set by MDX).
* e.g. `"language-javascript"` => `"javascript"`.
* Returns undefined if there is no language class name.
*/
export function parseLanguage(className: string): string | undefined {
const languageClassName = className
.split(' ')
.find((str) => str.startsWith('language-'));
return languageClassName?.replace(/language-/, '');
}
type ParseCodeLinesParam = {
/**
* The full metastring, as received from MDX. Line ranges declared here
@ -215,26 +204,23 @@ type ParseCodeLinesParam = {
};
/**
* Code lines after applying magic comments or metastring highlight ranges
*/
type CodeLines = {
/**
* If there's number range declared in the metastring, the code block is
* returned as-is (no parsing); otherwise, this is the clean code with all
* magic comments stripped away.
*/
code: string;
/**
* The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }`
* means the 1st line should have `highlight` and `sample` as class names.
*/
lineClassNames: {[lineIndex: number]: string[]};
type CodeLineClassNames = {[lineIndex: number]: string[]};
/**
* Code lines after applying magic comments or metastring highlight ranges
*/
type ParsedCodeLines = {
code: string;
lineClassNames: CodeLineClassNames;
};
function parseCodeLinesFromMetastring(
code: string,
{metastring, magicComments}: ParseCodeLinesParam,
): CodeLines | null {
): ParsedCodeLines | null {
// Highlighted lines specified in props: don't parse the content
if (metastring && metastringLinesRangeRegex.test(metastring)) {
const linesRange = metastring.match(metastringLinesRangeRegex)!.groups!
@ -256,7 +242,7 @@ function parseCodeLinesFromMetastring(
function parseCodeLinesFromContent(
code: string,
params: ParseCodeLinesParam,
): CodeLines {
): ParsedCodeLines {
const {language, magicComments} = params;
if (language === undefined) {
return {lineClassNames: {}, code};
@ -331,17 +317,134 @@ function parseCodeLinesFromContent(
export function parseLines(
code: string,
params: ParseCodeLinesParam,
): CodeLines {
): ParsedCodeLines {
// Historical behavior: we remove last line break
const newCode = code.replace(/\r?\n$/, '');
// Historical behavior: we try one strategy after the other
// we don't support mixing metastring ranges + magic comments
console.log('params', {params, code});
console.log('from meta', parseCodeLinesFromMetastring(newCode, {...params}));
console.log('from content', parseCodeLinesFromContent(newCode, {...params}));
return (
parseCodeLinesFromMetastring(newCode, {...params}) ??
parseCodeLinesFromContent(newCode, {...params})
);
}
/**
* Gets the language name from the class name (set by MDX).
* e.g. `"language-javascript"` => `"javascript"`.
* Returns undefined if there is no language class name.
*/
export function parseClassNameLanguage(
className: string | undefined,
): string | undefined {
if (!className) {
return undefined;
}
const languageClassName = className
.split(' ')
.find((str) => str.startsWith('language-'));
return languageClassName?.replace(/language-/, '');
}
// Prism languages are always lowercase
// We want to fail-safe and allow both "php" and "PHP"
// See https://github.com/facebook/docusaurus/issues/9012
function normalizeLanguage(language: string | undefined): string | undefined {
return language?.toLowerCase();
}
function getLanguage(params: {
language: string | undefined;
className: string | undefined;
defaultLanguage: string | undefined;
}): string {
return (
normalizeLanguage(
params.language ??
parseClassNameLanguage(params.className) ??
params.defaultLanguage,
) ?? 'text'
); // There's always a language, required by Prism;
}
/**
* This ensures that we always have the code block language as className
* For MDX code blocks this is provided automatically by MDX
* For JSX code blocks, the language gets added by this function
* This ensures both cases lead to a consistent HTML output
*/
function ensureLanguageClassName({
className,
language,
}: {
className: string | undefined;
language: string;
}): string {
return clsx(
className,
language &&
!className?.includes(`language-${language}`) &&
`language-${language}`,
);
}
export interface CodeBlockMetadata {
codeInput: string; // Including magic comments
code: string; // Rendered code, excluding magic comments
className: string; // There's always a "language-<lang>" className
language: string;
title: ReactNode;
lineNumbersStart: number | undefined;
lineClassNames: CodeLineClassNames;
}
export function createCodeBlockMetadata(params: {
code: string;
className: string | undefined;
language: string | undefined;
defaultLanguage: string | undefined;
metastring: string | undefined;
magicComments: MagicCommentConfig[];
title: ReactNode;
showLineNumbers: boolean | number | undefined;
}): CodeBlockMetadata {
const language = getLanguage({
language: params.language,
defaultLanguage: params.defaultLanguage,
className: params.className,
});
const {lineClassNames, code} = parseLines(params.code, {
metastring: params.metastring,
magicComments: params.magicComments,
language,
});
const className = ensureLanguageClassName({
className: params.className,
language,
});
const title = parseCodeBlockTitle(params.metastring) || params.title;
const lineNumbersStart = getLineNumbersStart({
showLineNumbers: params.showLineNumbers,
metastring: params.metastring,
});
return {
codeInput: params.code,
code,
className,
language,
title,
lineNumbersStart,
lineClassNames,
};
}
export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties {
const mapping: PrismThemeEntry = {
color: '--prism-color',