Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(mdx-loader): refactor mdx-loader, expose loader creation utils #10450

Merged
merged 4 commits into from
Aug 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions packages/docusaurus-mdx-loader/src/createMDXLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* 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 {createProcessors} from './processor';
import type {Options} from './loader';
import type {RuleSetRule, RuleSetUseItem} from 'webpack';

async function enhancedOptions(options: Options): Promise<Options> {
// Because Jest doesn't like ESM / createProcessors()
if (process.env.N0DE_ENV === 'test' || process.env.JEST_WORKER_ID) {
return options;
}

// We create the processor earlier here, to avoid the lazy processor creating
// Lazy creation messes-up with Rsdoctor ability to measure mdx-loader perf
const newOptions: Options = options.processors
? options
: {...options, processors: await createProcessors({options})};

return newOptions;
}

export async function createMDXLoaderItem(
options: Options,
): Promise<RuleSetUseItem> {
return {
loader: require.resolve('@docusaurus/mdx-loader'),
options: await enhancedOptions(options),
};
}

export async function createMDXLoaderRule({
include,
options,
}: {
include: RuleSetRule['include'];
options: Options;
}): Promise<RuleSetRule> {
return {
test: /\.mdx?$/i,
include,
use: [await createMDXLoaderItem(options)],
};
}
2 changes: 2 additions & 0 deletions packages/docusaurus-mdx-loader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {mdxLoader} from './loader';

import type {TOCItem as TOCItemImported} from './remark/toc/types';

export {createMDXLoaderRule, createMDXLoaderItem} from './createMDXLoader';

export default mdxLoader;

export type TOCItem = TOCItemImported;
Expand Down
156 changes: 20 additions & 136 deletions packages/docusaurus-mdx-loader/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,25 @@
* 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 {
compileToJSX,
createAssetsExportCode,
extractContentTitleData,
readMetadataPath,
} from './utils';
import type {
SimpleProcessors,
MDXOptions,
SimpleProcessorResult,
} from './processor';
import type {ResolveMarkdownLink} from './remark/resolveMarkdownLinks';
import type {MDXOptions} from './processor';

import type {MarkdownConfig} from '@docusaurus/types';
import type {LoaderContext} from 'webpack';
Expand All @@ -43,97 +48,10 @@ export type Options = Partial<MDXOptions> & {
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;
}
// Will usually be created by "createMDXLoaderItem"
processors?: SimpleProcessors;
};

export async function mdxLoader(
this: LoaderContext<Options>,
Expand All @@ -144,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
46 changes: 27 additions & 19 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 @@ -219,28 +222,22 @@ export async function createProcessorUncached(parameters: {
}

// We use different compilers depending on the file type (md vs mdx)
type ProcessorsCacheEntry = {
export type SimpleProcessors = {
mdProcessor: SimpleProcessor;
mdxProcessor: SimpleProcessor;
};

// Compilers are cached so that Remark/Rehype plugins can run
// expensive code during initialization
const ProcessorsCache = new Map<string | Options, ProcessorsCacheEntry>();
const ProcessorsCache = new Map<string | Options, SimpleProcessors>();

async function createProcessorsCacheEntry({
export async function createProcessors({
options,
}: {
options: Options;
}): Promise<ProcessorsCacheEntry> {
}): Promise<SimpleProcessors> {
const {createProcessorSync} = await createProcessorFactory();

const compilers = ProcessorsCache.get(options);
if (compilers) {
return compilers;
}

const compilerCacheEntry: ProcessorsCacheEntry = {
return {
mdProcessor: createProcessorSync({
options,
format: 'md',
Expand All @@ -250,13 +247,23 @@ async function createProcessorsCacheEntry({
format: 'mdx',
}),
};
}

ProcessorsCache.set(options, compilerCacheEntry);

return compilerCacheEntry;
async function createProcessorsCacheEntry({
options,
}: {
options: Options;
}): Promise<SimpleProcessors> {
const compilers = ProcessorsCache.get(options);
if (compilers) {
return compilers;
}
const processors = await createProcessors({options});
ProcessorsCache.set(options, processors);
return processors;
}

export async function createProcessorCached({
export async function getProcessor({
filePath,
mdxFrontMatter,
options,
Expand All @@ -265,13 +272,14 @@ export async function createProcessorCached({
mdxFrontMatter: MDXFrontMatter;
options: Options;
}): Promise<SimpleProcessor> {
const compilers = await createProcessorsCacheEntry({options});
const processors =
options.processors ?? (await createProcessorsCacheEntry({options}));

const format = getFormat({
filePath,
frontMatterFormat: mdxFrontMatter.format,
markdownConfigFormat: options.markdownConfig.format,
});

return format === 'md' ? compilers.mdProcessor : compilers.mdxProcessor;
return format === 'md' ? processors.mdProcessor : processors.mdxProcessor;
}
Loading
Loading