@docusaurus/plugin-content-blog 3.3.2 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/assets/atom.css +75 -0
  2. package/assets/atom.xsl +92 -0
  3. package/assets/rss.css +75 -0
  4. package/assets/rss.xsl +86 -0
  5. package/lib/authors.d.ts +9 -11
  6. package/lib/authors.js +42 -64
  7. package/lib/authorsMap.d.ts +23 -0
  8. package/lib/authorsMap.js +116 -0
  9. package/lib/authorsProblems.d.ts +21 -0
  10. package/lib/authorsProblems.js +51 -0
  11. package/lib/authorsSocials.d.ts +10 -0
  12. package/lib/authorsSocials.js +48 -0
  13. package/lib/blogUtils.d.ts +7 -12
  14. package/lib/blogUtils.js +44 -34
  15. package/lib/client/contexts.d.ts +33 -0
  16. package/lib/client/contexts.js +54 -0
  17. package/lib/client/index.d.ts +3 -3
  18. package/lib/client/index.js +3 -9
  19. package/lib/client/sidebarUtils.d.ts +21 -0
  20. package/lib/client/sidebarUtils.js +49 -0
  21. package/lib/client/sidebarUtils.test.d.ts +7 -0
  22. package/lib/client/sidebarUtils.test.js +43 -0
  23. package/lib/client/structuredDataUtils.d.ts +10 -0
  24. package/lib/client/structuredDataUtils.js +122 -0
  25. package/lib/feed.d.ts +8 -3
  26. package/lib/feed.js +111 -20
  27. package/lib/frontMatter.d.ts +0 -1
  28. package/lib/frontMatter.js +3 -2
  29. package/lib/index.d.ts +0 -1
  30. package/lib/index.js +132 -105
  31. package/lib/markdownLoader.js +3 -7
  32. package/lib/options.d.ts +4 -1
  33. package/lib/options.js +107 -26
  34. package/lib/props.d.ts +9 -2
  35. package/lib/props.js +23 -3
  36. package/lib/remark/footnoteIDFixer.js +1 -1
  37. package/lib/routes.d.ts +0 -1
  38. package/lib/routes.js +82 -14
  39. package/lib/translations.d.ts +0 -1
  40. package/lib/translations.js +2 -3
  41. package/lib/types.d.ts +1 -8
  42. package/package.json +13 -10
  43. package/src/authors.ts +56 -93
  44. package/src/authorsMap.ts +171 -0
  45. package/src/authorsProblems.ts +72 -0
  46. package/src/authorsSocials.ts +64 -0
  47. package/src/blogUtils.ts +51 -46
  48. package/src/client/contexts.tsx +95 -0
  49. package/src/client/index.tsx +24 -0
  50. package/src/client/sidebarUtils.test.ts +52 -0
  51. package/src/client/sidebarUtils.tsx +85 -0
  52. package/src/client/structuredDataUtils.ts +178 -0
  53. package/src/feed.ts +197 -18
  54. package/src/frontMatter.ts +2 -0
  55. package/src/index.ts +182 -137
  56. package/src/markdownLoader.ts +3 -7
  57. package/src/options.ts +132 -32
  58. package/src/plugin-content-blog.d.ts +252 -113
  59. package/src/props.ts +41 -1
  60. package/src/routes.ts +102 -12
  61. package/src/types.ts +1 -6
  62. package/src/client/index.ts +0 -20
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import {Joi} from '@docusaurus/utils-validation';
9
+
10
+ import type {
11
+ AuthorSocials,
12
+ SocialPlatformKey,
13
+ } from '@docusaurus/plugin-content-blog';
14
+
15
+ export const AuthorSocialsSchema = Joi.object<AuthorSocials>({
16
+ twitter: Joi.string(),
17
+ github: Joi.string(),
18
+ linkedin: Joi.string(),
19
+ // StackOverflow userIds like '82609' are parsed as numbers by Yaml
20
+ stackoverflow: Joi.alternatives()
21
+ .try(Joi.number(), Joi.string())
22
+ .custom((val) => String(val)),
23
+ x: Joi.string(),
24
+ }).unknown();
25
+
26
+ type PredefinedPlatformNormalizer = (value: string) => string;
27
+
28
+ const PredefinedPlatformNormalizers: Record<
29
+ SocialPlatformKey | string,
30
+ PredefinedPlatformNormalizer
31
+ > = {
32
+ x: (handle: string) => `https://x.com/${handle}`,
33
+ twitter: (handle: string) => `https://twitter.com/${handle}`,
34
+ github: (handle: string) => `https://github.com/${handle}`,
35
+ linkedin: (handle: string) => `https://www.linkedin.com/in/${handle}/`,
36
+ stackoverflow: (userId: string) =>
37
+ `https://stackoverflow.com/users/${userId}`,
38
+ };
39
+
40
+ type SocialEntry = [string, string];
41
+
42
+ function normalizeSocialEntry([platform, value]: SocialEntry): SocialEntry {
43
+ const normalizer = PredefinedPlatformNormalizers[platform.toLowerCase()];
44
+ const isAbsoluteUrl =
45
+ value.startsWith('http://') || value.startsWith('https://');
46
+ if (isAbsoluteUrl) {
47
+ return [platform, value];
48
+ } else if (value.includes('/')) {
49
+ throw new Error(
50
+ `Author socials should be usernames/userIds/handles, or fully qualified HTTP(s) absolute URLs.
51
+ Social platform '${platform}' has illegal value '${value}'`,
52
+ );
53
+ }
54
+ if (normalizer && !isAbsoluteUrl) {
55
+ const normalizedPlatform = platform.toLowerCase();
56
+ const normalizedValue = normalizer(value);
57
+ return [normalizedPlatform as SocialPlatformKey, normalizedValue];
58
+ }
59
+ return [platform, value];
60
+ }
61
+
62
+ export const normalizeSocials = (socials: AuthorSocials): AuthorSocials => {
63
+ return Object.fromEntries(Object.entries(socials).map(normalizeSocialEntry));
64
+ };
package/src/blogUtils.ts CHANGED
@@ -17,9 +17,7 @@ import {
17
17
  getEditUrl,
18
18
  getFolderContainingFile,
19
19
  posixPath,
20
- replaceMarkdownLinks,
21
20
  Globby,
22
- normalizeFrontMatterTags,
23
21
  groupTaggedItems,
24
22
  getTagVisibility,
25
23
  getFileCommitDate,
@@ -27,29 +25,49 @@ import {
27
25
  isUnlisted,
28
26
  isDraft,
29
27
  readLastUpdateData,
28
+ normalizeTags,
29
+ aliasedSitePathToRelativePath,
30
30
  } from '@docusaurus/utils';
31
+ import {getTagsFile} from '@docusaurus/utils-validation';
31
32
  import {validateBlogPostFrontMatter} from './frontMatter';
32
- import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
33
+ import {getBlogPostAuthors} from './authors';
34
+ import {reportAuthorsProblems} from './authorsProblems';
35
+ import type {TagsFile} from '@docusaurus/utils';
33
36
  import type {LoadContext, ParseFrontMatter} from '@docusaurus/types';
34
37
  import type {
38
+ AuthorsMap,
35
39
  PluginOptions,
36
40
  ReadingTimeFunction,
37
41
  BlogPost,
38
42
  BlogTags,
39
43
  BlogPaginated,
40
44
  } from '@docusaurus/plugin-content-blog';
41
- import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
45
+ import type {BlogContentPaths} from './types';
42
46
 
43
47
  export function truncate(fileString: string, truncateMarker: RegExp): string {
44
48
  return fileString.split(truncateMarker, 1).shift()!;
45
49
  }
46
50
 
47
- export function getSourceToPermalink(blogPosts: BlogPost[]): {
48
- [aliasedPath: string]: string;
49
- } {
50
- return Object.fromEntries(
51
- blogPosts.map(({metadata: {source, permalink}}) => [source, permalink]),
51
+ export function reportUntruncatedBlogPosts({
52
+ blogPosts,
53
+ onUntruncatedBlogPosts,
54
+ }: {
55
+ blogPosts: BlogPost[];
56
+ onUntruncatedBlogPosts: PluginOptions['onUntruncatedBlogPosts'];
57
+ }): void {
58
+ const untruncatedBlogPosts = blogPosts.filter(
59
+ (p) => !p.metadata.hasTruncateMarker,
52
60
  );
61
+ if (onUntruncatedBlogPosts !== 'ignore' && untruncatedBlogPosts.length > 0) {
62
+ const message = logger.interpolate`Docusaurus found blog posts without truncation markers:
63
+ - ${untruncatedBlogPosts
64
+ .map((p) => logger.path(aliasedSitePathToRelativePath(p.metadata.source)))
65
+ .join('\n- ')}
66
+
67
+ We recommend using truncation markers (code=${`<!-- truncate -->`} or code=${`{/* truncate */}`}) in blog posts to create shorter previews on blog paginated lists.
68
+ Tip: turn this security off with the code=${`onUntruncatedBlogPosts: 'ignore'`} blog plugin option.`;
69
+ logger.report(onUntruncatedBlogPosts)(message);
70
+ }
53
71
  }
54
72
 
55
73
  export function paginateBlogPosts({
@@ -70,7 +88,7 @@ export function paginateBlogPosts({
70
88
  const totalCount = blogPosts.length;
71
89
  const postsPerPage =
72
90
  postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption;
73
- const numberOfPages = Math.ceil(totalCount / postsPerPage);
91
+ const numberOfPages = Math.max(1, Math.ceil(totalCount / postsPerPage));
74
92
 
75
93
  const pages: BlogPaginated[] = [];
76
94
 
@@ -126,9 +144,11 @@ export function getBlogTags({
126
144
  isUnlisted: (item) => item.metadata.unlisted,
127
145
  });
128
146
  return {
147
+ inline: tag.inline,
129
148
  label: tag.label,
130
- items: tagVisibility.listedItems.map((item) => item.id),
131
149
  permalink: tag.permalink,
150
+ description: tag.description,
151
+ items: tagVisibility.listedItems.map((item) => item.id),
132
152
  pages: paginateBlogPosts({
133
153
  blogPosts: tagVisibility.listedItems,
134
154
  basePageUrl: tag.permalink,
@@ -198,6 +218,7 @@ async function processBlogSourceFile(
198
218
  contentPaths: BlogContentPaths,
199
219
  context: LoadContext,
200
220
  options: PluginOptions,
221
+ tagsFile: TagsFile | null,
201
222
  authorsMap?: AuthorsMap,
202
223
  ): Promise<BlogPost | undefined> {
203
224
  const {
@@ -316,12 +337,26 @@ async function processBlogSourceFile(
316
337
  return undefined;
317
338
  }
318
339
 
319
- const tagsBasePath = normalizeUrl([
340
+ const tagsBaseRoutePath = normalizeUrl([
320
341
  baseUrl,
321
342
  routeBasePath,
322
343
  tagsRouteBasePath,
323
344
  ]);
345
+
324
346
  const authors = getBlogPostAuthors({authorsMap, frontMatter, baseUrl});
347
+ reportAuthorsProblems({
348
+ authors,
349
+ blogSourceRelative,
350
+ options,
351
+ });
352
+
353
+ const tags = normalizeTags({
354
+ options,
355
+ source: blogSourceRelative,
356
+ frontMatterTags: frontMatter.tags,
357
+ tagsBaseRoutePath,
358
+ tagsFile,
359
+ });
325
360
 
326
361
  return {
327
362
  id: slug,
@@ -332,7 +367,7 @@ async function processBlogSourceFile(
332
367
  title,
333
368
  description,
334
369
  date,
335
- tags: normalizeFrontMatterTags(tagsBasePath, frontMatter.tags),
370
+ tags,
336
371
  readingTime: showReadingTime
337
372
  ? options.readingTime({
338
373
  content,
@@ -355,6 +390,7 @@ export async function generateBlogPosts(
355
390
  contentPaths: BlogContentPaths,
356
391
  context: LoadContext,
357
392
  options: PluginOptions,
393
+ authorsMap?: AuthorsMap,
358
394
  ): Promise<BlogPost[]> {
359
395
  const {include, exclude} = options;
360
396
 
@@ -367,10 +403,7 @@ export async function generateBlogPosts(
367
403
  ignore: exclude,
368
404
  });
369
405
 
370
- const authorsMap = await getAuthorsMap({
371
- contentPaths,
372
- authorsMapPath: options.authorsMapPath,
373
- });
406
+ const tagsFile = await getTagsFile({contentPaths, tags: options.tags});
374
407
 
375
408
  async function doProcessBlogSourceFile(blogSourceFile: string) {
376
409
  try {
@@ -379,6 +412,7 @@ export async function generateBlogPosts(
379
412
  contentPaths,
380
413
  context,
381
414
  options,
415
+ tagsFile,
382
416
  authorsMap,
383
417
  );
384
418
  } catch (err) {
@@ -403,35 +437,6 @@ export async function generateBlogPosts(
403
437
  return blogPosts;
404
438
  }
405
439
 
406
- export type LinkifyParams = {
407
- filePath: string;
408
- fileString: string;
409
- } & Pick<
410
- BlogMarkdownLoaderOptions,
411
- 'sourceToPermalink' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink'
412
- >;
413
-
414
- export function linkify({
415
- filePath,
416
- contentPaths,
417
- fileString,
418
- siteDir,
419
- sourceToPermalink,
420
- onBrokenMarkdownLink,
421
- }: LinkifyParams): string {
422
- const {newContent, brokenMarkdownLinks} = replaceMarkdownLinks({
423
- siteDir,
424
- fileString,
425
- filePath,
426
- contentPaths,
427
- sourceToPermalink,
428
- });
429
-
430
- brokenMarkdownLinks.forEach((l) => onBrokenMarkdownLink(l));
431
-
432
- return newContent;
433
- }
434
-
435
440
  export async function applyProcessBlogPosts({
436
441
  blogPosts,
437
442
  processBlogPosts,
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import React, {useMemo, type ReactNode, useContext} from 'react';
9
+ import {ReactContextError} from '@docusaurus/theme-common/internal';
10
+ import useRouteContext from '@docusaurus/useRouteContext';
11
+
12
+ import type {
13
+ PropBlogPostContent,
14
+ BlogMetadata,
15
+ } from '@docusaurus/plugin-content-blog';
16
+
17
+ export function useBlogMetadata(): BlogMetadata {
18
+ const routeContext = useRouteContext();
19
+ const blogMetadata = routeContext?.data?.blogMetadata;
20
+ if (!blogMetadata) {
21
+ throw new Error(
22
+ "useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
23
+ );
24
+ }
25
+ return blogMetadata as BlogMetadata;
26
+ }
27
+
28
+ /**
29
+ * The React context value returned by the `useBlogPost()` hook.
30
+ * It contains useful data related to the currently browsed blog post.
31
+ */
32
+ export type BlogPostContextValue = Pick<
33
+ PropBlogPostContent,
34
+ 'metadata' | 'frontMatter' | 'assets' | 'toc'
35
+ > & {
36
+ readonly isBlogPostPage: boolean;
37
+ };
38
+
39
+ const Context = React.createContext<BlogPostContextValue | null>(null);
40
+
41
+ /**
42
+ * Note: we don't use `PropBlogPostContent` as context value on purpose.
43
+ * Metadata is currently stored inside the MDX component, but we may want to
44
+ * change that in the future.
45
+ */
46
+ function useContextValue({
47
+ content,
48
+ isBlogPostPage,
49
+ }: {
50
+ content: PropBlogPostContent;
51
+ isBlogPostPage: boolean;
52
+ }): BlogPostContextValue {
53
+ return useMemo(
54
+ () => ({
55
+ metadata: content.metadata,
56
+ frontMatter: content.frontMatter,
57
+ assets: content.assets,
58
+ toc: content.toc,
59
+ isBlogPostPage,
60
+ }),
61
+ [content, isBlogPostPage],
62
+ );
63
+ }
64
+
65
+ /**
66
+ * This is a very thin layer around the `content` received from the MDX loader.
67
+ * It provides metadata about the blog post to the children tree.
68
+ */
69
+ export function BlogPostProvider({
70
+ children,
71
+ content,
72
+ isBlogPostPage = false,
73
+ }: {
74
+ children: ReactNode;
75
+ content: PropBlogPostContent;
76
+ isBlogPostPage?: boolean;
77
+ }): JSX.Element {
78
+ const contextValue = useContextValue({content, isBlogPostPage});
79
+ return <Context.Provider value={contextValue}>{children}</Context.Provider>;
80
+ }
81
+
82
+ /**
83
+ * Returns the data of the currently browsed blog post. Gives access to
84
+ * front matter, metadata, TOC, etc.
85
+ * When swizzling a low-level component (e.g. the "Edit this page" link)
86
+ * and you need some extra metadata, you don't have to drill the props
87
+ * all the way through the component tree: simply use this hook instead.
88
+ */
89
+ export function useBlogPost(): BlogPostContextValue {
90
+ const blogPost = useContext(Context);
91
+ if (blogPost === null) {
92
+ throw new ReactContextError('BlogPostProvider');
93
+ }
94
+ return blogPost;
95
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ export {
9
+ BlogPostProvider,
10
+ type BlogPostContextValue,
11
+ useBlogPost,
12
+ useBlogMetadata,
13
+ } from './contexts';
14
+
15
+ export {
16
+ useBlogListPageStructuredData,
17
+ useBlogPostStructuredData,
18
+ } from './structuredDataUtils';
19
+
20
+ export {
21
+ BlogSidebarItemList,
22
+ groupBlogSidebarItemsByYear,
23
+ useVisibleBlogSidebarItems,
24
+ } from './sidebarUtils';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import {groupBlogSidebarItemsByYear} from './sidebarUtils';
9
+ import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
10
+
11
+ describe('groupBlogSidebarItemsByYear', () => {
12
+ const post1: BlogSidebarItem = {
13
+ title: 'post1',
14
+ permalink: '/post1',
15
+ date: '2024-10-03',
16
+ unlisted: false,
17
+ };
18
+
19
+ const post2: BlogSidebarItem = {
20
+ title: 'post2',
21
+ permalink: '/post2',
22
+ date: '2024-05-02',
23
+ unlisted: false,
24
+ };
25
+
26
+ const post3: BlogSidebarItem = {
27
+ title: 'post3',
28
+ permalink: '/post3',
29
+ date: '2022-11-18',
30
+ unlisted: false,
31
+ };
32
+
33
+ it('can group items by year', () => {
34
+ const items: BlogSidebarItem[] = [post1, post2, post3];
35
+ const entries = groupBlogSidebarItemsByYear(items);
36
+
37
+ expect(entries).toEqual([
38
+ ['2024', [post1, post2]],
39
+ ['2022', [post3]],
40
+ ]);
41
+ });
42
+
43
+ it('always returns result in descending chronological order', () => {
44
+ const items: BlogSidebarItem[] = [post3, post1, post2];
45
+ const entries = groupBlogSidebarItemsByYear(items);
46
+
47
+ expect(entries).toEqual([
48
+ ['2024', [post1, post2]],
49
+ ['2022', [post3]],
50
+ ]);
51
+ });
52
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import React, {type ReactNode, useMemo} from 'react';
9
+ import {useLocation} from '@docusaurus/router';
10
+ import Link from '@docusaurus/Link';
11
+ import {groupBy} from '@docusaurus/theme-common';
12
+ import {isSamePath} from '@docusaurus/theme-common/internal';
13
+ import type {BlogSidebarItem} from '@docusaurus/plugin-content-blog';
14
+
15
+ function isVisible(item: BlogSidebarItem, pathname: string): boolean {
16
+ if (item.unlisted && !isSamePath(item.permalink, pathname)) {
17
+ return false;
18
+ }
19
+ return true;
20
+ }
21
+
22
+ /**
23
+ * Return the visible blog sidebar items to display.
24
+ * Unlisted items are filtered.
25
+ */
26
+ export function useVisibleBlogSidebarItems(
27
+ items: BlogSidebarItem[],
28
+ ): BlogSidebarItem[] {
29
+ const {pathname} = useLocation();
30
+ return useMemo(
31
+ () => items.filter((item) => isVisible(item, pathname)),
32
+ [items, pathname],
33
+ );
34
+ }
35
+
36
+ export function groupBlogSidebarItemsByYear(
37
+ items: BlogSidebarItem[],
38
+ ): [string, BlogSidebarItem[]][] {
39
+ const groupedByYear = groupBy(items, (item) => {
40
+ return `${new Date(item.date).getFullYear()}`;
41
+ });
42
+ // "as" is safe here
43
+ // see https://github.com/microsoft/TypeScript/pull/56805#issuecomment-2196526425
44
+ const entries = Object.entries(groupedByYear) as [
45
+ string,
46
+ BlogSidebarItem[],
47
+ ][];
48
+ // We have to use entries because of https://x.com/sebastienlorber/status/1806371668614369486
49
+ // Objects with string/number keys are automatically sorted asc...
50
+ // Even if keys are strings like "2024"
51
+ // We want descending order for years
52
+ // Alternative: using Map.groupBy (not affected by this "reordering")
53
+ entries.reverse();
54
+ return entries;
55
+ }
56
+
57
+ export function BlogSidebarItemList({
58
+ items,
59
+ ulClassName,
60
+ liClassName,
61
+ linkClassName,
62
+ linkActiveClassName,
63
+ }: {
64
+ items: BlogSidebarItem[];
65
+ ulClassName?: string;
66
+ liClassName?: string;
67
+ linkClassName?: string;
68
+ linkActiveClassName?: string;
69
+ }): ReactNode {
70
+ return (
71
+ <ul className={ulClassName}>
72
+ {items.map((item) => (
73
+ <li key={item.permalink} className={liClassName}>
74
+ <Link
75
+ isNavLink
76
+ to={item.permalink}
77
+ className={linkClassName}
78
+ activeClassName={linkActiveClassName}>
79
+ {item.title}
80
+ </Link>
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ );
85
+ }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import {useBaseUrlUtils, type BaseUrlUtils} from '@docusaurus/useBaseUrl';
9
+ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
10
+ import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client';
11
+ import type {Props as BlogListPageStructuredDataProps} from '@theme/BlogListPage/StructuredData';
12
+ import {useBlogPost} from './contexts';
13
+
14
+ import type {
15
+ Blog,
16
+ BlogPosting,
17
+ WithContext,
18
+ Person,
19
+ ImageObject,
20
+ } from 'schema-dts';
21
+ import type {
22
+ Author,
23
+ PropBlogPostContent,
24
+ } from '@docusaurus/plugin-content-blog';
25
+ import type {DocusaurusConfig} from '@docusaurus/types';
26
+
27
+ const convertDate = (dateMs: number) => new Date(dateMs).toISOString();
28
+
29
+ function getBlogPost(
30
+ blogPostContent: PropBlogPostContent,
31
+ siteConfig: DocusaurusConfig,
32
+ withBaseUrl: BaseUrlUtils['withBaseUrl'],
33
+ ): BlogPosting {
34
+ const {assets, frontMatter, metadata} = blogPostContent;
35
+ const {date, title, description, lastUpdatedAt} = metadata;
36
+
37
+ const image = assets.image ?? frontMatter.image;
38
+ const keywords = frontMatter.keywords ?? [];
39
+
40
+ const blogUrl = `${siteConfig.url}${metadata.permalink}`;
41
+
42
+ const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
43
+
44
+ return {
45
+ '@type': 'BlogPosting',
46
+ '@id': blogUrl,
47
+ mainEntityOfPage: blogUrl,
48
+ url: blogUrl,
49
+ headline: title,
50
+ name: title,
51
+ description,
52
+ datePublished: date,
53
+ ...(dateModified ? {dateModified} : {}),
54
+ ...getAuthor(metadata.authors),
55
+ ...getImage(image, withBaseUrl, title),
56
+ ...(keywords ? {keywords} : {}),
57
+ };
58
+ }
59
+
60
+ function getAuthor(authors: Author[]) {
61
+ const authorsStructuredData = authors.map(createPersonStructuredData);
62
+ return {
63
+ author:
64
+ authorsStructuredData.length === 1
65
+ ? authorsStructuredData[0]
66
+ : authorsStructuredData,
67
+ };
68
+ }
69
+
70
+ function getImage(
71
+ image: string | undefined,
72
+ withBaseUrl: BaseUrlUtils['withBaseUrl'],
73
+ title: string,
74
+ ) {
75
+ return image
76
+ ? {
77
+ image: createImageStructuredData({
78
+ imageUrl: withBaseUrl(image, {absolute: true}),
79
+ caption: `title image for the blog post: ${title}`,
80
+ }),
81
+ }
82
+ : {};
83
+ }
84
+
85
+ export function useBlogListPageStructuredData(
86
+ props: BlogListPageStructuredDataProps,
87
+ ): WithContext<Blog> {
88
+ const {siteConfig} = useDocusaurusContext();
89
+ const {withBaseUrl} = useBaseUrlUtils();
90
+
91
+ const {
92
+ metadata: {blogDescription, blogTitle, permalink},
93
+ } = props;
94
+
95
+ const url = `${siteConfig.url}${permalink}`;
96
+
97
+ // details on structured data support: https://schema.org/Blog
98
+ return {
99
+ '@context': 'https://schema.org',
100
+ '@type': 'Blog',
101
+ '@id': url,
102
+ mainEntityOfPage: url,
103
+ headline: blogTitle,
104
+ description: blogDescription,
105
+ blogPost: props.items.map((blogItem) =>
106
+ getBlogPost(blogItem.content, siteConfig, withBaseUrl),
107
+ ),
108
+ };
109
+ }
110
+
111
+ export function useBlogPostStructuredData(): WithContext<BlogPosting> {
112
+ const blogMetadata = useBlogMetadata();
113
+ const {assets, metadata} = useBlogPost();
114
+ const {siteConfig} = useDocusaurusContext();
115
+ const {withBaseUrl} = useBaseUrlUtils();
116
+
117
+ const {date, title, description, frontMatter, lastUpdatedAt} = metadata;
118
+
119
+ const image = assets.image ?? frontMatter.image;
120
+ const keywords = frontMatter.keywords ?? [];
121
+
122
+ const dateModified = lastUpdatedAt ? convertDate(lastUpdatedAt) : undefined;
123
+
124
+ const url = `${siteConfig.url}${metadata.permalink}`;
125
+
126
+ // details on structured data support: https://schema.org/BlogPosting
127
+ // BlogPosting is one of the structured data types that Google explicitly
128
+ // supports: https://developers.google.com/search/docs/appearance/structured-data/article#structured-data-type-definitions
129
+ return {
130
+ '@context': 'https://schema.org',
131
+ '@type': 'BlogPosting',
132
+ '@id': url,
133
+ mainEntityOfPage: url,
134
+ url,
135
+ headline: title,
136
+ name: title,
137
+ description,
138
+ datePublished: date,
139
+ ...(dateModified ? {dateModified} : {}),
140
+ ...getAuthor(metadata.authors),
141
+ ...getImage(image, withBaseUrl, title),
142
+ ...(keywords ? {keywords} : {}),
143
+ isPartOf: {
144
+ '@type': 'Blog',
145
+ '@id': `${siteConfig.url}${blogMetadata.blogBasePath}`,
146
+ name: blogMetadata.blogTitle,
147
+ },
148
+ };
149
+ }
150
+
151
+ /** @returns A {@link https://schema.org/Person} constructed from the {@link Author} */
152
+ function createPersonStructuredData(author: Author): Person {
153
+ return {
154
+ '@type': 'Person',
155
+ ...(author.name ? {name: author.name} : {}),
156
+ ...(author.title ? {description: author.title} : {}),
157
+ ...(author.url ? {url: author.url} : {}),
158
+ ...(author.email ? {email: author.email} : {}),
159
+ ...(author.imageURL ? {image: author.imageURL} : {}),
160
+ };
161
+ }
162
+
163
+ /** @returns A {@link https://schema.org/ImageObject} */
164
+ function createImageStructuredData({
165
+ imageUrl,
166
+ caption,
167
+ }: {
168
+ imageUrl: string;
169
+ caption: string;
170
+ }): ImageObject {
171
+ return {
172
+ '@type': 'ImageObject',
173
+ '@id': imageUrl,
174
+ url: imageUrl,
175
+ contentUrl: imageUrl,
176
+ caption,
177
+ };
178
+ }