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

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

Merged
merged 11 commits into from
May 24, 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
2 changes: 2 additions & 0 deletions packages/docusaurus-mdx-loader/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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';
Expand Down Expand Up @@ -45,6 +46,7 @@ export type Options = Partial<MDXOptions> & {
frontMatter: {[key: string]: unknown};
metadata: {[key: string]: unknown};
}) => {[key: string]: unknown};
resolveMarkdownLink?: ResolveMarkdownLink;
};

/**
Expand Down
8 changes: 8 additions & 0 deletions packages/docusaurus-mdx-loader/src/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import contentTitle from './remark/contentTitle';
import toc from './remark/toc';
import transformImage from './remark/transformImage';
import transformLinks from './remark/transformLinks';
import resolveMarkdownLinks from './remark/resolveMarkdownLinks';
import details from './remark/details';
import head from './remark/head';
import mermaid from './remark/mermaid';
Expand Down Expand Up @@ -120,6 +121,13 @@ async function createProcessorFactory() {
siteDir: options.siteDir,
},
],
// TODO merge this with transformLinks?
options.resolveMarkdownLink
? [
resolveMarkdownLinks,
{resolveMarkdownLink: options.resolveMarkdownLink},
]
: undefined,
[
transformLinks,
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* 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 plugin from '..';
import type {PluginOptions} from '../index';

async function process(content: string) {
const {remark} = await import('remark');

const options: PluginOptions = {
resolveMarkdownLink: ({linkPathname}) => `/RESOLVED---${linkPathname}`,
};

const result = await remark().use(plugin, options).process(content);

return result.value;
}

describe('resolveMarkdownLinks remark plugin', () => {
it('resolves Markdown and MDX links', async () => {
/* language=markdown */
const content = `[link1](link1.mdx)

[link2](../myLink2.md) [link3](myLink3.md)

[link4](../myLink4.mdx?qs#hash) [link5](./../my/great/link5.md?#)

[link6](../myLink6.mdx?qs#hash)

[link7](<link with spaces 7.md?qs#hash>)

<b>[link8](/link8.md)</b>

[**link** \`9\`](/link9.md)
`;

const result = await process(content);

expect(result).toMatchInlineSnapshot(`
"[link1](/RESOLVED---link1.mdx)

[link2](/RESOLVED---../myLink2.md) [link3](/RESOLVED---myLink3.md)

[link4](/RESOLVED---../myLink4.mdx?qs#hash) [link5](/RESOLVED---./../my/great/link5.md?#)

[link6](/RESOLVED---../myLink6.mdx?qs#hash)

[link7](</RESOLVED---link with spaces 7.md?qs#hash>)

<b>[link8](/RESOLVED---/link8.md)</b>

[**link** \`9\`](/RESOLVED---/link9.md)
"
`);
});

it('skips non-Markdown links', async () => {
/* language=markdown */
const content = `[link1](./myLink1.m)

[link2](../myLink2mdx)

[link3](https://github.com/facebook/docusaurus/blob/main/README.md)

[link4](ftp:///README.mdx)

[link5](../link5.js)

[link6](../link6.jsx)

[link7](../link7.tsx)

<!--
[link8](link8.mdx)
-->

\`\`\`md
[link9](link9.md)
\`\`\`
`;

const result = await process(content);

expect(result).toMatchInlineSnapshot(`
"[link1](./myLink1.m)

[link2](../myLink2mdx)

[link3](https://github.com/facebook/docusaurus/blob/main/README.md)

[link4](ftp:///README.mdx)

[link5](../link5.js)

[link6](../link6.jsx)

[link7](../link7.tsx)

<!--
[link8](link8.mdx)
-->

\`\`\`md
[link9](link9.md)
\`\`\`
"
`);
});

it('keeps regular Markdown unmodified', async () => {
/* language=markdown */
const content = `# Title

Simple link

\`\`\`js
this is a code block
\`\`\`
`;

const result = await process(content);

expect(result).toEqual(content);
});

it('supports link references', async () => {
/* language=markdown */
const content = `Testing some link refs:

* [link-ref1]
* [link-ref2]
* [link-ref3]

[link-ref1]: target.mdx
[link-ref2]: ./target.mdx
[link-ref3]: ../links/target.mdx?qs#target-heading
`;

const result = await process(content);

expect(result).toMatchInlineSnapshot(`
"Testing some link refs:

* [link-ref1]
* [link-ref2]
* [link-ref3]

[link-ref1]: /RESOLVED---target.mdx

[link-ref2]: /RESOLVED---./target.mdx

[link-ref3]: /RESOLVED---../links/target.mdx?qs#target-heading
"
`);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* 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 {
parseLocalURLPath,
serializeURLPath,
type URLPath,
} from '@docusaurus/utils';

// @ts-expect-error: TODO see https://github.com/microsoft/TypeScript/issues/49721
import type {Transformer} from 'unified';
import type {Definition, Link} from 'mdast';

type ResolveMarkdownLinkParams = {
/**
* Absolute path to the source file containing this Markdown link.
*/
sourceFilePath: string;
/**
* The Markdown link pathname to resolve, as found in the source file.
* If the link is "./myFile.mdx?qs#hash", this will be "./myFile.mdx"
*/
linkPathname: string;
};

export type ResolveMarkdownLink = (
params: ResolveMarkdownLinkParams,
) => string | null;

export interface PluginOptions {
resolveMarkdownLink: ResolveMarkdownLink;
}

// TODO as of April 2023, no way to import/re-export this ESM type easily :/
// TODO upgrade to TS 5.3
// See https://github.com/microsoft/TypeScript/issues/49721#issuecomment-1517839391
// import type {Plugin} from 'unified';
type Plugin = any; // TODO fix this asap

const HAS_MARKDOWN_EXTENSION = /\.mdx?$/i;

function parseMarkdownLinkURLPath(link: string): URLPath | null {
const urlPath = parseLocalURLPath(link);

// If it's not local, we don't resolve it even if it's a Markdown file
// Example, we don't resolve https://github.com/project/README.md
if (!urlPath) {
return null;
}

// Ignore links without a Markdown file extension (ignoring qs/hash)
if (!HAS_MARKDOWN_EXTENSION.test(urlPath.pathname)) {
return null;
}
return urlPath;
}

/**
* A remark plugin to extract the h1 heading found in Markdown files
* This is exposed as "data.contentTitle" to the processed vfile
* Also gives the ability to strip that content title (used for the blog plugin)
*/
const plugin: Plugin = function plugin(options: PluginOptions): Transformer {
const {resolveMarkdownLink} = options;
return async (root, file) => {
const {visit} = await import('unist-util-visit');

visit(root, ['link', 'definition'], (node) => {
const link = node as unknown as Link | Definition;
const linkURLPath = parseMarkdownLinkURLPath(link.url);
if (!linkURLPath) {
return;
}

const permalink = resolveMarkdownLink({
sourceFilePath: file.path,
linkPathname: linkURLPath.pathname,
});

if (permalink) {
// This reapplies the link ?qs#hash part to the resolved pathname
const resolvedUrl = serializeURLPath({
...linkURLPath,
pathname: permalink,
});
link.url = resolvedUrl;
}
});
};
};

export default plugin;
Original file line number Diff line number Diff line change
@@ -1,28 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`linkify reports broken markdown links 1`] = `
"---
title: This post links to another one!
---

[Good link 1](/blog/2018/12/14/Happy-First-Birthday-Slash)

[Good link 2](/blog/2018/12/14/Happy-First-Birthday-Slash)

[Bad link 1](postNotExist1.md)

[Bad link 1](./postNotExist2.mdx)
"
`;

exports[`linkify transforms to correct link 1`] = `
"---
title: This post links to another one!
---

[Linked post](/blog/2018/12/14/Happy-First-Birthday-Slash)"
`;

exports[`paginateBlogPosts generates a single page 1`] = `
[
{
Expand Down
Loading