diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts index a454826fa7d1..a9ff0879f151 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/blogUtils.test.ts @@ -9,6 +9,7 @@ import {describe, expect, it, vi} from 'vitest'; import {fromPartial} from '@total-typescript/shoehorn'; import { truncate, + resolveTruncatedAnchorLinks, parseBlogFileName, paginateBlogPosts, applyProcessBlogPosts, @@ -34,6 +35,52 @@ describe('truncate', () => { }); }); +describe('resolveTruncatedAnchorLinks', () => { + const permalink = '/blog/2021/01/01/my-post'; + + it('rebases a bare in-page anchor link to the post permalink', () => { + expect( + resolveTruncatedAnchorLinks('See [the section](#section).', permalink), + ).toBe('See [the section](/blog/2021/01/01/my-post#section).'); + }); + + it('preserves a link title after the anchor', () => { + expect( + resolveTruncatedAnchorLinks('[x](#section "Title")', permalink), + ).toBe('[x](/blog/2021/01/01/my-post#section "Title")'); + }); + + it('rebases an anchor inside angle brackets', () => { + expect(resolveTruncatedAnchorLinks('[x](<#section>)', permalink)).toBe( + '[x]()', + ); + }); + + it('rebases multiple anchor links', () => { + expect( + resolveTruncatedAnchorLinks('[a](#one) and [b](#two)', permalink), + ).toBe( + '[a](/blog/2021/01/01/my-post#one) and [b](/blog/2021/01/01/my-post#two)', + ); + }); + + it('leaves non-anchor links untouched', () => { + // Relative and Markdown links are resolved elsewhere in the MDX pipeline. + expect( + resolveTruncatedAnchorLinks( + '[a](./other.md) [b](../x) [c](#)', + permalink, + ), + ).toBe('[a](./other.md) [b](../x) [c](#)'); + expect( + resolveTruncatedAnchorLinks( + '[a](https://example.com#frag) [b](/blog/other#x)', + permalink, + ), + ).toBe('[a](https://example.com#frag) [b](/blog/other#x)'); + }); +}); + describe('reportUntruncatedBlogPosts', () => { function testPost({ source, diff --git a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts index a30480b84025..875c4643cf94 100644 --- a/packages/docusaurus-plugin-content-blog/src/blogUtils.ts +++ b/packages/docusaurus-plugin-content-blog/src/blogUtils.ts @@ -47,6 +47,38 @@ export function truncate(fileString: string, truncateMarker: RegExp): string { return fileString.split(truncateMarker, 1).shift()!; } +// Matches the destination of a Markdown inline link/image pointing to a bare +// in-page anchor, e.g. the "#anchor" in "[text](#anchor)" (an optional title or +// surrounding <> may follow). Other relative links (./x, ../x, x.md) are +// intentionally excluded: they are resolved elsewhere in the MDX pipeline. +const AnchorLinkTargetRegex = /(?\]\(\s*#[^\s)>]+)/g; + +/** + * In blog paginated list views (tags, authors, "/blog"...), a truncated post + * preview is rendered under the list page URL, not under the post permalink. + * A relative in-page anchor link such as `[jump](#section)` would then resolve + * against the list page (e.g. `/blog#section`) and lead nowhere. + * + * This rebases bare `#anchor` link targets found in a truncated preview to the + * post's own permalink, so those anchors keep pointing at the post in list + * views. Only bare anchors are rewritten; other relative links are handled by + * the regular MDX link-resolution plugins and are left untouched here. + * + * Note: like other string-level Markdown transforms in Docusaurus, this is a + * best-effort pass and does not skip anchors written inside code spans/blocks. + * + * See https://github.com/facebook/docusaurus/issues/9731 + */ +export function resolveTruncatedAnchorLinks( + fileString: string, + permalink: string, +): string { + return fileString.replace( + AnchorLinkTargetRegex, + (match, before: string, hash: string) => `${before}${permalink}${hash}`, + ); +} + export function reportUntruncatedBlogPosts({ blogPosts, onUntruncatedBlogPosts, diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index fd5e4d4eed42..556306c6311f 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -171,6 +171,8 @@ export default async function pluginContentBlog( function createBlogMarkdownLoader(): RuleSetUseItem { const markdownLoaderOptions: BlogMarkdownLoaderOptions = { truncateMarker, + siteDir, + sourceToPermalink: contentHelpers.sourceToPermalink, }; return { loader: path.resolve(__dirname, './markdownLoader.js'), diff --git a/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts b/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts index 830989fe4dd7..22a0c1d71d37 100644 --- a/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts +++ b/packages/docusaurus-plugin-content-blog/src/markdownLoader.ts @@ -5,7 +5,8 @@ * LICENSE file in the root directory of this source tree. */ -import {truncate} from './blogUtils'; +import {aliasedSitePath} from '@docusaurus/utils'; +import {resolveTruncatedAnchorLinks, truncate} from './blogUtils'; import type {BlogMarkdownLoaderOptions} from './types'; import type {LoaderContext} from 'webpack'; @@ -28,6 +29,15 @@ export default function markdownLoader( // TODO truncate with the AST instead of the string ? if (truncated) { finalContent = truncate(finalContent, markdownLoaderOptions.truncateMarker); + + // In list views the truncated preview is rendered under the list page URL, + // so in-page anchor links must be rebased to the post permalink (#9731). + const permalink = markdownLoaderOptions.sourceToPermalink.get( + aliasedSitePath(this.resourcePath, markdownLoaderOptions.siteDir), + ); + if (permalink) { + finalContent = resolveTruncatedAnchorLinks(finalContent, permalink); + } } return callback(null, finalContent); diff --git a/packages/docusaurus-plugin-content-blog/src/types.ts b/packages/docusaurus-plugin-content-blog/src/types.ts index 14820f32360e..1505e6f6485d 100644 --- a/packages/docusaurus-plugin-content-blog/src/types.ts +++ b/packages/docusaurus-plugin-content-blog/src/types.ts @@ -11,4 +11,8 @@ export type BlogContentPaths = ContentPaths; export type BlogMarkdownLoaderOptions = { truncateMarker: RegExp; + siteDir: string; + // Mutable map (see contentHelpers), keyed by aliased source path. + // Used to rebase in-page anchor links in truncated previews, see #9731. + sourceToPermalink: Map; };