feat(mdx): resolve `@site/*` markdown links, fix resolution priority bugs (#11397)
Some checks failed
Argos CI / take-screenshots (push) Has been cancelled
Build Hash Router / Build Hash Router (push) Has been cancelled
Canary Release / Publish Canary (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Continuous Releases / Continuous Releases (push) Has been cancelled
E2E Tests / E2E — Yarn v1 (18.0) (push) Has been cancelled
E2E Tests / E2E — Yarn v1 (20) (push) Has been cancelled
E2E Tests / E2E — Yarn v1 (22) (push) Has been cancelled
E2E Tests / E2E — Yarn v1 (24) (push) Has been cancelled
E2E Tests / E2E — Yarn v1 Windows (push) Has been cancelled
E2E Tests / E2E — Yarn Berry (node-modules, -s) (push) Has been cancelled
E2E Tests / E2E — Yarn Berry (node-modules, -st) (push) Has been cancelled
E2E Tests / E2E — Yarn Berry (pnp, -s) (push) Has been cancelled
E2E Tests / E2E — Yarn Berry (pnp, -st) (push) Has been cancelled
E2E Tests / E2E — npm (push) Has been cancelled
E2E Tests / E2E — pnpm (push) Has been cancelled

This commit is contained in:
Sébastien Lorber 2025-09-04 15:29:26 +02:00 committed by GitHub
parent 72c48b5806
commit 3dacdf33c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 120 additions and 19 deletions

View File

@ -75,4 +75,60 @@ describe('resolveMarkdownLinkPathname', () => {
test('api/classes/divine_uri.URI.md', '/docs/api/classes/uri');
test('another.md', '/docs/another');
});
it('uses relative file paths in priority - Fix #11099', () => {
// Unit test to fix bug https://github.com/facebook/docusaurus/issues/11099
const context: Context = {
siteDir: '.',
sourceFilePath: 'docs/test/file.mdx',
contentPaths: {
contentPath: 'docs',
contentPathLocalized: 'i18n/docs-localized',
},
sourceToPermalink: new Map(
Object.entries({
'@site/docs/file.mdx': '/docs/file',
'@site/docs/test/file.mdx': '/docs/test/file',
}),
),
};
function test(linkPathname: string, expectedOutput: string) {
const output = resolveMarkdownLinkPathname(linkPathname, context);
expect(output).toEqual(expectedOutput);
}
test('./file.mdx', '/docs/test/file');
test('file.mdx', '/docs/test/file');
});
it('can resolve @site/ links', () => {
const context: Context = {
siteDir: '.',
sourceFilePath: 'docs/test/file.mdx',
contentPaths: {
contentPath: 'docs',
contentPathLocalized: 'i18n/docs-localized',
},
sourceToPermalink: new Map(
Object.entries({
'@site/docs/file.mdx': '/docs/file',
'@site/docs/dir with spaces/file.mdx': '/docs/dir-with-spaces/file',
}),
),
};
function test(linkPathname: string, expectedOutput: string) {
const output = resolveMarkdownLinkPathname(linkPathname, context);
expect(output).toEqual(expectedOutput);
}
test('@site/docs/file.mdx', '/docs/file');
test('@site/docs/dir with spaces/file.mdx', '/docs/dir-with-spaces/file');
test(
'@site/docs/dir%20with%20spaces/file.mdx',
'/docs/dir-with-spaces/file',
);
});
});

View File

@ -47,9 +47,6 @@ export type SourceToPermalink = Map<
string // Permalink: "/docs/content"
>;
// Note this is historical logic extracted during a 2024 refactor
// The algo has been kept exactly as before for retro compatibility
// See also https://github.com/facebook/docusaurus/pull/10168
export function resolveMarkdownLinkPathname(
linkPathname: string,
context: {
@ -60,20 +57,45 @@ export function resolveMarkdownLinkPathname(
},
): string | null {
const {sourceFilePath, sourceToPermalink, contentPaths, siteDir} = context;
const sourceDirsToTry: string[] = [];
// ./file.md and ../file.md are always relative to the current file
if (!linkPathname.startsWith('./') && !linkPathname.startsWith('../')) {
sourceDirsToTry.push(...getContentPathList(contentPaths), siteDir);
}
// /file.md is never relative to the source file path
if (!linkPathname.startsWith('/')) {
sourceDirsToTry.push(path.dirname(sourceFilePath));
// If the link is already @site aliased, there's no need to resolve it
if (linkPathname.startsWith('@site/')) {
return sourceToPermalink.get(decodeURIComponent(linkPathname)) ?? null;
}
const aliasedSourceMatch = sourceDirsToTry
// Get the dirs to "look into", ordered by priority, when resolving the link
function getSourceDirsToTry() {
// /file.md is always resolved from
// - the plugin content paths,
// - then siteDir
if (linkPathname.startsWith('/')) {
return [...getContentPathList(contentPaths), siteDir];
}
// ./file.md and ../file.md are always resolved from
// - the current file dir
else if (linkPathname.startsWith('./') || linkPathname.startsWith('../')) {
return [path.dirname(sourceFilePath)];
}
// file.md is resolved from
// - the current file dir,
// - then from the plugin content paths,
// - then siteDir
else {
return [
path.dirname(sourceFilePath),
...getContentPathList(contentPaths),
siteDir,
];
}
}
const sourcesToTry = getSourceDirsToTry()
.map((sourceDir) => path.join(sourceDir, decodeURIComponent(linkPathname)))
.map((source) => aliasedSitePath(source, siteDir))
.find((source) => sourceToPermalink.has(source));
.map((source) => aliasedSitePath(source, siteDir));
const aliasedSourceMatch = sourcesToTry.find((source) =>
sourceToPermalink.has(source),
);
return aliasedSourceMatch
? sourceToPermalink.get(aliasedSourceMatch) ?? null

View File

@ -0,0 +1,19 @@
# Link resolution tests
## Test for issue [#11099](https://github.com/facebook/docusaurus/issues/11099)
These links should target the root `index.mdx` file:
[`/index.mdx`](/index.mdx)
[`@site/_dogfooding/_docs tests/index.mdx`](@site/_dogfooding/_docs%20tests/index.mdx)
These links should target the current `index.mdx` file:
[`/tests/links/index.mdx`](/tests/links/resolution/index.mdx)
[`@site/_dogfooding/_docs tests/tests/links/resolution/index.mdx`](@site/_dogfooding/_docs%20tests/tests/links/resolution/index.mdx)
[`index.mdx`](index.mdx)
[`./index.mdx`](./index.mdx)

View File

@ -29,13 +29,17 @@ Reference to another [document in a subfolder](subfolder/doc3.mdx).
Relative file paths are resolved against the current file's directory. Absolute file paths, on the other hand, are resolved relative to the **content root**, usually `docs/`, `blog/`, or [localized ones](../../i18n/i18n-tutorial.mdx) like `i18n/zh-Hans/plugin-content-docs/current`.
Absolute file paths can also be relative to the site directory. However, beware that links that begin with `/docs/` or `/blog/` are **not portable** as you would need to manually update them if you create new doc versions or localize them.
Here are some examples of file path links and how they get resolved, assuming the current file is `website/docs/category/source.mdx`:
```md
You can write [links](/otherFolder/doc4.mdx) relative to the content root (`/docs/`).
- `[link](./target.mdx)` is resolved from the current file's directory `website/docs/category`.
- `[link](../target.mdx)` is resolved from the parent file's directory `website/docs`.
- `[link](/target.mdx)` is resolved from the docs content root `website/docs`, using in priority the localized docs.
- `[link](target.mdx)` is resolved from the current directory `website/docs/category`, then from the docs content roots, then from the site root.
You can also write [links](/docs/otherFolder/doc4.mdx) relative to the site directory, but it's not recommended.
```
Absolute file paths can also be relative to the site directory. However, beware that links that begin with `/docs/`, `/blog/` or `@site/` are **not portable** as you would need to manually update them if you create new doc versions or localize them:
- `[link](/docs/target.mdx)` is resolved from the site root `website` (⚠️ less portable).
- `[link](@site/docs/target.mdx)` is relative to the site root `website` (⚠️ less portable).
Using relative _file_ paths (with `.md` extensions) instead of relative _URL_ links provides the following benefits: