fix(mdx-loader): resolve Markdown/MDX links with Remark instead of RegExp (#10168)

This commit is contained in:
Sébastien Lorber 2024-05-24 19:03:23 +02:00 committed by GitHub
parent aab332c2ae
commit e34614963e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 902 additions and 1620 deletions

View file

@ -17,6 +17,7 @@ import {
addTrailingPathSeparator,
createAbsoluteFilePathMatcher,
createSlugger,
resolveMarkdownLinkPathname,
DEFAULT_PLUGIN_ID,
} from '@docusaurus/utils';
import {loadSidebars, resolveSidebarPathOption} from './sidebars';
@ -28,7 +29,11 @@ import {
type DocEnv,
createDocsByIdIndex,
} from './docs';
import {readVersionsMetadata, toFullVersion} from './versions';
import {
getVersionFromSourceFilePath,
readVersionsMetadata,
toFullVersion,
} from './versions';
import {cliDocsVersionCommand} from './cli';
import {VERSIONS_JSON_FILE} from './constants';
import {toGlobalDataVersion} from './globalData';
@ -38,6 +43,7 @@ import {
} from './translations';
import {createAllRoutes} from './routes';
import {createSidebarsUtils} from './sidebars/utils';
import type {Options as MDXLoaderOptions} from '@docusaurus/mdx-loader';
import type {
PluginOptions,
@ -48,13 +54,8 @@ import type {
LoadedVersion,
} from '@docusaurus/plugin-content-docs';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {
SourceToPermalink,
DocFile,
DocsMarkdownOption,
FullVersion,
} from './types';
import type {RuleSetRule} from 'webpack';
import type {SourceToPermalink, DocFile, FullVersion} from './types';
import type {RuleSetUseItem} from 'webpack';
export default async function pluginContentDocs(
context: LoadContext,
@ -251,72 +252,71 @@ export default async function pluginContentDocs(
beforeDefaultRemarkPlugins,
} = options;
const contentDirs = versionsMetadata
.flatMap(getContentPathList)
// Trailing slash is important, see https://github.com/facebook/docusaurus/pull/3970
.map(addTrailingPathSeparator);
// TODO this does not re-run when content gets updated in dev!
// it's probably better to restore a mutable cache in the plugin
function getSourceToPermalink(): SourceToPermalink {
const allDocs = content.loadedVersions.flatMap((v) => v.docs);
return Object.fromEntries(
allDocs.map(({source, permalink}) => [source, permalink]),
);
}
const sourceToPermalink = getSourceToPermalink();
const docsMarkdownOptions: DocsMarkdownOption = {
siteDir,
sourceToPermalink: getSourceToPermalink(),
versionsMetadata,
onBrokenMarkdownLink: (brokenMarkdownLink) => {
logger.report(
siteConfig.onBrokenMarkdownLinks,
)`Docs markdown link couldn't be resolved: (url=${brokenMarkdownLink.link}) in path=${brokenMarkdownLink.filePath} for version number=${brokenMarkdownLink.contentPaths.versionName}`;
},
};
function createMDXLoader(): RuleSetUseItem {
const loaderOptions: MDXLoaderOptions = {
admonitions: options.admonitions,
remarkPlugins,
rehypePlugins,
beforeDefaultRehypePlugins,
beforeDefaultRemarkPlugins,
staticDirs: siteConfig.staticDirectories.map((dir) =>
path.resolve(siteDir, dir),
),
siteDir,
isMDXPartial: createAbsoluteFilePathMatcher(
options.exclude,
contentDirs,
),
metadataPath: (mdxPath: string) => {
// Note that metadataPath must be the same/in-sync as
// the path from createData for each MDX.
const aliasedPath = aliasedSitePath(mdxPath, siteDir);
return path.join(dataDir, `${docuHash(aliasedPath)}.json`);
},
// Assets allow to convert some relative images paths to
// require(...) calls
createAssets: ({frontMatter}: {frontMatter: DocFrontMatter}) => ({
image: frontMatter.image,
}),
markdownConfig: siteConfig.markdown,
resolveMarkdownLink: ({linkPathname, sourceFilePath}) => {
const version = getVersionFromSourceFilePath(
sourceFilePath,
content.loadedVersions,
);
const permalink = resolveMarkdownLinkPathname(linkPathname, {
sourceFilePath,
sourceToPermalink,
siteDir,
contentPaths: version,
});
if (permalink === null) {
logger.report(
siteConfig.onBrokenMarkdownLinks,
)`Docs markdown link couldn't be resolved: (url=${linkPathname}) in source file path=${sourceFilePath} for version number=${version.versionName}`;
}
return permalink;
},
};
function createMDXLoaderRule(): RuleSetRule {
const contentDirs = versionsMetadata
.flatMap(getContentPathList)
// Trailing slash is important, see https://github.com/facebook/docusaurus/pull/3970
.map(addTrailingPathSeparator);
return {
test: /\.mdx?$/i,
include: contentDirs,
use: [
{
loader: require.resolve('@docusaurus/mdx-loader'),
options: {
admonitions: options.admonitions,
remarkPlugins,
rehypePlugins,
beforeDefaultRehypePlugins,
beforeDefaultRemarkPlugins,
staticDirs: siteConfig.staticDirectories.map((dir) =>
path.resolve(siteDir, dir),
),
siteDir,
isMDXPartial: createAbsoluteFilePathMatcher(
options.exclude,
contentDirs,
),
metadataPath: (mdxPath: string) => {
// Note that metadataPath must be the same/in-sync as
// the path from createData for each MDX.
const aliasedPath = aliasedSitePath(mdxPath, siteDir);
return path.join(dataDir, `${docuHash(aliasedPath)}.json`);
},
// Assets allow to convert some relative images paths to
// require(...) calls
createAssets: ({
frontMatter,
}: {
frontMatter: DocFrontMatter;
}) => ({
image: frontMatter.image,
}),
markdownConfig: siteConfig.markdown,
},
},
{
loader: path.resolve(__dirname, './markdown/index.js'),
options: docsMarkdownOptions,
},
].filter(Boolean),
loader: require.resolve('@docusaurus/mdx-loader'),
options: loaderOptions,
};
}
@ -333,7 +333,13 @@ export default async function pluginContentDocs(
},
},
module: {
rules: [createMDXLoaderRule()],
rules: [
{
test: /\.mdx?$/i,
include: contentDirs,
use: [createMDXLoader()],
},
],
},
};
},

View file

@ -1,13 +0,0 @@
# Don't transform any link here
![image1](assets/image1.png)
# Don't replace inside fenced codeblock
```md
![doc4](doc4.md)
```
### Non-existing Docs
- [hahaha](hahaha.md)

View file

@ -1,12 +0,0 @@
### Existing Docs
- [doc1](doc1.md)
- [doc2](./doc2.md)
- [doc3](subdir/doc3.md)
## Repeating Docs
- [doc1](doc1.md)
- [doc2](./doc2.md)
- [doc-localized](/doc-localized.md)

View file

@ -1,19 +0,0 @@
### Existing Docs
- [doc1][doc1]
- [doc2][doc2]
## Repeating Docs
- [doc1][doc1]
- [doc2][doc2]
## Do not replace this
```md
![image1][image1]
```
[doc1]: doc1.md
[doc2]: ./doc2.md
[image1]: assets/image1.png

View file

@ -1,6 +0,0 @@
### Not Existing Docs
- [docNotExist1](docNotExist1.md)
- [docNotExist2](./docNotExist2.mdx)
- [docNotExist3](../docNotExist3.mdx)
- [docNotExist4](./subdir/docNotExist4.md)

View file

@ -1,3 +0,0 @@
### Relative linking
- [doc1](../doc2.md)

View file

@ -1 +0,0 @@
[link](../docs/doc1.md)

View file

@ -1,7 +0,0 @@
### Existing Docs
- [doc1](subdir/doc1.md)
### With hash
- [doc2](doc2.md#existing-docs)

View file

@ -1,3 +0,0 @@
### Relative linking
- [doc1](../doc2.md)

View file

@ -1,82 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`linkify transforms absolute links in versioned docs 1`] = `
"### Existing Docs
- [doc1](/docs/1.0.0/subdir/doc1)
### With hash
- [doc2](/docs/1.0.0/doc2#existing-docs)
"
`;
exports[`linkify transforms nothing with no links 1`] = `
"# Don't transform any link here
![image1](assets/image1.png)
# Don't replace inside fenced codeblock
\`\`\`md
![doc4](doc4.md)
\`\`\`
### Non-existing Docs
- [hahaha](hahaha.md)
"
`;
exports[`linkify transforms reference links 1`] = `
"### Existing Docs
- [doc1][doc1]
- [doc2][doc2]
## Repeating Docs
- [doc1][doc1]
- [doc2][doc2]
## Do not replace this
\`\`\`md
![image1][image1]
\`\`\`
[doc1]: /docs/doc1
[doc2]: /docs/doc2
[image1]: assets/image1.png
"
`;
exports[`linkify transforms relative links 1`] = `
"### Relative linking
- [doc1](/docs/doc2)
"
`;
exports[`linkify transforms relative links in versioned docs 1`] = `
"### Relative linking
- [doc1](/docs/1.0.0/doc2)
"
`;
exports[`linkify transforms to correct links 1`] = `
"### Existing Docs
- [doc1](/docs/doc1)
- [doc2](/docs/doc2)
- [doc3](/docs/subdir/doc3)
## Repeating Docs
- [doc1](/docs/doc1)
- [doc2](/docs/doc2)
- [doc-localized](/fr/doc-localized)
"
`;

View file

@ -1,210 +0,0 @@
/**
* 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 {jest} from '@jest/globals';
import fs from 'fs-extra';
import path from 'path';
import {linkify} from '../linkify';
import {VERSIONED_DOCS_DIR, CURRENT_VERSION_NAME} from '../../constants';
import type {
DocsMarkdownOption,
SourceToPermalink,
DocBrokenMarkdownLink,
} from '../../types';
import type {VersionMetadata} from '@docusaurus/plugin-content-docs';
function createFakeVersion({
versionName,
contentPath,
contentPathLocalized,
}: {
versionName: string;
contentPath: string;
contentPathLocalized: string;
}): VersionMetadata {
return {
versionName,
label: 'Any',
path: 'any',
badge: true,
banner: null,
tagsPath: '/tags/',
className: '',
contentPath,
contentPathLocalized,
sidebarFilePath: 'any',
routePriority: undefined,
isLast: false,
};
}
const siteDir = path.join(__dirname, '__fixtures__');
const versionCurrent = createFakeVersion({
versionName: CURRENT_VERSION_NAME,
contentPath: path.join(siteDir, 'docs'),
contentPathLocalized: path.join(
siteDir,
'i18n',
'fr',
'docusaurus-plugin-content-docs',
CURRENT_VERSION_NAME,
),
});
const version100 = createFakeVersion({
versionName: '1.0.0',
contentPath: path.join(siteDir, VERSIONED_DOCS_DIR, 'version-1.0.0'),
contentPathLocalized: path.join(
siteDir,
'i18n',
'fr',
'docusaurus-plugin-content-docs',
'version-1.0.0',
),
});
const sourceToPermalink: SourceToPermalink = {
'@site/docs/doc1.md': '/docs/doc1',
'@site/docs/doc2.md': '/docs/doc2',
'@site/docs/subdir/doc3.md': '/docs/subdir/doc3',
'@site/docs/doc4.md': '/docs/doc4',
'@site/versioned_docs/version-1.0.0/doc2.md': '/docs/1.0.0/doc2',
'@site/versioned_docs/version-1.0.0/subdir/doc1.md':
'/docs/1.0.0/subdir/doc1',
'@site/i18n/fr/docusaurus-plugin-content-docs/current/doc-localized.md':
'/fr/doc-localized',
'@site/docs/doc-localized.md': '/doc-localized',
};
function createMarkdownOptions(
options?: Partial<DocsMarkdownOption>,
): DocsMarkdownOption {
return {
sourceToPermalink,
onBrokenMarkdownLink: () => {},
versionsMetadata: [versionCurrent, version100],
siteDir,
...options,
};
}
const transform = async (
filepath: string,
options?: Partial<DocsMarkdownOption>,
) => {
const markdownOptions = createMarkdownOptions(options);
const content = await fs.readFile(filepath, 'utf-8');
const transformedContent = linkify(content, filepath, markdownOptions);
return [content, transformedContent];
};
describe('linkify', () => {
it('transforms nothing with no links', async () => {
const doc1 = path.join(versionCurrent.contentPath, 'doc1.md');
const [content, transformedContent] = await transform(doc1);
expect(transformedContent).toMatchSnapshot();
expect(content).toEqual(transformedContent);
});
it('transforms to correct links', async () => {
const doc2 = path.join(versionCurrent.contentPath, 'doc2.md');
const [content, transformedContent] = await transform(doc2);
expect(transformedContent).toMatchSnapshot();
expect(transformedContent).toContain('](/docs/doc1');
expect(transformedContent).toContain('](/docs/doc2');
expect(transformedContent).toContain('](/docs/subdir/doc3');
expect(transformedContent).toContain('](/fr/doc-localized');
expect(transformedContent).not.toContain('](doc1.md)');
expect(transformedContent).not.toContain('](./doc2.md)');
expect(transformedContent).not.toContain('](subdir/doc3.md)');
expect(transformedContent).not.toContain('](/doc-localized');
expect(content).not.toEqual(transformedContent);
});
it('transforms relative links', async () => {
const doc3 = path.join(versionCurrent.contentPath, 'subdir', 'doc3.md');
const [content, transformedContent] = await transform(doc3);
expect(transformedContent).toMatchSnapshot();
expect(transformedContent).toContain('](/docs/doc2');
expect(transformedContent).not.toContain('](../doc2.md)');
expect(content).not.toEqual(transformedContent);
});
it('transforms reference links', async () => {
const doc4 = path.join(versionCurrent.contentPath, 'doc4.md');
const [content, transformedContent] = await transform(doc4);
expect(transformedContent).toMatchSnapshot();
expect(transformedContent).toContain('[doc1]: /docs/doc1');
expect(transformedContent).toContain('[doc2]: /docs/doc2');
expect(transformedContent).not.toContain('[doc1]: doc1.md');
expect(transformedContent).not.toContain('[doc2]: ./doc2.md');
expect(content).not.toEqual(transformedContent);
});
it('reports broken markdown links', async () => {
const doc5 = path.join(versionCurrent.contentPath, 'doc5.md');
const onBrokenMarkdownLink = jest.fn();
const [content, transformedContent] = await transform(doc5, {
onBrokenMarkdownLink,
});
expect(transformedContent).toEqual(content);
expect(onBrokenMarkdownLink).toHaveBeenCalledTimes(4);
expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(1, {
filePath: doc5,
link: 'docNotExist1.md',
contentPaths: versionCurrent,
} as DocBrokenMarkdownLink);
expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(2, {
filePath: doc5,
link: './docNotExist2.mdx',
contentPaths: versionCurrent,
} as DocBrokenMarkdownLink);
expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(3, {
filePath: doc5,
link: '../docNotExist3.mdx',
contentPaths: versionCurrent,
} as DocBrokenMarkdownLink);
expect(onBrokenMarkdownLink).toHaveBeenNthCalledWith(4, {
filePath: doc5,
link: './subdir/docNotExist4.md',
contentPaths: versionCurrent,
} as DocBrokenMarkdownLink);
});
it('transforms absolute links in versioned docs', async () => {
const doc2 = path.join(version100.contentPath, 'doc2.md');
const [content, transformedContent] = await transform(doc2);
expect(transformedContent).toMatchSnapshot();
expect(transformedContent).toContain('](/docs/1.0.0/subdir/doc1');
expect(transformedContent).toContain('](/docs/1.0.0/doc2#existing-docs');
expect(transformedContent).not.toContain('](subdir/doc1.md)');
expect(transformedContent).not.toContain('](doc2.md#existing-docs)');
expect(content).not.toEqual(transformedContent);
});
it('transforms relative links in versioned docs', async () => {
const doc1 = path.join(version100.contentPath, 'subdir', 'doc1.md');
const [content, transformedContent] = await transform(doc1);
expect(transformedContent).toMatchSnapshot();
expect(transformedContent).toContain('](/docs/1.0.0/doc2');
expect(transformedContent).not.toContain('](../doc2.md)');
expect(content).not.toEqual(transformedContent);
});
// See comment in linkify.ts
it('throws for file outside version', async () => {
const doc1 = path.join(__dirname, '__fixtures__/outside/doc1.md');
await expect(() =>
transform(doc1),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Unexpected error: Markdown file at "<PROJECT_ROOT>/packages/docusaurus-plugin-content-docs/src/markdown/__tests__/__fixtures__/outside/doc1.md" does not belong to any docs version!"`,
);
});
});

View file

@ -1,20 +0,0 @@
/**
* 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 {linkify} from './linkify';
import type {DocsMarkdownOption} from '../types';
import type {LoaderContext} from 'webpack';
export default function markdownLoader(
this: LoaderContext<DocsMarkdownOption>,
source: string,
): void {
const fileString = source;
const callback = this.async();
const options = this.getOptions();
return callback(null, linkify(fileString, this.resourcePath, options));
}

View file

@ -1,47 +0,0 @@
/**
* 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 {replaceMarkdownLinks, getContentPathList} from '@docusaurus/utils';
import type {DocsMarkdownOption} from '../types';
function getVersion(filePath: string, options: DocsMarkdownOption) {
const versionFound = options.versionsMetadata.find((version) =>
getContentPathList(version).some((docsDirPath) =>
filePath.startsWith(docsDirPath),
),
);
// At this point, this should never happen, because the MDX loaders' paths are
// literally using the version content paths; but if we allow sourcing content
// from outside the docs directory (through the `include` option, for example;
// is there a compelling use-case?), this would actually be testable
if (!versionFound) {
throw new Error(
`Unexpected error: Markdown file at "${filePath}" does not belong to any docs version!`,
);
}
return versionFound;
}
export function linkify(
fileString: string,
filePath: string,
options: DocsMarkdownOption,
): string {
const {siteDir, sourceToPermalink, onBrokenMarkdownLink} = options;
const {newContent, brokenMarkdownLinks} = replaceMarkdownLinks({
siteDir,
fileString,
filePath,
contentPaths: getVersion(filePath, options),
sourceToPermalink,
});
brokenMarkdownLinks.forEach((l) => onBrokenMarkdownLink(l));
return newContent;
}

View file

@ -5,9 +5,8 @@
* LICENSE file in the root directory of this source tree.
*/
import type {BrokenMarkdownLink, Tag} from '@docusaurus/utils';
import type {Tag} from '@docusaurus/utils';
import type {
VersionMetadata,
LoadedVersion,
CategoryGeneratedIndexMetadata,
} from '@docusaurus/plugin-content-docs';
@ -37,12 +36,3 @@ export type FullVersion = LoadedVersion & {
sidebarsUtils: SidebarsUtils;
categoryGeneratedIndices: CategoryGeneratedIndexMetadata[];
};
export type DocBrokenMarkdownLink = BrokenMarkdownLink<VersionMetadata>;
export type DocsMarkdownOption = {
versionsMetadata: VersionMetadata[];
siteDir: string;
sourceToPermalink: SourceToPermalink;
onBrokenMarkdownLink: (brokenMarkdownLink: DocBrokenMarkdownLink) => void;
};

View file

@ -6,7 +6,7 @@
*/
import path from 'path';
import {normalizeUrl, posixPath} from '@docusaurus/utils';
import {getContentPathList, normalizeUrl, posixPath} from '@docusaurus/utils';
import {CURRENT_VERSION_NAME} from '../constants';
import {validateVersionsOptions} from './validation';
import {
@ -268,3 +268,20 @@ export function toFullVersion(version: LoadedVersion): FullVersion {
}),
};
}
export function getVersionFromSourceFilePath(
filePath: string,
versionsMetadata: VersionMetadata[],
): VersionMetadata {
const versionFound = versionsMetadata.find((version) =>
getContentPathList(version).some((docsDirPath) =>
filePath.startsWith(docsDirPath),
),
);
if (!versionFound) {
throw new Error(
`Unexpected error: file at "${filePath}" does not belong to any docs version!`,
);
}
return versionFound;
}