The Node.js® Website

feat: new home page, blog engine, blog layouts and routing engine (#6215)

Co-authored-by: Caner Akdas <canerakdas@gmail.com>

authored by Claudio W Caner Akdas and committed by GitHub c811ac78 2c878257

Changed files
+1530 -1101
.github
app
[locale]
[[...path]]
feed
[feed]
next-data
blog-data
[category]
release-data
components
i18n
locales
layouts
next-data
pages
en
blog
advisory-board
announcements
community
feature
module
npm
release
uncategorized
video
vulnerability
weekly-updates
download
learn
new-design
providers
styles
types
util
+2 -2
.github/workflows/build.yml
··· 91 91 node-version-file: '.nvmrc' 92 92 cache: 'npm' 93 93 94 - - name: Install NPM packages 95 - # We want to avoid NPM from running the Audit Step and Funding messages on a CI environment 94 + - name: Install npm packages 95 + # We want to avoid npm from running the Audit Step and Funding messages on a CI environment 96 96 # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted 97 97 # We also use `--omit=dev` to avoid installing devDependencies as we don't need them during the build step 98 98 run: npm i --no-audit --no-fund --userconfig=/dev/null --omit=dev
+4 -4
.github/workflows/lint-and-tests.yml
··· 111 111 node-version-file: '.nvmrc' 112 112 cache: 'npm' 113 113 114 - - name: Install NPM packages 115 - # We want to avoid NPM from running the Audit Step and Funding messages on a CI environment 114 + - name: Install npm packages 115 + # We want to avoid npm from running the Audit Step and Funding messages on a CI environment 116 116 # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted 117 117 run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null 118 118 ··· 209 209 node-version-file: '.nvmrc' 210 210 cache: 'npm' 211 211 212 - - name: Install NPM packages 213 - # We want to avoid NPM from running the Audit Step and Funding messages on a CI environment 212 + - name: Install npm packages 213 + # We want to avoid npm from running the Audit Step and Funding messages on a CI environment 214 214 # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted 215 215 run: npm i --no-audit --no-fund --userconfig=/dev/null 216 216
+2 -2
.github/workflows/translations-pr.yml
··· 100 100 node-version-file: '.nvmrc' 101 101 cache: 'npm' 102 102 103 - - name: Install NPM packages 104 - # We want to avoid NPM from running the Audit Step and Funding messages on a CI environment 103 + - name: Install npm packages 104 + # We want to avoid npm from running the Audit Step and Funding messages on a CI environment 105 105 # We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted 106 106 run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null 107 107
+1 -1
DEPENDENCY_PINNING.md
··· 1 1 ## Dependency Pinning 2 2 3 - Based on the initial discussions from [this discussion thread](https://github.com/nodejs/nodejs.org/discussions/5491), we've decided to use a more strict strategy for handling NPM dependencies within the Node.js Website. 3 + Based on the initial discussions from [this discussion thread](https://github.com/nodejs/nodejs.org/discussions/5491), we've decided to use a more strict strategy for handling npm dependencies within the Node.js Website. 4 4 5 5 The intent here is to prevent the build process, or the website itself, from breaking due to changes in dependencies. As some dependencies do not respect semantic versioning, this is a real concern. Pinning dependencies also ensures that we stay fixed on a specific dependency version. For security updates, Dependabot is still configured to give us security alerts when specific dependencies got security advisories. 6 6
+44 -25
app/[locale]/[[...path]]/page.tsx
··· 7 7 import { MDXRenderer } from '@/components/mdxRenderer'; 8 8 import WithLayout from '@/components/withLayout'; 9 9 import { ENABLE_STATIC_EXPORT, VERCEL_REVALIDATE } from '@/next.constants.mjs'; 10 - import { DEFAULT_VIEWPORT } from '@/next.dynamic.constants.mjs'; 10 + import { PAGE_VIEWPORT, DYNAMIC_ROUTES } from '@/next.dynamic.constants.mjs'; 11 11 import { dynamicRouter } from '@/next.dynamic.mjs'; 12 12 import { availableLocaleCodes, defaultLocale } from '@/next.locales.mjs'; 13 13 import { MatterProvider } from '@/providers/matterProvider'; ··· 17 17 18 18 // This is the default Viewport Metadata 19 19 // @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function 20 - export const generateViewport = async () => ({ ...DEFAULT_VIEWPORT }); 20 + export const generateViewport = async () => ({ ...PAGE_VIEWPORT }); 21 21 22 22 // This generates each page's HTML Metadata 23 23 // @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata ··· 26 26 27 27 const pathname = dynamicRouter.getPathname(path); 28 28 29 - // Retrieves and rewriting rule if the pathname matches any rule 30 - const [, rewriteRule] = dynamicRouter.getRouteRewrite(pathname); 29 + return dynamicRouter.getPageMetadata(locale, pathname); 30 + }; 31 31 32 - return dynamicRouter.getPageMetadata( 33 - locale, 34 - rewriteRule ? rewriteRule(pathname) : pathname 32 + // Gets all mapped routes to the Next.js Routing Engine by Locale 33 + const mapRoutesForLocale = async (locale: string) => { 34 + const routesForLanguage = await dynamicRouter.getRoutesByLanguage(locale); 35 + 36 + return routesForLanguage.map(pathname => 37 + dynamicRouter.mapPathToRoute(locale, pathname) 35 38 ); 36 39 }; 37 40 ··· 40 43 export const generateStaticParams = async () => { 41 44 const paths: Array<DynamicStaticPaths> = []; 42 45 43 - // We don't need to compute all possible paths on regular builds 44 - // as we benefit from Next.js's ISR (Incremental Static Regeneration) 45 - if (!ENABLE_STATIC_EXPORT) { 46 - return []; 47 - } 48 - 49 - for (const locale of availableLocaleCodes) { 50 - const routesForLanguage = await dynamicRouter.getRoutesByLanguage(locale); 51 - 52 - const mappedRoutesWithLocale = routesForLanguage.map(pathname => 53 - dynamicRouter.mapPathToRoute(locale, pathname) 46 + // If static exports are enabled we need to compute all available routes 47 + // And then append them to Next.js's Route Engine 48 + if (ENABLE_STATIC_EXPORT) { 49 + const allAvailableRoutes = await Promise.all( 50 + availableLocaleCodes.map(mapRoutesForLocale) 54 51 ); 55 52 56 - paths.push(...mappedRoutesWithLocale); 53 + paths.push(...allAvailableRoutes.flat()); 57 54 } 58 55 59 56 return paths.sort(); ··· 76 73 // Configures the current Locale to be the given Locale of the Request 77 74 unstable_setRequestLocale(locale); 78 75 76 + // Gets the current full pathname for a given path 79 77 const pathname = dynamicRouter.getPathname(path); 80 78 81 - if (dynamicRouter.shouldIgnoreRoute(pathname)) { 82 - return notFound(); 83 - } 79 + // @todo: once removed the legacy layouts remove the any casting 80 + // eslint-disable-next-line @typescript-eslint/no-explicit-any 81 + const staticGeneratedLayout = DYNAMIC_ROUTES.get(pathname) as any; 84 82 85 - // Retrieves and rewriting rule if the pathname matches any rule 86 - const [, rewriteRule] = dynamicRouter.getRouteRewrite(pathname); 83 + // If the current patname is a statically generated route 84 + // it means it does not have a Markdown file nor exists under the filesystem 85 + // but it is a valid route with an assigned layout that should be rendered 86 + if (staticGeneratedLayout !== undefined) { 87 + // Decorate the Locale and current Pathname to Sentry 88 + setTags({ pathname, locale }); 89 + 90 + // Metadata and shared Context to be available through the lifecycle of the page 91 + const sharedContext = { pathname: `/${pathname}` }; 92 + 93 + // Defines a shared Server Context for the Client-Side 94 + // That is shared for all pages under the dynamic router 95 + setClientContext(sharedContext); 96 + 97 + // The Matter Provider allows Client-Side injection of the data 98 + // to a shared React Client Provider even though the page is rendered 99 + // within a server-side context 100 + return ( 101 + <MatterProvider {...sharedContext}> 102 + <WithLayout layout={staticGeneratedLayout} /> 103 + </MatterProvider> 104 + ); 105 + } 87 106 88 107 // We retrieve the source of the Markdown file by doing an educated guess 89 108 // of what possible files could be the source of the page, since the extension 90 109 // context is lost from `getStaticProps` as a limitation of Next.js itself 91 110 const { source, filename } = await dynamicRouter.getMarkdownFile( 92 111 locale, 93 - rewriteRule ? rewriteRule(pathname) : pathname 112 + pathname 94 113 ); 95 114 96 115 // Decorate the Locale and current Pathname to Sentry
+1 -1
app/[locale]/feed/[feed]/route.ts
··· 15 15 // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 16 16 export const GET = async (_: Request, { params }: StaticParams) => { 17 17 // Generate the Feed for the given feed type (blog, releases, etc) 18 - const websiteFeed = await provideWebsiteFeeds(params.feed); 18 + const websiteFeed = provideWebsiteFeeds(params.feed); 19 19 20 20 return new NextResponse(websiteFeed, { 21 21 headers: { 'Content-Type': 'application/xml' },
+67
app/[locale]/next-data/blog-data/[category]/[page]/route.ts
··· 1 + import { 2 + provideBlogCategories, 3 + provideBlogPosts, 4 + providePaginatedBlogPosts, 5 + } from '@/next-data/providers/blogData'; 6 + import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; 7 + import { defaultLocale } from '@/next.locales.mjs'; 8 + 9 + type StaticParams = { 10 + params: { locale: string; category: string; page: string }; 11 + }; 12 + 13 + // This is the Route Handler for the `GET` method which handles the request 14 + // for providing Blog Posts for Blog Categories and Pagination Metadata 15 + // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 16 + export const GET = async (_: Request, { params }: StaticParams) => { 17 + const requestedPage = Number(params.page); 18 + 19 + const data = 20 + requestedPage >= 1 21 + ? // This allows us to blindly get all blog posts from a given category 22 + // if the page number is 0 or something smaller than 1 23 + providePaginatedBlogPosts(params.category, requestedPage) 24 + : provideBlogPosts(params.category); 25 + 26 + return Response.json(data, { status: data.posts.length ? 200 : 404 }); 27 + }; 28 + 29 + // This function generates the static paths that come from the dynamic segments 30 + // `[locale]/next-data/blog-data/[category]` and returns an array of all available static paths 31 + // This is used for ISR static validation and generation 32 + export const generateStaticParams = async () => { 33 + // This metadata is the original list of all available categories and all available years 34 + // within the Node.js Website Blog Posts (2011, 2012...) 35 + const categories = provideBlogCategories(); 36 + 37 + const mappedCategories = categories.map(category => { 38 + // gets the current pagination meta for a given category 39 + const { pagination } = provideBlogPosts(category); 40 + 41 + // creates a sequential array containing each page number 42 + const pages = [...Array(pagination.pages).keys()].map((_, key) => key + 1); 43 + 44 + // maps the data into valid Next.js Route Engine routes with all required params 45 + // notice that we add an extra 0 in the beginning in case we want a non-paginated route 46 + return [0, ...pages].map(page => ({ 47 + locale: defaultLocale.code, 48 + page: String(page), 49 + category, 50 + })); 51 + }); 52 + 53 + return mappedCategories.flat(); 54 + }; 55 + 56 + // Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary 57 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams 58 + export const dynamicParams = false; 59 + 60 + // Enforces that this route is cached and static as much as possible 61 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic 62 + export const dynamic = 'force-static'; 63 + 64 + // Ensures that this endpoint is invalidated and re-executed every X minutes 65 + // so that when new deployments happen, the data is refreshed 66 + // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate 67 + export const revalidate = VERCEL_REVALIDATE;
-45
app/[locale]/next-data/blog-data/[category]/route.ts
··· 1 - import provideBlogData from '@/next-data/providers/blogData'; 2 - import { VERCEL_REVALIDATE } from '@/next.constants.mjs'; 3 - import { defaultLocale } from '@/next.locales.mjs'; 4 - 5 - // We only support fetching these pages from the /en/ locale code 6 - const locale = defaultLocale.code; 7 - 8 - type StaticParams = { params: { category: string; locale: string } }; 9 - 10 - // This is the Route Handler for the `GET` method which handles the request 11 - // for providing Blog Posts, Pagination for every supported Blog Category 12 - // this includes the `year-XXXX` categories for yearly archives (pagination) 13 - // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 14 - export const GET = async (_: Request, { params }: StaticParams) => { 15 - const data = await provideBlogData(params.category); 16 - 17 - return Response.json(data, { status: data.posts.length ? 200 : 404 }); 18 - }; 19 - 20 - // This function generates the static paths that come from the dynamic segments 21 - // `[locale]/next-data/blog-data/[category]` and returns an array of all available static paths 22 - // This is used for ISR static validation and generation 23 - export const generateStaticParams = async () => { 24 - // This metadata is the original list of all available categories and all available years 25 - // within the Node.js Website Blog Posts (2011, 2012...) 26 - const { meta } = await provideBlogData(); 27 - 28 - return [ 29 - ...meta.categories.map(category => ({ category, locale })), 30 - ...meta.pagination.map(year => ({ category: `year-${year}`, locale })), 31 - ]; 32 - }; 33 - 34 - // Forces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary 35 - // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams 36 - export const dynamicParams = true; 37 - 38 - // Enforces that this route is cached and static as much as possible 39 - // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic 40 - export const dynamic = 'force-static'; 41 - 42 - // Ensures that this endpoint is invalidated and re-executed every X minutes 43 - // so that when new deployments happen, the data is refreshed 44 - // @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate 45 - export const revalidate = VERCEL_REVALIDATE;
+1 -1
app/[locale]/next-data/release-data/route.ts
··· 9 9 // for generating static data related to the Node.js Release Data 10 10 // @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers 11 11 export const GET = async () => { 12 - const releaseData = await provideReleaseData(); 12 + const releaseData = provideReleaseData(); 13 13 14 14 return Response.json(releaseData); 15 15 };
+11 -13
client-context.ts
··· 2 2 3 3 import type { ClientSharedServerContext } from '@/types'; 4 4 5 + import { assignClientContext } from './util/assignClientContext'; 6 + 5 7 // This allows us to have Server-Side Context's of the shared "contextual" data 6 8 // which includes the frontmatter, the current pathname from the dynamic segments 7 9 // and the current headings of the current markdown context 8 10 export const getClientContext = cache(() => { 9 - const serverSharedContext: ClientSharedServerContext = { 10 - frontmatter: {}, 11 - pathname: '', 12 - headings: [], 13 - readingTime: { text: '', minutes: 0, time: 0, words: 0 }, 14 - filename: '', 15 - }; 11 + const serverSharedContext = assignClientContext({}); 16 12 17 13 return serverSharedContext; 18 14 }); 19 15 20 16 // This is used by the dynamic router to define on the request 21 17 // the current set of information we use (shared) 22 - export const setClientContext = (data: ClientSharedServerContext) => { 23 - getClientContext().frontmatter = data.frontmatter; 24 - getClientContext().pathname = data.pathname; 25 - getClientContext().headings = data.headings; 26 - getClientContext().readingTime = data.readingTime; 27 - getClientContext().filename = data.filename; 18 + export const setClientContext = (data: Partial<ClientSharedServerContext>) => { 19 + const _data = assignClientContext(data); 20 + 21 + getClientContext().frontmatter = _data.frontmatter; 22 + getClientContext().pathname = _data.pathname; 23 + getClientContext().headings = _data.headings; 24 + getClientContext().readingTime = _data.readingTime; 25 + getClientContext().filename = _data.filename; 28 26 };
+2 -4
components/Common/AvatarGroup/Avatar/index.module.css
··· 1 1 .avatar { 2 2 @apply flex 3 - h-8 4 - w-8 3 + size-8 5 4 items-center 6 5 justify-center 7 6 rounded-full ··· 18 17 19 18 .avatarRoot { 20 19 @apply -ml-2 21 - h-8 22 - w-8 20 + size-8 23 21 flex-shrink-0 24 22 first:ml-0; 25 23 }
+6 -1
components/Common/AvatarGroup/Avatar/index.tsx
··· 10 10 11 11 const Avatar: FC<AvatarProps> = ({ src, alt }) => ( 12 12 <RadixAvatar.Root className={styles.avatarRoot}> 13 - <RadixAvatar.Image src={src} alt={alt} className={styles.avatar} /> 13 + <RadixAvatar.Image 14 + loading="lazy" 15 + src={src} 16 + alt={alt} 17 + className={styles.avatar} 18 + /> 14 19 <RadixAvatar.Fallback delayMs={500} className={styles.avatar}> 15 20 {alt} 16 21 </RadixAvatar.Fallback>
+2
components/Common/AvatarGroup/index.tsx
··· 1 + 'use client'; 2 + 1 3 import classNames from 'classnames'; 2 4 import type { ComponentProps, FC } from 'react'; 3 5 import { useState, useMemo } from 'react';
+1 -2
components/Common/Badge/index.module.css
··· 11 11 font-medium; 12 12 13 13 .icon { 14 - @apply h-4 15 - w-4; 14 + @apply size-4; 16 15 } 17 16 18 17 .badge {
+1 -2
components/Common/Banner/index.module.css
··· 22 22 } 23 23 24 24 svg { 25 - @apply h-4 26 - w-4 25 + @apply size-4 27 26 text-white/50; 28 27 } 29 28 }
+10 -11
components/Common/BlogPostCard/__tests__/index.test.mjs
··· 8 8 description = 'Blog post description', 9 9 authors = [], 10 10 date = new Date(), 11 + slug = '', 11 12 }) { 12 13 render( 13 14 <BlogPostCard 14 15 title={title} 15 - type={type} 16 + category={type} 16 17 description={description} 17 18 authors={authors} 18 19 date={date} 20 + slug={slug} 19 21 /> 20 22 ); 21 23 ··· 33 35 it('Renders the title prop correctly', () => { 34 36 const { title } = renderBlogPostCard({}); 35 37 36 - // Title from Preview component 37 - expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( 38 - title 39 - ); 38 + expect(screen.getAllByText(title).length).toBe(2); 40 39 41 - // The second title should be hidden for screen-readers 42 - // to prevent them from reading it twice 43 - expect(screen.getAllByText(title)[1]).toHaveAttribute( 40 + // title from preview should be ignored as the one from Links 41 + // and blog card/post are what matter 42 + expect(screen.getAllByText(title)[0]).toHaveAttribute( 44 43 'aria-hidden', 45 44 'true' 46 45 ); ··· 53 52 }); 54 53 55 54 it.each([ 56 - { label: 'components.common.card.vulnerability', type: 'vulnerability' }, 57 - { label: 'components.common.card.announcement', type: 'announcement' }, 58 - { label: 'components.common.card.release', type: 'release' }, 55 + { label: 'layouts.blog.categories.vulnerability', type: 'vulnerability' }, 56 + { label: 'layouts.blog.categories.announcements', type: 'announcements' }, 57 + { label: 'layouts.blog.categories.release', type: 'release' }, 59 58 ])( 60 59 'Renders "%label" text when passing it the type "%type"', 61 60 ({ label, type }) => {
+4 -10
components/Common/BlogPostCard/index.module.css
··· 1 1 .container { 2 - @apply max-w-full 3 - bg-white 4 - dark:bg-neutral-950; 5 - } 6 - 7 - .preview { 8 - @apply mb-6 9 - max-w-full 10 - rounded 11 - p-4; 2 + @apply max-w-full; 12 3 } 13 4 14 5 .subtitle { 15 6 @apply mb-2 7 + mt-6 8 + inline-block 16 9 text-xs 17 10 font-semibold 18 11 text-green-600 ··· 21 14 22 15 .title { 23 16 @apply mb-2 17 + block 24 18 text-xl 25 19 font-semibold 26 20 text-neutral-900
+2 -1
components/Common/BlogPostCard/index.stories.tsx
··· 8 8 export const Default: Story = { 9 9 args: { 10 10 title: 'Node.js March 17th Infrastructure Incident Post-mortem', 11 - type: 'vulnerability', 11 + category: 'vulnerability', 12 12 description: 13 13 'Starting on March 15th and going through to March 17th (with much of the issue being mitigated on the 16th), users were receiving intermittent 404 responses when trying to download Node.js from nodejs.org, or even accessing parts of the website.', 14 14 authors: [ ··· 17 17 src: 'https://avatars.githubusercontent.com/u/', 18 18 }, 19 19 ], 20 + slug: '/blog/vulnerability/something', 20 21 date: new Date('17 October 2023'), 21 22 }, 22 23 decorators: [
+29 -30
components/Common/BlogPostCard/index.tsx
··· 1 1 import { useTranslations } from 'next-intl'; 2 - import { useMemo } from 'react'; 3 - import type { ComponentProps, FC } from 'react'; 2 + import type { FC } from 'react'; 4 3 5 4 import AvatarGroup from '@/components/Common/AvatarGroup'; 6 5 import Preview from '@/components/Common/Preview'; 7 6 import { Time } from '@/components/Common/Time'; 7 + import Link from '@/components/Link'; 8 + import { mapBlogCategoryToPreviewType } from '@/util/blogUtils'; 8 9 9 10 import styles from './index.module.css'; 10 11 11 - type Author = { 12 - fullName: string; 13 - src: string; 14 - }; 12 + // @todo: this should probably be a global type? 13 + type Author = { fullName: string; src: string }; 15 14 16 15 type BlogPostCardProps = { 17 - title: ComponentProps<typeof Preview>['title']; 18 - type: Required<ComponentProps<typeof Preview>>['type']; 19 - description: string; 16 + title: string; 17 + category: string; 18 + description?: string; 20 19 authors: Array<Author>; 21 20 date: Date; 21 + slug: string; 22 22 }; 23 23 24 24 const BlogPostCard: FC<BlogPostCardProps> = ({ 25 25 title, 26 - type, 26 + slug, 27 + category, 27 28 description, 28 29 authors, 29 30 date, 30 31 }) => { 31 32 const t = useTranslations(); 32 33 33 - const avatars = useMemo( 34 - () => 35 - authors.map(({ fullName, src }) => ({ 36 - alt: fullName, 37 - src, 38 - toString: () => fullName, 39 - })), 40 - [authors] 41 - ); 34 + const avatars = authors.map(({ fullName, src }) => ({ alt: fullName, src })); 35 + 36 + const type = mapBlogCategoryToPreviewType(category); 42 37 43 38 return ( 44 39 <article className={styles.container}> 45 - <Preview 46 - title={title} 47 - type={type} 48 - height="auto" 49 - className={styles.preview} 50 - /> 51 - <p className={styles.subtitle}>{t(`components.common.card.${type}`)}</p> 52 - <p aria-hidden="true" className={styles.title}> 40 + <Link href={slug} aria-label={title}> 41 + <Preview title={title} type={type} /> 42 + </Link> 43 + 44 + <Link href={`/blog/${category}`} className={styles.subtitle}> 45 + {t(`layouts.blog.categories.${category}`)} 46 + </Link> 47 + 48 + <Link href={slug} className={styles.title}> 53 49 {title} 54 - </p> 55 - <p className={styles.description}>{description}</p> 50 + </Link> 51 + 52 + {description && <p className={styles.description}>{description}</p>} 53 + 56 54 <footer className={styles.footer}> 57 55 <AvatarGroup avatars={avatars} /> 56 + 58 57 <div className={styles.author}> 59 - <p>{avatars.join(', ')}</p> 58 + <p>{avatars.map(avatar => avatar.alt).join(', ')}</p> 60 59 61 60 <Time 62 61 date={date}
+1 -2
components/Common/Breadcrumbs/BreadcrumbHomeLink/index.module.css
··· 1 1 .icon { 2 - @apply h-4 3 - w-4; 2 + @apply size-4; 4 3 }
+1 -2
components/Common/Breadcrumbs/BreadcrumbItem/index.module.css
··· 22 22 } 23 23 24 24 .separator { 25 - @apply h-4 26 - w-4 25 + @apply size-4 27 26 flex-shrink-0 28 27 flex-grow 29 28 text-neutral-600
+4
components/Common/Button/index.module.css
··· 69 69 70 70 &::before { 71 71 @apply absolute 72 + left-0 72 73 right-0 73 74 top-0 74 75 -z-10 76 + mx-auto 75 77 h-full 76 78 w-full 77 79 bg-gradient-glow-backdrop ··· 82 84 &::after { 83 85 @apply absolute 84 86 -top-px 87 + left-0 85 88 right-0 89 + mx-auto 86 90 h-px 87 91 w-2/5 88 92 bg-gradient-to-r
+1 -2
components/Common/CodeBox/index.module.css
··· 83 83 } 84 84 85 85 .icon { 86 - @apply h-4 87 - w-4; 86 + @apply size-4; 88 87 }
+40 -39
components/Common/CodeTabs/index.module.css
··· 1 - .root > [role='tabpanel'] > :first-child { 2 - @apply rounded-t-none; 3 - } 1 + .root { 2 + > [role='tabpanel'] > :first-child { 3 + @apply rounded-t-none; 4 + } 4 5 5 - .header { 6 - @apply flex 7 - rounded-t 8 - border-x 9 - border-t 10 - border-neutral-900 11 - bg-neutral-950 12 - px-4 13 - pt-3 14 - xs:px-2; 6 + > div:nth-of-type(1) { 7 + @apply flex 8 + rounded-t 9 + border-x 10 + border-t 11 + border-neutral-900 12 + bg-neutral-950 13 + px-4 14 + pt-3 15 + xs:px-2; 15 16 16 - & [role='tablist'] > button { 17 - @apply border-b 18 - border-b-transparent 19 - px-1 20 - text-neutral-200; 17 + > button { 18 + @apply border-b 19 + border-b-transparent 20 + px-1 21 + text-neutral-200; 21 22 22 - &[aria-selected='true'] { 23 - @apply border-b-green-400 24 - text-green-400; 23 + &[data-state='active'] { 24 + @apply border-b-green-400 25 + text-green-400; 26 + } 25 27 } 26 - } 27 28 28 - .link { 29 - @apply hidden 30 - items-center 31 - gap-2 32 - text-center 33 - text-neutral-200 34 - lg:flex; 29 + .link { 30 + @apply hidden 31 + items-center 32 + gap-2 33 + text-center 34 + text-neutral-200 35 + lg:flex; 35 36 36 - & > .icon { 37 - @apply h-4 38 - w-4 39 - text-neutral-300; 40 - } 37 + & > .icon { 38 + @apply size-4 39 + text-neutral-300; 40 + } 41 41 42 - &:is(:link, :visited) { 43 - &:hover { 44 - @apply text-neutral-400; 42 + &:is(:link, :visited) { 43 + &:hover { 44 + @apply text-neutral-400; 45 45 46 - & > .icon { 47 - @apply text-neutral-600; 46 + & > .icon { 47 + @apply text-neutral-600; 48 + } 48 49 } 49 50 } 50 51 }
+4 -8
components/Common/CodeTabs/index.tsx
··· 6 6 7 7 import styles from './index.module.css'; 8 8 9 - export type CodeTabsExternaLink = { 9 + type CodeTabsProps = Pick< 10 + ComponentProps<typeof Tabs>, 11 + 'tabs' | 'defaultValue' 12 + > & { 10 13 linkUrl?: string; 11 14 linkText?: string; 12 15 }; 13 16 14 - type CodeTabsProps = Pick< 15 - ComponentProps<typeof Tabs>, 16 - 'tabs' | 'onValueChange' | 'defaultValue' 17 - > & 18 - CodeTabsExternaLink; 19 - 20 17 const CodeTabs: FC<PropsWithChildren<CodeTabsProps>> = ({ 21 18 children, 22 19 linkUrl, ··· 26 23 <Tabs 27 24 {...props} 28 25 className={styles.root} 29 - headerClassName={styles.header} 30 26 addons={ 31 27 linkUrl && 32 28 linkText && (
+1 -2
components/Common/CrossLink/index.module.css
··· 28 28 } 29 29 30 30 .icon { 31 - @apply h-4 32 - w-4 31 + @apply size-4 33 32 text-neutral-600 34 33 dark:text-neutral-400; 35 34 }
+36
components/Common/LinkTabs/index.module.css
··· 1 + .tabsList { 2 + @apply mb-6 3 + mt-10 4 + flex 5 + gap-2 6 + border-b 7 + border-b-neutral-200 8 + font-open-sans 9 + xs:hidden 10 + dark:border-b-neutral-800; 11 + 12 + .tabsTrigger { 13 + @apply border-b-2 14 + border-b-transparent 15 + px-1 16 + pb-[11px] 17 + text-sm 18 + font-semibold 19 + text-neutral-800 20 + dark:text-neutral-200; 21 + 22 + &[data-state='active'] { 23 + @apply border-b-green-600 24 + text-green-600 25 + dark:border-b-green-400 26 + dark:text-green-400; 27 + } 28 + } 29 + } 30 + 31 + .tabsSelect > div { 32 + @apply my-6 33 + hidden 34 + w-full 35 + xs:flex; 36 + }
+48
components/Common/LinkTabs/index.tsx
··· 1 + import type { FC, PropsWithChildren } from 'react'; 2 + 3 + import Link from '@/components/Link'; 4 + import WithRouterSelect from '@/components/withRouterSelect'; 5 + 6 + import styles from './index.module.css'; 7 + 8 + type LinkTab = { key: string; label: string; link: string }; 9 + 10 + type LinkTabsProps = { 11 + label?: string; 12 + tabs: Array<LinkTab>; 13 + activeTab: string; 14 + }; 15 + 16 + const LinkTabs: FC<PropsWithChildren<LinkTabsProps>> = ({ 17 + tabs, 18 + label, 19 + activeTab, 20 + children, 21 + }) => ( 22 + <> 23 + <div className={styles.tabsList}> 24 + {tabs.map(tab => ( 25 + <Link 26 + key={tab.key} 27 + href={tab.link} 28 + className={styles.tabsTrigger} 29 + data-state={tab.key === activeTab ? 'active' : 'inactive'} 30 + > 31 + {tab.label} 32 + </Link> 33 + ))} 34 + </div> 35 + 36 + <div className={styles.tabsSelect}> 37 + <WithRouterSelect 38 + label={label} 39 + defaultValue={tabs.find(tab => tab.key === activeTab)?.link} 40 + values={tabs.map(tab => ({ label: tab.label, value: tab.link }))} 41 + /> 42 + </div> 43 + 44 + {children} 45 + </> 46 + ); 47 + 48 + export default LinkTabs;
+4 -3
components/Common/Pagination/PaginationListItem/index.module.css
··· 2 2 .listItem:link, 3 3 .listItem:active { 4 4 @apply flex 5 - h-10 6 - w-10 5 + size-10 7 6 items-center 8 7 justify-center 9 8 rounded ··· 16 15 17 16 &:hover { 18 17 @apply bg-neutral-100 19 - dark:bg-neutral-900; 18 + text-neutral-800 19 + dark:bg-neutral-900 20 + dark:text-neutral-200; 20 21 } 21 22 }
+1 -1
components/Common/Pagination/index.module.css
··· 35 35 @apply flex 36 36 list-none 37 37 justify-center 38 - gap-0.5 38 + gap-1 39 39 [grid-area:pages]; 40 40 }
+4
components/Common/Pagination/index.tsx
··· 41 41 disabled={currentPage === 1} 42 42 kind="secondary" 43 43 className={styles.previousButton} 44 + href={pages[currentPage - 2]?.url} 44 45 > 45 46 <ArrowLeftIcon className={styles.arrowIcon} /> 46 47 <span>{t('components.common.pagination.prev')}</span> 47 48 </Button> 49 + 48 50 <ol className={styles.list}>{parsedPages}</ol> 51 + 49 52 <Button 50 53 aria-label={t('components.common.pagination.nextAriaLabel')} 51 54 disabled={currentPage === pages.length} 52 55 kind="secondary" 53 56 className={styles.nextButton} 57 + href={pages[currentPage]?.url} 54 58 > 55 59 <span>{t('components.common.pagination.next')}</span> 56 60 <ArrowRightIcon className={styles.arrowIcon} />
+29 -10
components/Common/Preview/index.module.css
··· 1 1 .root { 2 2 @apply relative 3 3 flex 4 + aspect-[1.90/1] 4 5 items-center 6 + rounded 7 + border 8 + border-neutral-900 5 9 bg-neutral-950 6 10 bg-[url('/static/images/patterns/hexagon-grid.svg')] 7 11 bg-contain 8 - bg-center; 12 + bg-center 13 + @container/preview; 9 14 10 15 &::after { 11 16 @apply absolute ··· 15 20 w-1/3 16 21 rounded-full 17 22 bg-gradient-radial 18 - blur-3xl 19 - content-['']; 23 + blur-2xl 24 + content-[''] 25 + @md/preview:blur-3xl; 20 26 21 - &.announcement { 27 + &.announcements { 22 28 @apply from-green-700/90; 23 29 } 24 30 ··· 31 37 } 32 38 } 33 39 34 - & > .container { 40 + .container { 35 41 @apply z-10 36 42 mx-auto 37 43 flex 44 + w-2/3 38 45 max-w-xl 39 46 flex-col 40 - gap-12 47 + gap-4 41 48 text-center 42 - text-3xl 49 + text-xs 43 50 font-semibold 44 - text-white; 51 + text-white 52 + @sm/preview:text-base 53 + @md/preview:gap-6 54 + @md/preview:text-lg 55 + @lg/preview:gap-8 56 + @lg/preview:text-xl 57 + @xl/preview:gap-12 58 + @xl/preview:text-2xl 59 + @2xl/preview:text-3xl; 45 60 46 - & > .logo { 47 - @apply mx-auto; 61 + .logo { 62 + @apply mx-auto 63 + size-6 64 + @md/preview:size-14 65 + @lg/preview:size-16 66 + @xl/preview:size-20; 48 67 } 49 68 } 50 69 }
+8 -10
components/Common/Preview/index.stories.tsx
··· 5 5 type Story = StoryObj<typeof Preview>; 6 6 type Meta = MetaObj<typeof Preview>; 7 7 8 - export const Default: Story = { 9 - args: { 10 - title: 11 - 'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023', 12 - }, 13 - }; 14 - 15 8 export const Announcement: Story = { 16 9 args: { 17 - type: 'announcement', 10 + type: 'announcements', 18 11 title: 19 12 'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023', 20 13 }, ··· 38 31 args: { 39 32 title: 40 33 'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023', 41 - width: 600, 42 - height: 315, 43 34 }, 35 + decorators: [ 36 + Story => ( 37 + <div className="w-[600px]"> 38 + <Story /> 39 + </div> 40 + ), 41 + ], 44 42 }; 45 43 46 44 export default { component: Preview } as Meta;
+10 -21
components/Common/Preview/index.tsx
··· 1 1 import classNames from 'classnames'; 2 - import type { CSSProperties, ComponentProps, FC, ReactNode } from 'react'; 2 + import type { FC } from 'react'; 3 3 4 4 import JsIconWhite from '@/components/Icons/Logos/JsIconWhite'; 5 + import type { BlogPreviewType } from '@/types'; 5 6 6 7 import styles from './index.module.css'; 7 8 8 9 type PreviewProps = { 9 - type?: 'announcement' | 'release' | 'vulnerability'; 10 - title: ReactNode; 11 - height?: CSSProperties['height']; 12 - width?: CSSProperties['width']; 13 - } & Omit<ComponentProps<'div'>, 'children'>; 10 + title: string; 11 + type?: BlogPreviewType; 12 + }; 14 13 15 - const Preview: FC<PreviewProps> = ({ 16 - type = 'announcement', 17 - title, 18 - height = 630, 19 - width = 1200, 20 - ...props 21 - }) => ( 22 - <div 23 - {...props} 24 - style={{ width, height, ...props.style }} 25 - className={classNames(styles.root, styles[type], props.className)} 26 - > 27 - <div className={styles.container}> 28 - <JsIconWhite className={styles.logo} width={71} height={80} /> 29 - <h2>{title}</h2> 14 + const Preview: FC<PreviewProps> = ({ type = 'announcements', title }) => ( 15 + <div className={classNames(styles.root, styles[type])}> 16 + <div className={styles.container} aria-hidden={true}> 17 + <JsIconWhite className={styles.logo} /> 18 + {title} 30 19 </div> 31 20 </div> 32 21 );
+1 -1
components/Common/ProgressionSidebar/index.stories.tsx
··· 32 32 link: '/the-v8-javascript-engine', 33 33 }, 34 34 { 35 - label: 'An introduction to the NPM package manager', 35 + label: 'An introduction to the npm package manager', 36 36 link: '/an-introduction-to-the-npm-package-manager', 37 37 }, 38 38 ],
+34 -13
components/Common/ProgressionSidebar/index.tsx
··· 1 + import { useTranslations } from 'next-intl'; 1 2 import type { ComponentProps, FC } from 'react'; 2 3 3 4 import ProgressionSidebarGroup from '@/components/Common/ProgressionSidebar/ProgressionSidebarGroup'; 4 - import WithSidebarSelect from '@/components/withSidebarSelect'; 5 + import WithRouterSelect from '@/components/withRouterSelect'; 6 + import { useClientContext } from '@/hooks/react-server'; 5 7 6 8 import styles from './index.module.css'; 7 9 ··· 9 11 groups: Array<ComponentProps<typeof ProgressionSidebarGroup>>; 10 12 }; 11 13 12 - const ProgressionSidebar: FC<ProgressionSidebarProps> = ({ groups }) => ( 13 - <nav className={styles.wrapper}> 14 - {groups.map(({ groupName, items }) => ( 15 - <ProgressionSidebarGroup 16 - key={groupName.toString()} 17 - groupName={groupName} 18 - items={items} 14 + const ProgressionSidebar: FC<ProgressionSidebarProps> = ({ groups }) => { 15 + const t = useTranslations(); 16 + const { pathname } = useClientContext(); 17 + 18 + const selectItems = groups.map(({ items, groupName }) => ({ 19 + label: groupName, 20 + items: items.map(({ label, link }) => ({ value: link, label })), 21 + })); 22 + 23 + const currentItem = selectItems 24 + .map(item => item.items) 25 + .flat() 26 + .find(item => pathname === item.value); 27 + 28 + return ( 29 + <nav className={styles.wrapper}> 30 + {groups.map(({ groupName, items }) => ( 31 + <ProgressionSidebarGroup 32 + key={groupName.toString()} 33 + groupName={groupName} 34 + items={items} 35 + /> 36 + ))} 37 + 38 + <WithRouterSelect 39 + label={t('components.common.sidebar.title')} 40 + values={selectItems} 41 + defaultValue={currentItem?.value} 19 42 /> 20 - ))} 21 - 22 - <WithSidebarSelect groups={groups} /> 23 - </nav> 24 - ); 43 + </nav> 44 + ); 45 + }; 25 46 26 47 export default ProgressionSidebar;
+1 -2
components/Common/Select/index.module.css
··· 109 109 } 110 110 111 111 .icon { 112 - @apply h-4 113 - w-4; 112 + @apply size-4; 114 113 } 115 114 116 115 .text {
+1 -1
components/Common/Select/index.stories.tsx
··· 51 51 }, 52 52 { 53 53 value: 'section-6', 54 - label: 'An introduction to the NPM package manager', 54 + label: 'An introduction to the npm package manager', 55 55 }, 56 56 { 57 57 value: 'section-7',
+11 -12
components/Common/Tabs/index.module.css
··· 11 11 text-sm 12 12 font-semibold 13 13 text-neutral-800 14 - data-[state=active]:border-b-green-600 15 - data-[state=active]:text-green-600 16 - dark:text-neutral-200 17 - dark:data-[state=active]:border-b-green-400 18 - dark:data-[state=active]:text-green-400; 14 + dark:text-neutral-200; 15 + 16 + &[data-state='active'] { 17 + @apply border-b-green-600 18 + text-green-600 19 + dark:border-b-green-400 20 + dark:text-green-400; 21 + } 19 22 } 20 - } 21 23 22 - .tabsWithAddons { 23 - @apply flex 24 - justify-between; 25 - 26 - & > .addons { 27 - @apply border-b-2 24 + .addons { 25 + @apply ml-auto 26 + border-b-2 28 27 border-b-transparent 29 28 px-1 30 29 pb-[11px]
+14 -26
components/Common/Tabs/index.tsx
··· 1 1 import * as TabsPrimitive from '@radix-ui/react-tabs'; 2 - import classNames from 'classnames'; 3 2 import type { FC, PropsWithChildren, ReactNode } from 'react'; 4 3 5 4 import styles from './index.module.css'; 6 5 7 - type Tab = { 8 - key: string; 9 - label: string; 10 - }; 6 + type Tab = { key: string; label: string }; 11 7 12 - type TabsProps = { 8 + type TabsProps = TabsPrimitive.TabsProps & { 13 9 tabs: Array<Tab>; 14 10 addons?: ReactNode; 15 - headerClassName?: string; 16 - } & TabsPrimitive.TabsProps; 11 + }; 17 12 18 13 const Tabs: FC<PropsWithChildren<TabsProps>> = ({ 19 14 tabs, 20 15 addons, 21 - headerClassName, 22 16 children, 23 17 ...props 24 18 }) => ( 25 19 <TabsPrimitive.Root {...props}> 26 - <div 27 - className={classNames(headerClassName, { 28 - [styles.tabsWithAddons]: addons != null, 29 - })} 30 - > 31 - <TabsPrimitive.List className={classNames(styles.tabsList)}> 32 - {tabs.map(tab => ( 33 - <TabsPrimitive.Trigger 34 - key={tab.key} 35 - value={tab.key} 36 - className={styles.tabsTrigger} 37 - > 38 - {tab.label} 39 - </TabsPrimitive.Trigger> 40 - ))} 41 - </TabsPrimitive.List> 20 + <TabsPrimitive.List className={styles.tabsList}> 21 + {tabs.map(tab => ( 22 + <TabsPrimitive.Trigger 23 + key={tab.key} 24 + value={tab.key} 25 + className={styles.tabsTrigger} 26 + > 27 + {tab.label} 28 + </TabsPrimitive.Trigger> 29 + ))} 42 30 43 31 {addons && <div className={styles.addons}>{addons}</div>} 44 - </div> 32 + </TabsPrimitive.List> 45 33 46 34 {children} 47 35 </TabsPrimitive.Root>
+1 -2
components/Common/ThemeToggle/index.module.css
··· 1 1 .themeToggle { 2 - @apply h-9 3 - w-9 2 + @apply size-9 4 3 rounded-md 5 4 p-2 6 5 text-neutral-700
+4 -2
components/Containers/MetaBar/index.module.css
··· 3 3 flex-col 4 4 items-start 5 5 gap-8 6 + overflow-y-auto 6 7 border-l 7 8 border-l-neutral-200 8 9 px-4 ··· 38 39 @apply font-semibold 39 40 text-neutral-900 40 41 underline 42 + xs:inline-block 43 + xs:py-1 41 44 dark:text-white; 42 45 43 46 &:hover { ··· 55 58 } 56 59 57 60 svg { 58 - @apply h-4 59 - w-4 61 + @apply size-4 60 62 text-neutral-600 61 63 dark:text-neutral-400; 62 64 }
+8 -6
components/Containers/MetaBar/index.tsx
··· 29 29 return ( 30 30 <div className={styles.wrapper}> 31 31 <dl> 32 - {Object.entries(items).map(([key, value]) => ( 33 - <Fragment key={key}> 34 - <dt>{t(key)}</dt> 35 - <dd>{value}</dd> 36 - </Fragment> 37 - ))} 32 + {Object.entries(items) 33 + .filter(([, value]) => !!value) 34 + .map(([key, value]) => ( 35 + <Fragment key={key}> 36 + <dt>{t(key)}</dt> 37 + <dd>{value}</dd> 38 + </Fragment> 39 + ))} 38 40 39 41 {heading.length > 0 && ( 40 42 <>
+1 -2
components/Containers/NavBar/NavItem/index.module.css
··· 13 13 } 14 14 15 15 .icon { 16 - @apply h-3 17 - w-3 16 + @apply size-3 18 17 text-neutral-500 19 18 dark:text-neutral-200; 20 19 }
+2 -4
components/Containers/NavBar/index.module.css
··· 45 45 } 46 46 47 47 .navInteractionIcon { 48 - @apply h-6 49 - w-6; 48 + @apply size-6; 50 49 } 51 50 52 51 .sidebarItemTogglerLabel { ··· 91 90 } 92 91 93 92 .ghIconWrapper { 94 - @apply h-9 95 - w-9 93 + @apply size-9 96 94 rounded-md 97 95 p-2; 98 96
+36 -13
components/Containers/Sidebar/index.tsx
··· 1 + import { useTranslations } from 'next-intl'; 1 2 import type { ComponentProps, FC } from 'react'; 2 3 3 4 import SidebarGroup from '@/components/Containers/Sidebar/SidebarGroup'; 4 - import WithSidebarSelect from '@/components/withSidebarSelect'; 5 + import WithRouterSelect from '@/components/withRouterSelect'; 6 + import { useClientContext } from '@/hooks/react-server'; 5 7 6 8 import styles from './index.module.css'; 7 9 ··· 9 11 groups: Array<ComponentProps<typeof SidebarGroup>>; 10 12 }; 11 13 12 - const SideBar: FC<SidebarProps> = ({ groups }) => ( 13 - <aside className={styles.wrapper}> 14 - {groups.map(({ groupName, items }) => ( 15 - <SidebarGroup 16 - key={groupName.toString()} 17 - groupName={groupName} 18 - items={items} 19 - /> 20 - ))} 14 + const SideBar: FC<SidebarProps> = ({ groups }) => { 15 + const t = useTranslations(); 16 + const { pathname } = useClientContext(); 17 + 18 + const selectItems = groups.map(({ items, groupName }) => ({ 19 + label: groupName, 20 + items: items.map(({ label, link }) => ({ value: link, label })), 21 + })); 22 + 23 + const currentItem = selectItems 24 + .map(item => item.items) 25 + .flat() 26 + .find(item => pathname === item.value); 27 + 28 + return ( 29 + <aside className={styles.wrapper}> 30 + {groups.map(({ groupName, items }) => ( 31 + <SidebarGroup 32 + key={groupName.toString()} 33 + groupName={groupName} 34 + items={items} 35 + /> 36 + ))} 21 37 22 - <WithSidebarSelect groups={groups} /> 23 - </aside> 24 - ); 38 + {selectItems.length > 0 && ( 39 + <WithRouterSelect 40 + label={t('components.common.sidebar.title')} 41 + values={selectItems} 42 + defaultValue={currentItem?.value} 43 + /> 44 + )} 45 + </aside> 46 + ); 47 + }; 25 48 26 49 export default SideBar;
+2 -4
components/Downloads/ChangelogModal/index.module.css
··· 36 36 right-3 37 37 top-3 38 38 block 39 - h-6 40 - w-6 39 + size-6 41 40 cursor-pointer 42 41 sm:hidden; 43 42 } ··· 76 75 } 77 76 78 77 svg { 79 - @apply h-3 80 - w-3 78 + @apply size-3 81 79 text-neutral-600; 82 80 } 83 81 }
+3 -2
components/Downloads/DownloadButton/index.tsx
··· 18 18 children, 19 19 }) => { 20 20 const { os, bitness } = useDetectOS(); 21 + const downloadLink = downloadUrlByOS(versionWithPrefix, os, bitness); 21 22 22 23 return ( 23 24 <> 24 25 <Button 25 26 kind="special" 26 - href={downloadUrlByOS(versionWithPrefix, os, bitness)} 27 + href={downloadLink} 27 28 className={classNames(styles.downloadButton, 'hidden dark:flex')} 28 29 > 29 30 {children} ··· 33 34 34 35 <Button 35 36 kind="primary" 36 - href={downloadUrlByOS(versionWithPrefix, os, bitness)} 37 + href={downloadLink} 37 38 className={classNames(styles.downloadButton, 'flex dark:hidden')} 38 39 > 39 40 {children}
+21
components/Downloads/DownloadLink.tsx
··· 1 + 'use client'; 2 + 3 + import type { FC, PropsWithChildren } from 'react'; 4 + 5 + import { useDetectOS } from '@/hooks'; 6 + import type { NodeRelease } from '@/types'; 7 + import { downloadUrlByOS } from '@/util/downloadUrlByOS'; 8 + 9 + type DownloadLinkProps = { release: NodeRelease }; 10 + 11 + const DownloadLink: FC<PropsWithChildren<DownloadLinkProps>> = ({ 12 + release: { versionWithPrefix }, 13 + children, 14 + }) => { 15 + const { os, bitness } = useDetectOS(); 16 + const downloadLink = downloadUrlByOS(versionWithPrefix, os, bitness); 17 + 18 + return <a href={downloadLink}>{children}</a>; 19 + }; 20 + 21 + export default DownloadLink;
+6 -4
components/MDX/CodeTabs/index.tsx
··· 1 1 'use client'; 2 2 3 3 import * as TabsPrimitive from '@radix-ui/react-tabs'; 4 - import type { FC, ReactElement } from 'react'; 4 + import type { ComponentProps, FC, ReactElement } from 'react'; 5 5 6 - import type { CodeTabsExternaLink } from '@/components/Common/CodeTabs'; 7 6 import CodeTabs from '@/components/Common/CodeTabs'; 8 7 9 - type MDXCodeTabsProps = { 8 + type MDXCodeTabsProps = Pick< 9 + ComponentProps<typeof CodeTabs>, 10 + 'linkText' | 'linkUrl' 11 + > & { 10 12 children: Array<ReactElement>; 11 13 languages: string; 12 14 displayNames?: string; 13 15 defaultTab?: string; 14 - } & CodeTabsExternaLink; 16 + }; 15 17 16 18 const MDXCodeTabs: FC<MDXCodeTabsProps> = ({ 17 19 languages: rawLanguages,
+11 -8
components/Pagination.tsx
··· 2 2 import type { FC } from 'react'; 3 3 4 4 import Link from '@/components/Link'; 5 + import type { BlogPagination } from '@/types'; 5 6 6 - type PaginationProps = { prev?: number | null; next?: number | null }; 7 + type PaginationProps = BlogPagination & { category: string }; 7 8 8 - const Pagination: FC<PaginationProps> = ({ next, prev }) => { 9 + const Pagination: FC<PaginationProps> = ({ category, next, prev }) => { 9 10 const t = useTranslations(); 10 11 11 12 return ( 12 13 <nav aria-label="pagination" className="pagination"> 13 - {next && ( 14 - <Link href={`/blog/year-${next}`}> 15 - &lt; {t('components.pagination.next')} 14 + {prev && ( 15 + <Link href={`/blog/${category}/page/${prev}`}> 16 + &lt; {t('components.pagination.previous')} 16 17 </Link> 17 18 )} 18 19 19 - {prev && ( 20 - <Link href={`/blog/year-${prev}`}> 21 - {t('components.pagination.previous')} &gt; 20 + {prev && next && ' | '} 21 + 22 + {next && ( 23 + <Link href={`/blog/${category}/page/${next}`}> 24 + {t('components.pagination.next')} &gt; 22 25 </Link> 23 26 )} 24 27 </nav>
+3 -1
components/__design__/text.stories.tsx
··· 24 24 export const InlineCode: StoryObj = { 25 25 render: () => ( 26 26 <main> 27 - This is an example of <code>inline code block</code> 27 + <p> 28 + This is an example of <code>inline code block</code> 29 + </p> 28 30 </main> 29 31 ), 30 32 };
+60
components/withBlogCategories.tsx
··· 1 + import { useTranslations } from 'next-intl'; 2 + import type { ComponentProps, FC } from 'react'; 3 + 4 + import BlogPostCard from '@/components/Common/BlogPostCard'; 5 + import LinkTabs from '@/components/Common/LinkTabs'; 6 + import Pagination from '@/components/Common/Pagination'; 7 + import type { BlogPostsRSC } from '@/types'; 8 + import { mapAuthorToCardAuthors } from '@/util/blogUtils'; 9 + 10 + type WithBlogCategoriesProps = { 11 + categories: ComponentProps<typeof LinkTabs>['tabs']; 12 + blogData: BlogPostsRSC & { category: string; page: number }; 13 + }; 14 + 15 + const mapPaginationPages = (category: string, pages: number) => 16 + [...Array(pages).keys()].map(page => ({ 17 + url: `/blog/${category}/page/${page + 1}`, 18 + })); 19 + 20 + const WithBlogCategories: FC<WithBlogCategoriesProps> = ({ 21 + categories, 22 + blogData, 23 + }) => { 24 + const t = useTranslations(); 25 + 26 + return ( 27 + <> 28 + <LinkTabs 29 + label={t('layouts.blog.selectCategory')} 30 + tabs={categories} 31 + activeTab={blogData.category} 32 + > 33 + <div className="grid grid-cols-[repeat(auto-fill,minmax(theme(spacing.96),1fr))] [grid-gap:theme(spacing.12)_theme(spacing.8)]"> 34 + {blogData.posts.map(post => ( 35 + <BlogPostCard 36 + key={post.slug} 37 + title={post.title} 38 + category={post.categories[0]} 39 + authors={mapAuthorToCardAuthors(post.author)} 40 + date={post.date} 41 + slug={post.slug} 42 + /> 43 + ))} 44 + </div> 45 + </LinkTabs> 46 + 47 + <div className="mt-4 border-t border-t-neutral-200 pt-5 md:mt-8 dark:border-t-neutral-900"> 48 + <Pagination 49 + currentPage={blogData.page} 50 + pages={mapPaginationPages( 51 + blogData.category, 52 + blogData.pagination.pages 53 + )} 54 + /> 55 + </div> 56 + </> 57 + ); 58 + }; 59 + 60 + export default WithBlogCategories;
+45
components/withBlogCrossLinks.tsx
··· 1 + import type { FC } from 'react'; 2 + 3 + import { getClientContext } from '@/client-context'; 4 + import CrossLink from '@/components/Common/CrossLink'; 5 + import getBlogData from '@/next-data/blogData'; 6 + 7 + const WithBlogCrossLinks: FC = async () => { 8 + const { pathname } = getClientContext(); 9 + 10 + // Extracts from the static URL the components used for the Blog Post slug 11 + const [, , category, postname] = pathname.split('/'); 12 + 13 + const { posts } = await getBlogData(category); 14 + 15 + const currentItem = posts.findIndex( 16 + ({ slug }) => slug === `/blog/${category}/${postname}` 17 + ); 18 + 19 + const [previousCrossLink, nextCrossLink] = [ 20 + posts[currentItem - 1], 21 + posts[currentItem + 1], 22 + ]; 23 + 24 + return ( 25 + <div className="mt-4 grid w-full grid-cols-2 gap-4 xs:grid-cols-1"> 26 + {(previousCrossLink && ( 27 + <CrossLink 28 + type="previous" 29 + text={previousCrossLink.title} 30 + link={previousCrossLink.slug} 31 + /> 32 + )) || <div />} 33 + 34 + {nextCrossLink && ( 35 + <CrossLink 36 + type="next" 37 + text={nextCrossLink.title} 38 + link={nextCrossLink.slug} 39 + /> 40 + )} 41 + </div> 42 + ); 43 + }; 44 + 45 + export default WithBlogCrossLinks;
+3 -5
components/withCrossLinks.tsx components/withSidebarCrossLinks.tsx
··· 4 4 import { useClientContext, useSiteNavigation } from '@/hooks/server'; 5 5 import type { NavigationKeys } from '@/types'; 6 6 7 - type WithCrossLinksProps = { 8 - navKey: NavigationKeys; 9 - }; 7 + type WithCrossLinksProps = { navKey: NavigationKeys }; 10 8 11 - const WithCrossLinks: FC<WithCrossLinksProps> = ({ navKey }) => { 9 + const WithSidebarCrossLinks: FC<WithCrossLinksProps> = ({ navKey }) => { 12 10 const { getSideNavigation } = useSiteNavigation(); 13 11 const { pathname } = useClientContext(); 14 12 ··· 46 44 ); 47 45 }; 48 46 49 - export default WithCrossLinks; 47 + export default WithSidebarCrossLinks;
+4
components/withLayout.tsx
··· 9 9 import LegacyIndexLayout from '@/layouts/IndexLayout'; 10 10 import LegacyLearnLayout from '@/layouts/LearnLayout'; 11 11 import AboutLayout from '@/layouts/New/About'; 12 + import BlogLayout from '@/layouts/New/Blog'; 12 13 import DefaultLayout from '@/layouts/New/Default'; 13 14 import DocsLayout from '@/layouts/New/Docs'; 14 15 import HomeLayout from '@/layouts/New/Home'; 15 16 import LearnLayout from '@/layouts/New/Learn'; 17 + import PostLayout from '@/layouts/New/Post'; 16 18 import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs'; 17 19 import type { Layouts, LegacyLayouts } from '@/types'; 18 20 ··· 35 37 'home.hbs': HomeLayout, 36 38 'learn.hbs': LearnLayout, 37 39 'page.hbs': DefaultLayout, 40 + 'blog-post.hbs': PostLayout, 41 + 'blog-category.hbs': BlogLayout, 38 42 } satisfies Record<Layouts, FC>; 39 43 40 44 type WithLayout<L = Layouts | LegacyLayouts> = PropsWithChildren<{ layout: L }>;
+9 -5
components/withMetaBar.tsx
··· 7 7 import { useClientContext } from '@/hooks/server'; 8 8 import { getGitHubEditPageUrl } from '@/util/gitHubUtils'; 9 9 10 + const DATE_FORMAT = { 11 + month: 'short', 12 + day: '2-digit', 13 + year: 'numeric', 14 + } as const; 15 + 10 16 const WithMetaBar: FC = () => { 11 17 const { headings, readingTime, frontmatter, filename } = useClientContext(); 12 18 const formatter = useFormatter(); 13 19 14 - const lastUpdated = formatter.dateTime(frontmatter.date ?? new Date(), { 15 - month: 'short', 16 - day: '2-digit', 17 - year: 'numeric', 18 - }); 20 + const lastUpdated = frontmatter.date 21 + ? formatter.dateTime(new Date(frontmatter.date), DATE_FORMAT) 22 + : undefined; 19 23 20 24 return ( 21 25 <MetaBar
+30
components/withRouterSelect.tsx
··· 1 + 'use client'; 2 + 3 + import type { ComponentProps, FC } from 'react'; 4 + 5 + import Select from '@/components/Common/Select'; 6 + import { useRouter } from '@/navigation.mjs'; 7 + 8 + type WithSidebarSelectProps = Pick< 9 + ComponentProps<typeof Select>, 10 + 'values' | 'defaultValue' | 'label' 11 + >; 12 + 13 + const WithRouterSelect: FC<WithSidebarSelectProps> = ({ 14 + values, 15 + label, 16 + defaultValue, 17 + }) => { 18 + const { push } = useRouter(); 19 + 20 + return ( 21 + <Select 22 + label={label} 23 + values={values} 24 + defaultValue={defaultValue} 25 + onChange={value => push(value)} 26 + /> 27 + ); 28 + }; 29 + 30 + export default WithRouterSelect;
-46
components/withSidebarSelect.tsx
··· 1 - 'use client'; 2 - 3 - import { useTranslations } from 'next-intl'; 4 - import type { FC } from 'react'; 5 - 6 - import Select from '@/components/Common/Select'; 7 - import { useClientContext } from '@/hooks'; 8 - import { useRouter } from '@/navigation.mjs'; 9 - import type { FormattedMessage } from '@/types'; 10 - 11 - type SelectItem = { 12 - label: FormattedMessage; 13 - link: string; 14 - }; 15 - 16 - type WithSidebarSelectProps = { 17 - groups: Array<{ groupName: FormattedMessage; items: Array<SelectItem> }>; 18 - }; 19 - 20 - const WithSidebarSelect: FC<WithSidebarSelectProps> = ({ groups }) => { 21 - const t = useTranslations(); 22 - 23 - const { pathname } = useClientContext(); 24 - const { push } = useRouter(); 25 - 26 - const selectItems = groups.map(({ items, groupName }) => ({ 27 - label: groupName, 28 - items: items.map(({ label, link }) => ({ value: link, label })), 29 - })); 30 - 31 - const currentItem = selectItems 32 - .map(item => item.items) 33 - .flat() 34 - .find(item => pathname === item.value); 35 - 36 - return ( 37 - <Select 38 - label={t('components.common.sidebar.title')} 39 - values={selectItems} 40 - defaultValue={currentItem?.value} 41 - onChange={value => push(value)} 42 - /> 43 - ); 44 - }; 45 - 46 - export default WithSidebarSelect;
+24 -9
i18n/locales/en.json
··· 38 38 "howMuchJavascriptDoYouNeedToKnowToUseNodejs": "How much JavaScript do you need to know to use Node.js?", 39 39 "differencesBetweenNodejsAndTheBrowser": "Differences between Node.js and the Browser", 40 40 "theV8JavascriptEngine": "The V8 JavaScript Engine", 41 - "anIntroductionToTheNpmPackageManager": "An introduction to the NPM package manager", 41 + "anIntroductionToTheNpmPackageManager": "An introduction to the npm package manager", 42 42 "ecmascript2015Es6AndBeyond": "ECMAScript 2015 (ES6) and beyond", 43 43 "nodejsTheDifferenceBetweenDevelopmentAndProduction": "Node.js, the difference between development and production", 44 44 "nodejsWithTypescript": "Node.js with TypeScript", ··· 118 118 "docs": "Docs" 119 119 }, 120 120 "pagination": { 121 - "next": "Newer | ", 122 - "previous": "Older" 121 + "next": "Next", 122 + "previous": "Previous" 123 123 }, 124 124 "common": { 125 125 "breadcrumbs": { ··· 146 146 }, 147 147 "languageDropdown": { 148 148 "label": "Choose Language" 149 - }, 150 - "card": { 151 - "announcement": "Announcements", 152 - "release": "Releases", 153 - "vulnerability": "Vulnerabilities" 154 149 } 155 150 }, 156 151 "metabar": { ··· 192 187 } 193 188 }, 194 189 "blogIndex": { 195 - "currentYear": "Blog from {year}" 190 + "categoryName": "{category, select, all {Blog} other {{category} Blog Posts}}" 191 + }, 192 + "blog": { 193 + "title": "Blog", 194 + "subtitle": "The latest Node.js news, case studies, tutorials, and resources.", 195 + "selectCategory": "Categories", 196 + "categories": { 197 + "all": "Everything", 198 + "announcements": "Announcements", 199 + "release": "Releases", 200 + "vulnerability": "Vulnerabilities", 201 + "advisory-board": "Advisory Board", 202 + "community": "Community", 203 + "feature": "Feature", 204 + "module": "Module", 205 + "npm": "npm", 206 + "uncategorized": "Uncategorized", 207 + "video": "Video", 208 + "weekly-updates": "Weekly Updates", 209 + "wg": "Working Groups" 210 + } 196 211 } 197 212 }, 198 213 "pages": {
+19 -43
layouts/BlogCategoryLayout.tsx
··· 1 - import { notFound } from 'next/navigation'; 2 1 import { getTranslations } from 'next-intl/server'; 3 2 import type { FC } from 'react'; 4 3 ··· 9 8 import getBlogData from '@/next-data/blogData'; 10 9 11 10 const getCategoryData = async (pathname: string) => { 12 - // We split the pathname to retrieve the blog category from it since the 13 - // URL is usually /blog/{category} the second path piece is usually the 14 - // category name, which usually year-YYYY 15 - const [, _pathname, category] = pathname.split('/'); 16 - if (_pathname === 'blog' && category && category.length) { 17 - const data = await getBlogData(category); 18 - return { ...data, category }; 19 - } 11 + // pathname format can either be: /en/blog/{category} 12 + // or /en/blog/{category}/page/{page} 13 + // hence we attempt to interpolate the full /en/blog/{categoy}/page/{page} 14 + // and in case of course no page argument is provided we define it to 1 15 + // note that malformed routes can't happen as they are all statically generated 16 + const [, , category = 'all', , page = 1] = pathname.split('/'); 20 17 21 - // If the pathname does not match to a blog page, 22 - // which should not happen (as this hook should only be used in blog pages), 23 - // or, if there is no category in the URL, 24 - // which happens when we're on the blog overview page (index), 25 - // then we attempt to get the posts for the current year 26 - // @TODO: Year-based pagination is deprecated and going away soon 27 - let year = `year-${new Date().getFullYear()}`; 28 - let data = await getBlogData(year); 29 - 30 - // If there are no posts in the current year, 31 - // and there is at least one year in the pagination array, 32 - // we'll get the posts for the most recent year 33 - if (!data.posts.length && data.meta.pagination.length) { 34 - year = `year-${Math.max(...data.meta.pagination)}`; 35 - data = await getBlogData(year); 36 - } 18 + const { posts, pagination } = await getBlogData(category, Number(page)); 37 19 38 - return { ...data, category: year }; 20 + return { posts, category, pagination }; 39 21 }; 40 22 41 23 // This is a React Async Server Component 42 24 // Note that Hooks cannot be used in a RSC async component 43 25 // Async Components do not get re-rendered at all. 44 26 const BlogCategoryLayout: FC = async () => { 45 - const { frontmatter, pathname } = getClientContext(); 27 + const { pathname } = getClientContext(); 46 28 47 29 const t = await getTranslations(); 48 30 49 31 const { posts, pagination, category } = await getCategoryData(pathname); 50 32 51 - // This only applies if current category is a year category 52 - const year = category.replace('year-', ''); 53 - const title = category.startsWith('year-') 54 - ? t('layouts.blogIndex.currentYear', { year }) 55 - : frontmatter.title; 56 - 57 - // This ensures that whenever we're running on dynamic generation (SSG) 58 - // that invalid categories or categories/pages without posts will redirect to the 404 page 59 - // however, the blog overview page (index) will always be generated, even if there are no posts 60 - if (posts.length === 0 && pathname !== '/blog') { 61 - return notFound(); 62 - } 63 - 64 33 return ( 65 34 <div className="container" dir="auto"> 66 - <h2>{title}</h2> 35 + <h2 style={{ textTransform: 'capitalize' }}> 36 + {t('layouts.blogIndex.categoryName', { 37 + category: category.replace('year-', ''), 38 + })} 39 + </h2> 67 40 68 41 <ul className="blog-index"> 69 42 {posts.map(({ slug, date, title }) => ( 70 43 <li key={slug}> 71 - <Time date={date} format={{ month: 'short', day: '2-digit' }} /> 44 + <Time 45 + date={date} 46 + format={{ year: 'numeric', month: 'short', day: '2-digit' }} 47 + /> 72 48 <Link href={slug}>{title}</Link> 73 49 </li> 74 50 ))} 75 51 </ul> 76 52 77 - <Pagination {...pagination} /> 53 + <Pagination category={category} {...pagination} /> 78 54 </div> 79 55 ); 80 56 };
+65
layouts/New/Blog.tsx
··· 1 + import { getTranslations } from 'next-intl/server'; 2 + import type { FC } from 'react'; 3 + 4 + import { getClientContext } from '@/client-context'; 5 + import WithBlogCategories from '@/components/withBlogCategories'; 6 + import WithFooter from '@/components/withFooter'; 7 + import WithNavBar from '@/components/withNavBar'; 8 + import getBlogData from '@/next-data/blogData'; 9 + 10 + import styles from './layouts.module.css'; 11 + 12 + const getBlogCategory = async (pathname: string) => { 13 + // pathname format can either be: /en/blog/{category} 14 + // or /en/blog/{category}/page/{page} 15 + // hence we attempt to interpolate the full /en/blog/{categoy}/page/{page} 16 + // and in case of course no page argument is provided we define it to 1 17 + // note that malformed routes can't happen as they are all statically generated 18 + const [, , category = 'all', , page = 1] = pathname.split('/'); 19 + 20 + const { posts, pagination } = await getBlogData(category, Number(page)); 21 + 22 + return { category, posts, pagination, page: Number(page) }; 23 + }; 24 + 25 + const BlogLayout: FC = async () => { 26 + const { pathname } = getClientContext(); 27 + const t = await getTranslations(); 28 + 29 + const mapCategoriesToTabs = (categories: Array<string>) => 30 + categories.map(category => ({ 31 + key: category, 32 + label: t(`layouts.blog.categories.${category}`), 33 + link: `/blog/${category}`, 34 + })); 35 + 36 + const blogData = await getBlogCategory(pathname); 37 + 38 + return ( 39 + <> 40 + <WithNavBar /> 41 + 42 + <div className={styles.blogLayout}> 43 + <main> 44 + <h1>{t('layouts.blog.title')}</h1> 45 + 46 + <p>{t('layouts.blog.subtitle')}</p> 47 + 48 + <WithBlogCategories 49 + blogData={blogData} 50 + categories={mapCategoriesToTabs([ 51 + 'all', 52 + 'announcements', 53 + 'release', 54 + 'vulnerability', 55 + ])} 56 + /> 57 + </main> 58 + </div> 59 + 60 + <WithFooter /> 61 + </> 62 + ); 63 + }; 64 + 65 + export default BlogLayout;
+9
layouts/New/Content.tsx
··· 1 + import type { FC, PropsWithChildren } from 'react'; 2 + 3 + import styles from './layouts.module.css'; 4 + 5 + const ContentLayout: FC<PropsWithChildren> = ({ children }) => ( 6 + <div className={styles.contentLayout}>{children}</div> 7 + ); 8 + 9 + export default ContentLayout;
+6 -7
layouts/New/Docs.tsx
··· 3 3 import WithFooter from '@/components/withFooter'; 4 4 import WithMetaBar from '@/components/withMetaBar'; 5 5 import WithNavBar from '@/components/withNavBar'; 6 - import WithSideBar from '@/components/withSidebar'; 7 - import ArticleLayout from '@/layouts/New/Article'; 6 + import ContentLayout from '@/layouts/New/Content'; 8 7 9 8 // @deprecated: This Layout is Temporary. The `en/docs` route should eventually be removed 10 9 // and all "guides" moved to the Learn section. ··· 13 12 <> 14 13 <WithNavBar /> 15 14 16 - <ArticleLayout> 17 - <WithSideBar navKeys={[]} /> 18 - 19 - <main>{children}</main> 15 + <ContentLayout> 16 + <div> 17 + <main>{children}</main> 18 + </div> 20 19 21 20 <WithMetaBar /> 22 - </ArticleLayout> 21 + </ContentLayout> 23 22 24 23 <WithFooter /> 25 24 </>
+4 -4
layouts/New/Home.tsx
··· 9 9 <> 10 10 <WithNavBar /> 11 11 12 - <main className={styles.homeLayout}> 13 - <div className={styles.hexagonBackdrop} /> 12 + <div className={styles.homeLayout}> 13 + <div className="glowingBackdrop" /> 14 14 15 - {children} 16 - </main> 15 + <main>{children}</main> 16 + </div> 17 17 18 18 <WithFooter /> 19 19 </>
+2 -2
layouts/New/Learn.tsx
··· 1 1 import type { FC, PropsWithChildren } from 'react'; 2 2 3 3 import WithBreadcrumbs from '@/components/withBreadcrumbs'; 4 - import WithCrossLinks from '@/components/withCrossLinks'; 5 4 import WithMetaBar from '@/components/withMetaBar'; 6 5 import WithNavBar from '@/components/withNavBar'; 7 6 import WithProgressionSidebar from '@/components/withProgressionSidebar'; 7 + import WithSidebarCrossLinks from '@/components/withSidebarCrossLinks'; 8 8 import ArticleLayout from '@/layouts/New/Article'; 9 9 10 10 const LearnLayout: FC<PropsWithChildren> = ({ children }) => ( ··· 17 17 <main> 18 18 {children} 19 19 20 - <WithCrossLinks navKey="learn" /> 20 + <WithSidebarCrossLinks navKey="learn" /> 21 21 </main> 22 22 23 23 <WithMetaBar />
+60
layouts/New/Post.tsx
··· 1 + import type { FC, PropsWithChildren } from 'react'; 2 + 3 + import AvatarGroup from '@/components/Common/AvatarGroup'; 4 + import Preview from '@/components/Common/Preview'; 5 + import WithBlogCrossLinks from '@/components/withBlogCrossLinks'; 6 + import WithFooter from '@/components/withFooter'; 7 + import WithMetaBar from '@/components/withMetaBar'; 8 + import WithNavBar from '@/components/withNavBar'; 9 + import { useClientContext } from '@/hooks/react-server'; 10 + import ContentLayout from '@/layouts/New/Content'; 11 + import { 12 + mapAuthorToCardAuthors, 13 + mapBlogCategoryToPreviewType, 14 + } from '@/util/blogUtils'; 15 + 16 + import styles from './layouts.module.css'; 17 + 18 + const PostLayout: FC<PropsWithChildren> = ({ children }) => { 19 + const { frontmatter } = useClientContext(); 20 + 21 + const authors = mapAuthorToCardAuthors(frontmatter.author); 22 + const type = mapBlogCategoryToPreviewType(frontmatter.category); 23 + 24 + return ( 25 + <> 26 + <WithNavBar /> 27 + 28 + <ContentLayout> 29 + <div className={styles.postLayout}> 30 + <main> 31 + <h1>{frontmatter.title}</h1> 32 + 33 + <section> 34 + <AvatarGroup 35 + avatars={authors.map(author => ({ 36 + alt: author.fullName, 37 + src: author.src, 38 + }))} 39 + /> 40 + 41 + <p>{authors.map(author => author.fullName).join(', ')}</p> 42 + </section> 43 + 44 + <Preview title={frontmatter.title!} type={type} /> 45 + 46 + {children} 47 + 48 + <WithBlogCrossLinks /> 49 + </main> 50 + </div> 51 + 52 + <WithMetaBar /> 53 + </ContentLayout> 54 + 55 + <WithFooter /> 56 + </> 57 + ); 58 + }; 59 + 60 + export default PostLayout;
+134 -90
layouts/New/layouts.module.css
··· 1 1 .baseLayout { 2 2 @apply grid 3 - h-screen 4 - w-screen 3 + size-full 5 4 grid-cols-[1fr] 6 5 grid-rows-[auto_1fr_auto]; 7 6 } ··· 25 24 } 26 25 27 26 > *:nth-child(2) { 28 - @apply flex 29 - w-full 30 - flex-col 31 - items-start 32 - gap-6 33 - self-stretch 34 - overflow-y-auto 27 + @apply overflow-y-auto 35 28 overflow-x-hidden 36 29 bg-gradient-subtle 37 30 p-12 ··· 66 59 } 67 60 68 61 .homeLayout { 69 - @apply mx-auto 70 - flex 62 + @apply flex 71 63 w-full 72 - flex-col 73 64 items-center 74 - gap-8 75 - self-stretch 65 + justify-center 76 66 px-4 77 67 py-14 78 - md:w-auto 79 - md:flex-row 80 - md:gap-14 81 68 md:px-14 82 - md:py-0 83 - lg:gap-28 84 69 lg:px-28; 85 70 86 - .hexagonBackdrop { 87 - @apply absolute 88 - left-0 89 - -z-10 90 - h-full 91 - w-full 92 - bg-[url('/static/images/patterns/hexagon-grid.svg')] 93 - bg-center 94 - bg-no-repeat 95 - opacity-50 96 - md:opacity-100; 71 + main { 72 + @apply items-center 73 + justify-center 74 + gap-8 75 + md:flex-row 76 + md:gap-14 77 + xl:gap-28 78 + 2xl:gap-32; 97 79 98 - &::after { 99 - @apply absolute 100 - inset-0 101 - m-auto 102 - aspect-square 103 - w-[300px] 104 - rounded-full 105 - bg-green-300 106 - blur-[120px] 107 - content-[''] 108 - dark:bg-green-700; 109 - } 110 - } 80 + section { 81 + &:nth-of-type(1) { 82 + @apply flex 83 + max-w-[500px] 84 + flex-[1_0] 85 + flex-col 86 + gap-8; 111 87 112 - section { 113 - &:nth-of-type(1) { 114 - @apply flex 115 - max-w-md 116 - flex-[1_0_0] 117 - flex-col 118 - items-start 119 - gap-8; 88 + > div { 89 + @apply flex 90 + max-w-[400px] 91 + flex-col 92 + gap-4; 120 93 121 - h1 { 122 - @apply bg-gradient-subtle-gray 123 - bg-clip-text 124 - text-4xl 125 - -tracking-[0.045rem] 126 - [-webkit-text-fill-color:transparent] 127 - md:text-5xl 128 - md:-tracking-[0.06rem] 129 - dark:bg-gradient-subtle-white; 130 - } 94 + p { 95 + @apply text-base 96 + md:text-lg; 97 + } 131 98 132 - p { 133 - @apply max-w-[400px] 134 - text-base 135 - text-neutral-900 136 - md:text-lg 137 - dark:text-white; 99 + small { 100 + @apply text-center 101 + text-sm 102 + text-neutral-800 103 + xs:text-xs 104 + dark:text-neutral-400; 105 + } 106 + } 138 107 } 139 108 140 - > div { 141 - &:nth-of-type(1) { 142 - @apply flex 143 - flex-col 144 - gap-4; 109 + &:nth-of-type(2) { 110 + @apply flex 111 + max-w-md 112 + flex-[1_1] 113 + flex-col 114 + items-center 115 + gap-4 116 + md:max-w-2xl 117 + lg:max-w-3xl; 118 + 119 + > div { 120 + @apply w-fit; 121 + 122 + div[data-state='active'] a { 123 + @apply border-none 124 + bg-neutral-900 125 + px-3 126 + py-1.5 127 + text-sm 128 + font-medium; 129 + 130 + &:hover { 131 + @apply bg-neutral-800; 132 + } 133 + } 145 134 } 146 135 147 - &:nth-of-type(2) { 148 - @apply flex 149 - w-full 150 - flex-col 151 - gap-2 152 - xl:flex-row; 136 + > p { 137 + @apply text-center 138 + text-sm 139 + text-neutral-800 140 + dark:text-neutral-200; 153 141 } 154 142 } 155 143 } 144 + } 145 + } 156 146 157 - &:nth-of-type(2) { 147 + .blogLayout { 148 + @apply flex 149 + w-full 150 + justify-center 151 + bg-gradient-subtle 152 + xs:bg-none 153 + dark:bg-gradient-subtle-dark 154 + xs:dark:bg-none; 155 + 156 + main { 157 + @apply max-w-8xl 158 + gap-4 159 + px-4 160 + py-12 161 + md:px-14 162 + lg:px-28; 163 + 164 + p { 165 + @apply text-lg 166 + font-medium 167 + text-neutral-800 168 + dark:text-neutral-200; 169 + } 170 + } 171 + } 172 + 173 + .contentLayout { 174 + @apply grid 175 + w-full 176 + max-w-8xl 177 + grid-rows-[1fr] 178 + sm:grid-cols-[1fr_theme(spacing.52)] 179 + xl:grid-cols-[1fr_theme(spacing.80)] 180 + xs:m-0 181 + xs:block; 182 + 183 + > *:nth-child(1) { 184 + @apply flex 185 + w-full 186 + justify-center 187 + bg-gradient-subtle 188 + px-4 189 + py-14 190 + md:px-14 191 + lg:px-28 192 + xs:bg-none 193 + xs:pb-4 194 + dark:bg-gradient-subtle-dark 195 + xs:dark:bg-none; 196 + 197 + main { 198 + @apply max-w-[660px] 199 + gap-4; 200 + } 201 + } 202 + } 203 + 204 + .postLayout { 205 + main { 206 + > section { 158 207 @apply flex 159 - max-w-md 160 - flex-col 161 - content-center 208 + flex-row 162 209 items-center 163 - gap-4 164 - md:max-w-2xl; 210 + gap-4; 211 + } 165 212 166 - p { 167 - @apply text-center 168 - text-sm 169 - text-neutral-800 170 - dark:text-neutral-200; 171 - } 213 + > div:nth-of-type(1) { 214 + @apply mb-4 215 + mt-2; 172 216 } 173 217 } 174 218 }
+10 -4
next-data/blogData.ts
··· 4 4 NEXT_DATA_URL, 5 5 VERCEL_ENV, 6 6 } from '@/next.constants.mjs'; 7 - import type { BlogDataRSC } from '@/types'; 7 + import type { BlogPostsRSC } from '@/types'; 8 8 9 - const getBlogData = (category: string): Promise<BlogDataRSC> => { 9 + const getBlogData = (cat: string, page?: number): Promise<BlogPostsRSC> => { 10 10 // When we're using Static Exports the Next.js Server is not running (during build-time) 11 11 // hence the self-ingestion APIs will not be available. In this case we want to load 12 12 // the data directly within the current thread, which will anyways be loaded only once 13 13 // We use lazy-imports to prevent `provideBlogData` from executing on import 14 14 if (ENABLE_STATIC_EXPORT || (!IS_DEVELOPMENT && !VERCEL_ENV)) { 15 15 return import('@/next-data/providers/blogData').then( 16 - ({ default: provideBlogData }) => provideBlogData(category) 16 + ({ provideBlogPosts, providePaginatedBlogPosts }) => 17 + page ? providePaginatedBlogPosts(cat, page) : provideBlogPosts(cat) 17 18 ); 18 19 } 19 20 21 + const fetchURL = page 22 + ? // Provides a conditional fetch URL based on the given function parameters 23 + `${NEXT_DATA_URL}blog-data/${cat}/${page}` 24 + : `${NEXT_DATA_URL}blog-data/${cat}/0`; 25 + 20 26 // When we're on RSC with Server capabilities we prefer using Next.js Data Fetching 21 27 // as this will load cached data from the server instead of generating data on the fly 22 28 // this is extremely useful for ISR and SSG as it will not generate this data on every request 23 - return fetch(`${NEXT_DATA_URL}blog-data/${category}`).then(r => r.json()); 29 + return fetch(fetchURL).then(r => r.json()); 24 30 }; 25 31 26 32 export default getBlogData;
+18 -25
next-data/generators/blogData.mjs
··· 12 12 const blogPath = join(process.cwd(), 'pages/en/blog'); 13 13 14 14 /** 15 - * This contains the metadata of all available blog categories and 16 - * available pagination entries (years) 17 - * 18 - * @type {{ pagination: Set<number>; categories: Set<string>}} 15 + * This contains the metadata of all available blog categories 19 16 */ 20 - const blogMetadata = { pagination: new Set(), categories: new Set() }; 17 + const blogCategories = new Set(['all']); 21 18 22 19 /** 23 20 * This method parses the source (raw) Markdown content into Frontmatter ··· 34 31 category = 'uncategorized', 35 32 } = graymatter(source).data; 36 33 37 - // we add the year to the pagination set 38 - blogMetadata.pagination.add(new Date(date).getUTCFullYear()); 34 + // We also use publishing years as categories for the blog 35 + const publishYear = new Date(date).getUTCFullYear(); 36 + 37 + // Provides a full list of categories for the Blog Post which consists of 38 + // all = (all blog posts), publish year and the actual blog category 39 + const categories = [category, `year-${publishYear}`, 'all']; 40 + 41 + // we add the year to the categories set 42 + blogCategories.add(`year-${publishYear}`); 39 43 40 44 // we add the category to the categories set 41 - blogMetadata.categories.add(category); 45 + blogCategories.add(category); 42 46 43 47 // this is the url used for the blog post it based on the category and filename 44 48 const slug = `/blog/${category}/${basename(filename, extname(filename))}`; 45 49 46 - return { title, author, date: new Date(date), category, slug }; 50 + return { title, author, date: new Date(date), categories, slug }; 47 51 }; 48 52 49 53 /** ··· 53 57 * @return {Promise<import('../../types').BlogData>} 54 58 */ 55 59 const generateBlogData = async () => { 56 - // we retrieve all the filenames of all blog posts 60 + // We retrieve the full pathnames of all Blog Posts to read each file individually 57 61 const filenames = await getMarkdownFiles(process.cwd(), 'pages/en/blog', [ 58 62 '**/index.md', 59 - '**/pagination.md', 60 63 ]); 61 64 62 65 return new Promise(resolve => { 63 - const blogPosts = []; 66 + const posts = []; 64 67 const rawFrontmatter = []; 65 68 66 69 filenames.forEach(filename => { ··· 95 98 // This allows us to only read the frontmatter part of each file 96 99 // and optimise the read-process as we have thousands of markdown files 97 100 _readLine.on('close', () => { 98 - const frontmatter = getFrontMatter( 99 - filename, 100 - rawFrontmatter[filename][1] 101 - ); 102 - 103 - blogPosts.push(frontmatter); 101 + posts.push(getFrontMatter(filename, rawFrontmatter[filename][1])); 104 102 105 - // Once we finish reading all fles 106 - if (blogPosts.length === filenames.length) { 107 - resolve({ 108 - pagination: [...blogMetadata.pagination].sort(), 109 - categories: [...blogMetadata.categories].sort(), 110 - posts: blogPosts.sort((a, b) => b.date - a.date), 111 - }); 103 + if (posts.length === filenames.length) { 104 + resolve({ categories: [...blogCategories], posts }); 112 105 } 113 106 }); 114 107 });
+30 -32
next-data/generators/websiteFeeds.mjs
··· 13 13 * This method generates RSS website feeds based on the current website configuration 14 14 * and the current blog data that is available 15 15 * 16 - * @param {Promise<import('../../types').BlogDataRSC>} blogData 16 + * @param {import('../../types').BlogPostsRSC} blogData 17 17 */ 18 - const generateWebsiteFeeds = blogData => { 19 - return blogData.then(({ posts }) => { 20 - /** 21 - * This generates all the Website RSS Feeds that are used for the website 22 - * 23 - * @type {[string, Feed][]} 24 - */ 25 - const websiteFeeds = siteConfig.rssFeeds.map( 26 - ({ category, title, description, file }) => { 27 - const feed = new Feed({ 28 - id: file, 29 - title: title, 30 - language: 'en', 31 - link: `${canonicalUrl}/feed/${file}`, 32 - description: description, 33 - }); 18 + const generateWebsiteFeeds = ({ posts }) => { 19 + /** 20 + * This generates all the Website RSS Feeds that are used for the website 21 + * 22 + * @type {[string, Feed][]} 23 + */ 24 + const websiteFeeds = siteConfig.rssFeeds.map( 25 + ({ category, title, description, file }) => { 26 + const feed = new Feed({ 27 + id: file, 28 + title: title, 29 + language: 'en', 30 + link: `${canonicalUrl}/feed/${file}`, 31 + description: description, 32 + }); 34 33 35 - const blogFeedEntries = posts 36 - .filter(post => !category || post.category === category) 37 - .map(post => ({ 38 - id: post.slug, 39 - title: post.title, 40 - author: post.author, 41 - date: new Date(post.date), 42 - link: `${canonicalUrl}${post.slug}`, 43 - })); 34 + const blogFeedEntries = posts 35 + .filter(post => post.categories.includes(category)) 36 + .map(post => ({ 37 + id: post.slug, 38 + title: post.title, 39 + author: post.author, 40 + date: new Date(post.date), 41 + link: `${canonicalUrl}${post.slug}`, 42 + })); 44 43 45 - blogFeedEntries.forEach(entry => feed.addItem(entry)); 44 + blogFeedEntries.forEach(entry => feed.addItem(entry)); 46 45 47 - return [file, feed]; 48 - } 49 - ); 46 + return [file, feed]; 47 + } 48 + ); 50 49 51 - return new Map(websiteFeeds); 52 - }); 50 + return new Map(websiteFeeds); 53 51 }; 54 52 55 53 export default generateWebsiteFeeds;
+54 -33
next-data/providers/blogData.ts
··· 1 1 import { cache } from 'react'; 2 2 3 3 import generateBlogData from '@/next-data/generators/blogData.mjs'; 4 - import type { BlogDataRSC } from '@/types'; 4 + import { BLOG_POSTS_PER_PAGE } from '@/next.constants.mjs'; 5 + import type { BlogPostsRSC } from '@/types'; 5 6 6 - const blogData = generateBlogData(); 7 + const { categories, posts } = await generateBlogData(); 7 8 8 - const provideBlogData = cache( 9 - async (category?: string): Promise<BlogDataRSC> => { 10 - return blogData.then(({ posts, categories, pagination }) => { 11 - const meta = { categories, pagination }; 9 + export const provideBlogCategories = cache(() => categories); 12 10 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 - } 11 + export const provideBlogPosts = cache((category: string): BlogPostsRSC => { 12 + const categoryPosts = posts 13 + .filter(post => post.categories.includes(category)) 14 + .sort((a, b) => b.date.getTime() - a.date.getTime()); 20 15 21 - if (category && category.startsWith('year-')) { 22 - const paramYear = Number(category.replace('year-', '')); 16 + // Total amount of possible pages given the amount of blog posts 17 + const total = categoryPosts.length / BLOG_POSTS_PER_PAGE; 23 18 24 - const isEqualYear = (date: string) => 25 - new Date(date).getFullYear() === paramYear; 19 + return { 20 + posts: categoryPosts, 21 + pagination: { 22 + prev: null, 23 + next: null, 24 + // In case the division results on a remainder we need 25 + // to have an extra page containing the remainder entries 26 + pages: Math.floor(total % 1 === 0 ? total : total + 1), 27 + total: categoryPosts.length, 28 + }, 29 + }; 30 + }); 26 31 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 - } 32 + export const providePaginatedBlogPosts = cache( 33 + (category: string, page: number): BlogPostsRSC => { 34 + const { posts, pagination } = provideBlogPosts(category); 35 + 36 + // This autocorrects if invalid numbers are given to only allow 37 + // actual valid numbers to be provided 38 + const actualPage = page < 1 ? 1 : page; 36 39 37 - if (category && !categories.includes(category)) { 38 - return { posts: [], pagination: { next: null, prev: null }, meta }; 39 - } 40 + // If the page is within the allowed range then we calculate 41 + // the pagination of Blog Posts for a given current page "page" 42 + if (actualPage <= pagination.pages) { 43 + return { 44 + posts: posts.slice( 45 + BLOG_POSTS_PER_PAGE * (actualPage - 1), 46 + BLOG_POSTS_PER_PAGE * actualPage 47 + ), 48 + pagination: { 49 + prev: actualPage > 1 ? actualPage - 1 : null, 50 + next: actualPage < pagination.pages ? actualPage + 1 : null, 51 + pages: pagination.pages, 52 + total: posts.length, 53 + }, 54 + }; 55 + } 40 56 41 - return { posts, pagination: { next: null, prev: null }, meta }; 42 - }); 57 + return { 58 + posts: [], 59 + pagination: { 60 + prev: pagination.total, 61 + next: null, 62 + pages: pagination.pages, 63 + total: posts.length, 64 + }, 65 + }; 43 66 } 44 67 ); 45 - 46 - export default provideBlogData;
+2 -2
next-data/providers/releaseData.ts
··· 2 2 3 3 import generateReleaseData from '@/next-data/generators/releaseData.mjs'; 4 4 5 - const releaseData = generateReleaseData(); 5 + const releaseData = await generateReleaseData(); 6 6 7 - const provideReleaseData = cache(async () => releaseData); 7 + const provideReleaseData = cache(() => releaseData); 8 8 9 9 export default provideReleaseData;
+7 -9
next-data/providers/websiteFeeds.ts
··· 1 1 import { cache } from 'react'; 2 2 3 3 import generateWebsiteFeeds from '@/next-data/generators/websiteFeeds.mjs'; 4 - import provideBlogData from '@/next-data/providers/blogData'; 4 + import { provideBlogPosts } from '@/next-data/providers/blogData'; 5 5 6 - const websiteFeeds = generateWebsiteFeeds(provideBlogData()); 6 + const websiteFeeds = await generateWebsiteFeeds(provideBlogPosts('all')); 7 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 - } 8 + const provideWebsiteFeeds = cache((feed: string) => { 9 + if (feed.includes('.xml') && websiteFeeds.has(feed)) { 10 + return websiteFeeds.get(feed)?.rss2(); 11 + } 13 12 14 - return undefined; 15 - }); 13 + return undefined; 16 14 }); 17 15 18 16 export default provideWebsiteFeeds;
+4 -2
next.config.mjs
··· 9 9 BASE_PATH, 10 10 ENABLE_STATIC_EXPORT, 11 11 ENABLE_WEBSITE_REDESIGN, 12 + } from './next.constants.mjs'; 13 + import { redirects, rewrites } from './next.rewrites.mjs'; 14 + import { 12 15 SENTRY_DSN, 13 16 SENTRY_ENABLE, 14 17 SENTRY_EXTENSIONS, 15 18 SENTRY_TUNNEL, 16 - } from './next.constants.mjs'; 17 - import { redirects, rewrites } from './next.rewrites.mjs'; 19 + } from './sentry.constants.mjs'; 18 20 19 21 /** @type {import('next').NextConfig} */ 20 22 const nextConfig = {
+14 -58
next.constants.mjs
··· 1 1 'use strict'; 2 2 3 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 4 * This is used to verify if the current Website is running on a Development Environment 12 5 */ 13 6 export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; ··· 115 108 */ 116 109 export const MD_EXTENSION_REGEX = /((\/)?(index))?\.mdx?$/i; 117 110 111 + /** 112 + * This defines how many blog posts each pagination page should have 113 + * 114 + * @todo: update the value when moving to website redesign 115 + */ 116 + export const BLOG_POSTS_PER_PAGE = ENABLE_WEBSITE_REDESIGN ? 6 : 20; 117 + 118 + /** 119 + * The `localStorage` key to store the theme choice of `next-themes` 120 + * 121 + * This is what allows us to store user preference for theming 122 + */ 123 + export const THEME_STORAGE_KEY = 'theme'; 124 + 118 125 /*** 119 126 * This is a list of all external links that are used on website sitemap. 120 127 * @see https://github.com/nodejs/nodejs.org/issues/5813 for more context ··· 128 135 'https://trademark-list.openjsf.org/', 129 136 'https://www.linuxfoundation.org/cookies', 130 137 ]; 131 - 132 - /** 133 - * The `localStorage` key to store the theme choice of `next-themes` 134 - * 135 - * This is what allows us to store user preference for theming 136 - */ 137 - export const THEME_STORAGE_KEY = 'theme'; 138 - 139 - /** 140 - * This is the Sentry DSN for the Node.js Website Project 141 - */ 142 - export const SENTRY_DSN = 143 - 'https://02884d0745aecaadf5f780278fe5fe70@o4506191161786368.ingest.sentry.io/4506191307735040'; 144 - 145 - /** 146 - * This states if Sentry should be enabled and bundled within our App 147 - * 148 - * We enable sentry by default if we're om development mode or deployed 149 - * on Vercel (either production or preview branches) 150 - */ 151 - export const SENTRY_ENABLE = IS_DEVELOPMENT || !!VERCEL_ENV; 152 - 153 - /** 154 - * This configures the sampling rate for Sentry 155 - * 156 - * We always want to capture 100% on Vercel Preview Branches 157 - * and not when it's on Production Mode (nodejs.org) 158 - */ 159 - export const SENTRY_CAPTURE_RATE = 160 - SENTRY_ENABLE && VERCEL_ENV && BASE_URL !== 'https://nodejs.org' ? 1.0 : 0.01; 161 - 162 - /** 163 - * Provides the Route for Sentry's Server-Side Tunnel 164 - * 165 - * This is a `@sentry/nextjs` specific feature 166 - */ 167 - export const SENTRY_TUNNEL = (components = '') => 168 - SENTRY_ENABLE ? `/monitoring${components}` : undefined; 169 - 170 - /** 171 - * This configures which Sentry features to tree-shake/remove from the Sentry bundle 172 - * 173 - * @see https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/ 174 - */ 175 - export const SENTRY_EXTENSIONS = { 176 - __SENTRY_DEBUG__: false, 177 - __SENTRY_TRACING__: false, 178 - __RRWEB_EXCLUDE_IFRAME__: true, 179 - __RRWEB_EXCLUDE_SHADOW_DOM__: true, 180 - __SENTRY_EXCLUDE_REPLAY_WORKER__: true, 181 - };
+29 -49
next.dynamic.constants.mjs
··· 1 1 'use strict'; 2 2 3 - import { BASE_PATH, BASE_URL, CURRENT_YEAR } from './next.constants.mjs'; 3 + import { 4 + provideBlogCategories, 5 + provideBlogPosts, 6 + } from './next-data/providers/blogData'; 7 + import { BASE_PATH, BASE_URL } from './next.constants.mjs'; 4 8 import { siteConfig } from './next.json.mjs'; 5 9 import { defaultLocale } from './next.locales.mjs'; 6 10 ··· 10 14 * 11 15 * @type {((route: import('./types').RouteSegment) => boolean)[]} A list of Ignored Routes by Regular Expressions 12 16 */ 13 - export const STATIC_ROUTES_IGNORES = [ 14 - // Ignore the 404 route on Static Generation 17 + export const IGNORED_ROUTES = [ 18 + // This is used to ignore the 404 route for the static generation (/404) 15 19 ({ pathname }) => pathname === '404', 16 - // This is used to ignore is used to ignore all blog routes except for the English language 20 + // This is used to ignore all blog routes except for the English language 17 21 ({ locale, pathname }) => 18 22 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 23 ]; 47 24 48 25 /** 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`. 26 + * This constant is used to create static routes on-the-fly that do not have a file-system 27 + * counterpart route. This is useful for providing routes with matching Layout Names 28 + * but that do not have Markdown content and a matching file for the route 55 29 * 56 - * @type {string[]} A list of all the Dynamic Routes that are generated by the Website 57 - * @deprecated remove with website redesign 30 + * @type {Map<string, import('./types').Layouts>} A Map of pathname and Layout Name 58 31 */ 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 - ]; 32 + export const DYNAMIC_ROUTES = new Map([ 33 + // Provides Routes for all Blog Categories 34 + ...provideBlogCategories().map(c => [`blog/${c}`, 'blog-category.hbs']), 35 + // Provides Routes for all Blog Categories w/ Pagination 36 + ...provideBlogCategories() 37 + // retrieves the amount of pages for each blog category 38 + .map(c => [c, provideBlogPosts(c).pagination.pages]) 39 + // creates a numeric array for each page and define a pathname for 40 + // each page for a category (i.e. blog/all/page/1) 41 + .map(([c, t]) => [...Array(t).keys()].map(p => `blog/${c}/page/${p + 1}`)) 42 + // creates a tuple of each pathname and layout for the route 43 + .map(paths => paths.map(path => [path, 'blog-category.hbs'])) 44 + // flattens the array since we have a .map inside another .map 45 + .flat(), 46 + ]); 67 47 68 48 /** 69 49 * This is the default Next.js Page Metadata for all pages 70 50 * 71 51 * @type {import('next').Metadata} 72 52 */ 73 - export const DEFAULT_METADATA = { 53 + export const PAGE_METADATA = { 74 54 metadataBase: new URL(`${BASE_URL}${BASE_PATH}`), 75 55 title: siteConfig.title, 76 56 description: siteConfig.description, ··· 100 80 * 101 81 * @return {import('next').Viewport} 102 82 */ 103 - export const DEFAULT_VIEWPORT = { 83 + export const PAGE_VIEWPORT = { 104 84 themeColor: siteConfig.accentColor, 105 85 width: 'device-width', 106 86 initialScale: 1,
+11 -30
next.dynamic.mjs
··· 8 8 import { cache } from 'react'; 9 9 import { VFile } from 'vfile'; 10 10 11 + import { BASE_URL, BASE_PATH, IS_DEVELOPMENT } from './next.constants.mjs'; 11 12 import { 12 - MD_EXTENSION_REGEX, 13 - BASE_URL, 14 - BASE_PATH, 15 - IS_DEVELOPMENT, 16 - } from './next.constants.mjs'; 17 - import { 18 - DYNAMIC_ROUTES_IGNORES, 19 - DYNAMIC_ROUTES_REWRITES, 20 - STATIC_ROUTES_IGNORES, 21 - DYNAMIC_GENERATED_ROUTES, 22 - DEFAULT_METADATA, 13 + IGNORED_ROUTES, 14 + DYNAMIC_ROUTES, 15 + PAGE_METADATA, 23 16 } from './next.dynamic.constants.mjs'; 24 17 import { getMarkdownFiles } from './next.helpers.mjs'; 25 18 import { siteConfig } from './next.json.mjs'; ··· 32 25 // This is a small utility that allows us to quickly separate locale from the remaning pathname 33 26 const getPathname = (path = []) => path.join('/'); 34 27 35 - // This tests if the current pathname matches any expression that belongs 36 - // to the list of ignored routes and if it does we return `true` to indicate that 37 - const shouldIgnoreRoute = pathname => 38 - pathname.length > 0 && DYNAMIC_ROUTES_IGNORES.some(e => e.test(pathname)); 39 - 40 - // This tests if the current pathname matches any sort of rewrite rule 41 - // and if it does we return a the replacement expression for the pathname 42 - const getRouteRewrite = pathname => 43 - (pathname.length > 0 && 44 - DYNAMIC_ROUTES_REWRITES.find(([e]) => e.test(pathname))) || 45 - []; 46 - 47 28 // This maps a pathname into an actual route object that can be used 48 29 // we use a platform-specific separator to split the pathname 49 30 // since we're using filepaths here and not URL paths ··· 79 60 ); 80 61 81 62 websitePages.forEach(filename => { 82 - let pathname = filename.replace(MD_EXTENSION_REGEX, ''); 63 + // This Regular Expression is used to remove the `index.md(x)` suffix 64 + // of a name and to remove the `.md(x)` extensions of a filename. 65 + let pathname = filename.replace(/((\/)?(index))?\.mdx?$/i, ''); 83 66 84 67 if (pathname.length > 1 && pathname.endsWith(sep)) { 85 68 pathname = pathname.substring(0, pathname.length - 1); ··· 100 83 */ 101 84 const getRoutesByLanguage = async (locale = defaultLocale.code) => { 102 85 const shouldIgnoreStaticRoute = pathname => 103 - STATIC_ROUTES_IGNORES.every(e => !e({ pathname, locale })); 86 + IGNORED_ROUTES.every(e => !e({ pathname, locale })); 104 87 105 88 return [...pathnameToFilename.keys()] 106 89 .filter(shouldIgnoreStaticRoute) 107 - .concat(DYNAMIC_GENERATED_ROUTES); 90 + .concat([...DYNAMIC_ROUTES.keys()]); 108 91 }; 109 92 110 93 /** ··· 207 190 * @returns {import('next').Metadata} 208 191 */ 209 192 const _getPageMetadata = async (locale = defaultLocale.code, path = '') => { 210 - const pageMetadata = { ...DEFAULT_METADATA }; 193 + const pageMetadata = { ...PAGE_METADATA }; 211 194 212 195 const { source = '' } = await getMarkdownFile(locale, path); 213 196 214 197 const { data } = matter(source); 215 198 216 199 pageMetadata.title = data.title 217 - ? `${data.title} | ${siteConfig.title}` 200 + ? `${siteConfig.title} — ${data.title}` 218 201 : siteConfig.title; 219 202 220 203 pageMetadata.twitter.title = pageMetadata.title; ··· 246 229 247 230 return { 248 231 mapPathToRoute, 249 - shouldIgnoreRoute, 250 232 getPathname, 251 - getRouteRewrite, 252 233 getRoutesByLanguage, 253 234 getMDXContent, 254 235 getMarkdownFile,
+2 -1
next.mdx.compiler.mjs
··· 19 19 * @returns {Promise<{ 20 20 * MDXContent: import('mdx/types').MDXContent; 21 21 * headings: import('@vcarl/remark-headings').Heading[]; 22 - * frontmatter: Record<string, any>, readingTime: import('reading-time').ReadTimeResults 22 + * frontmatter: Record<string, any>; 23 + * readingTime: import('reading-time').ReadTimeResults; 23 24 * }>} 24 25 */ 25 26 export async function compileMDX(source, fileExtension) {
+50 -96
next.mdx.shiki.mjs
··· 48 48 * @return {boolean} - True when it is a valid code element, false otherwise. 49 49 */ 50 50 function isCodeBlock(node) { 51 - return node?.tagName === 'pre' && node?.children[0].tagName === 'code'; 52 - } 53 - 54 - /** 55 - * Retrieves a list indicating the starting, and ending indexes of sequential 56 - * code elements. 57 - * 58 - * @param {Node} tree - The current MDX resolved content. 59 - * 60 - * @return {{start: number, end: number}[]} - The list containing every range of 61 - * sequential code elements. 62 - */ 63 - function getCodeTabsRange(tree) { 64 - const rangeMap = {}; 65 - let start = null; 66 - 67 - visit(tree, 'element', (node, index, parent) => { 68 - // Adding 2 since there is one text node between every element 69 - const next = index + 2; 70 - 71 - if (isCodeBlock(node) && isCodeBlock(parent?.children[next])) { 72 - start ??= index; 73 - rangeMap[start] = next; 74 - 75 - // Prevent visiting the code block children 76 - return SKIP; 77 - } 78 - 79 - // End of sequential code elements, reset the start for the next range 80 - start = null; 81 - }); 82 - 83 - return Object.entries(rangeMap).map(([start, end]) => ({ 84 - start: Number(start), 85 - end: Number(end), 86 - })); 51 + return Boolean( 52 + node?.tagName === 'pre' && node?.children[0].tagName === 'code' 53 + ); 87 54 } 88 55 89 56 export default function rehypeShikiji() { 90 57 return async function (tree) { 91 - // Retrieve all sequential code boxes to transform 92 - const ranges = getCodeTabsRange(tree); 93 - 94 - if (ranges.length > 0) { 95 - // Make a mutable clone without reference 96 - const children = [...tree.children]; 97 - 98 - for (const range of ranges) { 99 - // Simple tree containing the sequential code boxes among text nodes 100 - const slicedTree = { 101 - type: 'root', 102 - children: tree.children.slice(range.start, range.end + 1), 103 - }; 104 - 105 - const languages = []; 106 - const displayNames = []; 107 - const codeTabsChildren = []; 58 + visit(tree, 'element', (_, index, parent) => { 59 + const languages = []; 60 + const displayNames = []; 61 + const codeTabsChildren = []; 108 62 109 - let defaultTab = '0'; 63 + let defaultTab = '0'; 64 + let currentIndex = index; 110 65 111 - visit(slicedTree, 'element', node => { 112 - const codeElement = node.children[0]; 66 + while (isCodeBlock(parent?.children[currentIndex])) { 67 + const codeElement = parent?.children[currentIndex].children[0]; 113 68 114 - const displayName = getMetaParameter( 115 - codeElement.data?.meta, 116 - 'displayName' 117 - ); 69 + const displayName = getMetaParameter( 70 + codeElement.data?.meta, 71 + 'displayName' 72 + ); 118 73 119 - // We should get the language name from the class name 120 - if (codeElement.properties.className?.length) { 121 - const className = codeElement.properties.className.join(' '); 122 - const matches = className.match(/language-(?<language>.*)/); 74 + // We should get the language name from the class name 75 + if (codeElement.properties.className?.length) { 76 + const className = codeElement.properties.className.join(' '); 77 + const matches = className.match(/language-(?<language>.*)/); 123 78 124 - languages.push(matches?.groups.language ?? 'text'); 125 - } 79 + languages.push(matches?.groups.language ?? 'text'); 80 + } 126 81 127 - // Map the display names of each variant for the CodeTab 128 - displayNames.push(displayName?.replaceAll('|', '') ?? ''); 129 - codeTabsChildren.push(node); 82 + // Map the display names of each variant for the CodeTab 83 + displayNames.push(displayName?.replaceAll('|', '') ?? ''); 130 84 131 - // If `active="true"` is provided in a CodeBox 132 - // then the default selected entry of the CodeTabs will be the desired entry 133 - const specificActive = getMetaParameter( 134 - codeElement.data?.meta, 135 - 'active' 136 - ); 85 + codeTabsChildren.push(parent?.children[currentIndex]); 137 86 138 - if (specificActive === 'true') { 139 - defaultTab = String(codeTabsChildren.length - 1); 140 - } 87 + // If `active="true"` is provided in a CodeBox 88 + // then the default selected entry of the CodeTabs will be the desired entry 89 + const specificActive = getMetaParameter( 90 + codeElement.data?.meta, 91 + 'active' 92 + ); 141 93 142 - // Prevent visiting the code block children 143 - return SKIP; 144 - }); 94 + if (specificActive === 'true') { 95 + defaultTab = String(codeTabsChildren.length - 1); 96 + } 145 97 146 - // Each iteration reduces the `children` length, so it needs to be 147 - // accounted in the following operations 148 - const lengthOffset = tree.children.length - children.length; 149 - const compensatedRange = { 150 - start: range.start - lengthOffset, 151 - end: range.end - lengthOffset, 152 - }; 98 + const nextNode = parent?.children[currentIndex + 1]; 153 99 154 - const deleteCount = compensatedRange.end - compensatedRange.start + 1; 100 + // If the CodeBoxes are on the root tree the next Element will be 101 + // an empty text element so we should skip it 102 + currentIndex += nextNode && nextNode?.type === 'text' ? 2 : 1; 103 + } 155 104 156 - // Replace the sequential code boxes with a code tabs element 157 - children.splice(compensatedRange.start, deleteCount, { 105 + if (codeTabsChildren.length >= 2) { 106 + const codeTabElement = { 158 107 type: 'element', 159 108 tagName: 'CodeTabs', 160 109 children: codeTabsChildren, ··· 163 112 displayNames: displayNames.join('|'), 164 113 defaultTab, 165 114 }, 166 - }); 167 - } 115 + }; 168 116 169 - // Update the tree with the transformed children 170 - Object.assign(tree, { children: children }); 171 - } 117 + // This removes all the original Code Elements and adds a new CodeTab Element 118 + // at the original start of the first Code Element 119 + parent.children.splice(index, currentIndex, codeTabElement); 120 + 121 + // Prevent visiting the code block children and for the next N Elements 122 + // since all of them belong to this CodeTabs Element 123 + return [SKIP, currentIndex]; 124 + } 125 + }); 172 126 173 127 visit(tree, 'element', (node, index, parent) => { 174 128 // We only want to process <pre>...</pre> elements
+4 -3
next.mdx.use.mjs
··· 3 3 import Blockquote from './components/Common/Blockquote'; 4 4 import Button from './components/Common/Button'; 5 5 import DownloadButton from './components/Downloads/DownloadButton'; 6 + import DownloadLink from './components/Downloads/DownloadLink'; 6 7 import DownloadReleasesTable from './components/Downloads/DownloadReleasesTable'; 7 8 import HomeDownloadButton from './components/Home/HomeDownloadButton'; 8 9 import Link from './components/Link'; ··· 33 34 CodeTabs: MDXCodeTabs, 34 35 // Renders a Download Button 35 36 DownloadButton: DownloadButton, 37 + // Renders a Download Link 38 + DownloadLink: DownloadLink, 36 39 // Renders a Button Component for `button` tags 37 40 Button: Button, 38 41 }; ··· 51 54 ? Blockquote 52 55 : ({ children }) => <div className="highlight-box">{children}</div>, 53 56 // Renders a CodeBox Component for `pre` tags 54 - pre: ({ children, ...props }) => ( 55 - <MDXCodeBox {...props}>{children}</MDXCodeBox> 56 - ), 57 + pre: MDXCodeBox, 57 58 };
+9
package-lock.json
··· 19 19 "@radix-ui/react-toast": "^1.1.5", 20 20 "@savvywombat/tailwindcss-grid-areas": "~3.1.0", 21 21 "@sentry/nextjs": "~7.86.0", 22 + "@tailwindcss/container-queries": "~0.1.1", 22 23 "@types/node": "20.10.6", 23 24 "@vcarl/remark-headings": "~0.1.0", 24 25 "@vercel/analytics": "~1.1.1", ··· 6957 6958 "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", 6958 6959 "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", 6959 6960 "dev": true 6961 + }, 6962 + "node_modules/@tailwindcss/container-queries": { 6963 + "version": "0.1.1", 6964 + "resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz", 6965 + "integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==", 6966 + "peerDependencies": { 6967 + "tailwindcss": ">=3.2.0" 6968 + } 6960 6969 }, 6961 6970 "node_modules/@testing-library/dom": { 6962 6971 "version": "9.3.3",
+1 -5
package.json
··· 50 50 "@radix-ui/react-toast": "^1.1.5", 51 51 "@savvywombat/tailwindcss-grid-areas": "~3.1.0", 52 52 "@sentry/nextjs": "~7.86.0", 53 + "@tailwindcss/container-queries": "~0.1.1", 53 54 "@types/node": "20.10.6", 54 55 "@vcarl/remark-headings": "~0.1.0", 55 56 "@vercel/analytics": "~1.1.1", ··· 123 124 "stylelint-order": "6.0.4", 124 125 "stylelint-selector-bem-pattern": "3.0.1", 125 126 "user-agent-data-types": "0.4.2" 126 - }, 127 - "overrides": { 128 - "stylelint-selector-bem-pattern": { 129 - "stylelint": "16.1.0" 130 - } 131 127 } 132 128 }
-4
pages/en/blog/advisory-board/index.md
··· 1 - --- 2 - title: Advisory Board 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/announcements/index.md
··· 1 - --- 2 - title: Announcements 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/community/index.md
··· 1 - --- 2 - title: Community 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/feature/index.md
··· 1 - --- 2 - title: Features 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/module/index.md
··· 1 - --- 2 - title: Modules 3 - layout: blog-category.hbs 4 - ---
-54
pages/en/blog/nodejs-road-ahead.md
··· 1 - --- 2 - date: '2014-01-16T23:00:00.000Z' 3 - title: Node.js and the Road Ahead 4 - layout: blog-post.hbs 5 - author: Timothy J Fontaine 6 - --- 7 - 8 - As the new project lead for Node.js I am excited for our future, and want to 9 - give you an update on where we are. 10 - 11 - One of Node's major goals is to provide a small core, one that provides the 12 - right amount of surface area for consumers to achieve and innovate, without 13 - Node itself getting in the way. That ethos is alive and well, we're going to 14 - continue to provide a small, simple, and stable set of APIs that facilitate the 15 - amazing uses the community finds for Node. We're going to keep providing 16 - backward compatible APIs, so code you write today will continue to work on 17 - future versions of Node. And of course, performance tuning and bug fixing will 18 - always be an important part of every release cycle. 19 - 20 - The release of Node v0.12 is imminent, and a lot of significant work has gone 21 - into this release. There's streams3, a better keep alive agent for http, the vm 22 - module is now based on contextify, and significant performance work done in 23 - core features (Buffers, TLS, streams). We have a few APIs that are still being 24 - ironed out before we can feature freeze and branch (execSync, AsyncListeners, 25 - user definable instrumentation). We are definitely in the home stretch. 26 - 27 - But Node is far from done. In the short term there will be new releases of v8 28 - that we'll need to track, as well as integrating the new ABI stable C module 29 - interface. There are interesting language features that we can use to extend 30 - Node APIs (extend not replace). We need to write more tooling, we need to 31 - expose more interfaces to further enable innovation. We can explore 32 - functionality to embed Node in your existing project. 33 - 34 - The list can go on and on. Yet, Node is larger than the software itself. Node 35 - is also the community, the businesses, the ecosystems, and their related 36 - events. With that in mind there are things we can work to improve. 37 - 38 - The core team will be improving its procedures such that we can quickly and 39 - efficiently communicate with you. We want to provide high quality and timely 40 - responses to issues, describe our development roadmap, as well as provide our 41 - progress during each release cycle. We know you're interested in our plans for 42 - Node, and it's important we're able to provide that information. Communication 43 - should be bidirectional: we want to continue to receive feedback about how 44 - you're using Node, and what your pain points are. 45 - 46 - After the release of v0.12 we will facilitate the community to contribute and 47 - curate content for nodejs.org. Allowing the community to continue to invest in 48 - Node will ensure nodejs.org is an excellent starting point and the primary 49 - resource for tutorials, documentation, and materials regarding Node. We have an 50 - awesome and engaged community, and they're paramount to our success. 51 - 52 - I'm excited for Node's future, to see new and interesting use cases, and to 53 - continue to help businesses scale and innovate with Node. We have a lot we can 54 - accomplish together, and I look forward to seeing those results.
-4
pages/en/blog/npm/index.md
··· 1 - --- 2 - title: NPM 3 - layout: blog-category.hbs 4 - ---
-5
pages/en/blog/pagination.md
··· 1 - --- 2 - title: Blog from 3 - layout: blog-category.hbs 4 - author: The Node.js Project 5 - ---
-4
pages/en/blog/release/index.md
··· 1 - --- 2 - title: Releases 3 - layout: blog-category.hbs 4 - ---
+1 -1
pages/en/blog/release/v0.6.3.md
··· 8 8 9 9 2011.11.25, Version 0.6.3 (stable) 10 10 11 - - #2083 Land NPM in Node. It is included in packages/installers and installed on `make install`. 11 + - #2083 Land npm in Node. It is included in packages/installers and installed on `make install`. 12 12 - #2076 Add logos to windows installer. 13 13 - #1711 Correctly handle http requests without headers. (Ben Noordhuis, Felix Geisendörfer) 14 14 - TLS: expose more openssl SSL context options and constants. (Ben Noordhuis)
-4
pages/en/blog/uncategorized/index.md
··· 1 - --- 2 - title: Uncategorized 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/video/index.md
··· 1 - --- 2 - title: Videos 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/vulnerability/index.md
··· 1 - --- 2 - title: Vulnerabilities 3 - layout: blog-category.hbs 4 - ---
-4
pages/en/blog/weekly-updates/index.md
··· 1 - --- 2 - title: Weekly Updates 3 - layout: blog-category.hbs 4 - ---
+1 -1
pages/en/blog/weekly-updates/weekly-update.2015-04-17.md
··· 31 31 ## Community Updates 32 32 33 33 - Difference between io.js and The Node Foundation [iojs/io.js#1416](https://github.com/nodejs/node/issues/1416). 34 - - NPM launches private modules and npm inc [raises](http://techcrunch.com/2015/04/14/popular-javascript-package-manager-npm-raises-8m-launches-private-modules/). 34 + - npm launches private modules and npm inc [raises](http://techcrunch.com/2015/04/14/popular-javascript-package-manager-npm-raises-8m-launches-private-modules/). 35 35 - Thoughts of Node.js Foundation on [Medium](https://medium.com/@programmer/thoughts-on-node-foundation-abcf86c72786). 36 36 - io.js v1.8.0 crypto performance on [io.js wiki](https://github.com/nodejs/node/wiki/Crypto-Performance-Notes-for-OpenSSL-1.0.2a-on-iojs-v1.8.0). 37 37 - io.js mention on [Oracle's blog](https://blogs.oracle.com/java-platform-group/entry/node_js_and_io_js).
+1 -1
pages/en/blog/weekly-updates/weekly-update.2015-04-24.md
··· 37 37 38 38 - Fedor Indutny opened discussion about removing TLS `newSession` and `resumeSession` event. [iojs/io.js#1462](https://github.com/nodejs/node/issues/1462) 39 39 - Proposal to change the C HTTP parser JS HTTP parser [here](https://github.com/nodejs/node/pull/1457) 40 - - NPM founder talks about io.js at [InfoWorld](http://www.infoworld.com/article/2910594/node-js/npm-founder-foresees-merger-node-js-io-js.html) 40 + - npm founder talks about io.js at [InfoWorld](http://www.infoworld.com/article/2910594/node-js/npm-founder-foresees-merger-node-js-io-js.html) 41 41 - Proposal to add mikeal, mscdex, shigeki as new TC members. [iojs/io.js#1483](https://github.com/nodejs/node/issues/1483#issuecomment-95128140) 42 42 43 43 ## Upcoming Events
+1 -1
pages/en/download/package-manager.md
··· 1 1 --- 2 - layout: page.hbs 2 + layout: docs.hbs 3 3 title: Installing Node.js via package manager 4 4 --- 5 5
+2 -2
pages/en/learn/getting-started/an-introduction-to-the-npm-package-manager.md
··· 1 1 --- 2 - title: An introduction to the NPM package manager 2 + title: An introduction to the npm package manager 3 3 layout: learn.hbs 4 4 authors: flaviocopes, MylesBorins, LaRuaNa, jgb-solutions, amiller-gh, ahmadawais 5 5 --- 6 6 7 - # An introduction to the NPM package manager 7 + # An introduction to the npm package manager 8 8 9 9 ## Introduction to npm 10 10
-1
pages/en/new-design/.gitkeep
··· 1 - !.gitignore
+82 -32
pages/en/new-design/index.mdx
··· 1 1 --- 2 + title: Run JavaScript Everywhere 2 3 layout: home.hbs 3 4 --- 4 5 ··· 6 7 <WithBadge section="index" /> 7 8 8 9 <div> 9 - # Run JavaScript Everywhere 10 + <h1 className="special">Run JavaScript Everywhere</h1> 10 11 11 - Node.js is a free, open-sourced, cross-platform JavaScript run-time 12 + Node.js is a free, open-source, cross-platform JavaScript runtime 12 13 environment that lets developers write command line tools and server-side 13 14 scripts outside of a browser. 14 15 15 16 </div> 17 + 16 18 <div> 17 19 <WithNodeRelease status={['Active LTS', 'Maintenance LTS']}> 18 20 {({ release }) => ( 19 - <DownloadButton release={release}>Download Node.js (LTS)</DownloadButton> 21 + <> 22 + <DownloadButton release={release}>Download Node.js (LTS)</DownloadButton> 23 + <small> 24 + Downloads Node.js <b>{release.versionWithPrefix}</b> with long-term support. 25 + Node.js can also be installed via <a href="/download/package-manager">package managers</a>. 26 + </small> 27 + </> 28 + )} 29 + </WithNodeRelease> 30 + <WithNodeRelease status="Current"> 31 + {({ release }) => ( 32 + <small> 33 + Want new features sooner? 34 + Get <b>Node.js <DownloadLink release={release}>{release.versionWithPrefix}</DownloadLink></b> instead. 35 + </small> 20 36 )} 21 37 </WithNodeRelease> 22 - 23 - <Button kind="secondary" href="/learn">Get Started</Button> 24 - 25 38 </div> 26 39 </section> 27 40 28 41 <section> 29 - <CodeTabs 30 - languages="bash|bash" 31 - displayNames="macOS / Linux (nvm)|Windows (Chocolatey)" 32 - linkUrl="/download/package-manager" 33 - linkText="More Options" 34 - > 35 - ```bash 36 - # Install Node Version Manager (NVM) 37 - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash 42 + <div> 43 + ```js displayName="Create an HTTP Server" 44 + import { createServer } from 'node:http'; 45 + 46 + const server = createServer((req, res) => { 47 + res.writeHead(200, { 'Content-Type': 'text/plain' }); 48 + res.end('Hello World!\n'); 49 + }); 38 50 39 - # Install Node.js 40 - nvm install --lts 51 + // starts a simple http server locally on port 3000 52 + server.listen(3000, '127.0.0.1', () => { 53 + console.log('Listening on 127.0.0.1:3000'); 54 + }); 55 + ``` 41 56 42 - # Check that Node is installed 43 - node -v 57 + ```js displayName="Write Tests" 58 + import assert from 'node:assert'; 59 + import test from 'node:test'; 44 60 45 - # Check your NPM version 46 - npm -v 61 + test('that 1 is equal 1', () => { 62 + assert.strictEqual(1, 1); 63 + }); 64 + 65 + test('that throws as 1 is not equal 2', () => { 66 + // throws an exception because 1!=2 67 + assert.strictEqual(1, 2); 68 + }); 47 69 ``` 48 70 49 - ```bash 50 - # Install Chocolatey 51 - iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) 71 + ```js displayName="Read and Hash a File" 72 + import { createHash } from 'node:crypto'; 73 + import { readFile } from 'node:fs/promises'; 74 + 75 + const hasher = createHash('sha1'); 76 + const fileContent = await readFile('./package.json'); 77 + 78 + hasher.setEncoding('hex'); 79 + hasher.write(fileContent); 80 + hasher.end(); 81 + 82 + const fileHash = hasher.read(); 83 + ``` 84 + 85 + ```js displayName="Read Streams" 86 + import { createReadStream, createWriteStream } from 'node:fs'; 87 + 88 + const res = await fetch('https://nodejs.org/dist/index.json'); 89 + const json = await res.json(); // yields a json object 90 + 91 + const readableStream = createReadStream('./package.json'); 92 + const writableStream = createWriteStream('./package2.json'); 52 93 53 - # Install Node.js 54 - choco install nodejs-lts 94 + readableStream.setEncoding('utf8'); 55 95 56 - # Check that Node is installed 57 - node -v 96 + readableStream.on('data', chunk => writableStream.write(chunk)); 97 + ``` 58 98 59 - # Check your NPM version 60 - npm -v 99 + ```js displayName="Work with Workers" 100 + // file containing main thread (main.mjs) 101 + import { Worker } from 'node:worker_threads'; 102 + 103 + const w = new Worker('./worker.mjs', { workerData: 'hello!' }); 104 + w.on('message', data => console.log('processed data:', data)); 105 + 106 + // file containing worker (worker.mjs) 107 + import { parentPort, workerData } from 'node:worker_threads'; 108 + 109 + // do some complex computational workload 110 + parentPort.postMessage(btoa(workerData)); 61 111 ``` 62 112 63 - </CodeTabs> 64 - Copy and paste this snippet to install Node.js LTS via a Package Manager 113 + </div> 114 + Learn more what Node.js is able to offer with our [Learning materials](/learn). 65 115 </section>
+13 -20
providers/matterProvider.tsx
··· 1 1 'use client'; 2 2 3 - import type { Heading } from '@vcarl/remark-headings'; 4 3 import { createContext } from 'react'; 5 4 import type { FC, PropsWithChildren } from 'react'; 6 - import type { ReadTimeResults } from 'reading-time'; 7 5 8 - import type { LegacyFrontMatter } from '@/types'; 6 + import type { ClientSharedServerContext } from '@/types'; 7 + import { assignClientContext } from '@/util/assignClientContext'; 9 8 10 - type MatterContext = { 11 - frontmatter: LegacyFrontMatter; 12 - pathname: string; 13 - headings: Array<Heading>; 14 - readingTime: ReadTimeResults; 15 - filename: string; 16 - }; 9 + export const MatterContext = createContext<ClientSharedServerContext>( 10 + assignClientContext({}) 11 + ); 17 12 18 - export const MatterContext = createContext<MatterContext>({ 19 - frontmatter: {}, 20 - pathname: '', 21 - headings: [], 22 - readingTime: { text: '', minutes: 0, time: 0, words: 0 }, 23 - filename: '', 24 - }); 25 - 26 - type MatterProviderProps = PropsWithChildren<MatterContext>; 13 + type MatterProviderProps = PropsWithChildren< 14 + Partial<ClientSharedServerContext> 15 + >; 27 16 28 17 export const MatterProvider: FC<MatterProviderProps> = ({ 29 18 children, 30 19 ...data 31 - }) => <MatterContext.Provider value={data}>{children}</MatterContext.Provider>; 20 + }) => ( 21 + <MatterContext.Provider value={assignClientContext(data)}> 22 + {children} 23 + </MatterContext.Provider> 24 + );
+1 -1
sentry.client.config.ts
··· 14 14 SENTRY_ENABLE, 15 15 SENTRY_CAPTURE_RATE, 16 16 SENTRY_TUNNEL, 17 - } from '@/next.constants.mjs'; 17 + } from '@/sentry.constants.mjs'; 18 18 19 19 // This creates a custom Sentry Client with minimal integrations 20 20 export const sentryClient = new BrowserClient({
+45
sentry.constants.mjs
··· 1 + import { BASE_URL, IS_DEVELOPMENT, VERCEL_ENV } from './next.constants.mjs'; 2 + 3 + /** 4 + * This is the Sentry DSN for the Node.js Website Project 5 + */ 6 + export const SENTRY_DSN = 7 + 'https://02884d0745aecaadf5f780278fe5fe70@o4506191161786368.ingest.sentry.io/4506191307735040'; 8 + 9 + /** 10 + * This states if Sentry should be enabled and bundled within our App 11 + * 12 + * We enable sentry by default if we're om development mode or deployed 13 + * on Vercel (either production or preview branches) 14 + */ 15 + export const SENTRY_ENABLE = IS_DEVELOPMENT || !!VERCEL_ENV; 16 + 17 + /** 18 + * This configures the sampling rate for Sentry 19 + * 20 + * We always want to capture 100% on Vercel Preview Branches 21 + * and not when it's on Production Mode (nodejs.org) 22 + */ 23 + export const SENTRY_CAPTURE_RATE = 24 + SENTRY_ENABLE && VERCEL_ENV && BASE_URL !== 'https://nodejs.org' ? 1.0 : 0.01; 25 + 26 + /** 27 + * Provides the Route for Sentry's Server-Side Tunnel 28 + * 29 + * This is a `@sentry/nextjs` specific feature 30 + */ 31 + export const SENTRY_TUNNEL = (components = '') => 32 + SENTRY_ENABLE ? `/monitoring${components}` : undefined; 33 + 34 + /** 35 + * This configures which Sentry features to tree-shake/remove from the Sentry bundle 36 + * 37 + * @see https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/tree-shaking/ 38 + */ 39 + export const SENTRY_EXTENSIONS = { 40 + __SENTRY_DEBUG__: false, 41 + __SENTRY_TRACING__: false, 42 + __RRWEB_EXCLUDE_IFRAME__: true, 43 + __RRWEB_EXCLUDE_SHADOW_DOM__: true, 44 + __SENTRY_EXCLUDE_REPLAY_WORKER__: true, 45 + };
+1 -1
sentry.edge.config.ts
··· 4 4 SENTRY_CAPTURE_RATE, 5 5 SENTRY_DSN, 6 6 SENTRY_ENABLE, 7 - } from '@/next.constants.mjs'; 7 + } from '@/sentry.constants.mjs'; 8 8 9 9 init({ 10 10 // Only run Sentry on Vercel Environment
+1 -1
sentry.server.config.ts
··· 4 4 SENTRY_CAPTURE_RATE, 5 5 SENTRY_DSN, 6 6 SENTRY_ENABLE, 7 - } from '@/next.constants.mjs'; 7 + } from '@/sentry.constants.mjs'; 8 8 9 9 init({ 10 10 // Only run Sentry on Vercel Environment
+2 -1
site.json
··· 12 12 "rssFeeds": [ 13 13 { 14 14 "title": "Node.js Blog", 15 - "file": "blog.xml" 15 + "file": "blog.xml", 16 + "category": "all" 16 17 }, 17 18 { 18 19 "title": "Node.js Blog: Releases",
+37
styles/new/effects.css
··· 1 + h1.special { 2 + @apply bg-gradient-subtle-gray 3 + bg-clip-text 4 + text-4xl 5 + leading-[3rem] 6 + -tracking-[0.045rem] 7 + [-webkit-text-fill-color:transparent] 8 + md:text-5xl 9 + md:leading-[4rem] 10 + md:-tracking-[0.06rem] 11 + dark:bg-gradient-subtle-white; 12 + } 13 + 14 + div.glowingBackdrop { 15 + @apply absolute 16 + left-0 17 + -z-10 18 + size-full 19 + bg-[url('/static/images/patterns/hexagon-grid.svg')] 20 + bg-center 21 + bg-no-repeat 22 + opacity-50 23 + md:opacity-100; 24 + 25 + &::after { 26 + @apply absolute 27 + inset-0 28 + m-auto 29 + aspect-square 30 + w-[300px] 31 + rounded-full 32 + bg-green-300 33 + blur-[120px] 34 + content-[''] 35 + dark:bg-green-700; 36 + } 37 + }
+1
styles/new/index.css
··· 11 11 @import 'tailwindcss/utilities'; 12 12 @import './base.css'; 13 13 @import './markdown.css'; 14 + @import './effects.css';
+16
styles/new/markdown.css
··· 1 1 main { 2 + @apply flex 3 + w-full 4 + flex-col 5 + gap-6; 6 + 2 7 hr { 3 8 @apply w-full 4 9 border-t ··· 53 58 dark:text-white; 54 59 } 55 60 61 + p { 62 + @apply text-neutral-900 63 + dark:text-white; 64 + } 65 + 56 66 a { 57 67 @apply text-green-600 68 + xs:underline 58 69 dark:text-green-400; 59 70 60 71 &:hover { 61 72 @apply text-green-900 62 73 dark:text-green-300; 74 + } 75 + 76 + &:has(code) { 77 + @apply xs:decoration-neutral-800 78 + dark:xs:decoration-neutral-200; 63 79 } 64 80 } 65 81
+6 -3
tailwind.config.ts
··· 117 117 'ibm-plex-mono': ['var(--font-ibm-plex-mono)'], 118 118 }, 119 119 extend: { 120 - screens: { xs: { max: '670px' } }, 120 + screens: { xs: { max: '670px', min: '0px' } }, 121 121 backgroundImage: { 122 122 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 123 123 'gradient-subtle': ··· 129 129 'gradient-subtle-white': 130 130 'linear-gradient(180deg, theme(colors.white) 0%, theme(colors.white / 80%) 100%)', 131 131 'gradient-glow-backdrop': 132 - 'radial-gradient(8em circle at calc(100% - 40px) 10px, theme(colors.green.500), transparent 30%)', 132 + 'radial-gradient(8em circle at calc(50%) 10px, theme(colors.green.500), transparent 30%)', 133 133 }, 134 134 boxShadow: { 135 135 xs: '0px 1px 2px 0px theme(colors.shadow / 5%)', ··· 141 141 }, 142 142 }, 143 143 darkMode: ['class', '[data-theme="dark"]'], 144 - plugins: [require('@savvywombat/tailwindcss-grid-areas')], 144 + plugins: [ 145 + require('@savvywombat/tailwindcss-grid-areas'), 146 + require('@tailwindcss/container-queries'), 147 + ], 145 148 } satisfies Config;
+13 -12
types/blog.ts
··· 1 + export type BlogPreviewType = 'announcements' | 'release' | 'vulnerability'; 2 + 1 3 export interface BlogPost { 2 4 title: string; 3 5 author: string; 4 - date: string; 5 - category: string; 6 + date: Date; 7 + categories: Array<string>; 6 8 slug: string; 7 9 } 8 10 9 11 export interface BlogData { 10 12 posts: Array<BlogPost>; 11 - pagination: Array<number>; 12 13 categories: Array<string>; 13 14 } 14 15 15 - export interface BlogDataRSC { 16 + export interface BlogPagination { 17 + next: number | null; 18 + prev: number | null; 19 + pages: number; 20 + total: number; 21 + } 22 + 23 + export interface BlogPostsRSC { 16 24 posts: Array<BlogPost>; 17 - pagination: { 18 - next: number | null; 19 - prev: number | null; 20 - }; 21 - meta: { 22 - categories: Array<string>; 23 - pagination: Array<number>; 24 - }; 25 + pagination: BlogPagination; 25 26 }
+1 -1
types/features.ts
··· 1 1 export interface RSSFeed { 2 2 file: string; 3 3 title: string; 4 + blogCategory: string; 4 5 description?: string; 5 - blogCategory?: string; 6 6 } 7 7 8 8 interface WithRange {
+3 -1
types/layouts.ts
··· 4 4 | 'docs.hbs' 5 5 | 'home.hbs' 6 6 | 'learn.hbs' 7 - | 'page.hbs'; 7 + | 'page.hbs' 8 + | 'blog-category.hbs' 9 + | 'blog-post.hbs'; 8 10 9 11 // @TODO: These are legacy layouts that are going to be replaced with the `nodejs/nodejs.dev` Layouts in the future 10 12 export type LegacyLayouts =
+17
util/assignClientContext.ts
··· 1 + import type { ClientSharedServerContext } from '@/types'; 2 + 3 + export const assignClientContext = <T extends ClientSharedServerContext>( 4 + props: Partial<T> 5 + ) => 6 + ({ 7 + frontmatter: props.frontmatter ?? {}, 8 + pathname: props.pathname ?? '', 9 + headings: props.headings ?? [], 10 + readingTime: props.readingTime ?? { 11 + text: '', 12 + minutes: 0, 13 + time: 0, 14 + words: 0, 15 + }, 16 + filename: props.filename ?? '', 17 + }) as T;
+24
util/blogUtils.ts
··· 1 + import type { BlogPreviewType } from '@/types'; 2 + 3 + export const mapBlogCategoryToPreviewType = (type: string): BlogPreviewType => { 4 + switch (type) { 5 + case 'announcements': 6 + case 'release': 7 + case 'vulnerability': 8 + return type; 9 + default: 10 + return 'announcements'; 11 + } 12 + }; 13 + 14 + // @todo: we should check about the future of GitHub avatars 15 + // and mapping them to the respective users 16 + // @see https://github.com/nodejs/nodejs.dev/blob/main/src/data/blog/authors.yaml 17 + export const mapAuthorToCardAuthors = (author: string) => { 18 + const authors = author.split(/, | and |;| by /i); 19 + 20 + return authors.map(fullName => ({ 21 + fullName, 22 + src: `https://ui-avatars.com/api/?name=${fullName}`, 23 + })); 24 + };