diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 323ef6effb24..6710a774b8c3 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -405,11 +405,12 @@ declare module '@theme/BlogLayout' { declare module '@theme/CodeBlock' { import type {ReactNode} from 'react'; + import type {CodeBlockMeta} from '@docusaurus/theme-common'; export interface Props { readonly children: ReactNode; readonly className?: string; - readonly metastring?: string; + readonly metastring?: string | CodeBlockMeta; readonly title?: ReactNode; readonly language?: string; readonly showLineNumbers?: boolean | number; @@ -481,6 +482,7 @@ declare module '@theme/CodeBlock/Line' { readonly line: Token[]; readonly classNames: string[] | undefined; readonly showLineNumbers: boolean; + readonly meta?: CodeBlockMeta; readonly getLineProps: (input: LineInputProps) => LineOutputProps; readonly getTokenProps: (input: TokenInputProps) => TokenOutputProps; } @@ -488,6 +490,18 @@ declare module '@theme/CodeBlock/Line' { export default function CodeBlockLine(props: Props): ReactNode; } +declare module '@theme/CodeBlock/Token' { + import type {ReactNode} from 'react'; + import type {TokenOutputProps} from 'prism-react-renderer'; + + export interface Props { + readonly output: TokenOutputProps; + readonly meta?: CodeBlockMeta; + } + + export default function CodeBlockToken(props: Props): ReactNode; +} + declare module '@theme/CodeBlock/WordWrapButton' { import type {ReactNode} from 'react'; 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 fb6c6763dfbd..1e0024d3f35d 100644 --- a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Content/String.tsx @@ -9,7 +9,6 @@ import React, {type ReactNode} from 'react'; import clsx from 'clsx'; import {useThemeConfig, usePrismTheme} from '@docusaurus/theme-common'; import { - parseCodeBlockTitle, parseLanguage, parseLines, getLineNumbersStart, @@ -51,19 +50,19 @@ export default function CodeBlockString({ 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, { + const {meta, code} = parseLines(children, { metastring, language, magicComments, }); + + // Note that MDX doesn't strip quotes when parsing metastring: + // "title=\"xyz\"" => title: "\"xyz\"" + const title = meta.options.title ?? titleProp; + const lineNumbersStart = getLineNumbersStart({ showLineNumbers: showLineNumbersProp, - metastring, + meta, }); return ( @@ -105,7 +104,8 @@ export default function CodeBlockString({ line={line} getLineProps={getLineProps} getTokenProps={getTokenProps} - classNames={lineClassNames[i]} + classNames={meta.lineClassNames[i]} + meta={meta} showLineNumbers={lineNumbersStart !== undefined} /> ))} 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 e56bba27ed66..b70449c245e4 100644 --- a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Line/index.tsx @@ -9,6 +9,7 @@ import React, {type ReactNode} from 'react'; import clsx from 'clsx'; import type {Props} from '@theme/CodeBlock/Line'; +import CodeBlockToken from '@theme/CodeBlock/Token'; import styles from './styles.module.css'; type Token = Props['line'][number]; @@ -41,7 +42,7 @@ export default function CodeBlockLine({ }); const lineTokens = line.map((token, key) => ( - + )); return ( diff --git a/packages/docusaurus-theme-classic/src/theme/CodeBlock/Token/index.tsx b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Token/index.tsx new file mode 100644 index 000000000000..f25e44d3d4e6 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/CodeBlock/Token/index.tsx @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, {type ReactNode} from 'react'; +import type {Props} from '@theme/CodeBlock/Token'; + +export default function CodeBlockToken({output}: Props): ReactNode { + return ; +} diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 378828d9343a..7fed230db40e 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -144,3 +144,8 @@ export { ErrorBoundaryErrorMessageFallback, ErrorCauseBoundary, } from './utils/errorBoundaryUtils'; + +export { + type CodeBlockMeta, + type CodeMetaOptionValue, +} from './utils/codeBlockUtils'; diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index e4a3852774d8..9f09b5fcca44 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -34,7 +34,7 @@ export {ColorModeProvider} from './contexts/colorMode'; export {useAlternatePageUtils} from './utils/useAlternatePageUtils'; export { - parseCodeBlockTitle, + parseCodeBlockMeta, parseLanguage, parseLines, getLineNumbersStart, 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 7ab1326fddc8..9166013b54a4 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 @@ -1,38 +1,165 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`parseLines does not parse content with metastring 1`] = ` +exports[`parseCodeBlockMeta parses mixed values 1`] = ` { - "code": "aaaaa -nnnnn", "lineClassNames": { "0": [ "theme-code-block-highlighted-line", ], }, + "options": { + "a": "double'quote", + "b": "single"quote", + "c": "raw", + "d": true, + "e": false, + "f": 1, + "g": 0.5, + }, } `; -exports[`parseLines does not parse content with metastring 2`] = ` +exports[`parseCodeBlockMeta parses range and options 1`] = ` { - "code": "// highlight-next-line -aaaaa -bbbbb", "lineClassNames": { "0": [ "theme-code-block-highlighted-line", ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + }, + "options": { + "label": "JavaScript", + "title": "index.js", }, } `; -exports[`parseLines does not parse content with metastring 3`] = ` +exports[`parseCodeBlockMeta parses range and options end 1`] = ` { - "code": "aaaaa -bbbbb", "lineClassNames": { "0": [ "theme-code-block-highlighted-line", ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + }, + "options": { + "label": "JavaScript", + "title": "index.js", + }, +} +`; + +exports[`parseCodeBlockMeta parses range and options middle 1`] = ` +{ + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + }, + "options": { + "label": "JavaScript", + "title": "index.js", + }, +} +`; + +exports[`parseCodeBlockMeta parses range and options multiple 1`] = ` +{ + "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", + ], + }, + "options": { + "label": "JavaScript", + "title": "index.js", + }, +} +`; + +exports[`parseCodeBlockMeta parses range only 1`] = ` +{ + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, +} +`; + +exports[`parseLines does not parse content with metastring 1`] = ` +{ + "code": "aaaaa +nnnnn", + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, + }, +} +`; + +exports[`parseLines does not parse content with metastring 2`] = ` +{ + "code": "aaaaa +bbbbb", + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, + }, +} +`; + +exports[`parseLines does not parse content with metastring 3`] = ` +{ + "code": "aaaaa +bbbbb", + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -42,7 +169,10 @@ exports[`parseLines does not parse content with no language 1`] = ` "code": "// highlight-next-line aaaaa bbbbb", - "lineClassNames": {}, + "meta": { + "lineClassNames": {}, + "options": {}, + }, } `; @@ -58,40 +188,43 @@ 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", - ], + "meta": { + "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", + ], + }, + "options": {}, }, } `; @@ -100,17 +233,20 @@ exports[`parseLines handles one line with multiple class names 2`] = ` { "code": "line line", - "lineClassNames": { - "0": [ - "a", - "b", - "c", - "d", - ], - "1": [ - "b", - "d", - ], + "meta": { + "lineClassNames": { + "0": [ + "a", + "b", + "c", + "d", + ], + "1": [ + "b", + "d", + ], + }, + "options": {}, }, } `; @@ -122,19 +258,22 @@ highlighted collapsed collapsed collapsed", - "lineClassNames": { - "1": [ - "highlight", - ], - "2": [ - "collapse", - ], - "3": [ - "collapse", - ], - "4": [ - "collapse", - ], + "meta": { + "lineClassNames": { + "1": [ + "highlight", + ], + "2": [ + "collapse", + ], + "3": [ + "collapse", + ], + "4": [ + "collapse", + ], + }, + "options": {}, }, } `; @@ -143,10 +282,13 @@ exports[`parseLines removes lines correctly 1`] = ` { "code": "aaaaa bbbbb", - "lineClassNames": { - "0": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -155,10 +297,13 @@ exports[`parseLines removes lines correctly 2`] = ` { "code": "aaaaa bbbbb", - "lineClassNames": { - "0": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -168,17 +313,19 @@ exports[`parseLines removes lines correctly 3`] = ` "code": "aaaaa bbbbbbb bbbbb", - "lineClassNames": { - "0": [ - "theme-code-block-highlighted-line", - "theme-code-block-highlighted-line", - ], - "1": [ - "theme-code-block-highlighted-line", - ], - "2": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + "2": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -189,13 +336,16 @@ exports[`parseLines respects language: html 1`] = ` {/* highlight-next-line */} bbbbb dddd", - "lineClassNames": { - "0": [ - "theme-code-block-highlighted-line", - ], - "3": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "3": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -205,7 +355,10 @@ exports[`parseLines respects language: js 1`] = ` "code": "# highlight-next-line aaaaa bbbbb", - "lineClassNames": {}, + "meta": { + "lineClassNames": {}, + "options": {}, + }, } `; @@ -215,13 +368,16 @@ exports[`parseLines respects language: jsx 1`] = ` bbbbb dddd", - "lineClassNames": { - "0": [ - "theme-code-block-highlighted-line", - ], - "1": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "0": [ + "theme-code-block-highlighted-line", + ], + "1": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -245,16 +401,19 @@ dddd // highlight-next-line console.log("preserved"); \`\`\`", - "lineClassNames": { - "1": [ - "theme-code-block-highlighted-line", - ], - "11": [ - "theme-code-block-highlighted-line", - ], - "7": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "1": [ + "theme-code-block-highlighted-line", + ], + "11": [ + "theme-code-block-highlighted-line", + ], + "7": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; @@ -265,19 +424,22 @@ exports[`parseLines respects language: none 1`] = ` bbbbb ccccc dddd", - "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", - ], + "meta": { + "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", + ], + }, + "options": {}, }, } `; @@ -287,7 +449,10 @@ exports[`parseLines respects language: py 1`] = ` "code": "/* highlight-next-line */ aaaaa bbbbb", - "lineClassNames": {}, + "meta": { + "lineClassNames": {}, + "options": {}, + }, } `; @@ -300,10 +465,13 @@ bbbbb ccccc dddd", - "lineClassNames": { - "4": [ - "theme-code-block-highlighted-line", - ], + "meta": { + "lineClassNames": { + "4": [ + "theme-code-block-highlighted-line", + ], + }, + "options": {}, }, } `; 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 47c62a91ca69..39356b48fbf6 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts +++ b/packages/docusaurus-theme-common/src/utils/__tests__/codeBlockUtils.test.ts @@ -7,12 +7,34 @@ import { type MagicCommentConfig, - parseCodeBlockTitle, + parseCodeBlockMeta, parseLanguage, parseLines, } from '../codeBlockUtils'; -describe('parseCodeBlockTitle', () => { +const defaultMagicComments: MagicCommentConfig[] = [ + { + className: 'theme-code-block-highlighted-line', + line: 'highlight-next-line', + block: {start: 'highlight-start', end: 'highlight-end'}, + }, +]; + +describe('parseCodeBlockMeta', () => { + // shorthand for previously existing tests parsing the title + function parseCodeBlockTitle( + metastring?: string, + magicComments?: MagicCommentConfig[], + ): string { + const meta = parseCodeBlockMeta({ + language: undefined, + magicComments: magicComments ?? [], + metastring, + }); + + return (meta.options.title as string) ?? ''; + } + it('parses double quote delimited title', () => { expect(parseCodeBlockTitle(`title="index.js"`)).toBe(`index.js`); }); @@ -21,8 +43,8 @@ describe('parseCodeBlockTitle', () => { expect(parseCodeBlockTitle(`title='index.js'`)).toBe(`index.js`); }); - it('does not parse mismatched quote delimiters', () => { - expect(parseCodeBlockTitle(`title="index.js'`)).toBe(``); + it('parses mismatched quote delimiters as plain string', () => { + expect(parseCodeBlockTitle(`title="index.js'`)).toBe(`"index.js'`); }); it('parses undefined metastring', () => { @@ -30,7 +52,7 @@ describe('parseCodeBlockTitle', () => { }); it('parses metastring with no title specified', () => { - expect(parseCodeBlockTitle(`{1,2-3}`)).toBe(``); + expect(parseCodeBlockTitle(`{1,2-3}`, defaultMagicComments)).toBe(``); }); it('parses with multiple metadata title first', () => { @@ -56,6 +78,66 @@ describe('parseCodeBlockTitle', () => { `console.log('Hello, World!')`, ); }); + + it('parses range only', () => { + expect( + parseCodeBlockMeta({ + metastring: `{1,2-3}`, + language: undefined, + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); + }); + + it('parses range and options', () => { + expect( + parseCodeBlockMeta({ + metastring: `{1,2-3} title="index.js" label="JavaScript"`, + language: undefined, + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); + }); + + it('parses range and options end', () => { + expect( + parseCodeBlockMeta({ + metastring: `title="index.js" label="JavaScript" {1,2-3} `, + language: undefined, + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); + }); + + it('parses range and options middle', () => { + expect( + parseCodeBlockMeta({ + metastring: `title="index.js" {1,2-3} label="JavaScript"`, + language: undefined, + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); + }); + + it('parses range and options multiple', () => { + expect( + parseCodeBlockMeta({ + metastring: `{1} title="index.js" {1,2-3} label="JavaScript" {4}`, + language: undefined, + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); + }); + + it('parses mixed values', () => { + expect( + parseCodeBlockMeta({ + metastring: `{1} a="double'quote" b='single"quote' c=raw d=true e=false f=1 g=0.5`, + language: undefined, + magicComments: defaultMagicComments, + }), + ).toMatchSnapshot(); + }); }); describe('parseLanguage', () => { @@ -68,14 +150,6 @@ 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', { diff --git a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts index 305ba3e87703..b0349407929b 100644 --- a/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/codeBlockUtils.ts @@ -9,8 +9,36 @@ import type {CSSProperties} from 'react'; import rangeParser from 'parse-numeric-range'; import type {PrismTheme, PrismThemeEntry} from 'prism-react-renderer'; -const codeBlockTitleRegex = /title=(?["'])(?.*?)\1/; -const metastringLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; +// note: regexp/no-useless-non-capturing-group is a false positive +// the group is required or it breaks the correct alternation of +// <quote><stringValue><quote> | <rawValue> +const optionRegex = + // eslint-disable-next-line regexp/no-useless-non-capturing-group + /(?<key>\w+)(?:=(?:(?:(?<quote>["'])(?<stringValue>.*?)\k<quote>)|(?<rawValue>\S*)))?/g; +const metastringLinesRangeRegex = /\{(?<range>[\d,-]+)\}/g; +const highlightOptionKey = 'highlight'; + +/** + * The supported types for {@link CodeBlockMeta.options} values. + */ +export type CodeMetaOptionValue = string | boolean | number; + +/** + * Any options as specified by the user in the "metastring" of codeblocks. + */ +export interface CodeBlockMeta { + /** + * The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }` + * means the 1st line should have `highlight` and `sample` as class names. + */ + readonly lineClassNames: {[lineIndex: number]: string[]}; + + /** + * The parsed options, key converted to lowercase. + * e.g. `"title" => "file.js", "showlinenumbers" => true` + */ + readonly options: {[key: string]: CodeMetaOptionValue}; +} // Supported types of highlight comments const popularCommentPatterns = { @@ -147,32 +175,120 @@ function getAllMagicCommentDirectiveStyles( } } -export function parseCodeBlockTitle(metastring?: string): string { - return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? ''; +/** + * Rewrites the range syntax to special options. e.g. + * `{1,2,3-4,5} => highlight=1 highlight=2 highlight=3-4 highlight=5` + * @param metastring The input metastring with the range syntax + * @returns The string where the range syntax has been rewritten + */ +function rewriteLinesRange(metastring: string): string { + metastringLinesRangeRegex.lastIndex = 0; + + return metastring.replaceAll(metastringLinesRangeRegex, (_, range) => { + return (range as string) + .split(',') + .map((r) => `${highlightOptionKey}=${r}`) + .join(' '); + }); } -function getMetaLineNumbersStart(metastring?: string): number | undefined { - const showLineNumbersMeta = metastring - ?.split(' ') - .find((str) => str.startsWith('showLineNumbers')); - - if (showLineNumbersMeta) { - if (showLineNumbersMeta.startsWith('showLineNumbers=')) { - const value = showLineNumbersMeta.replace('showLineNumbers=', ''); - return parseInt(value, 10); +function parseCodeBlockOptions( + meta: CodeBlockMeta, + originalMetastring: string, + metastring: string, + magicComments: MagicCommentConfig[], +) { + if (metastring) { + optionRegex.lastIndex = 0; + + let match = optionRegex.exec(metastring); + + while (match) { + const {stringValue, rawValue} = match.groups!; + + const key = match.groups!.key!.toLowerCase(); + + // special highlight option + if (key === highlightOptionKey) { + if (magicComments.length === 0) { + throw new Error( + `A highlight range has been given in code block's metastring (\`\`\` ${originalMetastring}), but no magic comment config is available. Docusaurus applies the first magic comment entry's className for metastring ranges.`, + ); + } + const metastringRangeClassName = magicComments[0]!.className; + rangeParser(stringValue ?? rawValue!) + .filter((n) => n > 0) + .forEach((n) => { + meta.lineClassNames[n - 1] = [metastringRangeClassName]; + }); + } else if (stringValue === undefined && rawValue === undefined) { + // flag options + meta.options[key] = true; + } else if (stringValue !== undefined) { + // string option + meta.options[key] = stringValue; + } else if (rawValue === 'true') { + // boolean option + meta.options[key] = true; + } else if (rawValue === 'false') { + meta.options[key] = false; + } else { + const number = parseFloat(rawValue!); + if (!Number.isNaN(number)) { + // number value + meta.options[key] = number; + } else { + // non quoted string + meta.options[key] = rawValue!; + } + } + + match = optionRegex.exec(metastring); } - return 1; } +} + +export function parseCodeBlockMeta(options: ParseLineOptions): CodeBlockMeta { + if (typeof options.metastring === 'object') { + return options.metastring; + } + + const meta: CodeBlockMeta = { + lineClassNames: {}, + options: {}, + }; - return undefined; + const {metastring, magicComments} = options; + + // exit early if nothing to do. + if (!metastring) { + return meta; + } + + const rewritten = rewriteLinesRange(metastring); + parseCodeBlockOptions(meta, metastring, rewritten, magicComments); + + return meta; +} + +function getMetaLineNumbersStart(meta: CodeBlockMeta): number | undefined { + const showLineNumbers = meta.options.showlinenumbers; + switch (typeof showLineNumbers) { + case 'boolean': + return showLineNumbers ? 1 : 0; + case 'number': + return Math.floor(showLineNumbers); + default: + return undefined; + } } export function getLineNumbersStart({ showLineNumbers, - metastring, + meta, }: { showLineNumbers: boolean | number | undefined; - metastring: string | undefined; + meta: CodeBlockMeta; }): number | undefined { const defaultStart = 1; if (typeof showLineNumbers === 'boolean') { @@ -181,7 +297,7 @@ export function getLineNumbersStart({ if (typeof showLineNumbers === 'number') { return showLineNumbers; } - return getMetaLineNumbersStart(metastring); + return getMetaLineNumbersStart(meta); } /** @@ -196,6 +312,24 @@ export function parseLanguage(className: string): string | undefined { return languageClassName?.replace(/language-/, ''); } +export type ParseLineOptions = { + /** + * The full metastring, as received from MDX. Line ranges declared here + * start at 1. Or alternatively the parsed {@link CodeBlockMeta}. + */ + metastring: CodeBlockMeta | 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[]; +}; + /** * Parses the code content, strips away any magic comments, and returns the * clean content and the highlighted lines marked by the comments or metastring. @@ -211,29 +345,12 @@ export function parseLanguage(className: string): string | undefined { */ export function parseLines( content: 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[]; - }, + options: ParseLineOptions, ): { /** - * The highlighted lines, 0-indexed. e.g. `{ 0: ["highlight", "sample"] }` - * means the 1st line should have `highlight` and `sample` as class names. + * The metadata of the code block like highlighted lines and custom options. */ - lineClassNames: {[lineIndex: number]: string[]}; + meta: CodeBlockMeta; /** * 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 @@ -242,24 +359,12 @@ export function parseLines( code: string; } { let code = content.replace(/\n$/, ''); - const {language, magicComments, metastring} = options; - // Highlighted lines specified in props: don't parse the content - 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, [metastringRangeClassName]] as [number, string[]]); - return {lineClassNames: Object.fromEntries(lines), code}; - } + const {language, magicComments} = options; + + const meta = parseCodeBlockMeta(options); + if (language === undefined) { - return {lineClassNames: {}, code}; + return {meta, code}; } const directiveRegex = getAllMagicCommentDirectiveStyles( language, @@ -308,14 +413,15 @@ export function parseLines( lines.splice(lineNumber, 1); } code = lines.join('\n'); - const lineClassNames: {[lineIndex: number]: string[]} = {}; Object.entries(blocks).forEach(([className, {range}]) => { rangeParser(range).forEach((l) => { - lineClassNames[l] ??= []; - lineClassNames[l]!.push(className); + meta.lineClassNames[l] ??= []; + if (!meta.lineClassNames[l].includes(className)) { + meta.lineClassNames[l]!.push(className); + } }); }); - return {lineClassNames, code}; + return {meta, code}; } export function getPrismCssVariables(prismTheme: PrismTheme): CSSProperties { diff --git a/packages/docusaurus-theme-live-codeblock/src/theme/Playground/index.tsx b/packages/docusaurus-theme-live-codeblock/src/theme/Playground/index.tsx index 37cda3ee0280..4dc200c7be10 100644 --- a/packages/docusaurus-theme-live-codeblock/src/theme/Playground/index.tsx +++ b/packages/docusaurus-theme-live-codeblock/src/theme/Playground/index.tsx @@ -18,6 +18,7 @@ import { } from '@docusaurus/theme-common'; import ErrorBoundary from '@docusaurus/ErrorBoundary'; +import {parseCodeBlockMeta} from '@docusaurus/theme-common/internal'; import type {Props} from '@theme/Playground'; import type {ThemeConfig} from '@docusaurus/theme-live-codeblock'; @@ -115,7 +116,13 @@ export default function Playground({ } = themeConfig as ThemeConfig; const prismTheme = usePrismTheme(); - const noInline = props.metastring?.includes('noInline') ?? false; + const meta = parseCodeBlockMeta({ + metastring: props.metastring, + language: undefined, + magicComments: [], + }); + + const noInline = meta.options.noinline === true; return ( <div className={styles.playgroundContainer}> diff --git a/project-words.txt b/project-words.txt index a12674f54742..b1f93b8f9864 100644 --- a/project-words.txt +++ b/project-words.txt @@ -202,6 +202,7 @@ noflash noicon nojekyll noninteractive +noinline npmjs Nuxt ödingers @@ -297,6 +298,7 @@ SFNT shiki Shiki showinfo +showlinenumbers Sida Simen slorber