From 3dacdf33c9eeec14a1e7ca05f9dc83a69d8fd506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Thu, 4 Sep 2025 15:29:26 +0200 Subject: [PATCH] feat(mdx): resolve `@site/*` markdown links, fix resolution priority bugs (#11397) --- .../src/__tests__/markdownLinks.test.ts | 56 +++++++++++++++++++ .../docusaurus-utils/src/markdownLinks.ts | 50 ++++++++++++----- .../tests/links/resolution/index.mdx | 19 +++++++ .../markdown-features-links.mdx | 14 +++-- 4 files changed, 120 insertions(+), 19 deletions(-) create mode 100644 website/_dogfooding/_docs tests/tests/links/resolution/index.mdx diff --git a/packages/docusaurus-utils/src/__tests__/markdownLinks.test.ts b/packages/docusaurus-utils/src/__tests__/markdownLinks.test.ts index 9a3e0ddfaa..0cf953b5b8 100644 --- a/packages/docusaurus-utils/src/__tests__/markdownLinks.test.ts +++ b/packages/docusaurus-utils/src/__tests__/markdownLinks.test.ts @@ -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', + ); + }); }); diff --git a/packages/docusaurus-utils/src/markdownLinks.ts b/packages/docusaurus-utils/src/markdownLinks.ts index a045ab4cd5..5a7ad51495 100644 --- a/packages/docusaurus-utils/src/markdownLinks.ts +++ b/packages/docusaurus-utils/src/markdownLinks.ts @@ -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 diff --git a/website/_dogfooding/_docs tests/tests/links/resolution/index.mdx b/website/_dogfooding/_docs tests/tests/links/resolution/index.mdx new file mode 100644 index 0000000000..7b842a7c02 --- /dev/null +++ b/website/_dogfooding/_docs tests/tests/links/resolution/index.mdx @@ -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) diff --git a/website/docs/guides/markdown-features/markdown-features-links.mdx b/website/docs/guides/markdown-features/markdown-features-links.mdx index 47a37100ca..5bd0127855 100644 --- a/website/docs/guides/markdown-features/markdown-features-links.mdx +++ b/website/docs/guides/markdown-features/markdown-features-links.mdx @@ -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: