The Node.js® Website

meta: refactor data generation to server-side only (#6137)

* meta: refactor data generation to server-side only

* chore: use import for this small data

* meta: refactor to an inteligent cache system

* chore: some code review

* chore: final code review changes

* meta: updated certain packages

authored by Claudio W and committed by GitHub c463a360 e51a529d

-4
.eslintignore
··· 6 6 .swc 7 7 build 8 8 9 - # Public Files 10 - public/node-releases-data.json 11 - public/blog-posts-data.json 12 - 13 9 # We don't want to lint/prettify the Coverage Results 14 10 coverage 15 11 junit.xml
-4
.husky/pre-commit
··· 1 1 #!/usr/bin/env sh 2 2 . "$(dirname -- "$0")/_/husky.sh" 3 3 4 - # if the generated files got tracked to this commit we revert them 5 - git reset public/node-releases-data.json 6 - git reset public/blog-posts-data.json 7 - 8 4 # lint and format staged files 9 5 npx lint-staged
-2
.prettierignore
··· 9 9 10 10 # Next.js Generated Files 11 11 public/static/documents 12 - public/node-releases-data.json 13 - public/blog-posts-data.json 14 12 15 13 # Jest 16 14 coverage
+11 -4
app/[locale]/[[...path]]/page.tsx
··· 5 5 import { setClientContext } from '@/client-context'; 6 6 import { MDXRenderer } from '@/components/mdxRenderer'; 7 7 import { WithLayout } from '@/components/withLayout'; 8 - import { DEFAULT_VIEWPORT, ENABLE_STATIC_EXPORT } from '@/next.constants.mjs'; 8 + import { ENABLE_STATIC_EXPORT } from '@/next.constants.mjs'; 9 + import { DEFAULT_VIEWPORT } from '@/next.dynamic.constants.mjs'; 9 10 import { dynamicRouter } from '@/next.dynamic.mjs'; 10 11 import { availableLocaleCodes, defaultLocale } from '@/next.locales.mjs'; 11 12 import { MatterProvider } from '@/providers/matterProvider'; ··· 14 15 type DynamicParams = { params: DynamicStaticPaths }; 15 16 16 17 // This is the default Viewport Metadata 17 - // @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport 18 - export const viewport = DEFAULT_VIEWPORT; 18 + // @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function 19 + export const generateViewport = async () => ({ ...DEFAULT_VIEWPORT }); 19 20 20 21 // This generates each page's HTML Metadata 21 22 // @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata ··· 113 114 return notFound(); 114 115 }; 115 116 116 - // Enforce that all these routes are compatible with SSR 117 + // In this case we want to catch-all possible pages even to this page. This ensures that we use our 404 118 + // and that all pages including existing ones are handled here and provide `next-intl` locale also 119 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams 120 + export const dynamicParams = true; 121 + 122 + // Enforces that this route is used as static rendering 123 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic 117 124 export const dynamic = 'error'; 118 125 119 126 export default getPage;
+22 -20
app/[locale]/feed/[feed]/route.ts
··· 1 1 import { NextResponse } from 'next/server'; 2 2 3 - import { generateWebsiteFeeds } from '@/next.data.mjs'; 4 - import { blogData } from '@/next.json.mjs'; 3 + import provideWebsiteFeeds from '@/next-data/providers/websiteFeeds'; 4 + import { siteConfig } from '@/next.json.mjs'; 5 5 import { defaultLocale } from '@/next.locales.mjs'; 6 6 7 - // loads all the data from the blog-posts-data.json file 8 - const websiteFeeds = generateWebsiteFeeds(blogData); 7 + // We only support fetching these pages from the /en/ locale code 8 + const locale = defaultLocale.code; 9 9 10 - type StaticParams = { params: { feed: string } }; 10 + type StaticParams = { params: { feed: string; locale: string } }; 11 11 12 12 // This is the Route Handler for the `GET` method which handles the request 13 - // for Blog Feeds within the Node.js Website 13 + // for the Node.js Website Blog Feeds (RSS) 14 14 // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 15 - export const GET = (_: Request, { params }: StaticParams) => { 16 - if (params.feed.includes('.xml') && websiteFeeds.has(params.feed)) { 17 - return new NextResponse(websiteFeeds.get(params.feed)?.rss2(), { 18 - headers: { 'Content-Type': 'application/xml' }, 19 - }); 20 - } 15 + export const GET = async (_: Request, { params }: StaticParams) => { 16 + // Generate the Feed for the given feed type (blog, releases, etc) 17 + const websiteFeed = await provideWebsiteFeeds(params.feed); 21 18 22 - return new NextResponse(null, { status: 404 }); 19 + return new NextResponse(websiteFeed, { 20 + headers: { 'Content-Type': 'application/xml' }, 21 + status: websiteFeed ? 200 : 404, 22 + }); 23 23 }; 24 24 25 25 // This function generates the static paths that come from the dynamic segments 26 - // `en/feeds/[feed]` and returns an array of all available static paths 27 - // this is useful for static exports, for example. 28 - // Note that differently from the App Router these don't get built at the build time 29 - // only if the export is already set for static export 30 - export const generateStaticParams = () => 31 - [...websiteFeeds.keys()].map(feed => ({ feed, locale: defaultLocale.code })); 26 + // `[locale]/feeds/[feed]` and returns an array of all available static paths 27 + // This is used for ISR static validation and generation 28 + export const generateStaticParams = async () => 29 + siteConfig.rssFeeds.map(feed => ({ feed: feed.file, locale })); 30 + 31 + // Forces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary 32 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams 33 + export const dynamicParams = false; 32 34 33 - // Enforces that this route is used as static rendering 35 + // Enforces that this route is cached and static as much as possible 34 36 // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic 35 37 export const dynamic = 'error';
+42
app/[locale]/next-data/blog-data/[category]/route.ts
··· 1 + import provideBlogData from '@/next-data/providers/blogData'; 2 + import { defaultLocale } from '@/next.locales.mjs'; 3 + 4 + // We only support fetching these pages from the /en/ locale code 5 + const locale = defaultLocale.code; 6 + 7 + type StaticParams = { params: { category: string; locale: string } }; 8 + 9 + // This is the Route Handler for the `GET` method which handles the request 10 + // for providing Blog Posts, Pagination for every supported Blog Category 11 + // this includes the `year-XXXX` categories for yearly archives (pagination) 12 + // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 13 + export const GET = async (_: Request, { params }: StaticParams) => { 14 + const { posts, pagination } = await provideBlogData(params.category); 15 + 16 + return Response.json( 17 + { posts, pagination }, 18 + { status: posts.length ? 200 : 404 } 19 + ); 20 + }; 21 + 22 + // This function generates the static paths that come from the dynamic segments 23 + // `[locale]/next-data/blog-data/[category]` and returns an array of all available static paths 24 + // This is used for ISR static validation and generation 25 + export const generateStaticParams = async () => { 26 + // This metadata is the original list of all available categories and all available years 27 + // within the Node.js Website Blog Posts (2011, 2012...) 28 + const { meta } = await provideBlogData(); 29 + 30 + return [ 31 + ...meta.categories.map(category => ({ category, locale })), 32 + ...meta.pagination.map(year => ({ category: `year-${year}`, locale })), 33 + ]; 34 + }; 35 + 36 + // Forces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary 37 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams 38 + export const dynamicParams = false; 39 + 40 + // Enforces that this route is cached and static as much as possible 41 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic 42 + export const dynamic = 'error';
+27
app/[locale]/next-data/release-data/route.ts
··· 1 + import provideReleaseData from '@/next-data/providers/releaseData'; 2 + import { defaultLocale } from '@/next.locales.mjs'; 3 + 4 + // We only support fetching these pages from the /en/ locale code 5 + const locale = defaultLocale.code; 6 + 7 + // This is the Route Handler for the `GET` method which handles the request 8 + // for generating static data related to the Node.js Release Data 9 + // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 10 + export const GET = async () => { 11 + const releaseData = await provideReleaseData(); 12 + 13 + return Response.json(releaseData); 14 + }; 15 + 16 + // This function generates the static paths that come from the dynamic segments 17 + // `[locale]/next-data/release-data/` and returns an array of all available static paths 18 + // This is used for ISR static validation and generation 19 + export const generateStaticParams = async () => [{ locale }]; 20 + 21 + // Forces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary 22 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams 23 + export const dynamicParams = false; 24 + 25 + // Enforces that this route is used as static rendering 26 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic 27 + export const dynamic = 'error';
+3 -4
app/sitemap.ts
··· 18 18 const paths: string[] = []; 19 19 20 20 for (const locale of availableLocaleCodes) { 21 - const routesForLanguage = await dynamicRouter.getRoutesByLanguage(locale); 22 - paths.push( 23 - ...routesForLanguage.map(route => `${baseUrlAndPath}/${locale}/${route}`) 24 - ); 21 + const routes = await dynamicRouter.getRoutesByLanguage(locale); 22 + 23 + paths.push(...routes.map(route => `${baseUrlAndPath}/${locale}/${route}`)); 25 24 } 26 25 27 26 const currentDate = new Date().toISOString();
+9 -2
components/Docs/NodeApiVersionLinks.tsx
··· 1 + import type { FC } from 'react'; 2 + 3 + import getReleaseData from '@/next-data/releaseData'; 1 4 import { DOCS_URL } from '@/next.constants.mjs'; 2 - import { releaseData } from '@/next.json.mjs'; 5 + 6 + // This is a React Async Server Component 7 + // Note that Hooks cannot be used in a RSC async component 8 + // Async Components do not get re-rendered at all. 9 + const NodeApiVersionLinks: FC = async () => { 10 + const releaseData = await getReleaseData(); 3 11 4 - const NodeApiVersionLinks = () => { 5 12 // Gets all major releases without the 0x release as those are divided on 0.12x and 0.10x 6 13 const mappedReleases = releaseData.slice(0, -1).map(({ major }) => ( 7 14 <li key={major}>
+9 -4
components/Downloads/DownloadReleasesTable.tsx
··· 1 - import { useTranslations } from 'next-intl'; 1 + import { getTranslations } from 'next-intl/server'; 2 2 import type { FC } from 'react'; 3 3 4 - import { releaseData } from '@/next.json.mjs'; 4 + import getReleaseData from '@/next-data/releaseData'; 5 5 import { getNodeApiLink } from '@/util/getNodeApiLink'; 6 6 import { getNodejsChangelog } from '@/util/getNodeJsChangelog'; 7 7 8 - const DownloadReleasesTable: FC = () => { 9 - const t = useTranslations(); 8 + // This is a React Async Server Component 9 + // Note that Hooks cannot be used in a RSC async component 10 + // Async Components do not get re-rendered at all. 11 + const DownloadReleasesTable: FC = async () => { 12 + const releaseData = await getReleaseData(); 13 + 14 + const t = await getTranslations(); 10 15 11 16 return ( 12 17 <table id="tbVersions" className="download-table full-width">
+3 -15
components/Header.tsx
··· 3 3 import Image from 'next/image'; 4 4 import { useTranslations } from 'next-intl'; 5 5 import { useTheme } from 'next-themes'; 6 + import type { FC, PropsWithChildren } from 'react'; 6 7 import { useState } from 'react'; 7 8 8 - import ActiveLink from '@/components/Common/ActiveLink'; 9 9 import Link from '@/components/Link'; 10 - import { useSiteNavigation } from '@/hooks'; 11 10 import { usePathname } from '@/navigation.mjs'; 12 11 import { BASE_PATH } from '@/next.constants.mjs'; 13 12 import { availableLocales } from '@/next.locales.mjs'; 14 13 15 - const Header = () => { 16 - const { navigationItems } = useSiteNavigation(); 14 + const Header: FC<PropsWithChildren> = ({ children }) => { 17 15 const [showLangPicker, setShowLangPicker] = useState(false); 18 16 const { resolvedTheme, setTheme } = useTheme(); 19 17 ··· 36 34 /> 37 35 </Link> 38 36 39 - <nav aria-label="primary"> 40 - <ul className="list-divider-pipe"> 41 - {navigationItems.map((item, key) => ( 42 - <li key={key}> 43 - <ActiveLink href={item.link} allowSubPath> 44 - {item.text} 45 - </ActiveLink> 46 - </li> 47 - ))} 48 - </ul> 49 - </nav> 37 + {children} 50 38 51 39 <div className="switchers"> 52 40 <button
+1 -1
components/Pagination.tsx
··· 3 3 4 4 import Link from '@/components/Link'; 5 5 6 - type PaginationProps = { prev?: number; next?: number }; 6 + type PaginationProps = { prev?: number | null; next?: number | null }; 7 7 8 8 const Pagination: FC<PaginationProps> = ({ next, prev }) => { 9 9 const t = useTranslations();
+24
components/TopNavigation.tsx
··· 1 + import type { FC } from 'react'; 2 + 3 + import ActiveLink from '@/components/Common/ActiveLink'; 4 + import { useSiteNavigation } from '@/hooks/server'; 5 + 6 + const TopNavigation: FC = () => { 7 + const { navigationItems } = useSiteNavigation(); 8 + 9 + return ( 10 + <nav aria-label="primary"> 11 + <ul className="list-divider-pipe"> 12 + {navigationItems.map(({ link, text }) => ( 13 + <li key={link}> 14 + <ActiveLink href={link} allowSubPath> 15 + {text} 16 + </ActiveLink> 17 + </li> 18 + ))} 19 + </ul> 20 + </nav> 21 + ); 22 + }; 23 + 24 + export default TopNavigation;
+7 -2
components/withNodeRelease.tsx
··· 1 1 import type { FC } from 'react'; 2 2 3 - import { releaseData } from '@/next.json.mjs'; 3 + import getReleaseData from '@/next-data/releaseData'; 4 4 import type { NodeRelease, NodeReleaseStatus } from '@/types'; 5 5 6 6 type WithNodeReleaseProps = { ··· 8 8 children: FC<{ release: NodeRelease }>; 9 9 }; 10 10 11 - export const WithNodeRelease: FC<WithNodeReleaseProps> = ({ 11 + // This is a React Async Server Component 12 + // Note that Hooks cannot be used in a RSC async component 13 + // Async Components do not get re-rendered at all. 14 + export const WithNodeRelease: FC<WithNodeReleaseProps> = async ({ 12 15 status, 13 16 children: Component, 14 17 }) => { 18 + const releaseData = await getReleaseData(); 19 + 15 20 const matchingRelease = releaseData.find(release => 16 21 [status].flat().includes(release.status) 17 22 );
-1
hooks/react-client/index.ts
··· 3 3 export { default as useMediaQuery } from './useMediaQuery'; 4 4 export { default as useNotification } from './useNotification'; 5 5 export { default as useClientContext } from './useClientContext'; 6 - export { default as useBlogData } from './useBlogData'; 7 6 export { default as useSiteNavigation } from './useSiteNavigation';
-13
hooks/react-client/useBlogData.ts
··· 1 - 'use client'; 2 - 3 - import useClientContext from '@/hooks/react-client/useClientContext'; 4 - import { useBaseBlogData } from '@/hooks/useBaseBlogData'; 5 - 6 - const useBlogData = () => { 7 - const { pathname } = useClientContext(); 8 - const data = useBaseBlogData(pathname); 9 - 10 - return data; 11 - }; 12 - 13 - export default useBlogData;
+4 -2
hooks/react-client/useSiteNavigation.tsx
··· 1 1 'use client'; 2 2 3 - import useBaseSiteNavigation from '@/hooks/useBaseSiteNavigation'; 3 + const useSiteNavigation = () => { 4 + throw new Error('Attempted to call useSiteNavigation from RCC'); 5 + }; 4 6 5 - export default useBaseSiteNavigation; 7 + export default useSiteNavigation;
-1
hooks/react-server/index.ts
··· 3 3 export { default as useMediaQuery } from './useMediaQuery'; 4 4 export { default as useNotification } from './useNotification'; 5 5 export { default as useClientContext } from './useClientContext'; 6 - export { default as useBlogData } from './useBlogData'; 7 6 export { default as useSiteNavigation } from './useSiteNavigation';
-11
hooks/react-server/useBlogData.ts
··· 1 - import useClientContext from '@/hooks/react-server/useClientContext'; 2 - import { useBaseBlogData } from '@/hooks/useBaseBlogData'; 3 - 4 - const useBlogData = () => { 5 - const { pathname } = useClientContext(); 6 - const data = useBaseBlogData(pathname); 7 - 8 - return data; 9 - }; 10 - 11 - export default useBlogData;
+74 -2
hooks/react-server/useSiteNavigation.tsx
··· 1 - import useBaseSiteNavigation from '@/hooks/useBaseSiteNavigation'; 1 + import { useTranslations } from 'next-intl'; 2 + import type { RichTranslationValues } from 'next-intl'; 3 + 4 + import { siteNavigation } from '@/next.json.mjs'; 5 + import type { 6 + MappedNavigationEntry, 7 + NavigationEntry, 8 + NavigationKeys, 9 + } from '@/types'; 10 + 11 + type Context = Record<string, RichTranslationValues>; 12 + type Navigation = Record<string, NavigationEntry>; 2 13 3 - export default useBaseSiteNavigation; 14 + // Provides Context replacement for variables within the Link. This is also something that is not going 15 + // to happen in the future with `nodejs/nodejs.dev` codebase 16 + const replaceLinkWithContext = ( 17 + link: string, 18 + context?: RichTranslationValues 19 + ) => 20 + Object.entries(context || {}).reduce( 21 + (finalLink, [find, replace]) => 22 + finalLink.replace( 23 + `{${find}}`, 24 + typeof replace === 'string' ? replace : '' 25 + ), 26 + link 27 + ); 28 + 29 + const useSiteNavigation = () => { 30 + const t = useTranslations(); 31 + 32 + const mapNavigationEntries = ( 33 + entries: Navigation, 34 + context: Context = {}, 35 + level = 0, 36 + includeItems = true 37 + ): MappedNavigationEntry[] => { 38 + const getFormattedMessage = (translationId: string, key: string) => 39 + t.rich(translationId, context[key] || {}); 40 + 41 + return Object.entries(entries).map( 42 + ([key, { translationId, link, items }]) => { 43 + const mappedEntry: MappedNavigationEntry = { 44 + text: getFormattedMessage(translationId, key), 45 + link: replaceLinkWithContext(link, context[key]), 46 + items: [], 47 + level, 48 + key, 49 + }; 50 + 51 + if (includeItems && items) { 52 + mappedEntry.items = mapNavigationEntries(items, context, level + 1); 53 + } 54 + 55 + return mappedEntry; 56 + } 57 + ); 58 + }; 59 + 60 + const getSideNavigation = (section: NavigationKeys, context?: Context) => { 61 + const { items, translationId, link } = siteNavigation[section]; 62 + 63 + return mapNavigationEntries( 64 + { [section]: { translationId, link }, ...items }, 65 + context 66 + ); 67 + }; 68 + 69 + return { 70 + getSideNavigation, 71 + navigationItems: mapNavigationEntries(siteNavigation, {}, 0, false), 72 + }; 73 + }; 74 + 75 + export default useSiteNavigation;
-52
hooks/useBaseBlogData.ts
··· 1 - import { useMemo } from 'react'; 2 - 3 - import { blogData } from '@/next.json.mjs'; 4 - 5 - export const useBaseBlogData = (pathname: string) => { 6 - const { posts, pagination, categories } = blogData; 7 - 8 - const getPostsByCategory = (category: string) => 9 - posts.filter(post => post.category === category); 10 - 11 - const getPostsByYear = (year: string) => 12 - posts.filter(post => new Date(post.date).getFullYear() === Number(year)); 13 - 14 - const getPagination = (currentYear: number | string) => { 15 - const _currentYear = Number(currentYear); 16 - 17 - return { 18 - next: pagination.includes(_currentYear + 1) 19 - ? _currentYear + 1 20 - : undefined, 21 - prev: pagination.includes(_currentYear - 1) 22 - ? _currentYear - 1 23 - : undefined, 24 - }; 25 - }; 26 - 27 - const currentCategory = useMemo(() => { 28 - // We split the pathname to retrieve the blog category from it since the 29 - // URL is usually blog/{category} the second path piece is usually the 30 - // category name 31 - const [_pathname, category] = pathname.split('/'); 32 - 33 - if (_pathname === 'blog' && category && category.length) { 34 - return category; 35 - } 36 - 37 - // if either the pathname does not match to a blog page 38 - // which should not happen (as this hook should only be used in blog pages) 39 - // or if there is no category in the URL we return the current year as category name 40 - // which is always the default category (for example, the blog index) 41 - return `year-${new Date().getFullYear()}`; 42 - }, [pathname]); 43 - 44 - return { 45 - posts, 46 - categories, 47 - currentCategory, 48 - getPostsByCategory, 49 - getPostsByYear, 50 - getPagination, 51 - }; 52 - };
-81
hooks/useBaseSiteNavigation.tsx
··· 1 - import { useTranslations } from 'next-intl'; 2 - import type { RichTranslationValues } from 'next-intl'; 3 - import { useMemo } from 'react'; 4 - 5 - import { siteNavigation } from '@/next.json.mjs'; 6 - import type { NavigationEntry, NavigationKeys } from '@/types'; 7 - 8 - // These are mapped navigation entries. Navigation Entries can have sub-entries 9 - type MappedItems = { 10 - text: ReturnType<ReturnType<typeof useTranslations>['rich']>; 11 - link: string; 12 - key: string; 13 - level: number; 14 - items: MappedItems[]; 15 - }; 16 - 17 - // Provides Context replacement for variables within the Link. This is also something that is not going 18 - // to happen in the future with `nodejs/nodejs.dev` codebase 19 - const replaceLinkWithContext = (link: string, context: RichTranslationValues) => 20 - Object.entries(context).reduce( 21 - (finalLink, [find, replace]) => 22 - finalLink.replace( 23 - `{${find}}`, 24 - typeof replace === 'string' ? replace : '' 25 - ), 26 - link 27 - ); 28 - 29 - const useBaseSiteNavigation = () => { 30 - const t = useTranslations(); 31 - 32 - const mapNavigationEntries = ( 33 - entries: Record<string, NavigationEntry>, 34 - context?: Record<string, RichTranslationValues>, 35 - level = 0 36 - ): MappedItems[] => { 37 - const getContext = (key: string) => (context && context[key]) || {}; 38 - 39 - const getFormattedMessage = (translationId: string, key: string) => 40 - t.rich(translationId, getContext(key)); 41 - 42 - return Object.entries(entries).map(([key, item]) => ({ 43 - text: getFormattedMessage(item.translationId, key), 44 - link: replaceLinkWithContext(item.link, getContext(key)), 45 - items: item.items 46 - ? mapNavigationEntries(item.items, context, level + 1) 47 - : [], 48 - level, 49 - key: key, 50 - })); 51 - }; 52 - 53 - const rootNavigationItems = useMemo( 54 - () => 55 - Object.entries(siteNavigation).reduce( 56 - (acc, [key, { translationId, link }]) => ({ 57 - ...acc, 58 - [key]: { translationId, link }, 59 - }), 60 - {} 61 - ), 62 - [] 63 - ); 64 - 65 - return { 66 - navigationItems: mapNavigationEntries(rootNavigationItems), 67 - getSideNavigation: ( 68 - section: NavigationKeys, 69 - context?: Record<string, RichTranslationValues> 70 - ) => { 71 - const { items, translationId, link } = siteNavigation[section]; 72 - 73 - return mapNavigationEntries( 74 - { [section]: { translationId, link }, ...items }, 75 - context 76 - ); 77 - }, 78 - }; 79 - }; 80 - 81 - export default useBaseSiteNavigation;
+4 -1
layouts/BaseLayout.tsx
··· 4 4 5 5 import Footer from '@/components/Footer'; 6 6 import Header from '@/components/Header'; 7 + import TopNavigation from '@/components/TopNavigation'; 7 8 8 9 const BaseLayout: FC<PropsWithChildren> = ({ children }) => ( 9 10 <> 10 - <Header /> 11 + <Header> 12 + <TopNavigation /> 13 + </Header> 11 14 <main id="main">{children}</main> 12 15 <Footer /> 13 16 </>
+37 -41
layouts/BlogCategoryLayout.tsx
··· 1 - import { useTranslations } from 'next-intl'; 2 - import { useMemo } from 'react'; 1 + import { getTranslations } from 'next-intl/server'; 3 2 import type { FC } from 'react'; 4 3 4 + import { getClientContext } from '@/client-context'; 5 5 import { Time } from '@/components/Common/Time'; 6 6 import Link from '@/components/Link'; 7 7 import Pagination from '@/components/Pagination'; 8 - import { useClientContext, useBlogData } from '@/hooks/server'; 9 - import type { BlogPost } from '@/types'; 8 + import getBlogData from '@/next-data/blogData'; 10 9 11 - const BlogCategoryLayout: FC = () => { 12 - const t = useTranslations(); 13 - const { getPagination, getPostsByYear, getPostsByCategory, currentCategory } = 14 - useBlogData(); 10 + const getCurrentCategory = (pathname: string) => { 11 + // We split the pathname to retrieve the blog category from it since the 12 + // URL is usually blog/{category} the second path piece is usually the 13 + // category name 14 + const [_pathname, category] = pathname.split('/'); 15 15 16 - const { frontmatter } = useClientContext(); 16 + if (_pathname === 'blog' && category && category.length) { 17 + return category; 18 + } 17 19 18 - const { posts, pagination, title } = useMemo(() => { 19 - if (currentCategory.startsWith('year-')) { 20 - const categoryWithoutPrefix = currentCategory.replace('year-', ''); 20 + // if either the pathname does not match to a blog page 21 + // which should not happen (as this hook should only be used in blog pages) 22 + // or if there is no category in the URL we return the current year as category name 23 + // which is always the default category (for example, the blog index) 24 + return `year-${new Date().getFullYear()}`; 25 + }; 21 26 22 - return { 23 - posts: getPostsByYear(categoryWithoutPrefix), 24 - pagination: getPagination(categoryWithoutPrefix), 25 - title: t('layouts.blogIndex.currentYear', { 26 - year: categoryWithoutPrefix, 27 - }), 28 - }; 29 - } 27 + // This is a React Async Server Component 28 + // Note that Hooks cannot be used in a RSC async component 29 + // Async Components do not get re-rendered at all. 30 + const BlogCategoryLayout: FC = async () => { 31 + const { frontmatter, pathname } = getClientContext(); 32 + const category = getCurrentCategory(pathname); 30 33 31 - return { 32 - posts: getPostsByCategory(currentCategory), 33 - pagination: undefined, 34 - title: frontmatter.title, 35 - }; 36 - }, [ 37 - currentCategory, 38 - frontmatter.title, 39 - getPagination, 40 - getPostsByCategory, 41 - getPostsByYear, 42 - t, 43 - ]); 34 + const t = await getTranslations(); 35 + 36 + const { posts, pagination } = await getBlogData(category); 37 + 38 + // this only applies if current category is a year category 39 + const year = category.replace('year-', ''); 40 + 41 + const title = category.startsWith('year-') 42 + ? t('layouts.blogIndex.currentYear', { year }) 43 + : frontmatter.title; 44 44 45 45 return ( 46 46 <div className="container" dir="auto"> 47 47 <h2>{title}</h2> 48 48 49 49 <ul className="blog-index"> 50 - {posts.map((post: BlogPost) => ( 51 - <li key={post.slug}> 52 - <Time 53 - date={post.date} 54 - format={{ month: 'short', day: '2-digit' }} 55 - /> 56 - 57 - <Link href={post.slug}>{post.title}</Link> 50 + {posts.map(({ slug, date, title }) => ( 51 + <li key={slug}> 52 + <Time date={date} format={{ month: 'short', day: '2-digit' }} /> 53 + <Link href={slug}>{title}</Link> 58 54 </li> 59 55 ))} 60 56 </ul>
+7 -2
layouts/DocsLayout.tsx
··· 2 2 import type { FC, PropsWithChildren } from 'react'; 3 3 4 4 import SideNavigation from '@/components/SideNavigation'; 5 - import { releaseData } from '@/next.json.mjs'; 5 + import getReleaseData from '@/next-data/releaseData'; 6 + 7 + // This is a React Async Server Component 8 + // Note that Hooks cannot be used in a RSC async component 9 + // Async Components do not get re-rendered at all. 10 + const DocsLayout: FC<PropsWithChildren> = async ({ children }) => { 11 + const releaseData = await getReleaseData(); 6 12 7 - const DocsLayout: FC<PropsWithChildren> = ({ children }) => { 8 13 const [lts, current] = [ 9 14 releaseData.find(({ isLts }) => isLts), 10 15 releaseData.find(({ status }) => status === 'Current'),
+21
next-data/blogData.ts
··· 1 + import { ENABLE_STATIC_EXPORT, NEXT_DATA_URL } from '@/next.constants.mjs'; 2 + import type { BlogDataRSC } from '@/types'; 3 + 4 + const getBlogData = (category: string): Promise<BlogDataRSC> => { 5 + // When we're using Static Exports the Next.js Server is not running (during build-time) 6 + // hence the self-ingestion APIs will not be available. In this case we want to load 7 + // the data directly within the current thread, which will anyways be loaded only once 8 + // We use lazy-imports to prevent `provideBlogData` from executing on import 9 + if (ENABLE_STATIC_EXPORT) { 10 + return import('@/next-data/providers/blogData').then( 11 + ({ default: provideBlogData }) => provideBlogData(category) 12 + ); 13 + } 14 + 15 + // When we're on RSC with Server capabilities we prefer using Next.js Data Fetching 16 + // as this will load cached data from the server instead of generating data on the fly 17 + // this is extremely useful for ISR and SSG as it will not generate this data on every request 18 + return fetch(`${NEXT_DATA_URL}blog-data/${category}`).then(r => r.json()); 19 + }; 20 + 21 + export default getBlogData;
+12 -21
next-data/generateBlogPostsData.mjs next-data/generators/blogData.mjs
··· 1 1 'use strict'; 2 2 3 3 import { createReadStream } from 'node:fs'; 4 - import { writeFile } from 'node:fs/promises'; 5 4 import { basename, extname, join } from 'node:path'; 6 5 import readline from 'node:readline'; 7 6 8 7 import graymatter from 'gray-matter'; 9 8 10 - import * as nextHelpers from '../next.helpers.mjs'; 9 + import * as nextHelpers from '../../next.helpers.mjs'; 11 10 12 11 // gets the current blog path based on local module path 13 12 const blogPath = join(process.cwd(), 'pages/en/blog'); 14 - 15 - // this is the destination path for where the JSON file will be written 16 - const jsonFilePath = join(process.cwd(), 'public/blog-posts-data.json'); 17 13 18 14 /** 19 15 * This contains the metadata of all available blog categories and ··· 51 47 }; 52 48 53 49 /** 54 - * This method is used to generate the JSON file 50 + * This method is used to generate the Node.js Website Blog Data 51 + * for self-consumption during RSC and Static Builds 52 + * 53 + * @return {Promise<import('../../types').BlogData>} 55 54 */ 56 - const generateBlogPostsData = async () => { 55 + const generateBlogData = async () => { 57 56 // we retrieve all the filenames of all blog posts 58 57 const filenames = await nextHelpers.getMarkdownFiles( 59 58 process.cwd(), ··· 61 60 ['**/index.md', '**/pagination.md'] 62 61 ); 63 62 64 - // Writes the Blog Posts to the JSON file 65 - const writeResult = blogPosts => { 66 - return writeFile( 67 - jsonFilePath, 68 - JSON.stringify({ 69 - pagination: [...blogMetadata.pagination].sort(), 70 - categories: [...blogMetadata.categories].sort(), 71 - posts: blogPosts.sort((a, b) => b.date - a.date), 72 - }) 73 - ); 74 - }; 75 - 76 63 return new Promise(resolve => { 77 64 const blogPosts = []; 78 65 ··· 110 97 111 98 // Once we finish reading all fles 112 99 if (blogPosts.length === filenames.length) { 113 - resolve(writeResult(blogPosts)); 100 + resolve({ 101 + pagination: [...blogMetadata.pagination].sort(), 102 + categories: [...blogMetadata.categories].sort(), 103 + posts: blogPosts.sort((a, b) => b.date - a.date), 104 + }); 114 105 } 115 106 }); 116 107 } 117 108 }); 118 109 }; 119 110 120 - export default generateBlogPostsData; 111 + export default generateBlogData;
+17 -20
next-data/generateNodeReleasesJson.mjs next-data/generators/releaseData.mjs
··· 1 - import { writeFile } from 'node:fs/promises'; 2 - import { join } from 'node:path'; 3 - 4 1 import nodevu from '@nodevu/core'; 5 - 6 - // this is the destination path for where the JSON file will be written 7 - const jsonFilePath = join(process.cwd(), 'public/node-releases-data.json'); 8 2 9 3 // Gets the appropriate release status for each major release 10 4 const getNodeReleaseStatus = (now, support) => { ··· 29 23 return 'Pending'; 30 24 }; 31 25 32 - const generateNodeReleasesJson = async () => { 26 + /** 27 + * This method is used to generate the Node.js Release Data 28 + * for self-consumption during RSC and Static Builds 29 + * 30 + * @returns {Promise<import('../../types').NodeRelease[]>} 31 + */ 32 + const generateReleaseData = async () => { 33 33 const nodevuOutput = await nodevu({ fetch: fetch }); 34 34 35 35 // Filter out those without documented support ··· 63 63 }; 64 64 }); 65 65 66 - return writeFile( 67 - jsonFilePath, 68 - JSON.stringify( 69 - // nodevu returns duplicated v0.x versions (v0.12, v0.10, ...). 70 - // This behavior seems intentional as the case is hardcoded in nodevu, 71 - // see https://github.com/cutenode/nodevu/blob/0c8538c70195fb7181e0a4d1eeb6a28e8ed95698/core/index.js#L24. 72 - // This line ignores those duplicated versions and takes the latest 73 - // v0.x version (v0.12.18). It is also consistent with the legacy 74 - // nodejs.org implementation. 75 - nodeReleases.filter( 76 - release => release.major !== 0 || release.version === '0.12.18' 77 - ) 66 + return Promise.resolve( 67 + // nodevu returns duplicated v0.x versions (v0.12, v0.10, ...). 68 + // This behavior seems intentional as the case is hardcoded in nodevu, 69 + // see https://github.com/cutenode/nodevu/blob/0c8538c70195fb7181e0a4d1eeb6a28e8ed95698/core/index.js#L24. 70 + // This line ignores those duplicated versions and takes the latest 71 + // v0.x version (v0.12.18). It is also consistent with the legacy 72 + // nodejs.org implementation. 73 + nodeReleases.filter( 74 + release => release.major !== 0 || release.version === '0.12.18' 78 75 ) 79 76 ); 80 77 }; 81 78 82 - export default generateNodeReleasesJson; 79 + export default generateReleaseData;
+8 -5
next-data/generateWebsiteFeeds.mjs next-data/generators/websiteFeeds.mjs
··· 2 2 3 3 import { Feed } from 'feed'; 4 4 5 - import { BASE_URL, BASE_PATH } from '../next.constants.mjs'; 6 - import { siteConfig } from '../next.json.mjs'; 5 + import { BASE_URL, BASE_PATH } from '../../next.constants.mjs'; 6 + import { siteConfig } from '../../next.json.mjs'; 7 7 8 8 /** 9 9 * This method generates RSS website feeds based on the current website configuration 10 10 * and the current blog data that is available 11 11 * 12 - * @param {import('../types').BlogData} blogData 12 + * @param {Promise<import('../../types').BlogDataRSC>} blogData 13 13 */ 14 - const generateWebsiteFeeds = ({ posts }) => { 14 + const generateWebsiteFeeds = async blogData => { 15 15 const canonicalUrl = `${BASE_URL}${BASE_PATH}/en`; 16 + 17 + // Wait for the Blog Data for being generate 18 + const { posts } = await blogData; 16 19 17 20 /** 18 21 * This generates all the Website RSS Feeds that are used for the website ··· 26 29 title: title, 27 30 language: 'en', 28 31 link: `${canonicalUrl}/feed/${file}`, 29 - description: description || siteConfig.description, 32 + description: description || description, 30 33 }); 31 34 32 35 const blogFeedEntries = posts
+46
next-data/providers/blogData.ts
··· 1 + import { cache } from 'react'; 2 + 3 + import generateBlogData from '@/next-data/generators/blogData.mjs'; 4 + import type { BlogDataRSC } from '@/types'; 5 + 6 + const blogData = generateBlogData(); 7 + 8 + const provideBlogData = cache( 9 + async (category?: string): Promise<BlogDataRSC> => { 10 + return blogData.then(({ posts, categories, pagination }) => { 11 + const meta = { categories, pagination }; 12 + 13 + if (category && categories.includes(category)) { 14 + return { 15 + posts: posts.filter(post => post.category === category), 16 + pagination: { next: null, prev: null }, 17 + meta, 18 + }; 19 + } 20 + 21 + if (category && category.startsWith('year-')) { 22 + const paramYear = Number(category.replace('year-', '')); 23 + 24 + const isEqualYear = (date: string) => 25 + new Date(date).getFullYear() === paramYear; 26 + 27 + return { 28 + posts: posts.filter(({ date }) => isEqualYear(date)), 29 + pagination: { 30 + next: pagination.includes(paramYear + 1) ? paramYear + 1 : null, 31 + prev: pagination.includes(paramYear - 1) ? paramYear - 1 : null, 32 + }, 33 + meta, 34 + }; 35 + } 36 + 37 + if (category && !categories.includes(category)) { 38 + return { posts: [], pagination: { next: null, prev: null }, meta }; 39 + } 40 + 41 + return { posts, pagination: { next: null, prev: null }, meta }; 42 + }); 43 + } 44 + ); 45 + 46 + export default provideBlogData;
+9
next-data/providers/releaseData.ts
··· 1 + import { cache } from 'react'; 2 + 3 + import generateReleaseData from '@/next-data/generators/releaseData.mjs'; 4 + 5 + const releaseData = generateReleaseData(); 6 + 7 + const provideReleaseData = cache(async () => releaseData); 8 + 9 + export default provideReleaseData;
+18
next-data/providers/websiteFeeds.ts
··· 1 + import { cache } from 'react'; 2 + 3 + import generateWebsiteFeeds from '@/next-data/generators/websiteFeeds.mjs'; 4 + import provideBlogData from '@/next-data/providers/blogData'; 5 + 6 + const websiteFeeds = generateWebsiteFeeds(provideBlogData()); 7 + 8 + const provideWebsiteFeeds = cache(async (feed: string) => { 9 + return websiteFeeds.then(feeds => { 10 + if (feed.includes('.xml') && feeds.has(feed)) { 11 + return feeds.get(feed)?.rss2(); 12 + } 13 + 14 + return undefined; 15 + }); 16 + }); 17 + 18 + export default provideWebsiteFeeds;
+21
next-data/releaseData.ts
··· 1 + import { ENABLE_STATIC_EXPORT, NEXT_DATA_URL } from '@/next.constants.mjs'; 2 + import type { NodeRelease } from '@/types'; 3 + 4 + const getReleaseData = (): Promise<NodeRelease[]> => { 5 + // When we're using Static Exports the Next.js Server is not running (during build-time) 6 + // hence the self-ingestion APIs will not be available. In this case we want to load 7 + // the data directly within the current thread, which will anyways be loaded only once 8 + // We use lazy-imports to prevent `provideBlogData` from executing on import 9 + if (ENABLE_STATIC_EXPORT) { 10 + return import('@/next-data/providers/releaseData').then( 11 + ({ default: provideReleaseData }) => provideReleaseData() 12 + ); 13 + } 14 + 15 + // When we're on RSC with Server capabilities we prefer using Next.js Data Fetching 16 + // as this will load cached data from the server instead of generating data on the fly 17 + // this is extremely useful for ISR and SSG as it will not generate this data on every request 18 + return fetch(`${NEXT_DATA_URL}release-data`).then(r => r.json()); 19 + }; 20 + 21 + export default getReleaseData;
+26 -97
next.constants.mjs
··· 1 1 'use strict'; 2 2 3 - import { blogData, siteConfig } from './next.json.mjs'; 4 - import { defaultLocale } from './next.locales.mjs'; 3 + /** 4 + * This is used for the current Legacy Website Blog Pagination Generation 5 + * 6 + * @deperecated remove with website redesign 7 + */ 8 + export const CURRENT_YEAR = new Date().getFullYear(); 9 + 10 + /** 11 + * This is used to verify if the current Website is running on a Development Environment 12 + */ 13 + export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; 5 14 6 15 /** 7 16 * This is used for telling Next.js if the Website is deployed on Vercel ··· 65 74 export const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ''; 66 75 67 76 /** 77 + * This is used for fetching static next-data through the /en/next-data/ endpoint 78 + * 79 + * Note this is assumes that the Node.js Website is either running within Vercel Environment 80 + * or running locally (either production or development) mode 81 + * 82 + * Note this variable can be overrided via a manual Environment Variable defined by us if necessary. 83 + */ 84 + export const NEXT_DATA_URL = process.env.NEXT_PUBLIC_DATA_URL 85 + ? process.env.NEXT_PUBLIC_DATA_URL 86 + : VERCEL_ENV 87 + ? `${BASE_URL}${BASE_PATH}/en/next-data/` 88 + : `http://localhost:3000/en/next-data/`; 89 + 90 + /** 68 91 * This ReGeX is used to remove the `index.md(x)` suffix of a name and to remove 69 92 * the `.md(x)` extensions of a filename. 70 93 * ··· 73 96 */ 74 97 export const MD_EXTENSION_REGEX = /((\/)?(index))?\.mdx?$/i; 75 98 76 - /** 77 - * This is a list of all static routes or pages from the Website that we do not 78 - * want to allow to be statically built on our Static Export Build. 79 - * 80 - * @type {((route: import('./types').RouteSegment) => boolean)[]} A list of Ignored Routes by Regular Expressions 81 - */ 82 - export const STATIC_ROUTES_IGNORES = [ 83 - // Ignore the 404 route on Static Generation 84 - ({ pathname }) => pathname === '404', 85 - // This is used to ignore is used to ignore all blog routes except for the English language 86 - ({ locale, pathname }) => 87 - locale !== defaultLocale.code && /^blog\//.test(pathname), 88 - // This is used to ignore the blog/pagination meta route 89 - ({ pathname }) => /^blog\/pagination/.test(pathname), 90 - ]; 91 - 92 - /** 93 - * This is a list of all dynamic routes or pages from the Website that we do not 94 - * want to allow to be dynamically access by our Dynamic Route Engine 95 - * 96 - * @type {RegExp[]} A list of Ignored Routes by Regular Expressions 97 - */ 98 - export const DYNAMIC_ROUTES_IGNORES = [ 99 - // This is used to ignore the blog/pagination route 100 - /^blog\/pagination/, 101 - ]; 102 - 103 - /** 104 - * This is a list of all static routes that we want to rewrite their pathnames 105 - * into something else. This is useful when you want to have the current pathname in the route 106 - * but replace the actual Markdown file that is being loaded by the Dynamic Route to something else 107 - * 108 - * @type {[RegexExp, (pathname: string) => string][]} 109 - */ 110 - export const DYNAMIC_ROUTES_REWRITES = [ 111 - [/^blog\/year-/, () => 'blog/pagination'], 112 - ]; 113 - 114 - /** 115 - * This is a constant that should be used during runtime by (`getStaticPaths`) on `pages/[...path].tsx` 116 - * 117 - * This function is used to provide an extra set of routes that are not provided by `next.dynamic.mjs` 118 - * static route discovery. This can happen when we have dynamic routes that **must** be provided 119 - * within the static export (static build) of the website. This constant usually would be used along 120 - * with a matching pathname on `DYNAMIC_ROUTES_REWRITES`. 121 - * 122 - * @type {string[]} A list of all the Dynamic Routes that are generated by the Website 123 - */ 124 - export const DYNAMIC_GENERATED_ROUTES = [ 125 - ...blogData.pagination.map(year => `blog/year-${year}`), 126 - ]; 127 - 128 99 /*** 129 100 * This is a list of all external links that are used on website sitemap. 130 101 * @see https://github.com/nodejs/nodejs.org/issues/5813 for more context ··· 145 116 export const THEME_STORAGE_KEY = 'theme'; 146 117 147 118 /** 148 - * This is the default Next.js Page Metadata for all pages 149 - * 150 - * @type {import('next').Metadata} 151 - */ 152 - export const DEFAULT_METADATA = { 153 - metadataBase: new URL(`${BASE_URL}${BASE_PATH}`), 154 - title: siteConfig.title, 155 - description: siteConfig.description, 156 - robots: { index: true, follow: true }, 157 - twitter: { 158 - card: siteConfig.twitter.card, 159 - title: siteConfig.twitter.title, 160 - creator: siteConfig.twitter.username, 161 - images: { 162 - url: siteConfig.twitter.img, 163 - alt: siteConfig.twitter.imgAlt, 164 - }, 165 - }, 166 - alternates: { 167 - canonical: '', 168 - languages: { 'x-default': '' }, 169 - types: { 170 - 'application/rss+xml': 'https://nodejs.org/en/feed/blog.xml', 171 - }, 172 - }, 173 - icons: { icon: siteConfig.favicon }, 174 - openGraph: { images: siteConfig.twitter.img }, 175 - }; 176 - 177 - /** 178 - * This is the default Next.js Viewport Metadata for all pages 179 - * 180 - * @type {import('next').Viewport} 181 - */ 182 - export const DEFAULT_VIEWPORT = { 183 - themeColor: siteConfig.accentColor, 184 - width: 'device-width', 185 - initialScale: 1, 186 - }; 187 - 188 - /** 189 119 * This is the Sentry DSN for the Node.js Website Project 190 120 */ 191 121 export const SENTRY_DSN = ··· 194 124 /** 195 125 * This states if Sentry should be enabled and bundled within our App 196 126 */ 197 - export const SENTRY_ENABLE = 198 - process.env.NODE_ENV === 'development' || !!VERCEL_ENV; 127 + export const SENTRY_ENABLE = IS_DEVELOPMENT || !!VERCEL_ENV; 199 128 200 129 /** 201 130 * This configures the sampling rate for Sentry
-11
next.data.mjs
··· 1 - 'use strict'; 2 - 3 - import generateBlogPostsData from './next-data/generateBlogPostsData.mjs'; 4 - import generateNodeReleasesJson from './next-data/generateNodeReleasesJson.mjs'; 5 - import generateWebsiteFeeds from './next-data/generateWebsiteFeeds.mjs'; 6 - 7 - export { 8 - generateWebsiteFeeds, 9 - generateBlogPostsData, 10 - generateNodeReleasesJson, 11 - };
+107
next.dynamic.constants.mjs
··· 1 + 'use strict'; 2 + 3 + import { BASE_PATH, BASE_URL, CURRENT_YEAR } from './next.constants.mjs'; 4 + import { siteConfig } from './next.json.mjs'; 5 + import { defaultLocale } from './next.locales.mjs'; 6 + 7 + /** 8 + * This is a list of all static routes or pages from the Website that we do not 9 + * want to allow to be statically built on our Static Export Build. 10 + * 11 + * @type {((route: import('./types').RouteSegment) => boolean)[]} A list of Ignored Routes by Regular Expressions 12 + */ 13 + export const STATIC_ROUTES_IGNORES = [ 14 + // Ignore the 404 route on Static Generation 15 + ({ pathname }) => pathname === '404', 16 + // This is used to ignore is used to ignore all blog routes except for the English language 17 + ({ locale, pathname }) => 18 + locale !== defaultLocale.code && /^blog\//.test(pathname), 19 + // This is used to ignore the blog/pagination meta route 20 + // @deprecated remove with website redesign 21 + ({ pathname }) => /^blog\/pagination/.test(pathname), 22 + ]; 23 + 24 + /** 25 + * This is a list of all dynamic routes or pages from the Website that we do not 26 + * want to allow to be dynamically access by our Dynamic Route Engine 27 + * 28 + * @type {RegExp[]} A list of Ignored Routes by Regular Expressions 29 + * @deprecated remove with website redesign 30 + */ 31 + export const DYNAMIC_ROUTES_IGNORES = [ 32 + // This is used to ignore the blog/pagination route 33 + /^blog\/pagination/, 34 + ]; 35 + 36 + /** 37 + * This is a list of all static routes that we want to rewrite their pathnames 38 + * into something else. This is useful when you want to have the current pathname in the route 39 + * but replace the actual Markdown file that is being loaded by the Dynamic Route to something else 40 + * 41 + * @type {[RegexExp, (pathname: string) => string][]} 42 + * @deprecated remove with website redesign 43 + */ 44 + export const DYNAMIC_ROUTES_REWRITES = [ 45 + [/^blog\/year-/, () => 'blog/pagination'], 46 + ]; 47 + 48 + /** 49 + * This is a constant that should be used during runtime by (`getStaticPaths`) on `pages/[...path].tsx` 50 + * 51 + * This function is used to provide an extra set of routes that are not provided by `next.dynamic.mjs` 52 + * static route discovery. This can happen when we have dynamic routes that **must** be provided 53 + * within the static export (static build) of the website. This constant usually would be used along 54 + * with a matching pathname on `DYNAMIC_ROUTES_REWRITES`. 55 + * 56 + * @type {string[]} A list of all the Dynamic Routes that are generated by the Website 57 + * @deprecated remove with website redesign 58 + */ 59 + export const DYNAMIC_GENERATED_ROUTES = [ 60 + ...Array.from( 61 + // Statically generate a List of Years from Current Year 62 + // til 2011 which is the oldest year with blog posts 63 + { length: CURRENT_YEAR - 2011 }, 64 + (_, i) => CURRENT_YEAR - i 65 + ).map(year => `blog/year-${year}`), 66 + ]; 67 + 68 + /** 69 + * This is the default Next.js Page Metadata for all pages 70 + * 71 + * @type {import('next').Metadata} 72 + */ 73 + export const DEFAULT_METADATA = { 74 + metadataBase: new URL(`${BASE_URL}${BASE_PATH}`), 75 + title: siteConfig.title, 76 + description: siteConfig.description, 77 + robots: { index: true, follow: true }, 78 + twitter: { 79 + card: siteConfig.twitter.card, 80 + title: siteConfig.twitter.title, 81 + creator: siteConfig.twitter.username, 82 + images: { 83 + url: siteConfig.twitter.img, 84 + alt: siteConfig.twitter.imgAlt, 85 + }, 86 + }, 87 + alternates: { 88 + canonical: '', 89 + languages: { 'x-default': '' }, 90 + types: { 91 + 'application/rss+xml': `${BASE_URL}${BASE_PATH}/en/feed/blog.xml`, 92 + }, 93 + }, 94 + icons: { icon: siteConfig.favicon }, 95 + openGraph: { images: siteConfig.twitter.img }, 96 + }; 97 + 98 + /** 99 + * This is the default Next.js Viewport Metadata for all pages 100 + * 101 + * @return {import('next').Viewport} 102 + */ 103 + export const DEFAULT_VIEWPORT = { 104 + themeColor: siteConfig.accentColor, 105 + width: 'device-width', 106 + initialScale: 1, 107 + };
+3 -5
next.dynamic.mjs
··· 8 8 import { cache } from 'react'; 9 9 import { VFile } from 'vfile'; 10 10 11 + import { MD_EXTENSION_REGEX, BASE_URL, BASE_PATH } from './next.constants.mjs'; 11 12 import { 12 - DYNAMIC_GENERATED_ROUTES, 13 13 DYNAMIC_ROUTES_IGNORES, 14 14 DYNAMIC_ROUTES_REWRITES, 15 - MD_EXTENSION_REGEX, 16 15 STATIC_ROUTES_IGNORES, 16 + DYNAMIC_GENERATED_ROUTES, 17 17 DEFAULT_METADATA, 18 - BASE_URL, 19 - BASE_PATH, 20 - } from './next.constants.mjs'; 18 + } from './next.dynamic.constants.mjs'; 21 19 import { getMarkdownFiles } from './next.helpers.mjs'; 22 20 import { siteConfig } from './next.json.mjs'; 23 21 import { availableLocaleCodes, defaultLocale } from './next.locales.mjs';
-12
next.json.mjs
··· 1 1 'use strict'; 2 2 3 - import _localeConfig from './i18n/config.json' assert { type: 'json' }; 4 3 import _siteNavigation from './navigation.json' assert { type: 'json' }; 5 - import _blogData from './public/blog-posts-data.json' assert { type: 'json' }; 6 - import _releaseData from './public/node-releases-data.json' assert { type: 'json' }; 7 4 import _siteRedirects from './redirects.json' assert { type: 'json' }; 8 5 import _siteConfig from './site.json' assert { type: 'json' }; 9 - 10 - /** @type {import('./types').LocaleConfig[]} */ 11 - export const localeConfig = _localeConfig; 12 6 13 7 /** @type {Record<string, import('./types').NavigationEntry>} */ 14 8 export const siteNavigation = _siteNavigation; 15 - 16 - /** @type {import('./types').BlogData} */ 17 - export const blogData = _blogData; 18 - 19 - /** @type {import('./types').NodeRelease[]} */ 20 - export const releaseData = _releaseData; 21 9 22 10 /** @type {Record<string, import('./types').Redirect[]>} */ 23 11 export const siteRedirects = _siteRedirects;
+1 -1
next.locales.mjs
··· 1 1 'use strict'; 2 2 3 - import { localeConfig } from './next.json.mjs'; 3 + import localeConfig from './i18n/config.json' assert { type: 'json' }; 4 4 5 5 // As set of available and enabled locales for the website 6 6 // This is used for allowing us to redirect the user to any
+10 -8
next.rewrites.mjs
··· 16 16 * These are sourced originally from https://github.com/nodejs/build/blob/main/ansible/www-standalone/resources/config/nodejs.org?plain=1 17 17 * and were then converted to Next.js rewrites. Note that only relevant rewrites were added, and some were modified to match Next.js's syntax 18 18 * 19 - * @type {import('next').NextConfig['redirects']} 19 + * @return {Promise<import('next').NextConfig['redirects']>} 20 20 */ 21 21 const redirects = async () => { 22 22 return siteRedirects.external.map(({ source, destination }) => ({ ··· 32 32 * These are sourced originally from https://github.com/nodejs/build/blob/main/ansible/www-standalone/resources/config/nodejs.org?plain=1 33 33 * and were then converted to Next.js rewrites. Note that only relevant rewrites were added, and some were modified to match Next.js's syntax 34 34 * 35 - * @type {import('next').NextConfig['rewrites']} 35 + * @return {Promise<import('next').NextConfig['rewrites']>} 36 36 */ 37 - const rewrites = async () => ({ 38 - afterFiles: siteRedirects.internal.map(({ source, destination }) => ({ 39 - source: source.replace('/:locale/', localesMatch), 40 - destination, 41 - })), 42 - }); 37 + const rewrites = async () => { 38 + return { 39 + afterFiles: siteRedirects.internal.map(({ source, destination }) => ({ 40 + source: source.replace('/:locale/', localesMatch), 41 + destination, 42 + })), 43 + }; 44 + }; 43 45 44 46 export { rewrites, redirects };
+12 -12
package-lock.json
··· 31 31 "husky": "8.0.3", 32 32 "lint-staged": "15.0.2", 33 33 "next": "~14.0.3", 34 - "next-intl": "^3.1.0", 34 + "next-intl": "^3.1.3", 35 35 "next-themes": "~0.2.1", 36 36 "postcss": "~8.4.30", 37 37 "postcss-calc": "~9.0.1", ··· 45 45 "remark-gfm": "~4.0.0", 46 46 "semver": "~7.5.4", 47 47 "sharp": "0.32.6", 48 - "shikiji": "~0.6.13", 48 + "shikiji": "~0.7.3", 49 49 "tailwindcss": "^3.3.5", 50 50 "turbo": "1.10.16", 51 51 "typescript": "~5.2.2", ··· 26776 26776 } 26777 26777 }, 26778 26778 "node_modules/next-intl": { 26779 - "version": "3.1.0", 26780 - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.1.0.tgz", 26781 - "integrity": "sha512-ZbQeJO0RcORFljtNjQmbXRnyS4KwiI0DCMemdIl0OmwoI3PWSpJojE/0hmy6RRZJHEtrWQnpS+afCBpvqgii+A==", 26779 + "version": "3.1.3", 26780 + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-3.1.3.tgz", 26781 + "integrity": "sha512-pjR6px7qj5i7gNgFMFlk6nX8Y0A2xeoYUYXEMrvg9dg3peRuZYGpcf6r46+paEd2JvvIZSb7WzlUH98nu5S5HQ==", 26782 26782 "dependencies": { 26783 26783 "@formatjs/intl-localematcher": "^0.2.32", 26784 26784 "negotiator": "^0.6.3", 26785 - "use-intl": "^3.1.0" 26785 + "use-intl": "^3.1.3" 26786 26786 }, 26787 26787 "peerDependencies": { 26788 26788 "next": "^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0", ··· 32744 32744 } 32745 32745 }, 32746 32746 "node_modules/shikiji": { 32747 - "version": "0.6.13", 32748 - "resolved": "https://registry.npmjs.org/shikiji/-/shikiji-0.6.13.tgz", 32749 - "integrity": "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA==", 32747 + "version": "0.7.3", 32748 + "resolved": "https://registry.npmjs.org/shikiji/-/shikiji-0.7.3.tgz", 32749 + "integrity": "sha512-kMb6bfZ+VrTcVn35RGnYjCfRnnPvg08UUXnpVW5bqT5S8UyrrUtUJjhzQHkMi5k8N1aS/FO5YDl+sHRiRdtcLQ==", 32750 32750 "dependencies": { 32751 32751 "hast-util-to-html": "^9.0.0" 32752 32752 } ··· 35566 35566 } 35567 35567 }, 35568 35568 "node_modules/use-intl": { 35569 - "version": "3.1.0", 35570 - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.1.0.tgz", 35571 - "integrity": "sha512-PT6g2BISl93vzkUlbEPG6WN6ztOe75F+d3flxCzn4MpHEDdiDwlc6WpNcoOnlIfxEQvYKr66zDA0QrJAEB8yAQ==", 35569 + "version": "3.1.3", 35570 + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-3.1.3.tgz", 35571 + "integrity": "sha512-rYP1O+RiVef8/iMdZxYp4KIuPszzFQQMzp1y0pMm1VBRdrwWw9YA5j2bEIbjxRk6DaVGW+cpW9mdPe5X2snNvg==", 35572 35572 "dependencies": { 35573 35573 "@formatjs/ecma402-abstract": "^1.11.4", 35574 35574 "intl-messageformat": "^9.3.18"
+2 -5
package.json
··· 16 16 }, 17 17 "scripts": { 18 18 "scripts:release-post": "cross-env NODE_NO_WARNINGS=1 node scripts/release-post/index.mjs", 19 - "scripts:generate-next-data": "cross-env NODE_NO_WARNINGS=1 node scripts/generate-next-data/index.mjs", 20 - "preserve": "npm run scripts:generate-next-data", 21 19 "serve": "cross-env NODE_NO_WARNINGS=1 next dev --turbo", 22 - "prebuild": "npm run scripts:generate-next-data", 23 20 "build": "cross-env NODE_NO_WARNINGS=1 next build", 24 21 "start": "cross-env NODE_NO_WARNINGS=1 next start", 25 22 "deploy": "cross-env NEXT_STATIC_EXPORT=true npm run build", ··· 63 60 "husky": "8.0.3", 64 61 "lint-staged": "15.0.2", 65 62 "next": "~14.0.3", 66 - "next-intl": "^3.1.0", 63 + "next-intl": "^3.1.3", 67 64 "next-themes": "~0.2.1", 68 65 "postcss": "~8.4.30", 69 66 "postcss-calc": "~9.0.1", ··· 77 74 "remark-gfm": "~4.0.0", 78 75 "semver": "~7.5.4", 79 76 "sharp": "0.32.6", 80 - "shikiji": "~0.6.13", 77 + "shikiji": "~0.7.3", 81 78 "tailwindcss": "^3.3.5", 82 79 "turbo": "1.10.16", 83 80 "typescript": "~5.2.2",
-1
public/blog-posts-data.json
··· 1 - { "pagination": [], "categories": [], "posts": [] }
-1
public/node-releases-data.json
··· 1 - []
-14
scripts/generate-next-data/index.mjs
··· 1 - 'use strict'; 2 - 3 - const textToDisplay = 4 - '(blog-posts-data.json) and (node-releases-data.json) got generated.'; 5 - 6 - console.log(`- \x1b[0;34minfo\x1b[0m \x1b[1m${textToDisplay}\x1b[0m`); 7 - 8 - import * as nextData from '../../next.data.mjs'; 9 - 10 - // generate the node.js releases json file 11 - await nextData.generateNodeReleasesJson(); 12 - 13 - // generate the data from blog posts 14 - await nextData.generateBlogPostsData();
+12
types/blog.ts
··· 11 11 pagination: number[]; 12 12 categories: string[]; 13 13 } 14 + 15 + export interface BlogDataRSC { 16 + posts: BlogPost[]; 17 + pagination: { 18 + next: number | null; 19 + prev: number | null; 20 + }; 21 + meta: { 22 + categories: string[]; 23 + pagination: number[]; 24 + }; 25 + }
+10
types/navigation.ts
··· 1 + import type { useTranslations } from 'next-intl'; 2 + 1 3 export type NavigationKeys = 2 4 | 'about' 3 5 | 'download' ··· 12 14 link: string; 13 15 items?: Record<string, NavigationEntry>; 14 16 } 17 + 18 + export interface MappedNavigationEntry { 19 + text: ReturnType<ReturnType<typeof useTranslations>['rich']>; 20 + link: string; 21 + key: string; 22 + level: number; 23 + items: MappedNavigationEntry[]; 24 + }