/** * 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 fs from 'fs-extra'; import logger from '@docusaurus/logger'; import { DEFAULT_PARSE_FRONT_MATTER, escapePath, getFileLoaderUtils, getWebpackLoaderCompilerName, } from '@docusaurus/utils'; import stringifyObject from 'stringify-object'; import preprocessor from './preprocessor'; import {validateMDXFrontMatter} from './frontMatter'; import {createProcessorCached} from './processor'; import type {ResolveMarkdownLink} from './remark/resolveMarkdownLinks'; import type {MDXOptions} from './processor'; import type {MarkdownConfig} from '@docusaurus/types'; import type {LoaderContext} from 'webpack'; // TODO as of April 2023, no way to import/re-export this ESM type easily :/ // This might change soon, likely after TS 5.2 // See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391 type Pluggable = any; // TODO fix this asap export type MDXPlugin = Pluggable; export type Options = Partial & { markdownConfig: MarkdownConfig; staticDirs: string[]; siteDir: string; isMDXPartial?: (filePath: string) => boolean; isMDXPartialFrontMatterWarningDisabled?: boolean; removeContentTitle?: boolean; metadataPath?: string | ((filePath: string) => string); createAssets?: (metadata: { frontMatter: {[key: string]: unknown}; metadata: {[key: string]: unknown}; }) => {[key: string]: unknown}; resolveMarkdownLink?: ResolveMarkdownLink; }; /** * When this throws, it generally means that there's no metadata file associated * with this MDX document. It can happen when using MDX partials (usually * starting with _). That's why it's important to provide the `isMDXPartial` * function in config */ async function readMetadataPath(metadataPath: string) { try { return await fs.readFile(metadataPath, 'utf8'); } catch (err) { logger.error`MDX loader can't read MDX metadata file path=${metadataPath}. Maybe the isMDXPartial option function was not provided?`; throw err; } } /** * Converts assets an object with Webpack require calls code. * This is useful for mdx files to reference co-located assets using relative * paths. Those assets should enter the Webpack assets pipeline and be hashed. * For now, we only handle that for images and paths starting with `./`: * * `{image: "./myImage.png"}` => `{image: require("./myImage.png")}` */ function createAssetsExportCode({ assets, inlineMarkdownAssetImageFileLoader, }: { assets: unknown; inlineMarkdownAssetImageFileLoader: string; }) { if ( typeof assets !== 'object' || !assets || Object.keys(assets).length === 0 ) { return 'undefined'; } // TODO implementation can be completed/enhanced function createAssetValueCode(assetValue: unknown): string | undefined { if (Array.isArray(assetValue)) { const arrayItemCodes = assetValue.map( (item: unknown) => createAssetValueCode(item) ?? 'undefined', ); return `[${arrayItemCodes.join(', ')}]`; } // Only process string values starting with ./ // We could enhance this logic and check if file exists on disc? if (typeof assetValue === 'string' && assetValue.startsWith('./')) { // TODO do we have other use-cases than image assets? // Probably not worth adding more support, as we want to move to Webpack 5 new asset system (https://github.com/facebook/docusaurus/pull/4708) return `require("${inlineMarkdownAssetImageFileLoader}${escapePath( assetValue, )}").default`; } return undefined; } const assetEntries = Object.entries(assets); const codeLines = assetEntries .map(([key, value]: [string, unknown]) => { const assetRequireCode = createAssetValueCode(value); return assetRequireCode ? `"${key}": ${assetRequireCode},` : undefined; }) .filter(Boolean); return `{\n${codeLines.join('\n')}\n}`; } // TODO temporary, remove this after v3.1? // Some plugin authors use our mdx-loader, despite it not being public API // see https://github.com/facebook/docusaurus/issues/8298 function ensureMarkdownConfig(reqOptions: Options) { if (!reqOptions.markdownConfig) { throw new Error( 'Docusaurus v3+ requires MDX loader options.markdownConfig - plugin authors using the MDX loader should make sure to provide that option', ); } } /** * data.contentTitle is set by the remark contentTitle plugin */ function extractContentTitleData(data: { [key: string]: unknown; }): string | undefined { return data.contentTitle as string | undefined; } export async function mdxLoader( this: LoaderContext, fileContent: string, ): Promise { const compilerName = getWebpackLoaderCompilerName(this); const callback = this.async(); const filePath = this.resourcePath; const options: Options = this.getOptions(); ensureMarkdownConfig(options); const {frontMatter} = await options.markdownConfig.parseFrontMatter({ filePath, fileContent, defaultParseFrontMatter: DEFAULT_PARSE_FRONT_MATTER, }); const mdxFrontMatter = validateMDXFrontMatter(frontMatter.mdx); const preprocessedContent = preprocessor({ fileContent, filePath, admonitions: options.admonitions, markdownConfig: options.markdownConfig, }); const hasFrontMatter = Object.keys(frontMatter).length > 0; const processor = await createProcessorCached({ filePath, options, mdxFrontMatter, }); let result: {content: string; data: {[key: string]: unknown}}; try { result = await processor.process({ content: preprocessedContent, filePath, frontMatter, compilerName, }); } catch (errorUnknown) { const error = errorUnknown as Error; // MDX can emit errors that have useful extra attributes const errorJSON = JSON.stringify(error, null, 2); const errorDetails = errorJSON === '{}' ? // regular JS error case: print stacktrace error.stack ?? 'N/A' : // MDX error: print extra attributes + stacktrace `${errorJSON}\n${error.stack}`; return callback( new Error( `MDX compilation failed for file ${logger.path(filePath)}\nCause: ${ error.message }\nDetails:\n${errorDetails}`, // TODO error cause doesn't seem to be used by Webpack stats.errors :s {cause: error}, ), ); } const contentTitle = extractContentTitleData(result.data); // MDX partials are MDX files starting with _ or in a folder starting with _ // Partial are not expected to have associated metadata files or front matter const isMDXPartial = options.isMDXPartial?.(filePath); if (isMDXPartial && hasFrontMatter) { const errorMessage = `Docusaurus MDX partial files should not contain front matter. Those partial files use the _ prefix as a convention by default, but this is configurable. File at ${filePath} contains front matter that will be ignored: ${JSON.stringify(frontMatter, null, 2)}`; if (!options.isMDXPartialFrontMatterWarningDisabled) { const shouldError = process.env.NODE_ENV === 'test' || process.env.CI; if (shouldError) { return callback(new Error(errorMessage)); } logger.warn(errorMessage); } } function getMetadataPath(): string | undefined { if (!isMDXPartial) { // Read metadata for this MDX and export it. if (options.metadataPath && typeof options.metadataPath === 'function') { return options.metadataPath(filePath); } } return undefined; } const metadataPath = getMetadataPath(); if (metadataPath) { this.addDependency(metadataPath); } const metadataJsonString = metadataPath ? await readMetadataPath(metadataPath) : undefined; const metadata = metadataJsonString ? (JSON.parse(metadataJsonString) as {[key: string]: unknown}) : undefined; const assets = options.createAssets && metadata ? options.createAssets({frontMatter, metadata}) : undefined; const fileLoaderUtils = getFileLoaderUtils(compilerName === 'server'); // TODO use remark plugins to insert extra exports instead of string concat? // cf how the toc is exported const exportsCode = ` export const frontMatter = ${stringifyObject(frontMatter)}; export const contentTitle = ${stringifyObject(contentTitle)}; ${metadataJsonString ? `export const metadata = ${metadataJsonString};` : ''} ${ assets ? `export const assets = ${createAssetsExportCode({ assets, inlineMarkdownAssetImageFileLoader: fileLoaderUtils.loaders.inlineMarkdownAssetImageFileLoader, })};` : '' } `; const code = ` ${exportsCode} ${result.content} `; return callback(null, code); }