Skip to content
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
56 changes: 56 additions & 0 deletions packages/docusaurus-utils/src/__tests__/markdownLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});
});
48 changes: 35 additions & 13 deletions packages/docusaurus-utils/src/markdownLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);

// If the link is already @site aliased, there's no need to resolve it
if (linkPathname.startsWith('@site/')) {
return sourceToPermalink.get(decodeURIComponent(linkPathname)) ?? null;
}
// /file.md is never relative to the source file path
if (!linkPathname.startsWith('/')) {
sourceDirsToTry.push(path.dirname(sourceFilePath));

// 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 aliasedSourceMatch = sourceDirsToTry
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
Expand Down
19 changes: 19 additions & 0 deletions website/_dogfooding/_docs tests/tests/links/resolution/index.mdx
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading