@docusaurus/plugin-content-blog 2.0.0-beta.1ab8aa0af → 2.0.0-beta.2

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.
@@ -26,18 +26,18 @@ const BlogFrontMatterSchema = utils_validation_1.JoiFrontMatter.object({
26
26
  date: utils_validation_1.JoiFrontMatter.date().raw(),
27
27
  author: utils_validation_1.JoiFrontMatter.string(),
28
28
  author_title: utils_validation_1.JoiFrontMatter.string(),
29
- author_url: utils_validation_1.JoiFrontMatter.string().uri(),
30
- author_image_url: utils_validation_1.JoiFrontMatter.string().uri(),
29
+ author_url: utils_validation_1.URISchema,
30
+ author_image_url: utils_validation_1.URISchema,
31
31
  slug: utils_validation_1.JoiFrontMatter.string(),
32
- image: utils_validation_1.JoiFrontMatter.string().uri({ relativeOnly: true }),
32
+ image: utils_validation_1.URISchema,
33
33
  keywords: utils_validation_1.JoiFrontMatter.array().items(utils_validation_1.JoiFrontMatter.string().required()),
34
34
  hide_table_of_contents: utils_validation_1.JoiFrontMatter.boolean(),
35
35
  // TODO re-enable warnings later, our v1 blog posts use those older frontmatter fields
36
- authorURL: utils_validation_1.JoiFrontMatter.string().uri(),
36
+ authorURL: utils_validation_1.URISchema,
37
37
  // .warning('deprecate.error', { alternative: '"author_url"'}),
38
38
  authorTitle: utils_validation_1.JoiFrontMatter.string(),
39
39
  // .warning('deprecate.error', { alternative: '"author_title"'}),
40
- authorImageURL: utils_validation_1.JoiFrontMatter.string().uri(),
40
+ authorImageURL: utils_validation_1.URISchema,
41
41
  // .warning('deprecate.error', { alternative: '"author_image_url"'}),
42
42
  })
43
43
  .unknown()
package/lib/blogUtils.js CHANGED
@@ -34,18 +34,31 @@ function toUrl({ date, link }) {
34
34
  .substring(0, '2019-01-01'.length)
35
35
  .replace(/-/g, '/')}/${link}`;
36
36
  }
37
+ function formatBlogPostDate(locale, date) {
38
+ try {
39
+ return new Intl.DateTimeFormat(locale, {
40
+ day: 'numeric',
41
+ month: 'long',
42
+ year: 'numeric',
43
+ timeZone: 'UTC',
44
+ }).format(date);
45
+ }
46
+ catch (e) {
47
+ throw new Error(`Can't format blog post date "${date}"`);
48
+ }
49
+ }
37
50
  async function generateBlogFeed(contentPaths, context, options) {
38
51
  if (!options.feedOptions) {
39
- throw new Error('Invalid options - `feedOptions` is not expected to be null.');
52
+ throw new Error('Invalid options: "feedOptions" is not expected to be null.');
40
53
  }
41
54
  const { siteConfig } = context;
42
55
  const blogPosts = await generateBlogPosts(contentPaths, context, options);
43
- if (blogPosts == null) {
56
+ if (!blogPosts.length) {
44
57
  return null;
45
58
  }
46
59
  const { feedOptions, routeBasePath } = options;
47
- const { url: siteUrl, title, favicon } = siteConfig;
48
- const blogBaseUrl = utils_1.normalizeUrl([siteUrl, routeBasePath]);
60
+ const { url: siteUrl, baseUrl, title, favicon } = siteConfig;
61
+ const blogBaseUrl = utils_1.normalizeUrl([siteUrl, baseUrl, routeBasePath]);
49
62
  const updated = (blogPosts[0] && blogPosts[0].metadata.date) ||
50
63
  new Date('2015-10-25T16:29:00.000-07:00');
51
64
  const feed = new feed_1.Feed({
@@ -55,7 +68,7 @@ async function generateBlogFeed(contentPaths, context, options) {
55
68
  language: feedOptions.language,
56
69
  link: blogBaseUrl,
57
70
  description: feedOptions.description || `${siteConfig.title} Blog`,
58
- favicon: utils_1.normalizeUrl([siteUrl, favicon]),
71
+ favicon: favicon ? utils_1.normalizeUrl([siteUrl, baseUrl, favicon]) : undefined,
59
72
  copyright: feedOptions.copyright,
60
73
  });
61
74
  blogPosts.forEach((post) => {
@@ -81,12 +94,12 @@ async function generateBlogPosts(contentPaths, { siteConfig, siteDir, i18n }, op
81
94
  cwd: contentPaths.contentPath,
82
95
  });
83
96
  const blogPosts = [];
84
- await Promise.all(blogSourceFiles.map(async (blogSourceFile) => {
97
+ async function processBlogSourceFile(blogSourceFile) {
85
98
  var _a, _b, _c, _d, _e, _f;
86
99
  // Lookup in localized folder in priority
87
100
  const blogDirPath = await utils_1.getFolderContainingFile(getContentPathList(contentPaths), blogSourceFile);
88
101
  const source = path_1.default.join(blogDirPath, blogSourceFile);
89
- const { frontMatter: unsafeFrontMatter, content, contentTitle, excerpt, } = await utils_1.parseMarkdownFile(source);
102
+ const { frontMatter: unsafeFrontMatter, content, contentTitle, excerpt, } = await utils_1.parseMarkdownFile(source, { removeContentTitle: true });
90
103
  const frontMatter = blogFrontMatter_1.validateBlogPostFrontMatter(unsafeFrontMatter);
91
104
  const aliasedSource = utils_1.aliasedSitePath(source, siteDir);
92
105
  const blogFileName = path_1.default.basename(blogSourceFile);
@@ -94,7 +107,7 @@ async function generateBlogPosts(contentPaths, { siteConfig, siteDir, i18n }, op
94
107
  return;
95
108
  }
96
109
  if (frontMatter.id) {
97
- console.warn(chalk_1.default.yellow(`${blogFileName} - 'id' header option is deprecated. Please use 'slug' option instead.`));
110
+ console.warn(chalk_1.default.yellow(`"id" header option is deprecated in ${blogFileName} file. Please use "slug" option instead.`));
98
111
  }
99
112
  let date;
100
113
  // Extract date and title from filename.
@@ -102,7 +115,8 @@ async function generateBlogPosts(contentPaths, { siteConfig, siteDir, i18n }, op
102
115
  let linkName = blogFileName.replace(/\.mdx?$/, '');
103
116
  if (dateFilenameMatch) {
104
117
  const [, dateString, name] = dateFilenameMatch;
105
- date = new Date(dateString);
118
+ // Always treat dates as UTC by adding the `Z`
119
+ date = new Date(`${dateString}Z`);
106
120
  linkName = name;
107
121
  }
108
122
  // Prefer user-defined date.
@@ -111,11 +125,7 @@ async function generateBlogPosts(contentPaths, { siteConfig, siteDir, i18n }, op
111
125
  }
112
126
  // Use file create time for blog.
113
127
  date = date !== null && date !== void 0 ? date : (await fs_extra_1.default.stat(source)).birthtime;
114
- const formattedDate = new Intl.DateTimeFormat(i18n.currentLocale, {
115
- day: 'numeric',
116
- month: 'long',
117
- year: 'numeric',
118
- }).format(date);
128
+ const formattedDate = formatBlogPostDate(i18n.currentLocale, date);
119
129
  const title = (_b = (_a = frontMatter.title) !== null && _a !== void 0 ? _a : contentTitle) !== null && _b !== void 0 ? _b : linkName;
120
130
  const description = (_d = (_c = frontMatter.description) !== null && _c !== void 0 ? _c : excerpt) !== null && _d !== void 0 ? _d : '';
121
131
  const slug = frontMatter.slug ||
@@ -157,12 +167,19 @@ async function generateBlogPosts(contentPaths, { siteConfig, siteDir, i18n }, op
157
167
  date,
158
168
  formattedDate,
159
169
  tags: (_f = frontMatter.tags) !== null && _f !== void 0 ? _f : [],
160
- readingTime: showReadingTime
161
- ? reading_time_1.default(content).minutes
162
- : undefined,
170
+ readingTime: showReadingTime ? reading_time_1.default(content).minutes : undefined,
163
171
  truncated: (truncateMarker === null || truncateMarker === void 0 ? void 0 : truncateMarker.test(content)) || false,
164
172
  },
165
173
  });
174
+ }
175
+ await Promise.all(blogSourceFiles.map(async (blogSourceFile) => {
176
+ try {
177
+ return await processBlogSourceFile(blogSourceFile);
178
+ }
179
+ catch (e) {
180
+ console.error(chalk_1.default.red(`Processing of blog source file failed for path "${blogSourceFile}"`));
181
+ throw e;
182
+ }
166
183
  }));
167
184
  blogPosts.sort((a, b) => b.metadata.date.getTime() - a.metadata.date.getTime());
168
185
  return blogPosts;
package/lib/index.d.ts CHANGED
@@ -6,5 +6,5 @@
6
6
  */
7
7
  import { PluginOptions, BlogContent } from './types';
8
8
  import { LoadContext, Plugin, OptionValidationContext, ValidationResult } from '@docusaurus/types';
9
- export default function pluginContentBlog(context: LoadContext, options: PluginOptions): Plugin<BlogContent | null>;
9
+ export default function pluginContentBlog(context: LoadContext, options: PluginOptions): Plugin<BlogContent>;
10
10
  export declare function validateOptions({ validate, options, }: OptionValidationContext<PluginOptions>): ValidationResult<PluginOptions>;
package/lib/index.js CHANGED
@@ -37,7 +37,6 @@ function pluginContentBlog(context, options) {
37
37
  const pluginDataDirRoot = path_1.default.join(generatedFilesDir, 'docusaurus-plugin-content-blog');
38
38
  const dataDir = path_1.default.join(pluginDataDirRoot, pluginId);
39
39
  const aliasedSource = (source) => `~blog/${utils_1.posixPath(path_1.default.relative(pluginDataDirRoot, source))}`;
40
- let blogPosts = [];
41
40
  return {
42
41
  name: 'docusaurus-plugin-content-blog',
43
42
  getPathsToWatch() {
@@ -56,9 +55,14 @@ function pluginContentBlog(context, options) {
56
55
  // Fetches blog contents and returns metadata for the necessary routes.
57
56
  async loadContent() {
58
57
  const { postsPerPage, routeBasePath } = options;
59
- blogPosts = await blogUtils_1.generateBlogPosts(contentPaths, context, options);
58
+ const blogPosts = await blogUtils_1.generateBlogPosts(contentPaths, context, options);
60
59
  if (!blogPosts.length) {
61
- return null;
60
+ return {
61
+ blogPosts: [],
62
+ blogListPaginated: [],
63
+ blogTags: {},
64
+ blogTagsListPath: null,
65
+ };
62
66
  }
63
67
  // Colocate next and prev metadata.
64
68
  blogPosts.forEach((blogPost, index) => {
@@ -155,7 +159,7 @@ function pluginContentBlog(context, options) {
155
159
  }
156
160
  const { blogListComponent, blogPostComponent, blogTagsListComponent, blogTagsPostsComponent, } = options;
157
161
  const { addRoute, createData } = actions;
158
- const { blogPosts: loadedBlogPosts, blogListPaginated, blogTags, blogTagsListPath, } = blogContents;
162
+ const { blogPosts, blogListPaginated, blogTags, blogTagsListPath, } = blogContents;
159
163
  const blogItemsToMetadata = {};
160
164
  const sidebarBlogPosts = options.blogSidebarCount === 'ALL'
161
165
  ? blogPosts
@@ -172,7 +176,7 @@ function pluginContentBlog(context, options) {
172
176
  })),
173
177
  }, null, 2));
174
178
  // Create routes for blog entries.
175
- await Promise.all(loadedBlogPosts.map(async (blogPost) => {
179
+ await Promise.all(blogPosts.map(async (blogPost) => {
176
180
  const { id, metadata } = blogPost;
177
181
  await createData(
178
182
  // Note that this created data path must be in sync with
@@ -183,7 +187,7 @@ function pluginContentBlog(context, options) {
183
187
  component: blogPostComponent,
184
188
  exact: true,
185
189
  modules: {
186
- sidebar: sidebarProp,
190
+ sidebar: aliasedSource(sidebarProp),
187
191
  content: metadata.source,
188
192
  },
189
193
  });
@@ -199,7 +203,7 @@ function pluginContentBlog(context, options) {
199
203
  component: blogListComponent,
200
204
  exact: true,
201
205
  modules: {
202
- sidebar: sidebarProp,
206
+ sidebar: aliasedSource(sidebarProp),
203
207
  items: items.map((postID) => {
204
208
  // To tell routes.js this is an import and not a nested object to recurse.
205
209
  return {
@@ -236,7 +240,7 @@ function pluginContentBlog(context, options) {
236
240
  component: blogTagsPostsComponent,
237
241
  exact: true,
238
242
  modules: {
239
- sidebar: sidebarProp,
243
+ sidebar: aliasedSource(sidebarProp),
240
244
  items: items.map((postID) => {
241
245
  const metadata = blogItemsToMetadata[postID];
242
246
  return {
@@ -261,19 +265,19 @@ function pluginContentBlog(context, options) {
261
265
  component: blogTagsListComponent,
262
266
  exact: true,
263
267
  modules: {
264
- sidebar: sidebarProp,
268
+ sidebar: aliasedSource(sidebarProp),
265
269
  tags: aliasedSource(tagsListPath),
266
270
  },
267
271
  });
268
272
  }
269
273
  },
270
- configureWebpack(_config, isServer, { getJSLoader }) {
274
+ configureWebpack(_config, isServer, { getJSLoader }, content) {
271
275
  const { rehypePlugins, remarkPlugins, truncateMarker, beforeDefaultRemarkPlugins, beforeDefaultRehypePlugins, } = options;
272
276
  const markdownLoaderOptions = {
273
277
  siteDir,
274
278
  contentPaths,
275
279
  truncateMarker,
276
- sourceToPermalink: blogUtils_1.getSourceToPermalink(blogPosts),
280
+ sourceToPermalink: blogUtils_1.getSourceToPermalink(content.blogPosts),
277
281
  onBrokenMarkdownLink: (brokenMarkdownLink) => {
278
282
  if (onBrokenMarkdownLinks === 'ignore') {
279
283
  return;
@@ -310,6 +314,9 @@ function pluginContentBlog(context, options) {
310
314
  const aliasedPath = utils_1.aliasedSitePath(mdxPath, siteDir);
311
315
  return path_1.default.join(dataDir, `${utils_1.docuHash(aliasedPath)}.json`);
312
316
  },
317
+ // For blog posts a title in markdown is always removed
318
+ // Blog posts title are rendered separately
319
+ removeContentTitle: true,
313
320
  },
314
321
  },
315
322
  {
@@ -339,12 +346,15 @@ function pluginContentBlog(context, options) {
339
346
  await fs_extra_1.default.outputFile(feedPath, feedContent);
340
347
  }
341
348
  catch (err) {
342
- throw new Error(`Generating ${feedType} feed failed: ${err}`);
349
+ throw new Error(`Generating ${feedType} feed failed: ${err}.`);
343
350
  }
344
351
  }));
345
352
  },
346
- injectHtmlTags() {
353
+ injectHtmlTags({ content }) {
347
354
  var _a;
355
+ if (!content.blogPosts.length) {
356
+ return {};
357
+ }
348
358
  if (!((_a = options.feedOptions) === null || _a === void 0 ? void 0 : _a.type)) {
349
359
  return {};
350
360
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docusaurus/plugin-content-blog",
3
- "version": "2.0.0-beta.1ab8aa0af",
3
+ "version": "2.0.0-beta.2",
4
4
  "description": "Blog plugin for Docusaurus.",
5
5
  "main": "lib/index.js",
6
6
  "types": "index.d.ts",
@@ -18,21 +18,22 @@
18
18
  },
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "@docusaurus/core": "2.0.0-beta.1ab8aa0af",
22
- "@docusaurus/mdx-loader": "2.0.0-beta.1ab8aa0af",
23
- "@docusaurus/types": "2.0.0-beta.1ab8aa0af",
24
- "@docusaurus/utils": "2.0.0-beta.1ab8aa0af",
25
- "@docusaurus/utils-validation": "2.0.0-beta.1ab8aa0af",
26
- "chalk": "^4.1.0",
21
+ "@docusaurus/core": "2.0.0-beta.2",
22
+ "@docusaurus/mdx-loader": "2.0.0-beta.2",
23
+ "@docusaurus/types": "2.0.0-beta.2",
24
+ "@docusaurus/utils": "2.0.0-beta.2",
25
+ "@docusaurus/utils-validation": "2.0.0-beta.2",
26
+ "chalk": "^4.1.1",
27
+ "escape-string-regexp": "^4.0.0",
27
28
  "feed": "^4.2.2",
28
- "fs-extra": "^9.1.0",
29
+ "fs-extra": "^10.0.0",
29
30
  "globby": "^11.0.2",
30
31
  "loader-utils": "^2.0.0",
31
32
  "lodash": "^4.17.20",
32
33
  "reading-time": "^1.3.0",
33
34
  "remark-admonitions": "^1.2.1",
34
- "tslib": "^2.1.0",
35
- "webpack": "^5.28.0"
35
+ "tslib": "^2.2.0",
36
+ "webpack": "^5.40.0"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "react": "^16.8.4 || ^17.0.0",
@@ -41,5 +42,5 @@
41
42
  "engines": {
42
43
  "node": ">=12.13.0"
43
44
  },
44
- "gitHead": "63c06baac39707a1a4c3cc4c0031cb2163f256c4"
45
+ "gitHead": "883f07fddffaf1657407c8e202e370cc436e25f7"
45
46
  }
@@ -1,75 +1,50 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
- exports[`blogFeed atom can show feed without posts 1`] = `
4
- "<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
5
- <feed xmlns=\\"http://www.w3.org/2005/Atom\\">
6
- <id>https://docusaurus.io/blog</id>
7
- <title>Hello Blog</title>
8
- <updated>2015-10-25T23:29:00.000Z</updated>
9
- <generator>https://github.com/jpmonette/feed</generator>
10
- <link rel=\\"alternate\\" href=\\"https://docusaurus.io/blog\\"/>
11
- <subtitle>Hello Blog</subtitle>
12
- <icon>https://docusaurus.io/image/favicon.ico</icon>
13
- <rights>Copyright</rights>
14
- </feed>"
15
- `;
3
+ exports[`blogFeed atom should not show feed without posts 1`] = `null`;
16
4
 
17
5
  exports[`blogFeed atom shows feed item for each post 1`] = `
18
6
  "<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
19
7
  <feed xmlns=\\"http://www.w3.org/2005/Atom\\">
20
- <id>https://docusaurus.io/blog</id>
8
+ <id>https://docusaurus.io/myBaseUrl/blog</id>
21
9
  <title>Hello Blog</title>
22
10
  <updated>2020-02-27T00:00:00.000Z</updated>
23
11
  <generator>https://github.com/jpmonette/feed</generator>
24
- <link rel=\\"alternate\\" href=\\"https://docusaurus.io/blog\\"/>
12
+ <link rel=\\"alternate\\" href=\\"https://docusaurus.io/myBaseUrl/blog\\"/>
25
13
  <subtitle>Hello Blog</subtitle>
26
- <icon>https://docusaurus.io/image/favicon.ico</icon>
14
+ <icon>https://docusaurus.io/myBaseUrl/image/favicon.ico</icon>
27
15
  <rights>Copyright</rights>
28
16
  <entry>
29
17
  <title type=\\"html\\"><![CDATA[draft]]></title>
30
18
  <id>draft</id>
31
- <link href=\\"https://docusaurus.io/blog/draft\\"/>
19
+ <link href=\\"https://docusaurus.io/myBaseUrl/blog/draft\\"/>
32
20
  <updated>2020-02-27T00:00:00.000Z</updated>
33
21
  <summary type=\\"html\\"><![CDATA[this post should not be published yet]]></summary>
34
22
  </entry>
35
23
  <entry>
36
24
  <title type=\\"html\\"><![CDATA[date-matter]]></title>
37
25
  <id>date-matter</id>
38
- <link href=\\"https://docusaurus.io/blog/date-matter\\"/>
26
+ <link href=\\"https://docusaurus.io/myBaseUrl/blog/date-matter\\"/>
39
27
  <updated>2019-01-01T00:00:00.000Z</updated>
40
28
  <summary type=\\"html\\"><![CDATA[date inside front matter]]></summary>
41
29
  </entry>
42
30
  <entry>
43
31
  <title type=\\"html\\"><![CDATA[Happy 1st Birthday Slash! (translated)]]></title>
44
32
  <id>Happy 1st Birthday Slash! (translated)</id>
45
- <link href=\\"https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash\\"/>
33
+ <link href=\\"https://docusaurus.io/myBaseUrl/blog/2018/12/14/Happy-First-Birthday-Slash\\"/>
46
34
  <updated>2018-12-14T00:00:00.000Z</updated>
47
35
  <summary type=\\"html\\"><![CDATA[Happy birthday! (translated)]]></summary>
48
36
  </entry>
49
37
  </feed>"
50
38
  `;
51
39
 
52
- exports[`blogFeed rss can show feed without posts 1`] = `
53
- "<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
54
- <rss version=\\"2.0\\">
55
- <channel>
56
- <title>Hello Blog</title>
57
- <link>https://docusaurus.io/blog</link>
58
- <description>Hello Blog</description>
59
- <lastBuildDate>Sun, 25 Oct 2015 23:29:00 GMT</lastBuildDate>
60
- <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
61
- <generator>https://github.com/jpmonette/feed</generator>
62
- <copyright>Copyright</copyright>
63
- </channel>
64
- </rss>"
65
- `;
40
+ exports[`blogFeed rss should not show feed without posts 1`] = `null`;
66
41
 
67
42
  exports[`blogFeed rss shows feed item for each post 1`] = `
68
43
  "<?xml version=\\"1.0\\" encoding=\\"utf-8\\"?>
69
44
  <rss version=\\"2.0\\">
70
45
  <channel>
71
46
  <title>Hello Blog</title>
72
- <link>https://docusaurus.io/blog</link>
47
+ <link>https://docusaurus.io/myBaseUrl/blog</link>
73
48
  <description>Hello Blog</description>
74
49
  <lastBuildDate>Thu, 27 Feb 2020 00:00:00 GMT</lastBuildDate>
75
50
  <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
@@ -77,21 +52,21 @@ exports[`blogFeed rss shows feed item for each post 1`] = `
77
52
  <copyright>Copyright</copyright>
78
53
  <item>
79
54
  <title><![CDATA[draft]]></title>
80
- <link>https://docusaurus.io/blog/draft</link>
55
+ <link>https://docusaurus.io/myBaseUrl/blog/draft</link>
81
56
  <guid>draft</guid>
82
57
  <pubDate>Thu, 27 Feb 2020 00:00:00 GMT</pubDate>
83
58
  <description><![CDATA[this post should not be published yet]]></description>
84
59
  </item>
85
60
  <item>
86
61
  <title><![CDATA[date-matter]]></title>
87
- <link>https://docusaurus.io/blog/date-matter</link>
62
+ <link>https://docusaurus.io/myBaseUrl/blog/date-matter</link>
88
63
  <guid>date-matter</guid>
89
64
  <pubDate>Tue, 01 Jan 2019 00:00:00 GMT</pubDate>
90
65
  <description><![CDATA[date inside front matter]]></description>
91
66
  </item>
92
67
  <item>
93
68
  <title><![CDATA[Happy 1st Birthday Slash! (translated)]]></title>
94
- <link>https://docusaurus.io/blog/2018/12/14/Happy-First-Birthday-Slash</link>
69
+ <link>https://docusaurus.io/myBaseUrl/blog/2018/12/14/Happy-First-Birthday-Slash</link>
95
70
  <guid>Happy 1st Birthday Slash! (translated)</guid>
96
71
  <pubDate>Fri, 14 Dec 2018 00:00:00 GMT</pubDate>
97
72
  <description><![CDATA[Happy birthday! (translated)]]></description>