diff --git a/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.ts b/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.ts index c80aeec5d1..a4ca7e3460 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.ts @@ -36,6 +36,13 @@ describe('themeConfig', () => { darkTheme, defaultLanguage: 'javascript', additionalLanguages: ['kotlin', 'java'], + magicComments: [ + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + ], }, docs: { versionPersistence: 'localStorage', @@ -549,16 +556,72 @@ describe('themeConfig', () => { }); }); - it('accepts valid prism config', () => { - const prismConfig = { - prism: { - additionalLanguages: ['kotlin', 'java'], - theme: darkTheme, - }, - }; - expect(testValidateThemeConfig(prismConfig)).toEqual({ - ...DEFAULT_CONFIG, - ...prismConfig, + describe('prism config', () => { + it('accepts a range of magic comments', () => { + const prismConfig = { + prism: { + additionalLanguages: ['kotlin', 'java'], + theme: darkTheme, + magicComments: [], + }, + }; + expect(testValidateThemeConfig(prismConfig)).toEqual({ + ...DEFAULT_CONFIG, + ...prismConfig, + }); + const prismConfig2 = { + prism: { + additionalLanguages: [], + theme: darkTheme, + magicComments: [ + { + className: 'a', + line: 'a-next-line', + }, + ], + }, + }; + expect(testValidateThemeConfig(prismConfig2)).toEqual({ + ...DEFAULT_CONFIG, + ...prismConfig2, + }); + const prismConfig3 = { + prism: { + additionalLanguages: [], + theme: darkTheme, + magicComments: [ + { + className: 'a', + block: {start: 'a-start', end: 'a-end'}, + }, + ], + }, + }; + expect(testValidateThemeConfig(prismConfig3)).toEqual({ + ...DEFAULT_CONFIG, + ...prismConfig3, + }); + }); + + it('rejects incomplete magic comments', () => { + expect(() => + testValidateThemeConfig({ + prism: { + magicComments: [{className: 'a'}], + }, + }), + ).toThrowErrorMatchingInlineSnapshot( + `""prism.magicComments[0]" must contain at least one of [line, block]"`, + ); + expect(() => + testValidateThemeConfig({ + prism: { + magicComments: [{className: 'a', block: {start: 'start'}}], + }, + }), + ).toThrowErrorMatchingInlineSnapshot( + `""prism.magicComments[0].block.end" is required"`, + ); }); }); diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index cd8929d733..9f1991ed57 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -219,7 +219,7 @@ declare module '@theme/CodeBlock/Line' { export interface Props { readonly line: Token[]; - readonly highlight: boolean; + readonly classNames: string[] | undefined; readonly showLineNumbers: boolean; readonly getLineProps: GetLineProps; readonly getTokenProps: GetTokenProps; 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 495b9cbbaa..f73819a37f 100644 --- a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx @@ -34,7 +34,7 @@ export default function CodeBlockString({ language: languageProp, }: Props): JSX.Element { const { - prism: {defaultLanguage}, + prism: {defaultLanguage, magicComments}, } = useThemeConfig(); const language = languageProp ?? parseLanguage(blockClassName) ?? defaultLanguage; @@ -46,7 +46,11 @@ export default function CodeBlockString({ // "title=\"xyz\"" => title: "\"xyz\"" const title = parseCodeBlockTitle(metastring) || titleProp; - const {highlightLines, code} = parseLines(children, metastring, language); + const {lineClassNames, code} = parseLines(children, { + metastring, + language, + magicComments, + }); const showLineNumbers = showLineNumbersProp || containsLineNumbers(metastring); @@ -83,7 +87,7 @@ export default function CodeBlockString({ line={line} getLineProps={getLineProps} getTokenProps={getTokenProps} - highlight={highlightLines.includes(i)} + classNames={lineClassNames[i]} showLineNumbers={showLineNumbers} /> ))} diff --git a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/index.tsx b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/index.tsx index 84b4fd0c40..e15d70abfa 100644 --- a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/index.tsx @@ -12,7 +12,7 @@ import styles from './styles.module.css'; export default function CodeBlockLine({ line, - highlight, + classNames, showLineNumbers, getLineProps, getTokenProps, @@ -23,17 +23,9 @@ export default function CodeBlockLine({ const lineProps = getLineProps({ line, - ...(showLineNumbers && {className: styles.codeLine}), + className: clsx(classNames, showLineNumbers && styles.codeLine), }); - if (highlight) { - lineProps.className = clsx( - lineProps.className, - styles.highlightedCodeLine, - 'theme-code-block-highlighted-line', - ); - } - const lineTokens = line.map((token, key) => ( )); diff --git a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/styles.module.css b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/styles.module.css index 64edaaff86..cd609f9917 100644 --- a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/styles.module.css @@ -5,6 +5,13 @@ * LICENSE file in the root directory of this source tree. */ +:global(.theme-code-block-highlighted-line) { + background-color: var(--docusaurus-highlighted-code-line-bg); + display: block; + margin: 0 calc(-1 * var(--ifm-pre-padding)); + padding: 0 var(--ifm-pre-padding); +} + /* Intentionally has zero specificity, so that to be able to override the background in custom CSS file due bug https://github.com/facebook/docusaurus/issues/3678 */ :where(:root) { @@ -15,13 +22,6 @@ the background in custom CSS file due bug https://github.com/facebook/docusaurus --docusaurus-highlighted-code-line-bg: rgb(100 100 100); } -.highlightedCodeLine { - background-color: var(--docusaurus-highlighted-code-line-bg); - display: block; - margin: 0 calc(-1 * var(--ifm-pre-padding)); - padding: 0 var(--ifm-pre-padding); -} - .codeLine { display: table-row; counter-increment: line-count; diff --git a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts index 64e835a28f..9bdf1a8d9d 100644 --- a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts +++ b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts @@ -44,6 +44,13 @@ export const DEFAULT_CONFIG = { prism: { additionalLanguages: [], theme: defaultPrismTheme, + magicComments: [ + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + ], }, navbar: { hideOnScroll: false, @@ -386,6 +393,18 @@ export const ThemeConfigSchema = Joi.object({ additionalLanguages: Joi.array() .items(Joi.string()) .default(DEFAULT_CONFIG.prism.additionalLanguages), + magicComments: Joi.array() + .items( + Joi.object({ + className: Joi.string().required(), + line: Joi.string(), + block: Joi.object({ + start: Joi.string().required(), + end: Joi.string().required(), + }), + }).or('line', 'block'), + ) + .default(DEFAULT_CONFIG.prism.magicComments), }) .default(DEFAULT_CONFIG.prism) .unknown(), diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/__snapshots__/codeBlockUtils.test.ts.snap b/packages/docusaurus-theme-common/src/utils/__tests__/__snapshots__/codeBlockUtils.test.ts.snap index 8b5e1adee6..7ab1326fdd 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/__snapshots__/codeBlockUtils.test.ts.snap +++ b/packages/docusaurus-theme-common/src/utils/__tests__/__snapshots__/codeBlockUtils.test.ts.snap @@ -4,9 +4,11 @@ exports[`parseLines does not parse content with metastring 1`] = ` { "code": "aaaaa nnnnn", - "highlightLines": [ - 0, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -15,9 +17,11 @@ exports[`parseLines does not parse content with metastring 2`] = ` "code": "// highlight-next-line aaaaa bbbbb", - "highlightLines": [ - 0, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -25,9 +29,11 @@ exports[`parseLines does not parse content with metastring 3`] = ` { "code": "aaaaa bbbbb", - "highlightLines": [ - 0, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -36,7 +42,100 @@ exports[`parseLines does not parse content with no language 1`] = ` "code": "// highlight-next-line aaaaa bbbbb", - "highlightLines": [], + "lineClassNames": {}, +} +`; + +exports[`parseLines handles one line with multiple class names 1`] = ` +{ + "code": " +highlighted and collapsed +highlighted and collapsed +highlighted and collapsed +Only highlighted +Only collapsed +highlighted and collapsed +highlighted and collapsed +Only collapsed +highlighted and collapsed", + "lineClassNames": { + "1": [ + "highlight", + "collapse", + ], + "2": [ + "highlight", + "collapse", + ], + "3": [ + "highlight", + "collapse", + ], + "4": [ + "highlight", + ], + "5": [ + "collapse", + ], + "6": [ + "highlight", + "collapse", + ], + "7": [ + "highlight", + "collapse", + ], + "8": [ + "collapse", + ], + "9": [ + "highlight", + "collapse", + ], + }, +} +`; + +exports[`parseLines handles one line with multiple class names 2`] = ` +{ + "code": "line +line", + "lineClassNames": { + "0": [ + "a", + "b", + "c", + "d", + ], + "1": [ + "b", + "d", + ], + }, +} +`; + +exports[`parseLines parses multiple types of magic comments 1`] = ` +{ + "code": " +highlighted +collapsed +collapsed +collapsed", + "lineClassNames": { + "1": [ + "highlight", + ], + "2": [ + "collapse", + ], + "3": [ + "collapse", + ], + "4": [ + "collapse", + ], + }, } `; @@ -44,9 +143,11 @@ exports[`parseLines removes lines correctly 1`] = ` { "code": "aaaaa bbbbb", - "highlightLines": [ - 0, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -54,9 +155,11 @@ exports[`parseLines removes lines correctly 2`] = ` { "code": "aaaaa bbbbb", - "highlightLines": [ - 0, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -65,12 +168,18 @@ exports[`parseLines removes lines correctly 3`] = ` "code": "aaaaa bbbbbbb bbbbb", - "highlightLines": [ - 0, - 2, - 0, - 1, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -80,10 +189,14 @@ exports[`parseLines respects language: html 1`] = ` {/* highlight-next-line */} bbbbb dddd", - "highlightLines": [ - 0, - 3, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "3": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -92,7 +205,7 @@ exports[`parseLines respects language: js 1`] = ` "code": "# highlight-next-line aaaaa bbbbb", - "highlightLines": [], + "lineClassNames": {}, } `; @@ -102,10 +215,14 @@ exports[`parseLines respects language: jsx 1`] = ` bbbbb dddd", - "highlightLines": [ - 0, - 1, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -128,11 +245,17 @@ dddd // highlight-next-line console.log("preserved"); \`\`\`", - "highlightLines": [ - 1, - 7, - 11, - ], + "lineClassNames": { + "1": [ + "theme-code-block-highlighted-line", + ], + "11": [ + "theme-code-block-highlighted-line", + ], + "7": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -142,12 +265,20 @@ exports[`parseLines respects language: none 1`] = ` bbbbb ccccc dddd", - "highlightLines": [ - 0, - 1, - 2, - 3, - ], + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + "3": [ + "theme-code-block-highlighted-line", + ], + }, } `; @@ -156,7 +287,7 @@ exports[`parseLines respects language: py 1`] = ` "code": "/* highlight-next-line */ aaaaa bbbbb", - "highlightLines": [], + "lineClassNames": {}, } `; @@ -169,8 +300,10 @@ bbbbb ccccc dddd", - "highlightLines": [ - 4, - ], + "lineClassNames": { + "4": [ + "theme-code-block-highlighted-line", + ], + }, } `; 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 f2ddc02bb6..47c62a91ca 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts @@ -6,6 +6,7 @@ */ import { + type MagicCommentConfig, parseCodeBlockTitle, parseLanguage, parseLines, @@ -67,24 +68,58 @@ describe('parseLanguage', () => { }); 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', '{1}', 'js')).toMatchSnapshot(); + expect( + parseLines('aaaaa\nnnnnn', { + metastring: '{1}', + language: 'js', + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); expect( parseLines( `// highlight-next-line aaaaa bbbbb`, - '{1}', - 'js', + { + metastring: '{1}', + language: 'js', + magicComments: defaultMagicComments, + }, ), ).toMatchSnapshot(); expect( parseLines( `aaaaa bbbbb`, - '{1}', + { + metastring: '{1}', + language: 'undefined', + magicComments: defaultMagicComments, + }, ), ).toMatchSnapshot(); + expect(() => + parseLines( + `aaaaa +bbbbb`, + { + metastring: '{1}', + language: 'js', + magicComments: [], + }, + ), + ).toThrowErrorMatchingInlineSnapshot( + `"A highlight range has been given in code block's metastring (\`\`\` {1}), but no magic comment config is available. Docusaurus applies the first magic comment entry's className for metastring ranges."`, + ); }); it('does not parse content with no language', () => { expect( @@ -92,8 +127,11 @@ bbbbb`, `// highlight-next-line aaaaa bbbbb`, - '', - undefined, + { + metastring: '', + language: undefined, + magicComments: defaultMagicComments, + }, ), ).toMatchSnapshot(); }); @@ -103,8 +141,7 @@ bbbbb`, `// highlight-next-line aaaaa bbbbb`, - '', - 'js', + {metastring: '', language: 'js', magicComments: defaultMagicComments}, ), ).toMatchSnapshot(); expect( @@ -113,8 +150,7 @@ bbbbb`, aaaaa // highlight-end bbbbb`, - '', - 'js', + {metastring: '', language: 'js', magicComments: defaultMagicComments}, ), ).toMatchSnapshot(); expect( @@ -126,8 +162,7 @@ bbbbbbb // highlight-next-line // highlight-end bbbbb`, - '', - 'js', + {metastring: '', language: 'js', magicComments: defaultMagicComments}, ), ).toMatchSnapshot(); }); @@ -137,8 +172,7 @@ bbbbb`, `# highlight-next-line aaaaa bbbbb`, - '', - 'js', + {metastring: '', language: 'js', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('js'); expect( @@ -146,8 +180,7 @@ bbbbb`, `/* highlight-next-line */ aaaaa bbbbb`, - '', - 'py', + {metastring: '', language: 'py', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('py'); expect( @@ -160,8 +193,7 @@ bbbbb ccccc dddd`, - '', - 'py', + {metastring: '', language: 'py', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('py'); expect( @@ -174,8 +206,7 @@ bbbbb ccccc dddd`, - '', - '', + {metastring: '', language: '', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('none'); expect( @@ -186,8 +217,7 @@ aaaa bbbbb dddd`, - '', - 'jsx', + {metastring: '', language: 'jsx', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('jsx'); expect( @@ -198,8 +228,7 @@ aaaa bbbbb dddd`, - '', - 'html', + {metastring: '', language: 'html', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('html'); expect( @@ -225,9 +254,109 @@ dddd console.log("preserved"); \`\`\` `, - '', - 'md', + {metastring: '', language: 'md', magicComments: defaultMagicComments}, ), ).toMatchSnapshot('md'); }); + + it('parses multiple types of magic comments', () => { + expect( + parseLines( + ` +// highlight-next-line +highlighted +// collapse-next-line +collapsed +/* collapse-start */ +collapsed +collapsed +/* collapse-end */ +`, + { + language: 'js', + metastring: '', + magicComments: [ + { + className: 'highlight', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + { + className: 'collapse', + line: 'collapse-next-line', + block: {start: 'collapse-start', end: 'collapse-end'}, + }, + ], + }, + ), + ).toMatchSnapshot(); + }); + + it('handles one line with multiple class names', () => { + expect( + parseLines( + ` +// highlight-next-line +// collapse-next-line +highlighted and collapsed +/* collapse-start */ +/* highlight-start */ +highlighted and collapsed +highlighted and collapsed +/* collapse-end */ +Only highlighted +/* highlight-end */ +/* collapse-start */ +Only collapsed +/* highlight-start */ +highlighted and collapsed +highlighted and collapsed +/* highlight-end */ +Only collapsed +// highlight-next-line +highlighted and collapsed +/* collapse-end */ +`, + { + language: 'js', + metastring: '', + magicComments: [ + { + className: 'highlight', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + { + className: 'collapse', + line: 'collapse-next-line', + block: {start: 'collapse-start', end: 'collapse-end'}, + }, + ], + }, + ), + ).toMatchSnapshot(); + expect( + parseLines( + `// a +// b +// c +// d +line +// b +// d +line +`, + { + language: 'js', + metastring: '', + magicComments: [ + {className: 'a', line: 'a'}, + {className: 'b', line: 'b'}, + {className: 'c', line: 'c'}, + {className: 'd', line: 'd'}, + ], + }, + ), + ).toMatchSnapshot(); + }); }); diff --git a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts index f542f13865..fa091fe7a1 100644 --- a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts @@ -10,7 +10,7 @@ import type {PrismTheme} from 'prism-react-renderer'; import type {CSSProperties} from 'react'; const codeBlockTitleRegex = /title=(?["'])(?.*?)\1/; -const highlightLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; +const metastringLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; // Supported types of highlight comments const commentPatterns = { @@ -23,18 +23,23 @@ const commentPatterns = { type CommentType = keyof typeof commentPatterns; -const magicCommentDirectives = [ - 'highlight-next-line', - 'highlight-start', - 'highlight-end', -]; +export type MagicCommentConfig = { + className: string; + line?: string; + block?: {start: string; end: string}; +}; -function getCommentPattern(languages: CommentType[]) { +function getCommentPattern( + languages: CommentType[], + magicCommentDirectives: MagicCommentConfig[], +) { // To be more reliable, the opening and closing comment must match const commentPattern = languages .map((lang) => { const {start, end} = commentPatterns[lang]; - return `(?:${start}\\s*(${magicCommentDirectives.join('|')})\\s*${end})`; + return `(?:${start}\\s*(${magicCommentDirectives + .flatMap((d) => [d.line, d.block?.start, d.block?.end].filter(Boolean)) + .join('|')})\\s*${end})`; }) .join('|'); // White space is allowed, but otherwise it should be on it's own line @@ -44,34 +49,46 @@ function getCommentPattern(languages: CommentType[]) { /** * Select comment styles based on language */ -function getAllMagicCommentDirectiveStyles(lang: string) { +function getAllMagicCommentDirectiveStyles( + lang: string, + magicCommentDirectives: MagicCommentConfig[], +) { switch (lang) { case 'js': case 'javascript': case 'ts': case 'typescript': - return getCommentPattern(['js', 'jsBlock']); + return getCommentPattern(['js', 'jsBlock'], magicCommentDirectives); case 'jsx': case 'tsx': - return getCommentPattern(['js', 'jsBlock', 'jsx']); + return getCommentPattern( + ['js', 'jsBlock', 'jsx'], + magicCommentDirectives, + ); case 'html': - return getCommentPattern(['js', 'jsBlock', 'html']); + return getCommentPattern( + ['js', 'jsBlock', 'html'], + magicCommentDirectives, + ); case 'python': case 'py': case 'bash': - return getCommentPattern(['bash']); + return getCommentPattern(['bash'], magicCommentDirectives); case 'markdown': case 'md': // Text uses HTML, front matter uses bash - return getCommentPattern(['html', 'jsx', 'bash']); + return getCommentPattern(['html', 'jsx', 'bash'], magicCommentDirectives); default: // All comment types - return getCommentPattern(Object.keys(commentPatterns) as CommentType[]); + return getCommentPattern( + Object.keys(commentPatterns) as CommentType[], + magicCommentDirectives, + ); } } @@ -99,50 +116,91 @@ export function parseLanguage(className: string): string | undefined { * Parses the code content, strips away any magic comments, and returns the * clean content and the highlighted lines marked by the comments or metastring. * - * If the metastring contains highlight range, the `content` will be returned - * as-is without any parsing. + * If the metastring contains a range, the `content` will be returned as-is + * without any parsing. The returned `lineClassNames` will be a map from that + * number range to the first magic comment config entry (which _should_ be for + * line highlight directives.) * * @param content The raw code with magic comments. Trailing newline will be * trimmed upfront. - * @param metastring The full metastring, as received from MDX. Highlight range - * declared here starts at 1. - * @param language Language of the code block, used to determine which kinds of - * magic comment styles to enable. + * @param options Options for parsing behavior. */ export function parseLines( content: string, - metastring?: string, - language?: string, + options: { + /** + * The full metastring, as received from MDX. Line ranges declared here + * start at 1. + */ + metastring: string | undefined; + /** + * Language of the code block, used to determine which kinds of magic + * comment styles to enable. + */ + language: string | undefined; + /** + * Magic comment types that we should try to parse. Each entry would + * correspond to one class name to apply to each line. + */ + magicComments: MagicCommentConfig[]; + }, ): { /** - * The highlighted lines, 0-indexed. e.g. `[0, 1, 4]` means the 1st, 2nd, and - * 5th lines are highlighted. + * The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }` + * means the 1st line should have `highlight` and `sample` as class names. */ - highlightLines: number[]; + lineClassNames: {[lineIndex: number]: string[]}; /** - * The clean code without any magic comments (only if highlight range isn't - * present in the metastring). + * 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; } { let code = content.replace(/\n$/, ''); + const {language, magicComments, metastring} = options; // Highlighted lines specified in props: don't parse the content - if (metastring && highlightLinesRangeRegex.test(metastring)) { - const highlightLinesRange = metastring.match(highlightLinesRangeRegex)! - .groups!.range!; - const highlightLines = rangeParser(highlightLinesRange) + if (metastring && metastringLinesRangeRegex.test(metastring)) { + const linesRange = metastring.match(metastringLinesRangeRegex)!.groups! + .range!; + if (magicComments.length === 0) { + throw new Error( + `A highlight range has been given in code block's metastring (\`\`\` ${metastring}), but no magic comment config is available. Docusaurus applies the first magic comment entry's className for metastring ranges.`, + ); + } + const metastringRangeClassName = magicComments[0]!.className; + const lines = rangeParser(linesRange) .filter((n) => n > 0) - .map((n) => n - 1); - return {highlightLines, code}; + .map((n) => [n - 1, [metastringRangeClassName]]); + return {lineClassNames: Object.fromEntries(lines), code}; } if (language === undefined) { - return {highlightLines: [], code}; + return {lineClassNames: {}, code}; } - const directiveRegex = getAllMagicCommentDirectiveStyles(language); + const directiveRegex = getAllMagicCommentDirectiveStyles( + language, + magicComments, + ); // Go through line by line const lines = code.split('\n'); - let highlightBlockStart: number; - let highlightRange = ''; + const blocks = Object.fromEntries( + magicComments.map((d) => [d.className, {start: 0, range: ''}]), + ); + const lineToClassName: {[comment: string]: string} = Object.fromEntries( + magicComments + .filter((d) => d.line) + .map(({className, line}) => [line, className]), + ); + const blockStartToClassName: {[comment: string]: string} = Object.fromEntries( + magicComments + .filter((d) => d.block) + .map(({className, block}) => [block!.start, className]), + ); + const blockEndToClassName: {[comment: string]: string} = Object.fromEntries( + magicComments + .filter((d) => d.block) + .map(({className, block}) => [block!.end, className]), + ); for (let lineNumber = 0; lineNumber < lines.length; ) { const line = lines[lineNumber]!; const match = line.match(directiveRegex); @@ -151,28 +209,27 @@ export function parseLines( lineNumber += 1; continue; } - const directive = match.slice(1).find((item) => item !== undefined); - switch (directive) { - case 'highlight-next-line': - highlightRange += `${lineNumber},`; - break; - - case 'highlight-start': - highlightBlockStart = lineNumber; - break; - - case 'highlight-end': - highlightRange += `${highlightBlockStart!}-${lineNumber - 1},`; - break; - - default: - break; + const directive = match.slice(1).find((item) => item !== undefined)!; + if (lineToClassName[directive]) { + blocks[lineToClassName[directive]!]!.range += `${lineNumber},`; + } else if (blockStartToClassName[directive]) { + blocks[blockStartToClassName[directive]!]!.start = lineNumber; + } else if (blockEndToClassName[directive]) { + blocks[blockEndToClassName[directive]!]!.range += `${ + blocks[blockEndToClassName[directive]!]!.start + }-${lineNumber - 1},`; } lines.splice(lineNumber, 1); } - const highlightLines = rangeParser(highlightRange); code = lines.join('\n'); - return {highlightLines, code}; + const lineClassNames: {[lineIndex: number]: string[]} = {}; + Object.entries(blocks).forEach(([className, {range}]) => { + rangeParser(range).forEach((l) => { + lineClassNames[l] ??= []; + lineClassNames[l]!.push(className); + }); + }); + return {lineClassNames, code}; } export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties { diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index 269888c123..03cf74d4ff 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -8,6 +8,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import type {PrismTheme} from 'prism-react-renderer'; import type {DeepPartial} from 'utility-types'; +import type {MagicCommentConfig} from './codeBlockUtils'; export type DocsVersionPersistence = 'localStorage' | 'none'; @@ -57,6 +58,7 @@ export type PrismConfig = { darkTheme?: PrismTheme; defaultLanguage?: string; additionalLanguages: string[]; + magicComments: MagicCommentConfig[]; }; export type FooterLinkItem = { diff --git a/website/docs/api/themes/theme-configuration.md b/website/docs/api/themes/theme-configuration.md index d496a7ed8b..02e3c66db8 100644 --- a/website/docs/api/themes/theme-configuration.md +++ b/website/docs/api/themes/theme-configuration.md @@ -700,9 +700,28 @@ Accepted fields: | `theme` | `PrismTheme` | `palenight` | The Prism theme to use for light-theme code blocks. | | `darkTheme` | `PrismTheme` | `palenight` | The Prism theme to use for dark-theme code blocks. | | `defaultLanguage` | `string` | `undefined` | The side of the navbar this item should appear on. | +| `magicComments` | `MagicCommentConfig[]` | _see below_ | The list of [magic comments](../../guides/markdown-features/markdown-features-code-blocks.mdx#custom-magic-comments). | </APITable> +```ts +type MagicCommentConfig = { + className: string; + line?: string; + block?: {start: string; end: string}; +}; +``` + +```js +const defaultMagicComments = [ + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, +]; +``` + ### Theme {#theme} By default, we use [Palenight](https://github.com/FormidableLabs/prism-react-renderer/blob/master/src/themes/palenight.js) as syntax highlighting theme. You can specify a custom theme from the [list of available themes](https://github.com/FormidableLabs/prism-react-renderer/tree/master/src/themes). You may also use a different syntax highlighting theme when the site is in dark mode. diff --git a/website/docs/guides/markdown-features/markdown-features-code-blocks.mdx b/website/docs/guides/markdown-features/markdown-features-code-blocks.mdx index 4aa83b57db..9569c88b71 100644 --- a/website/docs/guides/markdown-features/markdown-features-code-blocks.mdx +++ b/website/docs/guides/markdown-features/markdown-features-code-blocks.mdx @@ -196,7 +196,7 @@ Supported commenting syntax: | Bash-style | `# ...` | | HTML-style | `<!-- ... -->` | -We will do our best to infer which set of comment styles to use based on the language, and default to allowing _all_ comment styles. If there's a comment style that is not currently supported, we are open to adding them! Pull requests welcome. +We will do our best to infer which set of comment styles to use based on the language, and default to allowing _all_ comment styles. If there's a comment style that is not currently supported, we are open to adding them! Pull requests welcome. Note that different comment styles have no semantic difference, only their content does. You can set your own background color for highlighted code line in your `src/css/custom.css` which will better fit to your selected syntax highlighting theme. The color given below works for the default highlighting theme (Palenight), so if you are using another theme, you will have to tweak the color accordingly. @@ -272,10 +272,104 @@ Prefer highlighting with comments where you can. By inlining highlight in the co ``` ```` -In the future, we may extend the magic comment system and let you define custom directives and their functionalities. The magic comments would only be parsed if a highlight metastring is not present. +Below, we will introduce how the magic comment system can be extended to define custom directives and their functionalities. The magic comments would only be parsed if a highlight metastring is not present. ::: +### Custom magic comments {#custom-magic-comments} + +`// highlight-next-line` and `// highlight-start` etc. are called "magic comments", because they will be parsed and removed, and their purposes are to add metadata to the next line, or the section that the pair of start- and end-comments enclose. + +You can declare custom magic comments through theme config. For example, you can register another magic comment that adds a `code-block-error-line` class name: + +<Tabs> +<TabItem value="docusaurus.config.js"> + +```js +module.exports = { + themeConfig: { + prism: { + magicComments: [ + // Remember to extend the default highlight class name as well! + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + // highlight-start + { + className: 'code-block-error-line', + line: 'This will error', + }, + // highlight-end + ], + }, + }, +}; +``` + +</TabItem> +<TabItem value="src/css/custom.css"> + +```css +.code-block-error-line { + background-color: #ff000020; + display: block; + margin: 0 calc(-1 * var(--ifm-pre-padding)); + padding: 0 var(--ifm-pre-padding); + border-left: 3px solid #ff000080; +} +``` + +</TabItem> +<TabItem value="myDoc.md"> + +````md +In TypeScript, types help prevent runtime errors. + +```ts +function greet(name: string) { + // This will error + console.log(name.toUpper()); + // .toUpper doesn't exist on string +} +``` +```` + +</TabItem> + +</Tabs> + +````mdx-code-block +<BrowserWindow> + +In TypeScript, types help prevent runtime errors. + +```ts +function greet(name: string) { + // This will error + console.log(name.toUpper()); + // .toUpper doesn't exist on string +} +``` + +</BrowserWindow> +```` + +If you use number ranges in metastring (the `{1,3-4}` syntax), Docusaurus will apply the **first `magicComments` entry**'s class name. This, by default, is `theme-code-block-highlighted-line`, but if you change the `magicComments` config and use a different entry as the first one, the meaning of the metastring range will change as well. + +You can disable the default line highlighting comments with `magicComments: []`. If there's no magic comment config, but Docusaurus encounters a code block containing a metastring range, it will error because there will be no class name to apply—the highlighting class name, after all, is just a magic comment entry. + +Every magic comment entry will contain three keys: `className` (required), `line`, which applies to the directly next line, or `block` (containing `start` and `end`), which applies to the entire block enclosed by the two comments. + +Using CSS to target the class can already do a lot, but you can unlock the full potential of this feature through [swizzling](../../swizzling.md). + +```bash npm2yarn +npm run swizzle @docusaurus/theme-classic CodeBlock/Line +``` + +The `Line` component will receive the list of class names, based on which you can conditionally render different markup. + ## Line numbering {#line-numbering} You can enable line numbering for your code block by using `showLineNumbers` key within the language meta string (don't forget to add space directly before the key). diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 93728881ad..acaf0e57a1 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -378,6 +378,17 @@ const config = { // TODO after we have forked prism-react-renderer, we should tweak the // import order and fix it there additionalLanguages: ['java', 'markdown', 'latex'], + magicComments: [ + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, + { + className: 'code-block-error-line', + line: 'This will error', + }, + ], }, image: 'img/docusaurus-soc.png', // metadata: [{name: 'twitter:card', content: 'summary'}], diff --git a/website/src/css/custom.css b/website/src/css/custom.css index d4f018b494..e6c7b4aed8 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -207,3 +207,11 @@ div[class^='announcementBar_'] { font-size: 0.875rem; padding: 0.2rem 0.5rem; } + +.code-block-error-line { + background-color: #ff000020; + display: block; + margin: 0 calc(-1 * var(--ifm-pre-padding)); + padding: 0 var(--ifm-pre-padding); + border-left: 3px solid #ff000080; +}