diff --git a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx index fb6c6763df..5a6b0a07a1 100644 --- a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx @@ -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,107 +23,139 @@ 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; + +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>( + (props, ref) => { + return ( +
+    );
+  },
+);
 
+function Code({
+  metadata,
+  ...props
+}: {metadata: CodeBlockMetadata} & ComponentProps<'code'>) {
   return (
-    
-      {title && 
{title}
} -
- - {({className, style, tokens, getLineProps, getTokenProps}) => ( -
-              
-                {tokens.map((line, i) => (
-                  
-                ))}
-              
-            
- )} -
- {isBrowser ? ( -
- {(wordWrap.isEnabled || wordWrap.isCodeScrollable) && ( - wordWrap.toggle()} - isEnabled={wordWrap.isEnabled} + props.className, + styles.codeBlockLines, + metadata.lineNumbersStart !== undefined && + styles.codeBlockLinesWithNumbering, + )} + style={{ + ...props.style, + counterReset: + metadata.lineNumbersStart === undefined + ? undefined + : `line-count ${metadata.lineNumbersStart - 1}`, + }} + /> + ); +} + +function CodeBlockContent({ + metadata, + wordWrap, +}: { + metadata: CodeBlockMetadata; + wordWrap: WordWrap; +}): ReactNode { + const prismTheme = usePrismTheme(); + const {code, language, lineNumbersStart, lineClassNames} = metadata; + return ( + + {({className, style, tokens: lines, getLineProps, getTokenProps}) => ( +
+          
+            {lines.map((line, i) => (
+              
-            )}
-            
-          
- ) : null} + ))} + +
+ )} + + ); +} + +function CodeBlockButtons({ + metadata, + wordWrap, +}: { + metadata: CodeBlockMetadata; + wordWrap: WordWrap; +}): ReactNode { + return ( +
+ {(wordWrap.isEnabled || wordWrap.isCodeScrollable) && ( + wordWrap.toggle()} + isEnabled={wordWrap.isEnabled} + /> + )} + +
+ ); +} + +function CodeBlockLayout({metadata}: {metadata: CodeBlockMetadata}): ReactNode { + const isBrowser = useIsBrowser(); + const wordWrap = useCodeWordWrap(); + return ( + + {metadata.title && ( +
+ {metadata.title} +
+ )} +
+ + {isBrowser && ( + + )}
); } + +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 ; +} diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index e4a3852774..d889a2f7a3 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -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 { diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts index f439e59e67..73ae42eb49 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts @@ -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[0]; + + const defaultParams: Params = { + code: '', + className: undefined, + metastring: '', + language: undefined, + defaultLanguage: undefined, + magicComments: defaultMagicComments, + title: undefined, + showLineNumbers: undefined, + }; + + function create(params?: Partial) { + 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); + }); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts index 56acec8fe3..6665a8f2ba 100644 --- a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts @@ -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 @@ -214,27 +203,24 @@ type ParseCodeLinesParam = { magicComments: MagicCommentConfig[]; }; +/** + * The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }` + * means the 1st line should have `highlight` and `sample` as class names. + */ +type CodeLineClassNames = {[lineIndex: number]: string[]}; + /** * 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. - */ +type ParsedCodeLines = { 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[]}; + 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-" 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',