@docusaurus/plugin-content-blog 3.1.1 → 3.2.1

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.
@@ -11,12 +11,13 @@ export declare function truncate(fileString: string, truncateMarker: RegExp): st
11
11
  export declare function getSourceToPermalink(blogPosts: BlogPost[]): {
12
12
  [aliasedPath: string]: string;
13
13
  };
14
- export declare function paginateBlogPosts({ blogPosts, basePageUrl, blogTitle, blogDescription, postsPerPageOption, }: {
14
+ export declare function paginateBlogPosts({ blogPosts, basePageUrl, blogTitle, blogDescription, postsPerPageOption, pageBasePath, }: {
15
15
  blogPosts: BlogPost[];
16
16
  basePageUrl: string;
17
17
  blogTitle: string;
18
18
  blogDescription: string;
19
19
  postsPerPageOption: number | 'ALL';
20
+ pageBasePath: string;
20
21
  }): BlogPaginated[];
21
22
  export declare function shouldBeListed(blogPost: BlogPost): boolean;
22
23
  export declare function getBlogTags({ blogPosts, ...params }: {
@@ -24,6 +25,7 @@ export declare function getBlogTags({ blogPosts, ...params }: {
24
25
  blogTitle: string;
25
26
  blogDescription: string;
26
27
  postsPerPageOption: number | 'ALL';
28
+ pageBasePath: string;
27
29
  }): BlogTags;
28
30
  type ParsedBlogFileName = {
29
31
  date: Date | undefined;
@@ -37,4 +39,8 @@ export type LinkifyParams = {
37
39
  fileString: string;
38
40
  } & Pick<BlogMarkdownLoaderOptions, 'sourceToPermalink' | 'siteDir' | 'contentPaths' | 'onBrokenMarkdownLink'>;
39
41
  export declare function linkify({ filePath, contentPaths, fileString, siteDir, sourceToPermalink, onBrokenMarkdownLink, }: LinkifyParams): string;
42
+ export declare function applyProcessBlogPosts({ blogPosts, processBlogPosts, }: {
43
+ blogPosts: BlogPost[];
44
+ processBlogPosts: PluginOptions['processBlogPosts'];
45
+ }): Promise<BlogPost[]>;
40
46
  export {};
package/lib/blogUtils.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.linkify = exports.generateBlogPosts = exports.parseBlogFileName = exports.getBlogTags = exports.shouldBeListed = exports.paginateBlogPosts = exports.getSourceToPermalink = exports.truncate = void 0;
9
+ exports.applyProcessBlogPosts = exports.linkify = exports.generateBlogPosts = exports.parseBlogFileName = exports.getBlogTags = exports.shouldBeListed = exports.paginateBlogPosts = exports.getSourceToPermalink = exports.truncate = void 0;
10
10
  const tslib_1 = require("tslib");
11
11
  const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
12
12
  const path_1 = tslib_1.__importDefault(require("path"));
@@ -24,14 +24,14 @@ function getSourceToPermalink(blogPosts) {
24
24
  return Object.fromEntries(blogPosts.map(({ metadata: { source, permalink } }) => [source, permalink]));
25
25
  }
26
26
  exports.getSourceToPermalink = getSourceToPermalink;
27
- function paginateBlogPosts({ blogPosts, basePageUrl, blogTitle, blogDescription, postsPerPageOption, }) {
27
+ function paginateBlogPosts({ blogPosts, basePageUrl, blogTitle, blogDescription, postsPerPageOption, pageBasePath, }) {
28
28
  const totalCount = blogPosts.length;
29
29
  const postsPerPage = postsPerPageOption === 'ALL' ? totalCount : postsPerPageOption;
30
30
  const numberOfPages = Math.ceil(totalCount / postsPerPage);
31
31
  const pages = [];
32
32
  function permalink(page) {
33
33
  return page > 0
34
- ? (0, utils_1.normalizeUrl)([basePageUrl, `page/${page + 1}`])
34
+ ? (0, utils_1.normalizeUrl)([basePageUrl, pageBasePath, `${page + 1}`])
35
35
  : basePageUrl;
36
36
  }
37
37
  for (let page = 0; page < numberOfPages; page += 1) {
@@ -96,21 +96,6 @@ function parseBlogFileName(blogSourceRelative) {
96
96
  return { date: undefined, text, slug };
97
97
  }
98
98
  exports.parseBlogFileName = parseBlogFileName;
99
- function formatBlogPostDate(locale, date, calendar) {
100
- try {
101
- return new Intl.DateTimeFormat(locale, {
102
- day: 'numeric',
103
- month: 'long',
104
- year: 'numeric',
105
- timeZone: 'UTC',
106
- calendar,
107
- }).format(date);
108
- }
109
- catch (err) {
110
- logger_1.default.error `Can't format blog post date "${String(date)}"`;
111
- throw err;
112
- }
113
- }
114
99
  async function parseBlogPostMarkdownFile({ filePath, parseFrontMatter, }) {
115
100
  const fileContent = await fs_extra_1.default.readFile(filePath, 'utf-8');
116
101
  try {
@@ -142,6 +127,7 @@ async function processBlogSourceFile(blogSourceRelative, contentPaths, context,
142
127
  parseFrontMatter,
143
128
  });
144
129
  const aliasedSource = (0, utils_1.aliasedSitePath)(blogSourceAbsolute, siteDir);
130
+ const lastUpdate = await (0, utils_1.readLastUpdateData)(blogSourceAbsolute, options, frontMatter.last_update);
145
131
  const draft = (0, utils_1.isDraft)({ frontMatter });
146
132
  const unlisted = (0, utils_1.isUnlisted)({ frontMatter });
147
133
  if (draft) {
@@ -165,7 +151,7 @@ async function processBlogSourceFile(blogSourceRelative, contentPaths, context,
165
151
  return parsedBlogFileName.date;
166
152
  }
167
153
  try {
168
- const result = (0, utils_1.getFileCommitDate)(blogSourceAbsolute, {
154
+ const result = await (0, utils_1.getFileCommitDate)(blogSourceAbsolute, {
169
155
  age: 'oldest',
170
156
  includeAuthor: false,
171
157
  });
@@ -177,7 +163,6 @@ async function processBlogSourceFile(blogSourceRelative, contentPaths, context,
177
163
  }
178
164
  }
179
165
  const date = await getDate();
180
- const formattedDate = formatBlogPostDate(i18n.currentLocale, date, i18n.localeConfigs[i18n.currentLocale].calendar);
181
166
  const title = frontMatter.title ?? contentTitle ?? parsedBlogFileName.text;
182
167
  const description = frontMatter.description ?? excerpt ?? '';
183
168
  const slug = frontMatter.slug ?? parsedBlogFileName.slug;
@@ -220,7 +205,6 @@ async function processBlogSourceFile(blogSourceRelative, contentPaths, context,
220
205
  title,
221
206
  description,
222
207
  date,
223
- formattedDate,
224
208
  tags: (0, utils_1.normalizeFrontMatterTags)(tagsBasePath, frontMatter.tags),
225
209
  readingTime: showReadingTime
226
210
  ? options.readingTime({
@@ -233,6 +217,8 @@ async function processBlogSourceFile(blogSourceRelative, contentPaths, context,
233
217
  authors,
234
218
  frontMatter,
235
219
  unlisted,
220
+ lastUpdatedAt: lastUpdate.lastUpdatedAt,
221
+ lastUpdatedBy: lastUpdate.lastUpdatedBy,
236
222
  },
237
223
  content,
238
224
  };
@@ -278,3 +264,11 @@ function linkify({ filePath, contentPaths, fileString, siteDir, sourceToPermalin
278
264
  return newContent;
279
265
  }
280
266
  exports.linkify = linkify;
267
+ async function applyProcessBlogPosts({ blogPosts, processBlogPosts, }) {
268
+ const processedBlogPosts = await processBlogPosts({ blogPosts });
269
+ if (Array.isArray(processedBlogPosts)) {
270
+ return processedBlogPosts;
271
+ }
272
+ return blogPosts;
273
+ }
274
+ exports.applyProcessBlogPosts = applyProcessBlogPosts;
@@ -0,0 +1,8 @@
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
+ import type { BlogMetadata } from '@docusaurus/plugin-content-blog';
8
+ export declare function useBlogMetadata(): BlogMetadata;
@@ -0,0 +1,15 @@
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
+ import useRouteContext from '@docusaurus/useRouteContext';
8
+ export function useBlogMetadata() {
9
+ const routeContext = useRouteContext();
10
+ const blogMetadata = routeContext?.data?.blogMetadata;
11
+ if (!blogMetadata) {
12
+ throw new Error("useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context");
13
+ }
14
+ return blogMetadata;
15
+ }
package/lib/feed.js CHANGED
@@ -21,8 +21,11 @@ async function generateBlogFeed({ blogPosts, options, siteConfig, outDir, locale
21
21
  return null;
22
22
  }
23
23
  const { feedOptions, routeBasePath } = options;
24
- const { url: siteUrl, baseUrl, title, favicon } = siteConfig;
25
- const blogBaseUrl = (0, utils_1.normalizeUrl)([siteUrl, baseUrl, routeBasePath]);
24
+ const { url: siteUrl, baseUrl, title, favicon, trailingSlash } = siteConfig;
25
+ const blogBaseUrl = (0, utils_common_1.applyTrailingSlash)((0, utils_1.normalizeUrl)([siteUrl, baseUrl, routeBasePath]), {
26
+ trailingSlash,
27
+ baseUrl,
28
+ });
26
29
  const blogPostsForFeed = feedOptions.limit === false || feedOptions.limit === null
27
30
  ? blogPosts
28
31
  : blogPosts.slice(0, feedOptions.limit);
@@ -48,15 +51,18 @@ async function generateBlogFeed({ blogPosts, options, siteConfig, outDir, locale
48
51
  return feed;
49
52
  }
50
53
  async function defaultCreateFeedItems({ blogPosts, siteConfig, outDir, }) {
51
- const { url: siteUrl } = siteConfig;
54
+ const { url: siteUrl, baseUrl, trailingSlash } = siteConfig;
52
55
  function toFeedAuthor(author) {
53
56
  return { name: author.name, link: author.url, email: author.email };
54
57
  }
55
58
  return Promise.all(blogPosts.map(async (post) => {
56
59
  const { metadata: { title: metadataTitle, permalink, date, description, authors, tags, }, } = post;
57
- const content = await (0, utils_1.readOutputHTMLFile)(permalink.replace(siteConfig.baseUrl, ''), outDir, siteConfig.trailingSlash);
60
+ const content = await (0, utils_1.readOutputHTMLFile)(permalink.replace(baseUrl, ''), outDir, trailingSlash);
58
61
  const $ = (0, cheerio_1.load)(content);
59
- const blogPostAbsoluteUrl = (0, utils_1.normalizeUrl)([siteUrl, permalink]);
62
+ const blogPostAbsoluteUrl = (0, utils_common_1.applyTrailingSlash)((0, utils_1.normalizeUrl)([siteUrl, permalink]), {
63
+ trailingSlash,
64
+ baseUrl,
65
+ });
60
66
  const toAbsoluteUrl = (src) => String(new URL(src, blogPostAbsoluteUrl));
61
67
  // Make links and image urls absolute
62
68
  // See https://github.com/facebook/docusaurus/issues/9136
@@ -1,9 +1,3 @@
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
1
  import type { BlogPostFrontMatter } from '@docusaurus/plugin-content-blog';
8
2
  export declare function validateBlogPostFrontMatter(frontMatter: {
9
3
  [key: string]: unknown;
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateBlogPostFrontMatter = void 0;
2
4
  /**
3
5
  * Copyright (c) Facebook, Inc. and its affiliates.
4
6
  *
5
7
  * This source code is licensed under the MIT license found in the
6
8
  * LICENSE file in the root directory of this source tree.
7
9
  */
8
- Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.validateBlogPostFrontMatter = void 0;
10
10
  const utils_validation_1 = require("@docusaurus/utils-validation");
11
11
  const BlogPostFrontMatterAuthorSchema = utils_validation_1.JoiFrontMatter.object({
12
12
  key: utils_validation_1.JoiFrontMatter.string(),
@@ -52,6 +52,7 @@ const BlogFrontMatterSchema = utils_validation_1.JoiFrontMatter.object({
52
52
  keywords: utils_validation_1.JoiFrontMatter.array().items(utils_validation_1.JoiFrontMatter.string().required()),
53
53
  hide_table_of_contents: utils_validation_1.JoiFrontMatter.boolean(),
54
54
  ...utils_validation_1.FrontMatterTOCHeadingLevels,
55
+ last_update: utils_validation_1.FrontMatterLastUpdateSchema,
55
56
  })
56
57
  .messages({
57
58
  'deprecate.error': '{#label} blog frontMatter field is deprecated. Please use {#alternative} instead.',
package/lib/index.js CHANGED
@@ -47,10 +47,14 @@ async function pluginContentBlog(context, options) {
47
47
  },
48
48
  // Fetches blog contents and returns metadata for the necessary routes.
49
49
  async loadContent() {
50
- const { postsPerPage: postsPerPageOption, routeBasePath, tagsBasePath, blogDescription, blogTitle, blogSidebarTitle, } = options;
50
+ const { postsPerPage: postsPerPageOption, routeBasePath, tagsBasePath, blogDescription, blogTitle, blogSidebarTitle, pageBasePath, } = options;
51
51
  const baseBlogUrl = (0, utils_1.normalizeUrl)([baseUrl, routeBasePath]);
52
52
  const blogTagsListPath = (0, utils_1.normalizeUrl)([baseBlogUrl, tagsBasePath]);
53
- const blogPosts = await (0, blogUtils_1.generateBlogPosts)(contentPaths, context, options);
53
+ let blogPosts = await (0, blogUtils_1.generateBlogPosts)(contentPaths, context, options);
54
+ blogPosts = await (0, blogUtils_1.applyProcessBlogPosts)({
55
+ blogPosts,
56
+ processBlogPosts: options.processBlogPosts,
57
+ });
54
58
  const listedBlogPosts = blogPosts.filter(blogUtils_1.shouldBeListed);
55
59
  if (!blogPosts.length) {
56
60
  return {
@@ -59,10 +63,9 @@ async function pluginContentBlog(context, options) {
59
63
  blogListPaginated: [],
60
64
  blogTags: {},
61
65
  blogTagsListPath,
62
- blogTagsPaginated: [],
63
66
  };
64
67
  }
65
- // Colocate next and prev metadata.
68
+ // Collocate next and prev metadata.
66
69
  listedBlogPosts.forEach((blogPost, index) => {
67
70
  const prevItem = index > 0 ? listedBlogPosts[index - 1] : null;
68
71
  if (prevItem) {
@@ -87,12 +90,14 @@ async function pluginContentBlog(context, options) {
87
90
  blogDescription,
88
91
  postsPerPageOption,
89
92
  basePageUrl: baseBlogUrl,
93
+ pageBasePath,
90
94
  });
91
95
  const blogTags = (0, blogUtils_1.getBlogTags)({
92
96
  blogPosts,
93
97
  postsPerPageOption,
94
98
  blogDescription,
95
99
  blogTitle,
100
+ pageBasePath,
96
101
  });
97
102
  return {
98
103
  blogSidebarTitle,
@@ -103,7 +108,7 @@ async function pluginContentBlog(context, options) {
103
108
  };
104
109
  },
105
110
  async contentLoaded({ content: blogContents, actions }) {
106
- const { blogListComponent, blogPostComponent, blogTagsListComponent, blogTagsPostsComponent, blogArchiveComponent, routeBasePath, archiveBasePath, } = options;
111
+ const { blogListComponent, blogPostComponent, blogTagsListComponent, blogTagsPostsComponent, blogArchiveComponent, routeBasePath, archiveBasePath, blogTitle, } = options;
107
112
  const { addRoute, createData } = actions;
108
113
  const { blogSidebarTitle, blogPosts, blogListPaginated, blogTags, blogTagsListPath, } = blogContents;
109
114
  const listedBlogPosts = blogPosts.filter(blogUtils_1.shouldBeListed);
@@ -154,6 +159,17 @@ async function pluginContentBlog(context, options) {
154
159
  unlisted: blogPost.metadata.unlisted,
155
160
  })),
156
161
  }, null, 2));
162
+ const blogMetadata = {
163
+ blogBasePath: (0, utils_1.normalizeUrl)([baseUrl, routeBasePath]),
164
+ blogTitle,
165
+ };
166
+ const blogMetadataPath = await createData(`blogMetadata-${pluginId}.json`, JSON.stringify(blogMetadata, null, 2));
167
+ function createBlogPostRouteMetadata(blogPostMeta) {
168
+ return {
169
+ sourceFilePath: (0, utils_1.aliasedSitePathToRelativePath)(blogPostMeta.source),
170
+ lastUpdatedAt: blogPostMeta.lastUpdatedAt,
171
+ };
172
+ }
157
173
  // Create routes for blog entries.
158
174
  await Promise.all(blogPosts.map(async (blogPost) => {
159
175
  const { id, metadata } = blogPost;
@@ -169,6 +185,10 @@ async function pluginContentBlog(context, options) {
169
185
  sidebar: aliasedSource(sidebarProp),
170
186
  content: metadata.source,
171
187
  },
188
+ metadata: createBlogPostRouteMetadata(metadata),
189
+ context: {
190
+ blogMetadata: aliasedSource(blogMetadataPath),
191
+ },
172
192
  });
173
193
  blogItemsToMetadata[id] = metadata;
174
194
  }));
package/lib/options.js CHANGED
@@ -33,11 +33,15 @@ exports.DEFAULT_OPTIONS = {
33
33
  routeBasePath: 'blog',
34
34
  tagsBasePath: 'tags',
35
35
  archiveBasePath: 'archive',
36
+ pageBasePath: 'page',
36
37
  path: 'blog',
37
38
  editLocalizedFiles: false,
38
39
  authorsMapPath: 'authors.yml',
39
40
  readingTime: ({ content, defaultReadingTime }) => defaultReadingTime({ content }),
40
41
  sortPosts: 'descending',
42
+ showLastUpdateTime: false,
43
+ showLastUpdateAuthor: false,
44
+ processBlogPosts: async () => undefined,
41
45
  };
42
46
  const PluginOptionSchema = utils_validation_1.Joi.object({
43
47
  path: utils_validation_1.Joi.string().default(exports.DEFAULT_OPTIONS.path),
@@ -46,6 +50,7 @@ const PluginOptionSchema = utils_validation_1.Joi.object({
46
50
  .allow(null),
47
51
  routeBasePath: utils_validation_1.RouteBasePathSchema.default(exports.DEFAULT_OPTIONS.routeBasePath),
48
52
  tagsBasePath: utils_validation_1.Joi.string().default(exports.DEFAULT_OPTIONS.tagsBasePath),
53
+ pageBasePath: utils_validation_1.Joi.string().default(exports.DEFAULT_OPTIONS.pageBasePath),
49
54
  include: utils_validation_1.Joi.array().items(utils_validation_1.Joi.string()).default(exports.DEFAULT_OPTIONS.include),
50
55
  exclude: utils_validation_1.Joi.array().items(utils_validation_1.Joi.string()).default(exports.DEFAULT_OPTIONS.exclude),
51
56
  postsPerPage: utils_validation_1.Joi.alternatives()
@@ -101,6 +106,11 @@ const PluginOptionSchema = utils_validation_1.Joi.object({
101
106
  sortPosts: utils_validation_1.Joi.string()
102
107
  .valid('descending', 'ascending')
103
108
  .default(exports.DEFAULT_OPTIONS.sortPosts),
109
+ showLastUpdateTime: utils_validation_1.Joi.bool().default(exports.DEFAULT_OPTIONS.showLastUpdateTime),
110
+ showLastUpdateAuthor: utils_validation_1.Joi.bool().default(exports.DEFAULT_OPTIONS.showLastUpdateAuthor),
111
+ processBlogPosts: utils_validation_1.Joi.function()
112
+ .optional()
113
+ .default(() => exports.DEFAULT_OPTIONS.processBlogPosts),
104
114
  }).default(exports.DEFAULT_OPTIONS);
105
115
  function validateOptions({ validate, options, }) {
106
116
  const validatedOptions = validate(PluginOptionSchema, options);
package/package.json CHANGED
@@ -1,12 +1,24 @@
1
1
  {
2
2
  "name": "@docusaurus/plugin-content-blog",
3
- "version": "3.1.1",
3
+ "version": "3.2.1",
4
4
  "description": "Blog plugin for Docusaurus.",
5
5
  "main": "lib/index.js",
6
6
  "types": "src/plugin-content-blog.d.ts",
7
+ "exports": {
8
+ "./lib/*": "./lib/*",
9
+ "./src/*": "./src/*",
10
+ "./client": {
11
+ "type": "./lib/client/index.d.ts",
12
+ "default": "./lib/client/index.js"
13
+ },
14
+ ".": {
15
+ "types": "./src/plugin-content-blog.d.ts",
16
+ "default": "./lib/index.js"
17
+ }
18
+ },
7
19
  "scripts": {
8
- "build": "tsc",
9
- "watch": "tsc --watch",
20
+ "build": "tsc --build",
21
+ "watch": "tsc --build --watch",
10
22
  "test:generate-build-snap": "yarn docusaurus build src/__tests__/__fixtures__/website --out-dir build-snap && yarn rimraf src/__tests__/__fixtures__/website/.docusaurus && yarn rimraf src/__tests__/__fixtures__/website/build-snap/assets && git add src/__tests__/__fixtures__/website/build-snap"
11
23
  },
12
24
  "repository": {
@@ -19,13 +31,13 @@
19
31
  },
20
32
  "license": "MIT",
21
33
  "dependencies": {
22
- "@docusaurus/core": "3.1.1",
23
- "@docusaurus/logger": "3.1.1",
24
- "@docusaurus/mdx-loader": "3.1.1",
25
- "@docusaurus/types": "3.1.1",
26
- "@docusaurus/utils": "3.1.1",
27
- "@docusaurus/utils-common": "3.1.1",
28
- "@docusaurus/utils-validation": "3.1.1",
34
+ "@docusaurus/core": "3.2.1",
35
+ "@docusaurus/logger": "3.2.1",
36
+ "@docusaurus/mdx-loader": "3.2.1",
37
+ "@docusaurus/types": "3.2.1",
38
+ "@docusaurus/utils": "3.2.1",
39
+ "@docusaurus/utils-common": "3.2.1",
40
+ "@docusaurus/utils-validation": "3.2.1",
29
41
  "cheerio": "^1.0.0-rc.12",
30
42
  "feed": "^4.2.2",
31
43
  "fs-extra": "^11.1.1",
@@ -44,5 +56,8 @@
44
56
  "engines": {
45
57
  "node": ">=18.0"
46
58
  },
47
- "gitHead": "8017f6a6776ba1bd7065e630a52fe2c2654e2f1b"
59
+ "devDependencies": {
60
+ "@total-typescript/shoehorn": "^0.1.2"
61
+ },
62
+ "gitHead": "f268e15264e208e6faf26117258162e988b53773"
48
63
  }
package/src/blogUtils.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  getContentPathList,
27
27
  isUnlisted,
28
28
  isDraft,
29
+ readLastUpdateData,
29
30
  } from '@docusaurus/utils';
30
31
  import {validateBlogPostFrontMatter} from './frontMatter';
31
32
  import {type AuthorsMap, getAuthorsMap, getBlogPostAuthors} from './authors';
@@ -57,12 +58,14 @@ export function paginateBlogPosts({
57
58
  blogTitle,
58
59
  blogDescription,
59
60
  postsPerPageOption,
61
+ pageBasePath,
60
62
  }: {
61
63
  blogPosts: BlogPost[];
62
64
  basePageUrl: string;
63
65
  blogTitle: string;
64
66
  blogDescription: string;
65
67
  postsPerPageOption: number | 'ALL';
68
+ pageBasePath: string;
66
69
  }): BlogPaginated[] {
67
70
  const totalCount = blogPosts.length;
68
71
  const postsPerPage =
@@ -73,7 +76,7 @@ export function paginateBlogPosts({
73
76
 
74
77
  function permalink(page: number) {
75
78
  return page > 0
76
- ? normalizeUrl([basePageUrl, `page/${page + 1}`])
79
+ ? normalizeUrl([basePageUrl, pageBasePath, `${page + 1}`])
77
80
  : basePageUrl;
78
81
  }
79
82
 
@@ -111,6 +114,7 @@ export function getBlogTags({
111
114
  blogTitle: string;
112
115
  blogDescription: string;
113
116
  postsPerPageOption: number | 'ALL';
117
+ pageBasePath: string;
114
118
  }): BlogTags {
115
119
  const groups = groupTaggedItems(
116
120
  blogPosts,
@@ -161,25 +165,6 @@ export function parseBlogFileName(
161
165
  return {date: undefined, text, slug};
162
166
  }
163
167
 
164
- function formatBlogPostDate(
165
- locale: string,
166
- date: Date,
167
- calendar: string,
168
- ): string {
169
- try {
170
- return new Intl.DateTimeFormat(locale, {
171
- day: 'numeric',
172
- month: 'long',
173
- year: 'numeric',
174
- timeZone: 'UTC',
175
- calendar,
176
- }).format(date);
177
- } catch (err) {
178
- logger.error`Can't format blog post date "${String(date)}"`;
179
- throw err;
180
- }
181
- }
182
-
183
168
  async function parseBlogPostMarkdownFile({
184
169
  filePath,
185
170
  parseFrontMatter,
@@ -247,6 +232,12 @@ async function processBlogSourceFile(
247
232
 
248
233
  const aliasedSource = aliasedSitePath(blogSourceAbsolute, siteDir);
249
234
 
235
+ const lastUpdate = await readLastUpdateData(
236
+ blogSourceAbsolute,
237
+ options,
238
+ frontMatter.last_update,
239
+ );
240
+
250
241
  const draft = isDraft({frontMatter});
251
242
  const unlisted = isUnlisted({frontMatter});
252
243
 
@@ -274,10 +265,11 @@ async function processBlogSourceFile(
274
265
  }
275
266
 
276
267
  try {
277
- const result = getFileCommitDate(blogSourceAbsolute, {
268
+ const result = await getFileCommitDate(blogSourceAbsolute, {
278
269
  age: 'oldest',
279
270
  includeAuthor: false,
280
271
  });
272
+
281
273
  return result.date;
282
274
  } catch (err) {
283
275
  logger.warn(err);
@@ -286,11 +278,6 @@ async function processBlogSourceFile(
286
278
  }
287
279
 
288
280
  const date = await getDate();
289
- const formattedDate = formatBlogPostDate(
290
- i18n.currentLocale,
291
- date,
292
- i18n.localeConfigs[i18n.currentLocale]!.calendar,
293
- );
294
281
 
295
282
  const title = frontMatter.title ?? contentTitle ?? parsedBlogFileName.text;
296
283
  const description = frontMatter.description ?? excerpt ?? '';
@@ -345,7 +332,6 @@ async function processBlogSourceFile(
345
332
  title,
346
333
  description,
347
334
  date,
348
- formattedDate,
349
335
  tags: normalizeFrontMatterTags(tagsBasePath, frontMatter.tags),
350
336
  readingTime: showReadingTime
351
337
  ? options.readingTime({
@@ -358,6 +344,8 @@ async function processBlogSourceFile(
358
344
  authors,
359
345
  frontMatter,
360
346
  unlisted,
347
+ lastUpdatedAt: lastUpdate.lastUpdatedAt,
348
+ lastUpdatedBy: lastUpdate.lastUpdatedBy,
361
349
  },
362
350
  content,
363
351
  };
@@ -443,3 +431,19 @@ export function linkify({
443
431
 
444
432
  return newContent;
445
433
  }
434
+
435
+ export async function applyProcessBlogPosts({
436
+ blogPosts,
437
+ processBlogPosts,
438
+ }: {
439
+ blogPosts: BlogPost[];
440
+ processBlogPosts: PluginOptions['processBlogPosts'];
441
+ }): Promise<BlogPost[]> {
442
+ const processedBlogPosts = await processBlogPosts({blogPosts});
443
+
444
+ if (Array.isArray(processedBlogPosts)) {
445
+ return processedBlogPosts;
446
+ }
447
+
448
+ return blogPosts;
449
+ }
@@ -0,0 +1,20 @@
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 useRouteContext from '@docusaurus/useRouteContext';
9
+ import type {BlogMetadata} from '@docusaurus/plugin-content-blog';
10
+
11
+ export function useBlogMetadata(): BlogMetadata {
12
+ const routeContext = useRouteContext();
13
+ const blogMetadata = routeContext?.data?.blogMetadata;
14
+ if (!blogMetadata) {
15
+ throw new Error(
16
+ "useBlogMetadata() can't be called on the current route because the blog metadata could not be found in route context",
17
+ );
18
+ }
19
+ return blogMetadata as BlogMetadata;
20
+ }
package/src/feed.ts CHANGED
@@ -11,7 +11,10 @@ import logger from '@docusaurus/logger';
11
11
  import {Feed, type Author as FeedAuthor} from 'feed';
12
12
  import * as srcset from 'srcset';
13
13
  import {normalizeUrl, readOutputHTMLFile} from '@docusaurus/utils';
14
- import {blogPostContainerID} from '@docusaurus/utils-common';
14
+ import {
15
+ blogPostContainerID,
16
+ applyTrailingSlash,
17
+ } from '@docusaurus/utils-common';
15
18
  import {load as cheerioLoad} from 'cheerio';
16
19
  import type {DocusaurusConfig} from '@docusaurus/types';
17
20
  import type {
@@ -40,8 +43,14 @@ async function generateBlogFeed({
40
43
  }
41
44
 
42
45
  const {feedOptions, routeBasePath} = options;
43
- const {url: siteUrl, baseUrl, title, favicon} = siteConfig;
44
- const blogBaseUrl = normalizeUrl([siteUrl, baseUrl, routeBasePath]);
46
+ const {url: siteUrl, baseUrl, title, favicon, trailingSlash} = siteConfig;
47
+ const blogBaseUrl = applyTrailingSlash(
48
+ normalizeUrl([siteUrl, baseUrl, routeBasePath]),
49
+ {
50
+ trailingSlash,
51
+ baseUrl,
52
+ },
53
+ );
45
54
 
46
55
  const blogPostsForFeed =
47
56
  feedOptions.limit === false || feedOptions.limit === null
@@ -85,7 +94,7 @@ async function defaultCreateFeedItems({
85
94
  siteConfig: DocusaurusConfig;
86
95
  outDir: string;
87
96
  }): Promise<BlogFeedItem[]> {
88
- const {url: siteUrl} = siteConfig;
97
+ const {url: siteUrl, baseUrl, trailingSlash} = siteConfig;
89
98
 
90
99
  function toFeedAuthor(author: Author): FeedAuthor {
91
100
  return {name: author.name, link: author.url, email: author.email};
@@ -105,13 +114,19 @@ async function defaultCreateFeedItems({
105
114
  } = post;
106
115
 
107
116
  const content = await readOutputHTMLFile(
108
- permalink.replace(siteConfig.baseUrl, ''),
117
+ permalink.replace(baseUrl, ''),
109
118
  outDir,
110
- siteConfig.trailingSlash,
119
+ trailingSlash,
111
120
  );
112
121
  const $ = cheerioLoad(content);
113
122
 
114
- const blogPostAbsoluteUrl = normalizeUrl([siteUrl, permalink]);
123
+ const blogPostAbsoluteUrl = applyTrailingSlash(
124
+ normalizeUrl([siteUrl, permalink]),
125
+ {
126
+ trailingSlash,
127
+ baseUrl,
128
+ },
129
+ );
115
130
 
116
131
  const toAbsoluteUrl = (src: string) =>
117
132
  String(new URL(src, blogPostAbsoluteUrl));
@@ -4,14 +4,14 @@
4
4
  * This source code is licensed under the MIT license found in the
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
-
8
7
  import {
8
+ ContentVisibilitySchema,
9
+ FrontMatterLastUpdateSchema,
10
+ FrontMatterTOCHeadingLevels,
11
+ FrontMatterTagsSchema,
9
12
  JoiFrontMatter as Joi, // Custom instance for front matter
10
13
  URISchema,
11
14
  validateFrontMatter,
12
- FrontMatterTagsSchema,
13
- FrontMatterTOCHeadingLevels,
14
- ContentVisibilitySchema,
15
15
  } from '@docusaurus/utils-validation';
16
16
  import type {BlogPostFrontMatter} from '@docusaurus/plugin-content-blog';
17
17
 
@@ -69,6 +69,7 @@ const BlogFrontMatterSchema = Joi.object<BlogPostFrontMatter>({
69
69
  hide_table_of_contents: Joi.boolean(),
70
70
 
71
71
  ...FrontMatterTOCHeadingLevels,
72
+ last_update: FrontMatterLastUpdateSchema,
72
73
  })
73
74
  .messages({
74
75
  'deprecate.error':
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  normalizeUrl,
12
12
  docuHash,
13
13
  aliasedSitePath,
14
+ aliasedSitePathToRelativePath,
14
15
  getPluginI18nPath,
15
16
  posixPath,
16
17
  addTrailingPathSeparator,
@@ -20,11 +21,12 @@ import {
20
21
  DEFAULT_PLUGIN_ID,
21
22
  } from '@docusaurus/utils';
22
23
  import {
23
- generateBlogPosts,
24
24
  getSourceToPermalink,
25
25
  getBlogTags,
26
26
  paginateBlogPosts,
27
27
  shouldBeListed,
28
+ applyProcessBlogPosts,
29
+ generateBlogPosts,
28
30
  } from './blogUtils';
29
31
  import footnoteIDFixer from './remark/footnoteIDFixer';
30
32
  import {translateContent, getTranslationFiles} from './translations';
@@ -32,7 +34,12 @@ import {createBlogFeedFiles} from './feed';
32
34
 
33
35
  import {toTagProp, toTagsProp} from './props';
34
36
  import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
35
- import type {LoadContext, Plugin, HtmlTags} from '@docusaurus/types';
37
+ import type {
38
+ LoadContext,
39
+ Plugin,
40
+ HtmlTags,
41
+ RouteMetadata,
42
+ } from '@docusaurus/types';
36
43
  import type {
37
44
  PluginOptions,
38
45
  BlogPostFrontMatter,
@@ -42,6 +49,7 @@ import type {
42
49
  BlogTags,
43
50
  BlogContent,
44
51
  BlogPaginated,
52
+ BlogMetadata,
45
53
  } from '@docusaurus/plugin-content-blog';
46
54
 
47
55
  export default async function pluginContentBlog(
@@ -107,11 +115,16 @@ export default async function pluginContentBlog(
107
115
  blogDescription,
108
116
  blogTitle,
109
117
  blogSidebarTitle,
118
+ pageBasePath,
110
119
  } = options;
111
120
 
112
121
  const baseBlogUrl = normalizeUrl([baseUrl, routeBasePath]);
113
122
  const blogTagsListPath = normalizeUrl([baseBlogUrl, tagsBasePath]);
114
- const blogPosts = await generateBlogPosts(contentPaths, context, options);
123
+ let blogPosts = await generateBlogPosts(contentPaths, context, options);
124
+ blogPosts = await applyProcessBlogPosts({
125
+ blogPosts,
126
+ processBlogPosts: options.processBlogPosts,
127
+ });
115
128
  const listedBlogPosts = blogPosts.filter(shouldBeListed);
116
129
 
117
130
  if (!blogPosts.length) {
@@ -121,11 +134,10 @@ export default async function pluginContentBlog(
121
134
  blogListPaginated: [],
122
135
  blogTags: {},
123
136
  blogTagsListPath,
124
- blogTagsPaginated: [],
125
137
  };
126
138
  }
127
139
 
128
- // Colocate next and prev metadata.
140
+ // Collocate next and prev metadata.
129
141
  listedBlogPosts.forEach((blogPost, index) => {
130
142
  const prevItem = index > 0 ? listedBlogPosts[index - 1] : null;
131
143
  if (prevItem) {
@@ -153,6 +165,7 @@ export default async function pluginContentBlog(
153
165
  blogDescription,
154
166
  postsPerPageOption,
155
167
  basePageUrl: baseBlogUrl,
168
+ pageBasePath,
156
169
  });
157
170
 
158
171
  const blogTags: BlogTags = getBlogTags({
@@ -160,6 +173,7 @@ export default async function pluginContentBlog(
160
173
  postsPerPageOption,
161
174
  blogDescription,
162
175
  blogTitle,
176
+ pageBasePath,
163
177
  });
164
178
 
165
179
  return {
@@ -180,6 +194,7 @@ export default async function pluginContentBlog(
180
194
  blogArchiveComponent,
181
195
  routeBasePath,
182
196
  archiveBasePath,
197
+ blogTitle,
183
198
  } = options;
184
199
 
185
200
  const {addRoute, createData} = actions;
@@ -255,6 +270,24 @@ export default async function pluginContentBlog(
255
270
  ),
256
271
  );
257
272
 
273
+ const blogMetadata: BlogMetadata = {
274
+ blogBasePath: normalizeUrl([baseUrl, routeBasePath]),
275
+ blogTitle,
276
+ };
277
+ const blogMetadataPath = await createData(
278
+ `blogMetadata-${pluginId}.json`,
279
+ JSON.stringify(blogMetadata, null, 2),
280
+ );
281
+
282
+ function createBlogPostRouteMetadata(
283
+ blogPostMeta: BlogPostMetadata,
284
+ ): RouteMetadata {
285
+ return {
286
+ sourceFilePath: aliasedSitePathToRelativePath(blogPostMeta.source),
287
+ lastUpdatedAt: blogPostMeta.lastUpdatedAt,
288
+ };
289
+ }
290
+
258
291
  // Create routes for blog entries.
259
292
  await Promise.all(
260
293
  blogPosts.map(async (blogPost) => {
@@ -274,6 +307,10 @@ export default async function pluginContentBlog(
274
307
  sidebar: aliasedSource(sidebarProp),
275
308
  content: metadata.source,
276
309
  },
310
+ metadata: createBlogPostRouteMetadata(metadata),
311
+ context: {
312
+ blogMetadata: aliasedSource(blogMetadataPath),
313
+ },
277
314
  });
278
315
 
279
316
  blogItemsToMetadata[id] = metadata;
package/src/options.ts CHANGED
@@ -45,11 +45,15 @@ export const DEFAULT_OPTIONS: PluginOptions = {
45
45
  routeBasePath: 'blog',
46
46
  tagsBasePath: 'tags',
47
47
  archiveBasePath: 'archive',
48
+ pageBasePath: 'page',
48
49
  path: 'blog',
49
50
  editLocalizedFiles: false,
50
51
  authorsMapPath: 'authors.yml',
51
52
  readingTime: ({content, defaultReadingTime}) => defaultReadingTime({content}),
52
53
  sortPosts: 'descending',
54
+ showLastUpdateTime: false,
55
+ showLastUpdateAuthor: false,
56
+ processBlogPosts: async () => undefined,
53
57
  };
54
58
 
55
59
  const PluginOptionSchema = Joi.object<PluginOptions>({
@@ -59,6 +63,7 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
59
63
  .allow(null),
60
64
  routeBasePath: RouteBasePathSchema.default(DEFAULT_OPTIONS.routeBasePath),
61
65
  tagsBasePath: Joi.string().default(DEFAULT_OPTIONS.tagsBasePath),
66
+ pageBasePath: Joi.string().default(DEFAULT_OPTIONS.pageBasePath),
62
67
  include: Joi.array().items(Joi.string()).default(DEFAULT_OPTIONS.include),
63
68
  exclude: Joi.array().items(Joi.string()).default(DEFAULT_OPTIONS.exclude),
64
69
  postsPerPage: Joi.alternatives()
@@ -132,6 +137,13 @@ const PluginOptionSchema = Joi.object<PluginOptions>({
132
137
  sortPosts: Joi.string()
133
138
  .valid('descending', 'ascending')
134
139
  .default(DEFAULT_OPTIONS.sortPosts),
140
+ showLastUpdateTime: Joi.bool().default(DEFAULT_OPTIONS.showLastUpdateTime),
141
+ showLastUpdateAuthor: Joi.bool().default(
142
+ DEFAULT_OPTIONS.showLastUpdateAuthor,
143
+ ),
144
+ processBlogPosts: Joi.function()
145
+ .optional()
146
+ .default(() => DEFAULT_OPTIONS.processBlogPosts),
135
147
  }).default(DEFAULT_OPTIONS);
136
148
 
137
149
  export function validateOptions({
@@ -5,10 +5,17 @@
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
7
 
8
+ /// <reference types="@docusaurus/module-type-aliases" />
9
+
8
10
  declare module '@docusaurus/plugin-content-blog' {
9
11
  import type {LoadedMDXContent} from '@docusaurus/mdx-loader';
10
12
  import type {MDXOptions} from '@docusaurus/mdx-loader';
11
- import type {FrontMatterTag, Tag} from '@docusaurus/utils';
13
+ import type {
14
+ FrontMatterTag,
15
+ Tag,
16
+ LastUpdateData,
17
+ FrontMatterLastUpdate,
18
+ } from '@docusaurus/utils';
12
19
  import type {DocusaurusConfig, Plugin, LoadContext} from '@docusaurus/types';
13
20
  import type {Item as FeedItem} from 'feed';
14
21
  import type {Overwrite} from 'utility-types';
@@ -154,6 +161,8 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
154
161
  toc_min_heading_level?: number;
155
162
  /** Maximum TOC heading level. Must be between 2 and 6. */
156
163
  toc_max_heading_level?: number;
164
+ /** Allows overriding the last updated author and/or date. */
165
+ last_update?: FrontMatterLastUpdate;
157
166
  };
158
167
 
159
168
  export type BlogPostFrontMatterAuthor = Author & {
@@ -178,7 +187,7 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
178
187
  | BlogPostFrontMatterAuthor
179
188
  | (string | BlogPostFrontMatterAuthor)[];
180
189
 
181
- export type BlogPostMetadata = {
190
+ export type BlogPostMetadata = LastUpdateData & {
182
191
  /** Path to the Markdown source, with `@site` alias. */
183
192
  readonly source: string;
184
193
  /**
@@ -190,11 +199,6 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
190
199
  * into a string.
191
200
  */
192
201
  readonly date: Date;
193
- /**
194
- * Publish date formatted according to the locale, so that the client can
195
- * render the date regardless of the existence of `Intl.DateTimeFormat`.
196
- */
197
- readonly formattedDate: string;
198
202
  /** Full link including base URL. */
199
203
  readonly permalink: string;
200
204
  /**
@@ -333,6 +337,11 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
333
337
  defaultReadingTime: ReadingTimeFunction;
334
338
  },
335
339
  ) => number | undefined;
340
+
341
+ export type ProcessBlogPostsFn = (params: {
342
+ blogPosts: BlogPost[];
343
+ }) => Promise<void | BlogPost[]>;
344
+
336
345
  /**
337
346
  * Plugin options after normalization.
338
347
  */
@@ -351,9 +360,14 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
351
360
  routeBasePath: string;
352
361
  /**
353
362
  * URL route for the tags section of your blog. Will be appended to
354
- * `routeBasePath`. **DO NOT** include a trailing slash.
363
+ * `routeBasePath`.
355
364
  */
356
365
  tagsBasePath: string;
366
+ /**
367
+ * URL route for the pages section of your blog. Will be appended to
368
+ * `routeBasePath`.
369
+ */
370
+ pageBasePath: string;
357
371
  /**
358
372
  * URL route for the archive section of your blog. Will be appended to
359
373
  * `routeBasePath`. **DO NOT** include a trailing slash. Use `null` to
@@ -419,6 +433,14 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
419
433
  readingTime: ReadingTimeFunctionOption;
420
434
  /** Governs the direction of blog post sorting. */
421
435
  sortPosts: 'ascending' | 'descending';
436
+ /** Whether to display the last date the doc was updated. */
437
+ showLastUpdateTime: boolean;
438
+ /** Whether to display the author who last updated the doc. */
439
+ showLastUpdateAuthor: boolean;
440
+ /** An optional function which can be used to transform blog posts
441
+ * (filter, modify, delete, etc...).
442
+ */
443
+ processBlogPosts: ProcessBlogPostsFn;
422
444
  };
423
445
 
424
446
  /**
@@ -461,6 +483,13 @@ yarn workspace v1.22.19image` is a collocated image path, this entry will be the
461
483
  blogTagsListPath: string;
462
484
  };
463
485
 
486
+ export type BlogMetadata = {
487
+ /** the path to the base of the blog */
488
+ blogBasePath: string;
489
+ /** title of the overall blog */
490
+ blogTitle: string;
491
+ };
492
+
464
493
  export type BlogTags = {
465
494
  [permalink: string]: BlogTag;
466
495
  };
@@ -532,6 +561,7 @@ declare module '@theme/BlogPostPage' {
532
561
  BlogPostFrontMatter,
533
562
  BlogSidebar,
534
563
  PropBlogPostContent,
564
+ BlogMetadata,
535
565
  } from '@docusaurus/plugin-content-blog';
536
566
 
537
567
  export type FrontMatter = BlogPostFrontMatter;
@@ -543,6 +573,8 @@ declare module '@theme/BlogPostPage' {
543
573
  readonly sidebar: BlogSidebar;
544
574
  /** Content of this post as an MDX component, with useful metadata. */
545
575
  readonly content: Content;
576
+ /** Metadata about the blog. */
577
+ readonly blogMetadata: BlogMetadata;
546
578
  }
547
579
 
548
580
  export default function BlogPostPage(props: Props): JSX.Element;
@@ -552,6 +584,10 @@ declare module '@theme/BlogPostPage/Metadata' {
552
584
  export default function BlogPostPageMetadata(): JSX.Element;
553
585
  }
554
586
 
587
+ declare module '@theme/BlogPostPage/StructuredData' {
588
+ export default function BlogPostStructuredData(): JSX.Element;
589
+ }
590
+
555
591
  declare module '@theme/BlogListPage' {
556
592
  import type {Content} from '@theme/BlogPostPage';
557
593
  import type {
@@ -574,6 +610,28 @@ declare module '@theme/BlogListPage' {
574
610
  export default function BlogListPage(props: Props): JSX.Element;
575
611
  }
576
612
 
613
+ declare module '@theme/BlogListPage/StructuredData' {
614
+ import type {Content} from '@theme/BlogPostPage';
615
+ import type {
616
+ BlogSidebar,
617
+ BlogPaginatedMetadata,
618
+ } from '@docusaurus/plugin-content-blog';
619
+
620
+ export interface Props {
621
+ /** Blog sidebar. */
622
+ readonly sidebar: BlogSidebar;
623
+ /** Metadata of the current listing page. */
624
+ readonly metadata: BlogPaginatedMetadata;
625
+ /**
626
+ * Array of blog posts included on this page. Every post's metadata is also
627
+ * available.
628
+ */
629
+ readonly items: readonly {readonly content: Content}[];
630
+ }
631
+
632
+ export default function BlogListPageStructuredData(props: Props): JSX.Element;
633
+ }
634
+
577
635
  declare module '@theme/BlogTagsListPage' {
578
636
  import type {BlogSidebar} from '@docusaurus/plugin-content-blog';
579
637
  import type {TagsListItem} from '@docusaurus/utils';