From c421f75e6452e2f6469856b460aed56972d90f0b Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 19 Oct 2025 15:12:59 -0400 Subject: [PATCH 1/2] chore: remove blog data from providers, cache snippets --- apps/site/app/[locale]/feed/[feed]/route.ts | 4 +- .../components/Blog/BlogPostCard/index.tsx | 2 +- apps/site/components/withBlogCrossLinks.tsx | 2 +- apps/site/components/withDownloadSection.tsx | 17 +-- apps/site/layouts/Blog.tsx | 2 +- apps/site/next-data/blogData.ts | 16 --- apps/site/next-data/generators/blogData.mjs | 116 ---------------- apps/site/next-data/providers/blogData.ts | 69 ---------- .../next-data/providers/downloadSnippets.ts | 8 +- apps/site/next-data/providers/websiteFeeds.ts | 16 --- apps/site/next.dynamic.constants.mjs | 4 +- apps/site/package.json | 4 +- .../blog-data/__test__/generate.test.mjs} | 28 ++-- apps/site/scripts/blog-data/generate.mjs | 124 ++++++++++++++++-- apps/site/scripts/blog-data/index.mjs | 11 ++ apps/site/types/blog.ts | 2 +- .../__tests__/feeds.test.mjs} | 10 +- apps/site/util/blog.ts | 67 +++++++++- .../websiteFeeds.mjs => util/feeds.ts} | 33 +++-- 19 files changed, 257 insertions(+), 278 deletions(-) delete mode 100644 apps/site/next-data/blogData.ts delete mode 100644 apps/site/next-data/generators/blogData.mjs delete mode 100644 apps/site/next-data/providers/blogData.ts delete mode 100644 apps/site/next-data/providers/websiteFeeds.ts rename apps/site/{next-data/generators/__tests__/blogData.test.mjs => scripts/blog-data/__test__/generate.test.mjs} (92%) create mode 100644 apps/site/scripts/blog-data/index.mjs rename apps/site/{next-data/generators/__tests__/websiteFeeds.test.mjs => util/__tests__/feeds.test.mjs} (80%) rename apps/site/{next-data/generators/websiteFeeds.mjs => util/feeds.ts} (66%) diff --git a/apps/site/app/[locale]/feed/[feed]/route.ts b/apps/site/app/[locale]/feed/[feed]/route.ts index a5abc2981a17b..9a101f55c3aa6 100644 --- a/apps/site/app/[locale]/feed/[feed]/route.ts +++ b/apps/site/app/[locale]/feed/[feed]/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from 'next/server'; -import provideWebsiteFeeds from '#site/next-data/providers/websiteFeeds'; import { siteConfig } from '#site/next.json.mjs'; import { defaultLocale } from '#site/next.locales.mjs'; +import { getFeeds } from '#site/util/feeds'; type DynamicStaticPaths = { locale: string; feed: string }; type StaticParams = { params: Promise }; @@ -14,7 +14,7 @@ export const GET = async (_: Request, props: StaticParams) => { const params = await props.params; // Generate the Feed for the given feed type (blog, releases, etc) - const websiteFeed = provideWebsiteFeeds(params.feed); + const websiteFeed = getFeeds(params.feed); return new NextResponse(websiteFeed, { headers: { 'Content-Type': 'application/xml' }, diff --git a/apps/site/components/Blog/BlogPostCard/index.tsx b/apps/site/components/Blog/BlogPostCard/index.tsx index 9f8c7442685b3..c7788c3de0f84 100644 --- a/apps/site/components/Blog/BlogPostCard/index.tsx +++ b/apps/site/components/Blog/BlogPostCard/index.tsx @@ -15,7 +15,7 @@ type BlogPostCardProps = { category: BlogCategory; description?: string; authors?: Array; - date?: Date; + date?: string | Date; slug?: string; }; diff --git a/apps/site/components/withBlogCrossLinks.tsx b/apps/site/components/withBlogCrossLinks.tsx index c128c919dfb03..6c00a728ff6d6 100644 --- a/apps/site/components/withBlogCrossLinks.tsx +++ b/apps/site/components/withBlogCrossLinks.tsx @@ -2,8 +2,8 @@ import type { FC } from 'react'; import { getClientContext } from '#site/client-context'; import CrossLink from '#site/components/Common/CrossLink'; -import getBlogData from '#site/next-data/blogData'; import type { BlogCategory } from '#site/types'; +import { getBlogData } from '#site/util/blog'; const WithBlogCrossLinks: FC = () => { const { pathname } = getClientContext(); diff --git a/apps/site/components/withDownloadSection.tsx b/apps/site/components/withDownloadSection.tsx index 3caa60d024598..6e435def26847 100644 --- a/apps/site/components/withDownloadSection.tsx +++ b/apps/site/components/withDownloadSection.tsx @@ -1,4 +1,4 @@ -import { useLocale } from 'next-intl'; +import { getLocale } from 'next-intl/server'; import type { FC, PropsWithChildren } from 'react'; import { getClientContext } from '#site/client-context'; @@ -12,21 +12,22 @@ import { import type { NodeRelease } from '../types'; -// By default the translated languages do not contain all the download snippets -// Hence we always merge any translated snippet with the fallbacks for missing snippets -const fallbackSnippets = provideDownloadSnippets(defaultLocale.code); - type WithDownloadSectionProps = PropsWithChildren<{ releases: Array; }>; -const WithDownloadSection: FC = ({ +const WithDownloadSection: FC = async ({ releases, children, }) => { - const locale = useLocale(); + const locale = await getLocale(); + + const snippets = await provideDownloadSnippets(locale); + + // By default the translated languages do not contain all the download snippets + // Hence we always merge any translated snippet with the fallbacks for missing snippets + const fallbackSnippets = await provideDownloadSnippets(defaultLocale.code); - const snippets = provideDownloadSnippets(locale); const { pathname } = getClientContext(); // Some available translations do not have download snippets translated or have them partially translated diff --git a/apps/site/layouts/Blog.tsx b/apps/site/layouts/Blog.tsx index c9d13d401aba8..4103bb258a5d4 100644 --- a/apps/site/layouts/Blog.tsx +++ b/apps/site/layouts/Blog.tsx @@ -6,8 +6,8 @@ import BlogHeader from '#site/components/Blog/BlogHeader'; import WithBlogCategories from '#site/components/withBlogCategories'; import WithFooter from '#site/components/withFooter'; import WithNavBar from '#site/components/withNavBar'; -import getBlogData from '#site/next-data/blogData'; import type { BlogCategory } from '#site/types'; +import { getBlogData } from '#site/util/blog'; import styles from './layouts.module.css'; diff --git a/apps/site/next-data/blogData.ts b/apps/site/next-data/blogData.ts deleted file mode 100644 index 74f068df3d929..0000000000000 --- a/apps/site/next-data/blogData.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { BlogCategory, BlogPostsRSC } from '#site/types'; - -import { - provideBlogPosts, - providePaginatedBlogPosts, -} from './providers/blogData'; - -const getBlogData = (cat: BlogCategory, page?: number): BlogPostsRSC => { - return page && page >= 1 - ? // This allows us to blindly get all blog posts from a given category - // if the page number is 0 or something smaller than 1 - providePaginatedBlogPosts(cat, page) - : provideBlogPosts(cat); -}; - -export default getBlogData; diff --git a/apps/site/next-data/generators/blogData.mjs b/apps/site/next-data/generators/blogData.mjs deleted file mode 100644 index 2041ebc4cacf3..0000000000000 --- a/apps/site/next-data/generators/blogData.mjs +++ /dev/null @@ -1,116 +0,0 @@ -'use strict'; - -import { createReadStream } from 'node:fs'; -import { basename, extname, join } from 'node:path'; -import readline from 'node:readline'; - -import graymatter from 'gray-matter'; - -import { getMarkdownFiles } from '../../next.helpers.mjs'; - -// gets the current blog path based on local module path -const blogPath = join(process.cwd(), 'pages/en/blog'); - -/** - * This method parses the source (raw) Markdown content into Frontmatter - * and returns basic information for blog posts - * - * @param {string} filename the filename related to the blogpost - * @param {string} source the source markdown content of the blog post - */ -const getFrontMatter = (filename, source) => { - const { - title = 'Untitled', - author = 'The Node.js Project', - username, - date = new Date(), - category = 'uncategorized', - } = graymatter(source).data; - - // We also use publishing years as categories for the blog - const publishYear = new Date(date).getUTCFullYear(); - - // Provides a full list of categories for the Blog Post which consists of - // all = (all blog posts), publish year and the actual blog category - const categories = [category, `year-${publishYear}`, 'all']; - - // this is the url used for the blog post it based on the category and filename - const slug = `/blog/${category}/${basename(filename, extname(filename))}`; - - return { - title, - author, - username, - date: new Date(date), - categories, - slug, - }; -}; - -/** - * This method is used to generate the Node.js Website Blog Data - * for self-consumption during RSC and Static Builds - * - * @return {Promise} - */ -const generateBlogData = async () => { - // We retrieve the full pathnames of all Blog Posts to read each file individually - const filenames = await getMarkdownFiles(process.cwd(), 'pages/en/blog', [ - '**/index.md', - ]); - - /** - * This contains the metadata of all available blog categories - */ - const blogCategories = new Set(['all']); - - const posts = await Promise.all( - filenames.map( - filename => - new Promise(resolve => { - // We create a stream for reading a file instead of reading the files - const _stream = createReadStream(join(blogPath, filename)); - - // We create a readline interface to read the file line-by-line - const _readLine = readline.createInterface({ input: _stream }); - - let rawFrontmatter = ''; - let frontmatterSeparatorsEncountered = 0; - - // We read line by line - _readLine.on('line', line => { - rawFrontmatter += `${line}\n`; - - // We observe the frontmatter separators - if (line === '---') { - frontmatterSeparatorsEncountered++; - } - - // Once we have two separators we close the readLine and the stream - if (frontmatterSeparatorsEncountered === 2) { - _readLine.close(); - _stream.close(); - } - }); - - // Then we parse gray-matter on the frontmatter - // This allows us to only read the frontmatter part of each file - // and optimise the read-process as we have thousands of markdown files - _readLine.on('close', () => { - const frontMatterData = getFrontMatter(filename, rawFrontmatter); - - frontMatterData.categories.forEach(category => { - // we add the category to the categories set - blogCategories.add(category); - }); - - resolve(frontMatterData); - }); - }) - ) - ); - - return { categories: [...blogCategories], posts }; -}; - -export default generateBlogData; diff --git a/apps/site/next-data/providers/blogData.ts b/apps/site/next-data/providers/blogData.ts deleted file mode 100644 index a73c4ed2d0b2c..0000000000000 --- a/apps/site/next-data/providers/blogData.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { cache } from 'react'; - -import { BLOG_POSTS_PER_PAGE } from '#site/next.constants.mjs'; -import { blogData } from '#site/next.json.mjs'; -import type { BlogCategory, BlogPostsRSC } from '#site/types'; - -const blogPosts = cache(() => - blogData.posts.map(post => ({ ...post, date: new Date(post.date) })) -); - -export const provideBlogPosts = cache( - (category: BlogCategory): BlogPostsRSC => { - const categoryPosts = blogPosts() - .filter(post => post.categories.includes(category)) - .sort((a, b) => b.date.getTime() - a.date.getTime()); - - // Total amount of possible pages given the amount of blog posts - const total = categoryPosts.length / BLOG_POSTS_PER_PAGE; - - return { - posts: categoryPosts, - pagination: { - prev: null, - next: null, - // In case the division results on a remainder we need - // to have an extra page containing the remainder entries - pages: Math.floor(total % 1 === 0 ? total : total + 1), - total: categoryPosts.length, - }, - }; - } -); - -export const providePaginatedBlogPosts = cache( - (category: BlogCategory, page: number): BlogPostsRSC => { - const { posts, pagination } = provideBlogPosts(category); - - // This autocorrects if invalid numbers are given to only allow - // actual valid numbers to be provided - const actualPage = page < 1 ? 1 : page; - - // If the page is within the allowed range then we calculate - // the pagination of Blog Posts for a given current page "page" - if (actualPage <= pagination.pages) { - return { - posts: posts.slice( - BLOG_POSTS_PER_PAGE * (actualPage - 1), - BLOG_POSTS_PER_PAGE * actualPage - ), - pagination: { - prev: actualPage > 1 ? actualPage - 1 : null, - next: actualPage < pagination.pages ? actualPage + 1 : null, - pages: pagination.pages, - total: posts.length, - }, - }; - } - - return { - posts: [], - pagination: { - prev: pagination.total, - next: null, - pages: pagination.pages, - total: posts.length, - }, - }; - } -); diff --git a/apps/site/next-data/providers/downloadSnippets.ts b/apps/site/next-data/providers/downloadSnippets.ts index 3467d02838275..2c1674ad0f9c8 100644 --- a/apps/site/next-data/providers/downloadSnippets.ts +++ b/apps/site/next-data/providers/downloadSnippets.ts @@ -1,15 +1,15 @@ -import { cache } from 'react'; +'use cache'; import generateDownloadSnippets from '#site/next-data/generators/downloadSnippets.mjs'; -const downloadSnippets = await generateDownloadSnippets(); +const provideDownloadSnippets = async (language: string) => { + const downloadSnippets = await generateDownloadSnippets(); -const provideDownloadSnippets = cache((language: string) => { if (downloadSnippets.has(language)) { return downloadSnippets.get(language)!; } return []; -}); +}; export default provideDownloadSnippets; diff --git a/apps/site/next-data/providers/websiteFeeds.ts b/apps/site/next-data/providers/websiteFeeds.ts deleted file mode 100644 index ca3b911d78cbe..0000000000000 --- a/apps/site/next-data/providers/websiteFeeds.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { cache } from 'react'; - -import generateWebsiteFeeds from '#site/next-data/generators/websiteFeeds.mjs'; -import { provideBlogPosts } from '#site/next-data/providers/blogData'; - -const websiteFeeds = generateWebsiteFeeds(provideBlogPosts('all')); - -const provideWebsiteFeeds = cache((feed: string) => { - if (feed.includes('.xml') && websiteFeeds.has(feed)) { - return websiteFeeds.get(feed)!.rss2(); - } - - return undefined; -}); - -export default provideWebsiteFeeds; diff --git a/apps/site/next.dynamic.constants.mjs b/apps/site/next.dynamic.constants.mjs index ce9f3128e0077..0aa3afbbb071e 100644 --- a/apps/site/next.dynamic.constants.mjs +++ b/apps/site/next.dynamic.constants.mjs @@ -1,10 +1,10 @@ 'use strict'; -import { provideBlogPosts } from '#site/next-data/providers/blogData'; import { blogData } from '#site/next.json.mjs'; import { BASE_PATH, BASE_URL } from './next.constants.mjs'; import { siteConfig } from './next.json.mjs'; +import { getBlogPosts } from './util/blog'; /** * This constant is used to create static routes on-the-fly that do not have a file-system @@ -19,7 +19,7 @@ export const BLOG_DYNAMIC_ROUTES = [ // Provides Routes for all Blog Categories w/ Pagination ...blogData.categories // retrieves the amount of pages for each blog category - .map(c => [c, provideBlogPosts(c).pagination.pages]) + .map(c => [c, getBlogPosts(c).pagination.pages]) // creates a numeric array for each page and define a pathname for // each page for a category (i.e. blog/all/page/1) .map(([c, t]) => [...Array(t).keys()].map(p => `${c}/page/${p + 1}`)) diff --git a/apps/site/package.json b/apps/site/package.json index a9cf2c9d1e441..3d819d22df09c 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -4,8 +4,8 @@ "scripts": { "prebuild": "node --run build:blog-data", "build": "node --run build:default -- --turbo", - "build:blog-data": "cross-env NODE_NO_WARNINGS=1 node ./scripts/blog-data/generate.mjs", - "build:blog-data:watch": "node --watch --watch-path=pages/en/blog ./scripts/blog-data/generate.mjs", + "build:blog-data": "cross-env NODE_NO_WARNINGS=1 node ./scripts/blog-data/index.mjs", + "build:blog-data:watch": "node --watch --watch-path=pages/en/blog ./scripts/blog-data/index.mjs", "build:default": "cross-env NODE_NO_WARNINGS=1 next build", "cloudflare:build:worker": "OPEN_NEXT_CLOUDFLARE=true opennextjs-cloudflare build", "cloudflare:deploy": "opennextjs-cloudflare deploy", diff --git a/apps/site/next-data/generators/__tests__/blogData.test.mjs b/apps/site/scripts/blog-data/__test__/generate.test.mjs similarity index 92% rename from apps/site/next-data/generators/__tests__/blogData.test.mjs rename to apps/site/scripts/blog-data/__test__/generate.test.mjs index 0825eac35f248..91c3ed7cdd2c9 100644 --- a/apps/site/next-data/generators/__tests__/blogData.test.mjs +++ b/apps/site/scripts/blog-data/__test__/generate.test.mjs @@ -28,7 +28,7 @@ mock.module('../../../next.helpers.mjs', { }, }); -const generateBlogData = (await import('../blogData.mjs')).default; +const generateBlogData = (await import('../generate.mjs')).default; describe('generateBlogData', () => { it('should return zero posts and only the default "all" category if no md file is found', async () => { @@ -112,27 +112,31 @@ describe('generateBlogData', () => { }, ]; + // The posts are sorted by date const blogData = await generateBlogData(); - assert(blogData.posts.length, 3); - assert.equal(blogData.posts[0].title, 'POST 1'); - assert.deepEqual( - blogData.posts[0].date, - new Date('2020-01-01T00:00:00.000Z') + assert.equal(blogData.posts.length, 3); + + assert.equal(blogData.posts[0].title, 'POST 3'); + assert.equal( + blogData.posts[0].date.setMilliseconds(0), + currentDate.setMilliseconds(0) ); - assert.equal(blogData.posts[0].author, 'author-a'); + assert.equal(blogData.posts[0].author, 'author-c'); + assert.equal(blogData.posts[1].title, 'POST 2'); assert.deepEqual( blogData.posts[1].date, new Date('2020-01-02T00:00:00.000Z') ); assert.equal(blogData.posts[1].author, 'author-b'); - assert.equal(blogData.posts[2].title, 'POST 3'); - assert.equal( - blogData.posts[2].date.setMilliseconds(0), - currentDate.setMilliseconds(0) + + assert.equal(blogData.posts[2].title, 'POST 1'); + assert.deepEqual( + blogData.posts[2].date, + new Date('2020-01-01T00:00:00.000Z') ); - assert.equal(blogData.posts[2].author, 'author-c'); + assert.equal(blogData.posts[2].author, 'author-a'); }); it('should generate categories based on the categories of md files and their years', async () => { diff --git a/apps/site/scripts/blog-data/generate.mjs b/apps/site/scripts/blog-data/generate.mjs index 7916fd0c61f85..b7946ef3e7e5a 100644 --- a/apps/site/scripts/blog-data/generate.mjs +++ b/apps/site/scripts/blog-data/generate.mjs @@ -1,11 +1,119 @@ -import { writeFileSync } from 'node:fs'; +'use strict'; -import generateBlogData from '../../next-data/generators/blogData.mjs'; +import { createReadStream } from 'node:fs'; +import { basename, extname, join } from 'node:path'; +import readline from 'node:readline'; -const blogData = await generateBlogData(); +import graymatter from 'gray-matter'; -writeFileSync( - new URL(`../../public/blog-data.json`, import.meta.url), - JSON.stringify(blogData), - 'utf8' -); +import { getMarkdownFiles } from '#site/next.helpers.mjs'; + +// gets the current blog path based on local module path +const blogPath = join(process.cwd(), 'pages/en/blog'); + +/** + * This method parses the source (raw) Markdown content into Frontmatter + * and returns basic information for blog posts + * + * @param {string} filename the filename related to the blogpost + * @param {string} source the source markdown content of the blog post + */ +const getFrontMatter = (filename, source) => { + const { + title = 'Untitled', + author = 'The Node.js Project', + username, + date = new Date(), + category = 'uncategorized', + } = graymatter(source).data; + + // We also use publishing years as categories for the blog + const publishYear = new Date(date).getUTCFullYear(); + + // Provides a full list of categories for the Blog Post which consists of + // all = (all blog posts), publish year and the actual blog category + const categories = [category, `year-${publishYear}`, 'all']; + + // this is the url used for the blog post it based on the category and filename + const slug = `/blog/${category}/${basename(filename, extname(filename))}`; + + return { + title, + author, + username, + date: new Date(date), + categories, + slug, + }; +}; + +/** + * This method is used to generate the Node.js Website Blog Data + * for self-consumption during RSC and Static Builds + * + * @return {Promise} + */ +const generateBlogData = async () => { + // We retrieve the full pathnames of all Blog Posts to read each file individually + const filenames = await getMarkdownFiles(process.cwd(), 'pages/en/blog', [ + '**/index.md', + ]); + + /** + * This contains the metadata of all available blog categories + */ + const blogCategories = new Set(['all']); + + const posts = await Promise.all( + filenames.map( + filename => + new Promise(resolve => { + // We create a stream for reading a file instead of reading the files + const _stream = createReadStream(join(blogPath, filename)); + + // We create a readline interface to read the file line-by-line + const _readLine = readline.createInterface({ input: _stream }); + + let rawFrontmatter = ''; + let frontmatterSeparatorsEncountered = 0; + + // We read line by line + _readLine.on('line', line => { + rawFrontmatter += `${line}\n`; + + // We observe the frontmatter separators + if (line === '---') { + frontmatterSeparatorsEncountered++; + } + + // Once we have two separators we close the readLine and the stream + if (frontmatterSeparatorsEncountered === 2) { + _readLine.close(); + _stream.close(); + } + }); + + // Then we parse gray-matter on the frontmatter + // This allows us to only read the frontmatter part of each file + // and optimise the read-process as we have thousands of markdown files + _readLine.on('close', () => { + const frontMatterData = getFrontMatter(filename, rawFrontmatter); + + frontMatterData.categories.forEach(category => { + // we add the category to the categories set + blogCategories.add(category); + }); + + resolve(frontMatterData); + }); + }) + ) + ); + + return { + categories: [...blogCategories], + posts: posts.sort((a, b) => b.date.getTime() - a.date.getTime()), + }; +}; + +export default generateBlogData; diff --git a/apps/site/scripts/blog-data/index.mjs b/apps/site/scripts/blog-data/index.mjs new file mode 100644 index 0000000000000..74fe63f9516e6 --- /dev/null +++ b/apps/site/scripts/blog-data/index.mjs @@ -0,0 +1,11 @@ +import { writeFileSync } from 'node:fs'; + +import generateBlogData from './generate.mjs'; + +const blogData = await generateBlogData(); + +writeFileSync( + new URL(`../../public/blog-data.json`, import.meta.url), + JSON.stringify(blogData), + 'utf8' +); diff --git a/apps/site/types/blog.ts b/apps/site/types/blog.ts index 341ed85e545a0..79a3a4d26b998 100644 --- a/apps/site/types/blog.ts +++ b/apps/site/types/blog.ts @@ -7,7 +7,7 @@ export type BlogPost = { title: string; author: string; username: string; - date: Date; + date: string; categories: Array; slug: string; }; diff --git a/apps/site/next-data/generators/__tests__/websiteFeeds.test.mjs b/apps/site/util/__tests__/feeds.test.mjs similarity index 80% rename from apps/site/next-data/generators/__tests__/websiteFeeds.test.mjs rename to apps/site/util/__tests__/feeds.test.mjs index bd24af0dbbfa7..24f4a54a06165 100644 --- a/apps/site/next-data/generators/__tests__/websiteFeeds.test.mjs +++ b/apps/site/util/__tests__/feeds.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import generateWebsiteFeeds from '#site/next-data/generators/websiteFeeds.mjs'; +import { BASE_URL, BASE_PATH } from '#site/next.constants.mjs'; +import { siteConfig } from '#site/next.json.mjs'; -import { BASE_URL, BASE_PATH } from '../../../next.constants.mjs'; -import { siteConfig } from '../../../next.json.mjs'; +import { generateWebsiteFeeds } from '../feeds'; const base = `${BASE_URL}${BASE_PATH}/en`; @@ -15,7 +15,7 @@ describe('generateWebsiteFeeds', () => { { slug: '/post-1', title: 'Post 1', - author: 'Author 1', + author: [{ name: 'Author 1' }], date: '2025-04-18', categories: ['all'], }, @@ -37,7 +37,7 @@ describe('generateWebsiteFeeds', () => { const date = new Date(blogData.posts[0].date); assert.deepEqual(blogFeed.items, [ { - author: blogData.posts[0].author, + author: [{ name: blogData.posts[0].author }], id: blogData.posts[0].slug, title: blogData.posts[0].title, guid: `${blogData.posts[0].slug}?${date.getTime()}`, diff --git a/apps/site/util/blog.ts b/apps/site/util/blog.ts index 2ddf810e1aaf6..f2d36aaecf723 100644 --- a/apps/site/util/blog.ts +++ b/apps/site/util/blog.ts @@ -1,4 +1,6 @@ -import type { BlogPreviewType } from '#site/types'; +import { BLOG_POSTS_PER_PAGE } from '#site/next.constants.mjs'; +import { blogData } from '#site/next.json.mjs'; +import type { BlogCategory, BlogPostsRSC, BlogPreviewType } from '#site/types'; export const mapBlogCategoryToPreviewType = (type: string): BlogPreviewType => { switch (type) { @@ -12,3 +14,66 @@ export const mapBlogCategoryToPreviewType = (type: string): BlogPreviewType => { return 'announcements'; } }; + +export const getBlogPosts = (category: BlogCategory): BlogPostsRSC => { + const categoryPosts = blogData.posts.filter(post => + post.categories.includes(category) + ); + + // Total amount of possible pages given the amount of blog posts + const total = categoryPosts.length / BLOG_POSTS_PER_PAGE; + + return { + posts: categoryPosts, + pagination: { + prev: null, + next: null, + // In case the division results on a remainder we need + // to have an extra page containing the remainder entries + pages: Math.floor(total % 1 === 0 ? total : total + 1), + total: categoryPosts.length, + }, + }; +}; + +export const paginateBlogPosts = ( + { posts, pagination }: BlogPostsRSC, + page: number +): BlogPostsRSC => { + // This autocorrects if invalid numbers are given to only allow + // actual valid numbers to be provided + const actualPage = page < 1 ? 1 : page; + + // If the page is within the allowed range then we calculate + // the pagination of Blog Posts for a given current page "page" + if (actualPage <= pagination.pages) { + return { + posts: posts.slice( + BLOG_POSTS_PER_PAGE * (actualPage - 1), + BLOG_POSTS_PER_PAGE * actualPage + ), + pagination: { + prev: actualPage > 1 ? actualPage - 1 : null, + next: actualPage < pagination.pages ? actualPage + 1 : null, + pages: pagination.pages, + total: posts.length, + }, + }; + } + + return { + posts: [], + pagination: { + prev: pagination.total, + next: null, + pages: pagination.pages, + total: posts.length, + }, + }; +}; + +export const getBlogData = (cat: BlogCategory, page?: number): BlogPostsRSC => { + const posts = getBlogPosts(cat); + + return page && page >= 1 ? paginateBlogPosts(posts, page) : posts; +}; diff --git a/apps/site/next-data/generators/websiteFeeds.mjs b/apps/site/util/feeds.ts similarity index 66% rename from apps/site/next-data/generators/websiteFeeds.mjs rename to apps/site/util/feeds.ts index 6be01b65c29be..30c0e33cf145c 100644 --- a/apps/site/next-data/generators/websiteFeeds.mjs +++ b/apps/site/util/feeds.ts @@ -1,9 +1,13 @@ 'use strict'; +import type { FeedOptions } from 'feed'; import { Feed } from 'feed'; -import { BASE_URL, BASE_PATH } from '../../next.constants.mjs'; -import { siteConfig } from '../../next.json.mjs'; +import { BASE_URL, BASE_PATH } from '#site/next.constants.mjs'; +import { siteConfig } from '#site/next.json.mjs'; +import type { BlogCategory, BlogPostsRSC } from '#site/types'; + +import { getBlogPosts } from './blog'; // This is the Base URL for the Node.js Website // with English locale (which is where the website feeds run) @@ -17,16 +21,12 @@ const guidTimestampStartDate = 1744761600000; /** * This method generates RSS website feeds based on the current website configuration * and the current blog data that is available - * - * @param {import('../../types').BlogPostsRSC} blogData */ -const generateWebsiteFeeds = ({ posts }) => { +export const generateWebsiteFeeds = ({ posts }: BlogPostsRSC) => { /** * This generates all the Website RSS Feeds that are used for the website - * - * @type {Array<[string, Feed]>} */ - const websiteFeeds = siteConfig.rssFeeds.map( + const websiteFeeds: Array<[string, Feed]> = siteConfig.rssFeeds.map( ({ category, title, description, file }) => { const feed = new Feed({ id: file, @@ -34,18 +34,16 @@ const generateWebsiteFeeds = ({ posts }) => { language: 'en', link: canonicalUrl, description: description || '', - }); + } as FeedOptions); const blogFeedEntries = posts - .filter(post => post.categories.includes(category)) + .filter(post => post.categories.includes(category as BlogCategory)) .map(post => { const date = new Date(post.date); const time = date.getTime(); return { - id: post.slug, title: post.title, - author: post.author, date, link: `${canonicalUrl}${post.slug}`, guid: @@ -64,4 +62,13 @@ const generateWebsiteFeeds = ({ posts }) => { return new Map(websiteFeeds); }; -export default generateWebsiteFeeds; +export const getFeeds = (feed: string) => { + const blogPosts = getBlogPosts('all'); + const websiteFeeds = generateWebsiteFeeds(blogPosts); + + if (feed.includes('.xml') && websiteFeeds.has(feed)) { + return websiteFeeds.get(feed)!.rss2(); + } + + return undefined; +}; From 0b0b0497d54536266f00f066d676ec64f392954b Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 19 Oct 2025 15:18:02 -0400 Subject: [PATCH 2/2] fixup! --- apps/site/util/__tests__/feeds.test.mjs | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/site/util/__tests__/feeds.test.mjs b/apps/site/util/__tests__/feeds.test.mjs index 24f4a54a06165..78152b992bbfa 100644 --- a/apps/site/util/__tests__/feeds.test.mjs +++ b/apps/site/util/__tests__/feeds.test.mjs @@ -15,7 +15,6 @@ describe('generateWebsiteFeeds', () => { { slug: '/post-1', title: 'Post 1', - author: [{ name: 'Author 1' }], date: '2025-04-18', categories: ['all'], }, @@ -37,8 +36,6 @@ describe('generateWebsiteFeeds', () => { const date = new Date(blogData.posts[0].date); assert.deepEqual(blogFeed.items, [ { - author: [{ name: blogData.posts[0].author }], - id: blogData.posts[0].slug, title: blogData.posts[0].title, guid: `${blogData.posts[0].slug}?${date.getTime()}`, date,