Skip to content

Commit

Permalink
mdx loader refactoring
Browse files Browse the repository at this point in the history
  • Loading branch information
slorber committed Aug 27, 2024
1 parent 71b2c25 commit 1b49ebf
Show file tree
Hide file tree
Showing 3 changed files with 173 additions and 140 deletions.
152 changes: 15 additions & 137 deletions packages/docusaurus-mdx-loader/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,23 @@
* 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,
type SimpleProcessors,
type MDXOptions,
compileToJSX,
createAssetsExportCode,
extractContentTitleData,
readMetadataPath,
} from './utils';
import type {
SimpleProcessors,
MDXOptions,
SimpleProcessorResult,
} from './processor';
import type {ResolveMarkdownLink} from './remark/resolveMarkdownLinks';

Expand Down Expand Up @@ -51,96 +53,6 @@ export type Options = Partial<MDXOptions> & {
processors?: SimpleProcessors;
};

/**
* 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<Options>,
fileContent: string,
Expand All @@ -150,59 +62,25 @@ export async function mdxLoader(
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}};
let result: SimpleProcessorResult;
try {
result = await processor.process({
content: preprocessedContent,
result = await compileToJSX({
fileContent,
filePath,
frontMatter,
options,
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},
),
);
} catch (error) {
return callback(error as Error);
}

const contentTitle = extractContentTitleData(result.data);
Expand Down
9 changes: 6 additions & 3 deletions packages/docusaurus-mdx-loader/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,13 @@ import type {ProcessorOptions} from '@mdx-js/mdx';
// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391
type Pluggable = any; // TODO fix this asap

type SimpleProcessorResult = {content: string; data: {[key: string]: unknown}};
export type SimpleProcessorResult = {
content: string;
data: {[key: string]: unknown};
};

// TODO alt interface because impossible to import type Processor (ESM + TS :/)
type SimpleProcessor = {
export type SimpleProcessor = {
process: ({
content,
filePath,
Expand Down Expand Up @@ -260,7 +263,7 @@ async function createProcessorsCacheEntry({
return processors;
}

export async function createProcessorCached({
export async function getProcessor({
filePath,
mdxFrontMatter,
options,
Expand Down
152 changes: 152 additions & 0 deletions packages/docusaurus-mdx-loader/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* 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 {escapePath, type WebpackCompilerName} from '@docusaurus/utils';
import {getProcessor, type SimpleProcessorResult} from './processor';
import {validateMDXFrontMatter} from './frontMatter';
import preprocessor from './preprocessor';
import type {Options} from './loader';

/**
* 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
*/
export async function readMetadataPath(metadataPath: string): Promise<string> {
try {
return await fs.readFile(metadataPath, 'utf8');
} catch (error) {
throw new Error(
logger.interpolate`MDX loader can't read MDX metadata file path=${metadataPath}. Maybe the isMDXPartial option function was not provided?`,
{cause: error as Error},
);
}
}

/**
* 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")}`
*/
export function createAssetsExportCode({
assets,
inlineMarkdownAssetImageFileLoader,
}: {
assets: unknown;
inlineMarkdownAssetImageFileLoader: string;
}): 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}`;
}

/**
* data.contentTitle is set by the remark contentTitle plugin
*/
export function extractContentTitleData(data: {
[key: string]: unknown;
}): string | undefined {
return data.contentTitle as string | undefined;
}

export async function compileToJSX({
filePath,
fileContent,
frontMatter,
options,
compilerName,
}: {
filePath: string;
fileContent: string;
frontMatter: Record<string, unknown>;
options: Options;
compilerName: WebpackCompilerName;
}): Promise<SimpleProcessorResult> {
const preprocessedFileContent = preprocessor({
fileContent,
filePath,
admonitions: options.admonitions,
markdownConfig: options.markdownConfig,
});

const mdxFrontMatter = validateMDXFrontMatter(frontMatter.mdx);

const processor = await getProcessor({
filePath,
options,
mdxFrontMatter,
});

try {
return await processor.process({
content: preprocessedFileContent,
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}`;

throw 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},
);
}
}

0 comments on commit 1b49ebf

Please sign in to comment.