Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {describe, expect, it, vi} from 'vitest';
import {fromPartial} from '@total-typescript/shoehorn';
import {
truncate,
resolveTruncatedAnchorLinks,
parseBlogFileName,
paginateBlogPosts,
applyProcessBlogPosts,
Expand All @@ -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](</blog/2021/01/01/my-post#section>)',
);
});

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,
Expand Down
32 changes: 32 additions & 0 deletions packages/docusaurus-plugin-content-blog/src/blogUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = /(?<before>\]\(\s*<?)(?<hash>#[^\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,
Expand Down
2 changes: 2 additions & 0 deletions packages/docusaurus-plugin-content-blog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
12 changes: 11 additions & 1 deletion packages/docusaurus-plugin-content-blog/src/markdownLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/docusaurus-plugin-content-blog/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
};