/** * 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 type {CSSProperties, ReactNode} from 'react'; import {createContext, useContext, useMemo} from 'react'; import clsx from 'clsx'; import rangeParser from 'parse-numeric-range'; import {ReactContextError} from './reactUtils'; import type {PrismTheme, PrismThemeEntry} from 'prism-react-renderer'; import type {WordWrap} from '../hooks/useCodeWordWrap'; const codeBlockTitleRegex = /title=(?["'])(?.*?)\1/; const metastringLinesRangeRegex = /\{(?<range>[\d,-]+)\}/; // Supported types of highlight comments const popularCommentPatterns = { js: {start: '\\/\\/', end: ''}, jsBlock: {start: '\\/\\*', end: '\\*\\/'}, jsx: {start: '\\{\\s*\\/\\*', end: '\\*\\/\\s*\\}'}, bash: {start: '#', end: ''}, html: {start: '<!--', end: '-->'}, } as const; const commentPatterns = { ...popularCommentPatterns, // shallow copy is sufficient // minor comment styles lua: {start: '--', end: ''}, wasm: {start: '\\;\\;', end: ''}, tex: {start: '%', end: ''}, vb: {start: "['‘’]", end: ''}, vbnet: {start: "(?:_\\s*)?['‘’]", end: ''}, // Visual Studio 2019 or later rem: {start: '[Rr][Ee][Mm]\\b', end: ''}, f90: {start: '!', end: ''}, // Free format only ml: {start: '\\(\\*', end: '\\*\\)'}, cobol: {start: '\\*>', end: ''}, // Free format only } as const; type CommentType = keyof typeof commentPatterns; const popularCommentTypes = Object.keys( popularCommentPatterns, ) as CommentType[]; export type MagicCommentConfig = { className: string; line?: string; block?: {start: string; end: string}; }; 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 .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 return new RegExp(`^\\s*(?:${commentPattern})\\s*$`); } /** * Select comment styles based on language */ function getAllMagicCommentDirectiveStyles( lang: string, magicCommentDirectives: MagicCommentConfig[], ) { switch (lang) { case 'js': case 'javascript': case 'ts': case 'typescript': return getCommentPattern(['js', 'jsBlock'], magicCommentDirectives); case 'jsx': case 'tsx': return getCommentPattern( ['js', 'jsBlock', 'jsx'], magicCommentDirectives, ); case 'html': return getCommentPattern( ['js', 'jsBlock', 'html'], magicCommentDirectives, ); case 'python': case 'py': case 'bash': return getCommentPattern(['bash'], magicCommentDirectives); case 'markdown': case 'md': // Text uses HTML, front matter uses bash return getCommentPattern(['html', 'jsx', 'bash'], magicCommentDirectives); case 'tex': case 'latex': case 'matlab': return getCommentPattern(['tex'], magicCommentDirectives); case 'lua': case 'haskell': return getCommentPattern(['lua'], magicCommentDirectives); case 'sql': return getCommentPattern(['lua', 'jsBlock'], magicCommentDirectives); case 'wasm': return getCommentPattern(['wasm'], magicCommentDirectives); case 'vb': case 'vba': case 'visual-basic': return getCommentPattern(['vb', 'rem'], magicCommentDirectives); case 'vbnet': return getCommentPattern(['vbnet', 'rem'], magicCommentDirectives); case 'batch': return getCommentPattern(['rem'], magicCommentDirectives); case 'basic': // https://github.com/PrismJS/prism/blob/master/components/prism-basic.js#L3 return getCommentPattern(['rem', 'f90'], magicCommentDirectives); case 'fsharp': return getCommentPattern(['js', 'ml'], magicCommentDirectives); case 'ocaml': case 'sml': return getCommentPattern(['ml'], magicCommentDirectives); case 'fortran': return getCommentPattern(['f90'], magicCommentDirectives); case 'cobol': return getCommentPattern(['cobol'], magicCommentDirectives); default: // All popular comment types return getCommentPattern(popularCommentTypes, magicCommentDirectives); } } export function parseCodeBlockTitle(metastring?: string): string { return metastring?.match(codeBlockTitleRegex)?.groups!.title ?? ''; } 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); } return 1; } return undefined; } export function getLineNumbersStart({ showLineNumbers, metastring, }: { showLineNumbers: boolean | number | undefined; metastring: string | undefined; }): number | undefined { const defaultStart = 1; if (typeof showLineNumbers === 'boolean') { return showLineNumbers ? defaultStart : undefined; } if (typeof showLineNumbers === 'number') { return showLineNumbers; } return getMetaLineNumbersStart(metastring); } // TODO Docusaurus v4: remove, only kept for internal retro-compatibility // See https://github.com/facebook/docusaurus/pull/11153 export function containsLineNumbers(metastring?: string): boolean { return Boolean(metastring?.includes('showLineNumbers')); } type ParseCodeLinesParam = { /** * 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: ["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 ParsedCodeLines = { code: string; lineClassNames: CodeLineClassNames; }; function parseCodeLinesFromMetastring( code: string, {metastring, magicComments}: ParseCodeLinesParam, ): ParsedCodeLines | null { // 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}; } return null; } function parseCodeLinesFromContent( code: string, params: ParseCodeLinesParam, ): ParsedCodeLines { const {language, magicComments} = params; if (language === undefined) { return {lineClassNames: {}, code}; } const directiveRegex = getAllMagicCommentDirectiveStyles( language, magicComments, ); // Go through line by line const lines = code.split(/\r?\n/); 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] as [string, string]), ); 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); if (!match) { // Lines without directives are unchanged lineNumber += 1; continue; } const directive = match .slice(1) .find((item: string | undefined) => 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 lineClassNames: {[lineIndex: number]: string[]} = {}; Object.entries(blocks).forEach(([className, {range}]) => { rangeParser(range).forEach((l) => { lineClassNames[l] ??= []; lineClassNames[l]!.push(className); }); }); return {code: lines.join('\n'), lineClassNames}; } /** * 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 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.) */ export function parseLines( code: string, params: ParseCodeLinesParam, ): 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 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', backgroundColor: '--prism-background-color', }; const properties: {[key: string]: string} = {}; Object.entries(prismTheme.plain).forEach(([key, value]) => { const varName = mapping[key as keyof PrismThemeEntry]; if (varName && typeof value === 'string') { properties[varName] = value; } }); return properties; } type CodeBlockContextValue = { metadata: CodeBlockMetadata; wordWrap: WordWrap; }; const CodeBlockContext = createContext<CodeBlockContextValue | null>(null); export function CodeBlockContextProvider({ metadata, wordWrap, children, }: { metadata: CodeBlockMetadata; wordWrap: WordWrap; children: ReactNode; }): ReactNode { // Should we optimize this in 2 contexts? // Unlike metadata, wordWrap is stateful and likely to trigger re-renders const value: CodeBlockContextValue = useMemo(() => { return {metadata, wordWrap}; }, [metadata, wordWrap]); return ( <CodeBlockContext.Provider value={value}> {children} </CodeBlockContext.Provider> ); } export function useCodeBlockContext(): CodeBlockContextValue { const value = useContext(CodeBlockContext); if (value === null) { throw new ReactContextError('CodeBlockContextProvider'); } return value; }