Skip to content
Closed
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 @@ -346,6 +346,104 @@ describe.each(['atom', 'rss', 'json'] as const)('%s', (feedType) => {
fsMock.mockClear();
});

it('emits trailing-slashed URLs in feed structure when trailingSlash is true', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const outDir = path.join(siteDir, 'build-snap');
const siteConfig = {
title: 'Hello',
baseUrl: '/myBaseUrl/',
url: 'https://docusaurus.io',
favicon: 'image/favicon.ico',
trailingSlash: true,
markdown,
};

await testGenerateFeeds(
fromPartial({
siteDir,
siteConfig,
i18n: DefaultI18N,
outDir,
}),
{
path: 'blog',
routeBasePath: 'blog',
tagsBasePath: 'tags',
authorsMapPath: 'authors.yml',
include: DEFAULT_OPTIONS.include,
exclude: DEFAULT_OPTIONS.exclude,
feedOptions: {
type: [feedType],
copyright: 'Copyright',
xslt: {atom: null, rss: null},
},
readingTime: ({content, defaultReadingTime}) =>
defaultReadingTime({content, locale: 'en'}),
truncateMarker: /<!--\s*truncate\s*-->/,
onInlineTags: 'ignore',
onInlineAuthors: 'ignore',
},
);

try {
const feedContent = fsMock.mock.calls[0]?.[1] as string | undefined;
expect(feedContent).toBeDefined();

// Extract URLs from feed-structural positions only — post body HTML
// legitimately may contain author-written non-slashed URLs, so we ignore
// CDATA sections for XML formats and content_html for JSON.
const blogUrlPrefix = 'https://docusaurus.io/myBaseUrl/blog/';
let structuralUrls: string[] = [];

if (feedType === 'atom' || feedType === 'rss') {
const xmlOnly = feedContent!.replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '');
if (feedType === 'atom') {
structuralUrls = [
...[...xmlOnly.matchAll(/<id>(?<url>[^<]+)<\/id>/g)].map(
(m) => m.groups!.url!,
),
...[...xmlOnly.matchAll(/<link[^>]*href="(?<url>[^"]+)"/g)].map(
(m) => m.groups!.url!,
),
];
} else {
structuralUrls = [
...[...xmlOnly.matchAll(/<link>(?<url>[^<]+)<\/link>/g)].map(
(m) => m.groups!.url!,
),
...[...xmlOnly.matchAll(/<guid[^>]*>(?<url>[^<]+)<\/guid>/g)].map(
(m) => m.groups!.url!,
),
];
}
} else {
const parsed = JSON.parse(feedContent!) as {
home_page_url?: string;
feed_url?: string;
items: {id: string; url: string}[];
};
structuralUrls = [
parsed.home_page_url,
parsed.feed_url,
...parsed.items.flatMap((item) => [item.id, item.url]),
].filter((u): u is string => typeof u === 'string');
}

const blogUrls = structuralUrls.filter((url) =>
url.startsWith(blogUrlPrefix),
);

// Sanity: confirm we actually checked something.
expect(blogUrls.length).toBeGreaterThan(0);

for (const url of blogUrls) {
expect(url, `${feedType} feed URL should end with "/"`).toMatch(/\/$/);
}
} finally {
fsMock.mockClear();
}
});

it('has xslt files for feed', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const outDir = path.join(siteDir, 'build-snap');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {getAbsoluteUrl} from './structuredDataUrl';

describe('getAbsoluteUrl', () => {
const siteUrl = 'https://example.com';

it('adds a trailing slash when trailingSlash is true', () => {
expect(
getAbsoluteUrl('/blog/my-post', {
url: siteUrl,
baseUrl: '/',
trailingSlash: true,
}),
).toBe('https://example.com/blog/my-post/');
});

it('removes a trailing slash when trailingSlash is false', () => {
expect(
getAbsoluteUrl('/blog/my-post/', {
url: siteUrl,
baseUrl: '/',
trailingSlash: false,
}),
).toBe('https://example.com/blog/my-post');
});

it('leaves the permalink untouched when trailingSlash is undefined', () => {
expect(
getAbsoluteUrl('/blog/my-post', {
url: siteUrl,
baseUrl: '/',
trailingSlash: undefined,
}),
).toBe('https://example.com/blog/my-post');
});

it('keeps the baseUrl trailing slash for the blog base path', () => {
expect(
getAbsoluteUrl('/myBase/', {
url: siteUrl,
baseUrl: '/myBase/',
trailingSlash: false,
}),
).toBe('https://example.com/myBase/');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

import {applyTrailingSlash} from '@docusaurus/utils-common';
import type {DocusaurusConfig} from '@docusaurus/types';

// Builds an absolute URL for a blog permalink, respecting the site's
// trailingSlash config so the structured data matches the canonical page URL.
export function getAbsoluteUrl(
permalink: string,
siteConfig: Pick<DocusaurusConfig, 'url' | 'baseUrl' | 'trailingSlash'>,
): string {
const pathname = applyTrailingSlash(permalink, {
trailingSlash: siteConfig.trailingSlash,
baseUrl: siteConfig.baseUrl,
});
return `${siteConfig.url}${pathname}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
import type {Props as BlogListPageStructuredDataProps} from '@theme/BlogListPage/StructuredData';
import {useBlogPost} from './contexts';
import {getAbsoluteUrl} from './structuredDataUrl';

import type {
Blog,
Expand Down Expand Up @@ -37,7 +38,7 @@ function getBlogPost(
const image = assets.image ?? frontMatter.image;
const keywords = frontMatter.keywords ?? [];

const blogUrl = `${siteConfig.url}${metadata.permalink}`;
const blogUrl = getAbsoluteUrl(metadata.permalink, siteConfig);

const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;

Expand Down Expand Up @@ -92,7 +93,7 @@ export function useBlogListPageStructuredData(
metadata: {blogDescription, blogTitle, permalink},
} = props;

const url = `${siteConfig.url}${permalink}`;
const url = getAbsoluteUrl(permalink, siteConfig);

// details on structured data support: https://schema.org/Blog
return {
Expand Down Expand Up @@ -121,7 +122,7 @@ export function useBlogPostStructuredData(): WithContext<BlogPosting> {

const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;

const url = `${siteConfig.url}${metadata.permalink}`;
const url = getAbsoluteUrl(metadata.permalink, siteConfig);

// details on structured data support: https://schema.org/BlogPosting
// BlogPosting is one of the structured data types that Google explicitly
Expand All @@ -142,7 +143,7 @@ export function useBlogPostStructuredData(): WithContext<BlogPosting> {
...(keywords ? {keywords} : {}),
isPartOf: {
'@type': 'Blog',
'@id': `${siteConfig.url}${blogMetadata.blogBasePath}`,
'@id': getAbsoluteUrl(blogMetadata.blogBasePath, siteConfig),
name: blogMetadata.blogTitle,
},
};
Expand Down