+2
-2
.github/workflows/build.yml
+2
-2
.github/workflows/build.yml
···
91
node-version-file: '.nvmrc'
92
cache: 'npm'
93
94
-
- name: Install NPM packages
95
-
# We want to avoid NPM from running the Audit Step and Funding messages on a CI environment
96
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
97
# We also use `--omit=dev` to avoid installing devDependencies as we don't need them during the build step
98
run: npm i --no-audit --no-fund --userconfig=/dev/null --omit=dev
···
91
node-version-file: '.nvmrc'
92
cache: 'npm'
93
94
+
- name: Install npm packages
95
+
# We want to avoid npm from running the Audit Step and Funding messages on a CI environment
96
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
97
# We also use `--omit=dev` to avoid installing devDependencies as we don't need them during the build step
98
run: npm i --no-audit --no-fund --userconfig=/dev/null --omit=dev
+4
-4
.github/workflows/lint-and-tests.yml
+4
-4
.github/workflows/lint-and-tests.yml
···
111
node-version-file: '.nvmrc'
112
cache: 'npm'
113
114
-
- name: Install NPM packages
115
-
# We want to avoid NPM from running the Audit Step and Funding messages on a CI environment
116
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
117
run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null
118
···
209
node-version-file: '.nvmrc'
210
cache: 'npm'
211
212
-
- name: Install NPM packages
213
-
# We want to avoid NPM from running the Audit Step and Funding messages on a CI environment
214
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
215
run: npm i --no-audit --no-fund --userconfig=/dev/null
216
···
111
node-version-file: '.nvmrc'
112
cache: 'npm'
113
114
+
- name: Install npm packages
115
+
# We want to avoid npm from running the Audit Step and Funding messages on a CI environment
116
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
117
run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null
118
···
209
node-version-file: '.nvmrc'
210
cache: 'npm'
211
212
+
- name: Install npm packages
213
+
# We want to avoid npm from running the Audit Step and Funding messages on a CI environment
214
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
215
run: npm i --no-audit --no-fund --userconfig=/dev/null
216
+2
-2
.github/workflows/translations-pr.yml
+2
-2
.github/workflows/translations-pr.yml
···
100
node-version-file: '.nvmrc'
101
cache: 'npm'
102
103
-
- name: Install NPM packages
104
-
# We want to avoid NPM from running the Audit Step and Funding messages on a CI environment
105
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
106
run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null
107
···
100
node-version-file: '.nvmrc'
101
cache: 'npm'
102
103
+
- name: Install npm packages
104
+
# We want to avoid npm from running the Audit Step and Funding messages on a CI environment
105
# We also use `npm i` instead of `npm ci` so that the node_modules/.cache folder doesn't get deleted
106
run: npm i --no-audit --no-fund --ignore-scripts --userconfig=/dev/null
107
+1
-1
DEPENDENCY_PINNING.md
+1
-1
DEPENDENCY_PINNING.md
···
1
## Dependency Pinning
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.
4
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
···
1
## Dependency Pinning
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.
4
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
+44
-25
app/[locale]/[[...path]]/page.tsx
+44
-25
app/[locale]/[[...path]]/page.tsx
···
7
import { MDXRenderer } from '@/components/mdxRenderer';
8
import WithLayout from '@/components/withLayout';
9
import { ENABLE_STATIC_EXPORT, VERCEL_REVALIDATE } from '@/next.constants.mjs';
10
-
import { DEFAULT_VIEWPORT } from '@/next.dynamic.constants.mjs';
11
import { dynamicRouter } from '@/next.dynamic.mjs';
12
import { availableLocaleCodes, defaultLocale } from '@/next.locales.mjs';
13
import { MatterProvider } from '@/providers/matterProvider';
···
17
18
// This is the default Viewport Metadata
19
// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function
20
-
export const generateViewport = async () => ({ ...DEFAULT_VIEWPORT });
21
22
// This generates each page's HTML Metadata
23
// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata
···
26
27
const pathname = dynamicRouter.getPathname(path);
28
29
-
// Retrieves and rewriting rule if the pathname matches any rule
30
-
const [, rewriteRule] = dynamicRouter.getRouteRewrite(pathname);
31
32
-
return dynamicRouter.getPageMetadata(
33
-
locale,
34
-
rewriteRule ? rewriteRule(pathname) : pathname
35
);
36
};
37
···
40
export const generateStaticParams = async () => {
41
const paths: Array<DynamicStaticPaths> = [];
42
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)
54
);
55
56
-
paths.push(...mappedRoutesWithLocale);
57
}
58
59
return paths.sort();
···
76
// Configures the current Locale to be the given Locale of the Request
77
unstable_setRequestLocale(locale);
78
79
const pathname = dynamicRouter.getPathname(path);
80
81
-
if (dynamicRouter.shouldIgnoreRoute(pathname)) {
82
-
return notFound();
83
-
}
84
85
-
// Retrieves and rewriting rule if the pathname matches any rule
86
-
const [, rewriteRule] = dynamicRouter.getRouteRewrite(pathname);
87
88
// We retrieve the source of the Markdown file by doing an educated guess
89
// of what possible files could be the source of the page, since the extension
90
// context is lost from `getStaticProps` as a limitation of Next.js itself
91
const { source, filename } = await dynamicRouter.getMarkdownFile(
92
locale,
93
-
rewriteRule ? rewriteRule(pathname) : pathname
94
);
95
96
// Decorate the Locale and current Pathname to Sentry
···
7
import { MDXRenderer } from '@/components/mdxRenderer';
8
import WithLayout from '@/components/withLayout';
9
import { ENABLE_STATIC_EXPORT, VERCEL_REVALIDATE } from '@/next.constants.mjs';
10
+
import { PAGE_VIEWPORT, DYNAMIC_ROUTES } from '@/next.dynamic.constants.mjs';
11
import { dynamicRouter } from '@/next.dynamic.mjs';
12
import { availableLocaleCodes, defaultLocale } from '@/next.locales.mjs';
13
import { MatterProvider } from '@/providers/matterProvider';
···
17
18
// This is the default Viewport Metadata
19
// @see https://nextjs.org/docs/app/api-reference/functions/generate-viewport#generateviewport-function
20
+
export const generateViewport = async () => ({ ...PAGE_VIEWPORT });
21
22
// This generates each page's HTML Metadata
23
// @see https://nextjs.org/docs/app/api-reference/functions/generate-metadata
···
26
27
const pathname = dynamicRouter.getPathname(path);
28
29
+
return dynamicRouter.getPageMetadata(locale, pathname);
30
+
};
31
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)
38
);
39
};
40
···
43
export const generateStaticParams = async () => {
44
const paths: Array<DynamicStaticPaths> = [];
45
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)
51
);
52
53
+
paths.push(...allAvailableRoutes.flat());
54
}
55
56
return paths.sort();
···
73
// Configures the current Locale to be the given Locale of the Request
74
unstable_setRequestLocale(locale);
75
76
+
// Gets the current full pathname for a given path
77
const pathname = dynamicRouter.getPathname(path);
78
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;
82
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
+
}
106
107
// We retrieve the source of the Markdown file by doing an educated guess
108
// of what possible files could be the source of the page, since the extension
109
// context is lost from `getStaticProps` as a limitation of Next.js itself
110
const { source, filename } = await dynamicRouter.getMarkdownFile(
111
locale,
112
+
pathname
113
);
114
115
// Decorate the Locale and current Pathname to Sentry
+1
-1
app/[locale]/feed/[feed]/route.ts
+1
-1
app/[locale]/feed/[feed]/route.ts
···
15
// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers
16
export const GET = async (_: Request, { params }: StaticParams) => {
17
// Generate the Feed for the given feed type (blog, releases, etc)
18
-
const websiteFeed = await provideWebsiteFeeds(params.feed);
19
20
return new NextResponse(websiteFeed, {
21
headers: { 'Content-Type': 'application/xml' },
···
15
// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers
16
export const GET = async (_: Request, { params }: StaticParams) => {
17
// Generate the Feed for the given feed type (blog, releases, etc)
18
+
const websiteFeed = provideWebsiteFeeds(params.feed);
19
20
return new NextResponse(websiteFeed, {
21
headers: { 'Content-Type': 'application/xml' },
+67
app/[locale]/next-data/blog-data/[category]/[page]/route.ts
+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
-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
+1
-1
app/[locale]/next-data/release-data/route.ts
+11
-13
client-context.ts
+11
-13
client-context.ts
···
2
3
import type { ClientSharedServerContext } from '@/types';
4
5
// This allows us to have Server-Side Context's of the shared "contextual" data
6
// which includes the frontmatter, the current pathname from the dynamic segments
7
// and the current headings of the current markdown context
8
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
-
};
16
17
return serverSharedContext;
18
});
19
20
// This is used by the dynamic router to define on the request
21
// 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;
28
};
···
2
3
import type { ClientSharedServerContext } from '@/types';
4
5
+
import { assignClientContext } from './util/assignClientContext';
6
+
7
// This allows us to have Server-Side Context's of the shared "contextual" data
8
// which includes the frontmatter, the current pathname from the dynamic segments
9
// and the current headings of the current markdown context
10
export const getClientContext = cache(() => {
11
+
const serverSharedContext = assignClientContext({});
12
13
return serverSharedContext;
14
});
15
16
// This is used by the dynamic router to define on the request
17
// the current set of information we use (shared)
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;
26
};
+2
-4
components/Common/AvatarGroup/Avatar/index.module.css
+2
-4
components/Common/AvatarGroup/Avatar/index.module.css
+6
-1
components/Common/AvatarGroup/Avatar/index.tsx
+6
-1
components/Common/AvatarGroup/Avatar/index.tsx
···
10
11
const Avatar: FC<AvatarProps> = ({ src, alt }) => (
12
<RadixAvatar.Root className={styles.avatarRoot}>
13
+
<RadixAvatar.Image
14
+
loading="lazy"
15
+
src={src}
16
+
alt={alt}
17
+
className={styles.avatar}
18
+
/>
19
<RadixAvatar.Fallback delayMs={500} className={styles.avatar}>
20
{alt}
21
</RadixAvatar.Fallback>
+2
components/Common/AvatarGroup/index.tsx
+2
components/Common/AvatarGroup/index.tsx
+1
-2
components/Common/Badge/index.module.css
+1
-2
components/Common/Badge/index.module.css
+1
-2
components/Common/Banner/index.module.css
+1
-2
components/Common/Banner/index.module.css
+10
-11
components/Common/BlogPostCard/__tests__/index.test.mjs
+10
-11
components/Common/BlogPostCard/__tests__/index.test.mjs
···
8
description = 'Blog post description',
9
authors = [],
10
date = new Date(),
11
}) {
12
render(
13
<BlogPostCard
14
title={title}
15
-
type={type}
16
description={description}
17
authors={authors}
18
date={date}
19
/>
20
);
21
···
33
it('Renders the title prop correctly', () => {
34
const { title } = renderBlogPostCard({});
35
36
-
// Title from Preview component
37
-
expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent(
38
-
title
39
-
);
40
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(
44
'aria-hidden',
45
'true'
46
);
···
53
});
54
55
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' },
59
])(
60
'Renders "%label" text when passing it the type "%type"',
61
({ label, type }) => {
···
8
description = 'Blog post description',
9
authors = [],
10
date = new Date(),
11
+
slug = '',
12
}) {
13
render(
14
<BlogPostCard
15
title={title}
16
+
category={type}
17
description={description}
18
authors={authors}
19
date={date}
20
+
slug={slug}
21
/>
22
);
23
···
35
it('Renders the title prop correctly', () => {
36
const { title } = renderBlogPostCard({});
37
38
+
expect(screen.getAllByText(title).length).toBe(2);
39
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(
43
'aria-hidden',
44
'true'
45
);
···
52
});
53
54
it.each([
55
+
{ label: 'layouts.blog.categories.vulnerability', type: 'vulnerability' },
56
+
{ label: 'layouts.blog.categories.announcements', type: 'announcements' },
57
+
{ label: 'layouts.blog.categories.release', type: 'release' },
58
])(
59
'Renders "%label" text when passing it the type "%type"',
60
({ label, type }) => {
+4
-10
components/Common/BlogPostCard/index.module.css
+4
-10
components/Common/BlogPostCard/index.module.css
···
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;
12
}
13
14
.subtitle {
15
@apply mb-2
16
text-xs
17
font-semibold
18
text-green-600
···
21
22
.title {
23
@apply mb-2
24
text-xl
25
font-semibold
26
text-neutral-900
+2
-1
components/Common/BlogPostCard/index.stories.tsx
+2
-1
components/Common/BlogPostCard/index.stories.tsx
···
8
export const Default: Story = {
9
args: {
10
title: 'Node.js March 17th Infrastructure Incident Post-mortem',
11
-
type: 'vulnerability',
12
description:
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
authors: [
···
17
src: 'https://avatars.githubusercontent.com/u/',
18
},
19
],
20
date: new Date('17 October 2023'),
21
},
22
decorators: [
···
8
export const Default: Story = {
9
args: {
10
title: 'Node.js March 17th Infrastructure Incident Post-mortem',
11
+
category: 'vulnerability',
12
description:
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
authors: [
···
17
src: 'https://avatars.githubusercontent.com/u/',
18
},
19
],
20
+
slug: '/blog/vulnerability/something',
21
date: new Date('17 October 2023'),
22
},
23
decorators: [
+29
-30
components/Common/BlogPostCard/index.tsx
+29
-30
components/Common/BlogPostCard/index.tsx
···
1
import { useTranslations } from 'next-intl';
2
-
import { useMemo } from 'react';
3
-
import type { ComponentProps, FC } from 'react';
4
5
import AvatarGroup from '@/components/Common/AvatarGroup';
6
import Preview from '@/components/Common/Preview';
7
import { Time } from '@/components/Common/Time';
8
9
import styles from './index.module.css';
10
11
-
type Author = {
12
-
fullName: string;
13
-
src: string;
14
-
};
15
16
type BlogPostCardProps = {
17
-
title: ComponentProps<typeof Preview>['title'];
18
-
type: Required<ComponentProps<typeof Preview>>['type'];
19
-
description: string;
20
authors: Array<Author>;
21
date: Date;
22
};
23
24
const BlogPostCard: FC<BlogPostCardProps> = ({
25
title,
26
-
type,
27
description,
28
authors,
29
date,
30
}) => {
31
const t = useTranslations();
32
33
-
const avatars = useMemo(
34
-
() =>
35
-
authors.map(({ fullName, src }) => ({
36
-
alt: fullName,
37
-
src,
38
-
toString: () => fullName,
39
-
})),
40
-
[authors]
41
-
);
42
43
return (
44
<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}>
53
{title}
54
-
</p>
55
-
<p className={styles.description}>{description}</p>
56
<footer className={styles.footer}>
57
<AvatarGroup avatars={avatars} />
58
<div className={styles.author}>
59
-
<p>{avatars.join(', ')}</p>
60
61
<Time
62
date={date}
···
1
import { useTranslations } from 'next-intl';
2
+
import type { FC } from 'react';
3
4
import AvatarGroup from '@/components/Common/AvatarGroup';
5
import Preview from '@/components/Common/Preview';
6
import { Time } from '@/components/Common/Time';
7
+
import Link from '@/components/Link';
8
+
import { mapBlogCategoryToPreviewType } from '@/util/blogUtils';
9
10
import styles from './index.module.css';
11
12
+
// @todo: this should probably be a global type?
13
+
type Author = { fullName: string; src: string };
14
15
type BlogPostCardProps = {
16
+
title: string;
17
+
category: string;
18
+
description?: string;
19
authors: Array<Author>;
20
date: Date;
21
+
slug: string;
22
};
23
24
const BlogPostCard: FC<BlogPostCardProps> = ({
25
title,
26
+
slug,
27
+
category,
28
description,
29
authors,
30
date,
31
}) => {
32
const t = useTranslations();
33
34
+
const avatars = authors.map(({ fullName, src }) => ({ alt: fullName, src }));
35
+
36
+
const type = mapBlogCategoryToPreviewType(category);
37
38
return (
39
<article className={styles.container}>
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}>
49
{title}
50
+
</Link>
51
+
52
+
{description && <p className={styles.description}>{description}</p>}
53
+
54
<footer className={styles.footer}>
55
<AvatarGroup avatars={avatars} />
56
+
57
<div className={styles.author}>
58
+
<p>{avatars.map(avatar => avatar.alt).join(', ')}</p>
59
60
<Time
61
date={date}
+1
-2
components/Common/Breadcrumbs/BreadcrumbHomeLink/index.module.css
+1
-2
components/Common/Breadcrumbs/BreadcrumbHomeLink/index.module.css
+1
-2
components/Common/Breadcrumbs/BreadcrumbItem/index.module.css
+1
-2
components/Common/Breadcrumbs/BreadcrumbItem/index.module.css
+4
components/Common/Button/index.module.css
+4
components/Common/Button/index.module.css
+1
-2
components/Common/CodeBox/index.module.css
+1
-2
components/Common/CodeBox/index.module.css
+40
-39
components/Common/CodeTabs/index.module.css
+40
-39
components/Common/CodeTabs/index.module.css
···
1
-
.root > [role='tabpanel'] > :first-child {
2
-
@apply rounded-t-none;
3
-
}
4
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;
15
16
-
& [role='tablist'] > button {
17
-
@apply border-b
18
-
border-b-transparent
19
-
px-1
20
-
text-neutral-200;
21
22
-
&[aria-selected='true'] {
23
-
@apply border-b-green-400
24
-
text-green-400;
25
}
26
-
}
27
28
-
.link {
29
-
@apply hidden
30
-
items-center
31
-
gap-2
32
-
text-center
33
-
text-neutral-200
34
-
lg:flex;
35
36
-
& > .icon {
37
-
@apply h-4
38
-
w-4
39
-
text-neutral-300;
40
-
}
41
42
-
&:is(:link, :visited) {
43
-
&:hover {
44
-
@apply text-neutral-400;
45
46
-
& > .icon {
47
-
@apply text-neutral-600;
48
}
49
}
50
}
···
1
+
.root {
2
+
> [role='tabpanel'] > :first-child {
3
+
@apply rounded-t-none;
4
+
}
5
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;
16
17
+
> button {
18
+
@apply border-b
19
+
border-b-transparent
20
+
px-1
21
+
text-neutral-200;
22
23
+
&[data-state='active'] {
24
+
@apply border-b-green-400
25
+
text-green-400;
26
+
}
27
}
28
29
+
.link {
30
+
@apply hidden
31
+
items-center
32
+
gap-2
33
+
text-center
34
+
text-neutral-200
35
+
lg:flex;
36
37
+
& > .icon {
38
+
@apply size-4
39
+
text-neutral-300;
40
+
}
41
42
+
&:is(:link, :visited) {
43
+
&:hover {
44
+
@apply text-neutral-400;
45
46
+
& > .icon {
47
+
@apply text-neutral-600;
48
+
}
49
}
50
}
51
}
+4
-8
components/Common/CodeTabs/index.tsx
+4
-8
components/Common/CodeTabs/index.tsx
···
6
7
import styles from './index.module.css';
8
9
-
export type CodeTabsExternaLink = {
10
linkUrl?: string;
11
linkText?: string;
12
};
13
14
-
type CodeTabsProps = Pick<
15
-
ComponentProps<typeof Tabs>,
16
-
'tabs' | 'onValueChange' | 'defaultValue'
17
-
> &
18
-
CodeTabsExternaLink;
19
-
20
const CodeTabs: FC<PropsWithChildren<CodeTabsProps>> = ({
21
children,
22
linkUrl,
···
26
<Tabs
27
{...props}
28
className={styles.root}
29
-
headerClassName={styles.header}
30
addons={
31
linkUrl &&
32
linkText && (
···
6
7
import styles from './index.module.css';
8
9
+
type CodeTabsProps = Pick<
10
+
ComponentProps<typeof Tabs>,
11
+
'tabs' | 'defaultValue'
12
+
> & {
13
linkUrl?: string;
14
linkText?: string;
15
};
16
17
const CodeTabs: FC<PropsWithChildren<CodeTabsProps>> = ({
18
children,
19
linkUrl,
···
23
<Tabs
24
{...props}
25
className={styles.root}
26
addons={
27
linkUrl &&
28
linkText && (
+1
-2
components/Common/CrossLink/index.module.css
+1
-2
components/Common/CrossLink/index.module.css
+36
components/Common/LinkTabs/index.module.css
+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
+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
+4
-3
components/Common/Pagination/PaginationListItem/index.module.css
+1
-1
components/Common/Pagination/index.module.css
+1
-1
components/Common/Pagination/index.module.css
+4
components/Common/Pagination/index.tsx
+4
components/Common/Pagination/index.tsx
···
41
disabled={currentPage === 1}
42
kind="secondary"
43
className={styles.previousButton}
44
>
45
<ArrowLeftIcon className={styles.arrowIcon} />
46
<span>{t('components.common.pagination.prev')}</span>
47
</Button>
48
<ol className={styles.list}>{parsedPages}</ol>
49
<Button
50
aria-label={t('components.common.pagination.nextAriaLabel')}
51
disabled={currentPage === pages.length}
52
kind="secondary"
53
className={styles.nextButton}
54
>
55
<span>{t('components.common.pagination.next')}</span>
56
<ArrowRightIcon className={styles.arrowIcon} />
···
41
disabled={currentPage === 1}
42
kind="secondary"
43
className={styles.previousButton}
44
+
href={pages[currentPage - 2]?.url}
45
>
46
<ArrowLeftIcon className={styles.arrowIcon} />
47
<span>{t('components.common.pagination.prev')}</span>
48
</Button>
49
+
50
<ol className={styles.list}>{parsedPages}</ol>
51
+
52
<Button
53
aria-label={t('components.common.pagination.nextAriaLabel')}
54
disabled={currentPage === pages.length}
55
kind="secondary"
56
className={styles.nextButton}
57
+
href={pages[currentPage]?.url}
58
>
59
<span>{t('components.common.pagination.next')}</span>
60
<ArrowRightIcon className={styles.arrowIcon} />
+29
-10
components/Common/Preview/index.module.css
+29
-10
components/Common/Preview/index.module.css
···
1
.root {
2
@apply relative
3
flex
4
items-center
5
bg-neutral-950
6
bg-[url('/static/images/patterns/hexagon-grid.svg')]
7
bg-contain
8
-
bg-center;
9
10
&::after {
11
@apply absolute
···
15
w-1/3
16
rounded-full
17
bg-gradient-radial
18
-
blur-3xl
19
-
content-[''];
20
21
-
&.announcement {
22
@apply from-green-700/90;
23
}
24
···
31
}
32
}
33
34
-
& > .container {
35
@apply z-10
36
mx-auto
37
flex
38
max-w-xl
39
flex-col
40
-
gap-12
41
text-center
42
-
text-3xl
43
font-semibold
44
-
text-white;
45
46
-
& > .logo {
47
-
@apply mx-auto;
48
}
49
}
50
}
···
1
.root {
2
@apply relative
3
flex
4
+
aspect-[1.90/1]
5
items-center
6
+
rounded
7
+
border
8
+
border-neutral-900
9
bg-neutral-950
10
bg-[url('/static/images/patterns/hexagon-grid.svg')]
11
bg-contain
12
+
bg-center
13
+
@container/preview;
14
15
&::after {
16
@apply absolute
···
20
w-1/3
21
rounded-full
22
bg-gradient-radial
23
+
blur-2xl
24
+
content-['']
25
+
@md/preview:blur-3xl;
26
27
+
&.announcements {
28
@apply from-green-700/90;
29
}
30
···
37
}
38
}
39
40
+
.container {
41
@apply z-10
42
mx-auto
43
flex
44
+
w-2/3
45
max-w-xl
46
flex-col
47
+
gap-4
48
text-center
49
+
text-xs
50
font-semibold
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;
60
61
+
.logo {
62
+
@apply mx-auto
63
+
size-6
64
+
@md/preview:size-14
65
+
@lg/preview:size-16
66
+
@xl/preview:size-20;
67
}
68
}
69
}
+8
-10
components/Common/Preview/index.stories.tsx
+8
-10
components/Common/Preview/index.stories.tsx
···
5
type Story = StoryObj<typeof Preview>;
6
type Meta = MetaObj<typeof Preview>;
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
export const Announcement: Story = {
16
args: {
17
-
type: 'announcement',
18
title:
19
'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023',
20
},
···
38
args: {
39
title:
40
'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023',
41
-
width: 600,
42
-
height: 315,
43
},
44
};
45
46
export default { component: Preview } as Meta;
···
5
type Story = StoryObj<typeof Preview>;
6
type Meta = MetaObj<typeof Preview>;
7
8
export const Announcement: Story = {
9
args: {
10
+
type: 'announcements',
11
title:
12
'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023',
13
},
···
31
args: {
32
title:
33
'Changing the End-of-Life Date for Node.js 16 to September 11th, 2023',
34
},
35
+
decorators: [
36
+
Story => (
37
+
<div className="w-[600px]">
38
+
<Story />
39
+
</div>
40
+
),
41
+
],
42
};
43
44
export default { component: Preview } as Meta;
+10
-21
components/Common/Preview/index.tsx
+10
-21
components/Common/Preview/index.tsx
···
1
import classNames from 'classnames';
2
-
import type { CSSProperties, ComponentProps, FC, ReactNode } from 'react';
3
4
import JsIconWhite from '@/components/Icons/Logos/JsIconWhite';
5
6
import styles from './index.module.css';
7
8
type PreviewProps = {
9
-
type?: 'announcement' | 'release' | 'vulnerability';
10
-
title: ReactNode;
11
-
height?: CSSProperties['height'];
12
-
width?: CSSProperties['width'];
13
-
} & Omit<ComponentProps<'div'>, 'children'>;
14
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>
30
</div>
31
</div>
32
);
···
1
import classNames from 'classnames';
2
+
import type { FC } from 'react';
3
4
import JsIconWhite from '@/components/Icons/Logos/JsIconWhite';
5
+
import type { BlogPreviewType } from '@/types';
6
7
import styles from './index.module.css';
8
9
type PreviewProps = {
10
+
title: string;
11
+
type?: BlogPreviewType;
12
+
};
13
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}
19
</div>
20
</div>
21
);
+1
-1
components/Common/ProgressionSidebar/index.stories.tsx
+1
-1
components/Common/ProgressionSidebar/index.stories.tsx
+34
-13
components/Common/ProgressionSidebar/index.tsx
+34
-13
components/Common/ProgressionSidebar/index.tsx
···
1
import type { ComponentProps, FC } from 'react';
2
3
import ProgressionSidebarGroup from '@/components/Common/ProgressionSidebar/ProgressionSidebarGroup';
4
-
import WithSidebarSelect from '@/components/withSidebarSelect';
5
6
import styles from './index.module.css';
7
···
9
groups: Array<ComponentProps<typeof ProgressionSidebarGroup>>;
10
};
11
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}
19
/>
20
-
))}
21
-
22
-
<WithSidebarSelect groups={groups} />
23
-
</nav>
24
-
);
25
26
export default ProgressionSidebar;
···
1
+
import { useTranslations } from 'next-intl';
2
import type { ComponentProps, FC } from 'react';
3
4
import ProgressionSidebarGroup from '@/components/Common/ProgressionSidebar/ProgressionSidebarGroup';
5
+
import WithRouterSelect from '@/components/withRouterSelect';
6
+
import { useClientContext } from '@/hooks/react-server';
7
8
import styles from './index.module.css';
9
···
11
groups: Array<ComponentProps<typeof ProgressionSidebarGroup>>;
12
};
13
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}
42
/>
43
+
</nav>
44
+
);
45
+
};
46
47
export default ProgressionSidebar;
+1
-2
components/Common/Select/index.module.css
+1
-2
components/Common/Select/index.module.css
+1
-1
components/Common/Select/index.stories.tsx
+1
-1
components/Common/Select/index.stories.tsx
+11
-12
components/Common/Tabs/index.module.css
+11
-12
components/Common/Tabs/index.module.css
···
11
text-sm
12
font-semibold
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;
19
}
20
-
}
21
22
-
.tabsWithAddons {
23
-
@apply flex
24
-
justify-between;
25
-
26
-
& > .addons {
27
-
@apply border-b-2
28
border-b-transparent
29
px-1
30
pb-[11px]
···
11
text-sm
12
font-semibold
13
text-neutral-800
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
+
}
22
}
23
24
+
.addons {
25
+
@apply ml-auto
26
+
border-b-2
27
border-b-transparent
28
px-1
29
pb-[11px]
+14
-26
components/Common/Tabs/index.tsx
+14
-26
components/Common/Tabs/index.tsx
···
1
import * as TabsPrimitive from '@radix-ui/react-tabs';
2
-
import classNames from 'classnames';
3
import type { FC, PropsWithChildren, ReactNode } from 'react';
4
5
import styles from './index.module.css';
6
7
-
type Tab = {
8
-
key: string;
9
-
label: string;
10
-
};
11
12
-
type TabsProps = {
13
tabs: Array<Tab>;
14
addons?: ReactNode;
15
-
headerClassName?: string;
16
-
} & TabsPrimitive.TabsProps;
17
18
const Tabs: FC<PropsWithChildren<TabsProps>> = ({
19
tabs,
20
addons,
21
-
headerClassName,
22
children,
23
...props
24
}) => (
25
<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>
42
43
{addons && <div className={styles.addons}>{addons}</div>}
44
-
</div>
45
46
{children}
47
</TabsPrimitive.Root>
···
1
import * as TabsPrimitive from '@radix-ui/react-tabs';
2
import type { FC, PropsWithChildren, ReactNode } from 'react';
3
4
import styles from './index.module.css';
5
6
+
type Tab = { key: string; label: string };
7
8
+
type TabsProps = TabsPrimitive.TabsProps & {
9
tabs: Array<Tab>;
10
addons?: ReactNode;
11
+
};
12
13
const Tabs: FC<PropsWithChildren<TabsProps>> = ({
14
tabs,
15
addons,
16
children,
17
...props
18
}) => (
19
<TabsPrimitive.Root {...props}>
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
+
))}
30
31
{addons && <div className={styles.addons}>{addons}</div>}
32
+
</TabsPrimitive.List>
33
34
{children}
35
</TabsPrimitive.Root>
+1
-2
components/Common/ThemeToggle/index.module.css
+1
-2
components/Common/ThemeToggle/index.module.css
+4
-2
components/Containers/MetaBar/index.module.css
+4
-2
components/Containers/MetaBar/index.module.css
···
3
flex-col
4
items-start
5
gap-8
6
+
overflow-y-auto
7
border-l
8
border-l-neutral-200
9
px-4
···
39
@apply font-semibold
40
text-neutral-900
41
underline
42
+
xs:inline-block
43
+
xs:py-1
44
dark:text-white;
45
46
&:hover {
···
58
}
59
60
svg {
61
+
@apply size-4
62
text-neutral-600
63
dark:text-neutral-400;
64
}
+8
-6
components/Containers/MetaBar/index.tsx
+8
-6
components/Containers/MetaBar/index.tsx
+36
-13
components/Containers/Sidebar/index.tsx
+36
-13
components/Containers/Sidebar/index.tsx
···
1
import type { ComponentProps, FC } from 'react';
2
3
import SidebarGroup from '@/components/Containers/Sidebar/SidebarGroup';
4
-
import WithSidebarSelect from '@/components/withSidebarSelect';
5
6
import styles from './index.module.css';
7
···
9
groups: Array<ComponentProps<typeof SidebarGroup>>;
10
};
11
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
-
))}
21
22
-
<WithSidebarSelect groups={groups} />
23
-
</aside>
24
-
);
25
26
export default SideBar;
···
1
+
import { useTranslations } from 'next-intl';
2
import type { ComponentProps, FC } from 'react';
3
4
import SidebarGroup from '@/components/Containers/Sidebar/SidebarGroup';
5
+
import WithRouterSelect from '@/components/withRouterSelect';
6
+
import { useClientContext } from '@/hooks/react-server';
7
8
import styles from './index.module.css';
9
···
11
groups: Array<ComponentProps<typeof SidebarGroup>>;
12
};
13
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
+
))}
37
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
+
};
48
49
export default SideBar;
+2
-4
components/Downloads/ChangelogModal/index.module.css
+2
-4
components/Downloads/ChangelogModal/index.module.css
+3
-2
components/Downloads/DownloadButton/index.tsx
+3
-2
components/Downloads/DownloadButton/index.tsx
···
18
children,
19
}) => {
20
const { os, bitness } = useDetectOS();
21
22
return (
23
<>
24
<Button
25
kind="special"
26
-
href={downloadUrlByOS(versionWithPrefix, os, bitness)}
27
className={classNames(styles.downloadButton, 'hidden dark:flex')}
28
>
29
{children}
···
33
34
<Button
35
kind="primary"
36
-
href={downloadUrlByOS(versionWithPrefix, os, bitness)}
37
className={classNames(styles.downloadButton, 'flex dark:hidden')}
38
>
39
{children}
···
18
children,
19
}) => {
20
const { os, bitness } = useDetectOS();
21
+
const downloadLink = downloadUrlByOS(versionWithPrefix, os, bitness);
22
23
return (
24
<>
25
<Button
26
kind="special"
27
+
href={downloadLink}
28
className={classNames(styles.downloadButton, 'hidden dark:flex')}
29
>
30
{children}
···
34
35
<Button
36
kind="primary"
37
+
href={downloadLink}
38
className={classNames(styles.downloadButton, 'flex dark:hidden')}
39
>
40
{children}
+21
components/Downloads/DownloadLink.tsx
+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
+6
-4
components/MDX/CodeTabs/index.tsx
···
1
'use client';
2
3
import * as TabsPrimitive from '@radix-ui/react-tabs';
4
-
import type { FC, ReactElement } from 'react';
5
6
-
import type { CodeTabsExternaLink } from '@/components/Common/CodeTabs';
7
import CodeTabs from '@/components/Common/CodeTabs';
8
9
-
type MDXCodeTabsProps = {
10
children: Array<ReactElement>;
11
languages: string;
12
displayNames?: string;
13
defaultTab?: string;
14
-
} & CodeTabsExternaLink;
15
16
const MDXCodeTabs: FC<MDXCodeTabsProps> = ({
17
languages: rawLanguages,
···
1
'use client';
2
3
import * as TabsPrimitive from '@radix-ui/react-tabs';
4
+
import type { ComponentProps, FC, ReactElement } from 'react';
5
6
import CodeTabs from '@/components/Common/CodeTabs';
7
8
+
type MDXCodeTabsProps = Pick<
9
+
ComponentProps<typeof CodeTabs>,
10
+
'linkText' | 'linkUrl'
11
+
> & {
12
children: Array<ReactElement>;
13
languages: string;
14
displayNames?: string;
15
defaultTab?: string;
16
+
};
17
18
const MDXCodeTabs: FC<MDXCodeTabsProps> = ({
19
languages: rawLanguages,
+11
-8
components/Pagination.tsx
+11
-8
components/Pagination.tsx
···
2
import type { FC } from 'react';
3
4
import Link from '@/components/Link';
5
6
-
type PaginationProps = { prev?: number | null; next?: number | null };
7
8
-
const Pagination: FC<PaginationProps> = ({ next, prev }) => {
9
const t = useTranslations();
10
11
return (
12
<nav aria-label="pagination" className="pagination">
13
-
{next && (
14
-
<Link href={`/blog/year-${next}`}>
15
-
< {t('components.pagination.next')}
16
</Link>
17
)}
18
19
-
{prev && (
20
-
<Link href={`/blog/year-${prev}`}>
21
-
{t('components.pagination.previous')} >
22
</Link>
23
)}
24
</nav>
···
2
import type { FC } from 'react';
3
4
import Link from '@/components/Link';
5
+
import type { BlogPagination } from '@/types';
6
7
+
type PaginationProps = BlogPagination & { category: string };
8
9
+
const Pagination: FC<PaginationProps> = ({ category, next, prev }) => {
10
const t = useTranslations();
11
12
return (
13
<nav aria-label="pagination" className="pagination">
14
+
{prev && (
15
+
<Link href={`/blog/${category}/page/${prev}`}>
16
+
< {t('components.pagination.previous')}
17
</Link>
18
)}
19
20
+
{prev && next && ' | '}
21
+
22
+
{next && (
23
+
<Link href={`/blog/${category}/page/${next}`}>
24
+
{t('components.pagination.next')} >
25
</Link>
26
)}
27
</nav>
+3
-1
components/__design__/text.stories.tsx
+3
-1
components/__design__/text.stories.tsx
+60
components/withBlogCategories.tsx
+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
+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
+3
-5
components/withCrossLinks.tsx
components/withSidebarCrossLinks.tsx
···
4
import { useClientContext, useSiteNavigation } from '@/hooks/server';
5
import type { NavigationKeys } from '@/types';
6
7
-
type WithCrossLinksProps = {
8
-
navKey: NavigationKeys;
9
-
};
10
11
-
const WithCrossLinks: FC<WithCrossLinksProps> = ({ navKey }) => {
12
const { getSideNavigation } = useSiteNavigation();
13
const { pathname } = useClientContext();
14
···
46
);
47
};
48
49
-
export default WithCrossLinks;
···
4
import { useClientContext, useSiteNavigation } from '@/hooks/server';
5
import type { NavigationKeys } from '@/types';
6
7
+
type WithCrossLinksProps = { navKey: NavigationKeys };
8
9
+
const WithSidebarCrossLinks: FC<WithCrossLinksProps> = ({ navKey }) => {
10
const { getSideNavigation } = useSiteNavigation();
11
const { pathname } = useClientContext();
12
···
44
);
45
};
46
47
+
export default WithSidebarCrossLinks;
+4
components/withLayout.tsx
+4
components/withLayout.tsx
···
9
import LegacyIndexLayout from '@/layouts/IndexLayout';
10
import LegacyLearnLayout from '@/layouts/LearnLayout';
11
import AboutLayout from '@/layouts/New/About';
12
import DefaultLayout from '@/layouts/New/Default';
13
import DocsLayout from '@/layouts/New/Docs';
14
import HomeLayout from '@/layouts/New/Home';
15
import LearnLayout from '@/layouts/New/Learn';
16
import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs';
17
import type { Layouts, LegacyLayouts } from '@/types';
18
···
35
'home.hbs': HomeLayout,
36
'learn.hbs': LearnLayout,
37
'page.hbs': DefaultLayout,
38
} satisfies Record<Layouts, FC>;
39
40
type WithLayout<L = Layouts | LegacyLayouts> = PropsWithChildren<{ layout: L }>;
···
9
import LegacyIndexLayout from '@/layouts/IndexLayout';
10
import LegacyLearnLayout from '@/layouts/LearnLayout';
11
import AboutLayout from '@/layouts/New/About';
12
+
import BlogLayout from '@/layouts/New/Blog';
13
import DefaultLayout from '@/layouts/New/Default';
14
import DocsLayout from '@/layouts/New/Docs';
15
import HomeLayout from '@/layouts/New/Home';
16
import LearnLayout from '@/layouts/New/Learn';
17
+
import PostLayout from '@/layouts/New/Post';
18
import { ENABLE_WEBSITE_REDESIGN } from '@/next.constants.mjs';
19
import type { Layouts, LegacyLayouts } from '@/types';
20
···
37
'home.hbs': HomeLayout,
38
'learn.hbs': LearnLayout,
39
'page.hbs': DefaultLayout,
40
+
'blog-post.hbs': PostLayout,
41
+
'blog-category.hbs': BlogLayout,
42
} satisfies Record<Layouts, FC>;
43
44
type WithLayout<L = Layouts | LegacyLayouts> = PropsWithChildren<{ layout: L }>;
+9
-5
components/withMetaBar.tsx
+9
-5
components/withMetaBar.tsx
···
7
import { useClientContext } from '@/hooks/server';
8
import { getGitHubEditPageUrl } from '@/util/gitHubUtils';
9
10
const WithMetaBar: FC = () => {
11
const { headings, readingTime, frontmatter, filename } = useClientContext();
12
const formatter = useFormatter();
13
14
-
const lastUpdated = formatter.dateTime(frontmatter.date ?? new Date(), {
15
-
month: 'short',
16
-
day: '2-digit',
17
-
year: 'numeric',
18
-
});
19
20
return (
21
<MetaBar
···
7
import { useClientContext } from '@/hooks/server';
8
import { getGitHubEditPageUrl } from '@/util/gitHubUtils';
9
10
+
const DATE_FORMAT = {
11
+
month: 'short',
12
+
day: '2-digit',
13
+
year: 'numeric',
14
+
} as const;
15
+
16
const WithMetaBar: FC = () => {
17
const { headings, readingTime, frontmatter, filename } = useClientContext();
18
const formatter = useFormatter();
19
20
+
const lastUpdated = frontmatter.date
21
+
? formatter.dateTime(new Date(frontmatter.date), DATE_FORMAT)
22
+
: undefined;
23
24
return (
25
<MetaBar
+30
components/withRouterSelect.tsx
+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
-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
+24
-9
i18n/locales/en.json
···
38
"howMuchJavascriptDoYouNeedToKnowToUseNodejs": "How much JavaScript do you need to know to use Node.js?",
39
"differencesBetweenNodejsAndTheBrowser": "Differences between Node.js and the Browser",
40
"theV8JavascriptEngine": "The V8 JavaScript Engine",
41
-
"anIntroductionToTheNpmPackageManager": "An introduction to the NPM package manager",
42
"ecmascript2015Es6AndBeyond": "ECMAScript 2015 (ES6) and beyond",
43
"nodejsTheDifferenceBetweenDevelopmentAndProduction": "Node.js, the difference between development and production",
44
"nodejsWithTypescript": "Node.js with TypeScript",
···
118
"docs": "Docs"
119
},
120
"pagination": {
121
-
"next": "Newer | ",
122
-
"previous": "Older"
123
},
124
"common": {
125
"breadcrumbs": {
···
146
},
147
"languageDropdown": {
148
"label": "Choose Language"
149
-
},
150
-
"card": {
151
-
"announcement": "Announcements",
152
-
"release": "Releases",
153
-
"vulnerability": "Vulnerabilities"
154
}
155
},
156
"metabar": {
···
192
}
193
},
194
"blogIndex": {
195
-
"currentYear": "Blog from {year}"
196
}
197
},
198
"pages": {
···
38
"howMuchJavascriptDoYouNeedToKnowToUseNodejs": "How much JavaScript do you need to know to use Node.js?",
39
"differencesBetweenNodejsAndTheBrowser": "Differences between Node.js and the Browser",
40
"theV8JavascriptEngine": "The V8 JavaScript Engine",
41
+
"anIntroductionToTheNpmPackageManager": "An introduction to the npm package manager",
42
"ecmascript2015Es6AndBeyond": "ECMAScript 2015 (ES6) and beyond",
43
"nodejsTheDifferenceBetweenDevelopmentAndProduction": "Node.js, the difference between development and production",
44
"nodejsWithTypescript": "Node.js with TypeScript",
···
118
"docs": "Docs"
119
},
120
"pagination": {
121
+
"next": "Next",
122
+
"previous": "Previous"
123
},
124
"common": {
125
"breadcrumbs": {
···
146
},
147
"languageDropdown": {
148
"label": "Choose Language"
149
}
150
},
151
"metabar": {
···
187
}
188
},
189
"blogIndex": {
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
+
}
211
}
212
},
213
"pages": {
+19
-43
layouts/BlogCategoryLayout.tsx
+19
-43
layouts/BlogCategoryLayout.tsx
···
1
-
import { notFound } from 'next/navigation';
2
import { getTranslations } from 'next-intl/server';
3
import type { FC } from 'react';
4
···
9
import getBlogData from '@/next-data/blogData';
10
11
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
-
}
20
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
-
}
37
38
-
return { ...data, category: year };
39
};
40
41
// This is a React Async Server Component
42
// Note that Hooks cannot be used in a RSC async component
43
// Async Components do not get re-rendered at all.
44
const BlogCategoryLayout: FC = async () => {
45
-
const { frontmatter, pathname } = getClientContext();
46
47
const t = await getTranslations();
48
49
const { posts, pagination, category } = await getCategoryData(pathname);
50
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
return (
65
<div className="container" dir="auto">
66
-
<h2>{title}</h2>
67
68
<ul className="blog-index">
69
{posts.map(({ slug, date, title }) => (
70
<li key={slug}>
71
-
<Time date={date} format={{ month: 'short', day: '2-digit' }} />
72
<Link href={slug}>{title}</Link>
73
</li>
74
))}
75
</ul>
76
77
-
<Pagination {...pagination} />
78
</div>
79
);
80
};
···
1
import { getTranslations } from 'next-intl/server';
2
import type { FC } from 'react';
3
···
8
import getBlogData from '@/next-data/blogData';
9
10
const getCategoryData = async (pathname: string) => {
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('/');
17
18
+
const { posts, pagination } = await getBlogData(category, Number(page));
19
20
+
return { posts, category, pagination };
21
};
22
23
// This is a React Async Server Component
24
// Note that Hooks cannot be used in a RSC async component
25
// Async Components do not get re-rendered at all.
26
const BlogCategoryLayout: FC = async () => {
27
+
const { pathname } = getClientContext();
28
29
const t = await getTranslations();
30
31
const { posts, pagination, category } = await getCategoryData(pathname);
32
33
return (
34
<div className="container" dir="auto">
35
+
<h2 style={{ textTransform: 'capitalize' }}>
36
+
{t('layouts.blogIndex.categoryName', {
37
+
category: category.replace('year-', ''),
38
+
})}
39
+
</h2>
40
41
<ul className="blog-index">
42
{posts.map(({ slug, date, title }) => (
43
<li key={slug}>
44
+
<Time
45
+
date={date}
46
+
format={{ year: 'numeric', month: 'short', day: '2-digit' }}
47
+
/>
48
<Link href={slug}>{title}</Link>
49
</li>
50
))}
51
</ul>
52
53
+
<Pagination category={category} {...pagination} />
54
</div>
55
);
56
};
+65
layouts/New/Blog.tsx
+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
+9
layouts/New/Content.tsx
+6
-7
layouts/New/Docs.tsx
+6
-7
layouts/New/Docs.tsx
···
3
import WithFooter from '@/components/withFooter';
4
import WithMetaBar from '@/components/withMetaBar';
5
import WithNavBar from '@/components/withNavBar';
6
-
import WithSideBar from '@/components/withSidebar';
7
-
import ArticleLayout from '@/layouts/New/Article';
8
9
// @deprecated: This Layout is Temporary. The `en/docs` route should eventually be removed
10
// and all "guides" moved to the Learn section.
···
13
<>
14
<WithNavBar />
15
16
-
<ArticleLayout>
17
-
<WithSideBar navKeys={[]} />
18
-
19
-
<main>{children}</main>
20
21
<WithMetaBar />
22
-
</ArticleLayout>
23
24
<WithFooter />
25
</>
···
3
import WithFooter from '@/components/withFooter';
4
import WithMetaBar from '@/components/withMetaBar';
5
import WithNavBar from '@/components/withNavBar';
6
+
import ContentLayout from '@/layouts/New/Content';
7
8
// @deprecated: This Layout is Temporary. The `en/docs` route should eventually be removed
9
// and all "guides" moved to the Learn section.
···
12
<>
13
<WithNavBar />
14
15
+
<ContentLayout>
16
+
<div>
17
+
<main>{children}</main>
18
+
</div>
19
20
<WithMetaBar />
21
+
</ContentLayout>
22
23
<WithFooter />
24
</>
+4
-4
layouts/New/Home.tsx
+4
-4
layouts/New/Home.tsx
+2
-2
layouts/New/Learn.tsx
+2
-2
layouts/New/Learn.tsx
···
1
import type { FC, PropsWithChildren } from 'react';
2
3
import WithBreadcrumbs from '@/components/withBreadcrumbs';
4
-
import WithCrossLinks from '@/components/withCrossLinks';
5
import WithMetaBar from '@/components/withMetaBar';
6
import WithNavBar from '@/components/withNavBar';
7
import WithProgressionSidebar from '@/components/withProgressionSidebar';
8
import ArticleLayout from '@/layouts/New/Article';
9
10
const LearnLayout: FC<PropsWithChildren> = ({ children }) => (
···
17
<main>
18
{children}
19
20
-
<WithCrossLinks navKey="learn" />
21
</main>
22
23
<WithMetaBar />
···
1
import type { FC, PropsWithChildren } from 'react';
2
3
import WithBreadcrumbs from '@/components/withBreadcrumbs';
4
import WithMetaBar from '@/components/withMetaBar';
5
import WithNavBar from '@/components/withNavBar';
6
import WithProgressionSidebar from '@/components/withProgressionSidebar';
7
+
import WithSidebarCrossLinks from '@/components/withSidebarCrossLinks';
8
import ArticleLayout from '@/layouts/New/Article';
9
10
const LearnLayout: FC<PropsWithChildren> = ({ children }) => (
···
17
<main>
18
{children}
19
20
+
<WithSidebarCrossLinks navKey="learn" />
21
</main>
22
23
<WithMetaBar />
+60
layouts/New/Post.tsx
+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
+134
-90
layouts/New/layouts.module.css
···
1
.baseLayout {
2
@apply grid
3
-
h-screen
4
-
w-screen
5
grid-cols-[1fr]
6
grid-rows-[auto_1fr_auto];
7
}
···
25
}
26
27
> *: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
35
overflow-x-hidden
36
bg-gradient-subtle
37
p-12
···
66
}
67
68
.homeLayout {
69
-
@apply mx-auto
70
-
flex
71
w-full
72
-
flex-col
73
items-center
74
-
gap-8
75
-
self-stretch
76
px-4
77
py-14
78
-
md:w-auto
79
-
md:flex-row
80
-
md:gap-14
81
md:px-14
82
-
md:py-0
83
-
lg:gap-28
84
lg:px-28;
85
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;
97
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
-
}
111
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;
120
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
-
}
131
132
-
p {
133
-
@apply max-w-[400px]
134
-
text-base
135
-
text-neutral-900
136
-
md:text-lg
137
-
dark:text-white;
138
}
139
140
-
> div {
141
-
&:nth-of-type(1) {
142
-
@apply flex
143
-
flex-col
144
-
gap-4;
145
}
146
147
-
&:nth-of-type(2) {
148
-
@apply flex
149
-
w-full
150
-
flex-col
151
-
gap-2
152
-
xl:flex-row;
153
}
154
}
155
}
156
157
-
&:nth-of-type(2) {
158
@apply flex
159
-
max-w-md
160
-
flex-col
161
-
content-center
162
items-center
163
-
gap-4
164
-
md:max-w-2xl;
165
166
-
p {
167
-
@apply text-center
168
-
text-sm
169
-
text-neutral-800
170
-
dark:text-neutral-200;
171
-
}
172
}
173
}
174
}
···
1
.baseLayout {
2
@apply grid
3
+
size-full
4
grid-cols-[1fr]
5
grid-rows-[auto_1fr_auto];
6
}
···
24
}
25
26
> *:nth-child(2) {
27
+
@apply overflow-y-auto
28
overflow-x-hidden
29
bg-gradient-subtle
30
p-12
···
59
}
60
61
.homeLayout {
62
+
@apply flex
63
w-full
64
items-center
65
+
justify-center
66
px-4
67
py-14
68
md:px-14
69
lg:px-28;
70
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;
79
80
+
section {
81
+
&:nth-of-type(1) {
82
+
@apply flex
83
+
max-w-[500px]
84
+
flex-[1_0]
85
+
flex-col
86
+
gap-8;
87
88
+
> div {
89
+
@apply flex
90
+
max-w-[400px]
91
+
flex-col
92
+
gap-4;
93
94
+
p {
95
+
@apply text-base
96
+
md:text-lg;
97
+
}
98
99
+
small {
100
+
@apply text-center
101
+
text-sm
102
+
text-neutral-800
103
+
xs:text-xs
104
+
dark:text-neutral-400;
105
+
}
106
+
}
107
}
108
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
+
}
134
}
135
136
+
> p {
137
+
@apply text-center
138
+
text-sm
139
+
text-neutral-800
140
+
dark:text-neutral-200;
141
}
142
}
143
}
144
+
}
145
+
}
146
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 {
207
@apply flex
208
+
flex-row
209
items-center
210
+
gap-4;
211
+
}
212
213
+
> div:nth-of-type(1) {
214
+
@apply mb-4
215
+
mt-2;
216
}
217
}
218
}
+10
-4
next-data/blogData.ts
+10
-4
next-data/blogData.ts
···
4
NEXT_DATA_URL,
5
VERCEL_ENV,
6
} from '@/next.constants.mjs';
7
-
import type { BlogDataRSC } from '@/types';
8
9
-
const getBlogData = (category: string): Promise<BlogDataRSC> => {
10
// When we're using Static Exports the Next.js Server is not running (during build-time)
11
// hence the self-ingestion APIs will not be available. In this case we want to load
12
// the data directly within the current thread, which will anyways be loaded only once
13
// We use lazy-imports to prevent `provideBlogData` from executing on import
14
if (ENABLE_STATIC_EXPORT || (!IS_DEVELOPMENT && !VERCEL_ENV)) {
15
return import('@/next-data/providers/blogData').then(
16
-
({ default: provideBlogData }) => provideBlogData(category)
17
);
18
}
19
20
// When we're on RSC with Server capabilities we prefer using Next.js Data Fetching
21
// as this will load cached data from the server instead of generating data on the fly
22
// 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());
24
};
25
26
export default getBlogData;
···
4
NEXT_DATA_URL,
5
VERCEL_ENV,
6
} from '@/next.constants.mjs';
7
+
import type { BlogPostsRSC } from '@/types';
8
9
+
const getBlogData = (cat: string, page?: number): Promise<BlogPostsRSC> => {
10
// When we're using Static Exports the Next.js Server is not running (during build-time)
11
// hence the self-ingestion APIs will not be available. In this case we want to load
12
// the data directly within the current thread, which will anyways be loaded only once
13
// We use lazy-imports to prevent `provideBlogData` from executing on import
14
if (ENABLE_STATIC_EXPORT || (!IS_DEVELOPMENT && !VERCEL_ENV)) {
15
return import('@/next-data/providers/blogData').then(
16
+
({ provideBlogPosts, providePaginatedBlogPosts }) =>
17
+
page ? providePaginatedBlogPosts(cat, page) : provideBlogPosts(cat)
18
);
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
+
26
// When we're on RSC with Server capabilities we prefer using Next.js Data Fetching
27
// as this will load cached data from the server instead of generating data on the fly
28
// this is extremely useful for ISR and SSG as it will not generate this data on every request
29
+
return fetch(fetchURL).then(r => r.json());
30
};
31
32
export default getBlogData;
+18
-25
next-data/generators/blogData.mjs
+18
-25
next-data/generators/blogData.mjs
···
12
const blogPath = join(process.cwd(), 'pages/en/blog');
13
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>}}
19
*/
20
-
const blogMetadata = { pagination: new Set(), categories: new Set() };
21
22
/**
23
* This method parses the source (raw) Markdown content into Frontmatter
···
34
category = 'uncategorized',
35
} = graymatter(source).data;
36
37
-
// we add the year to the pagination set
38
-
blogMetadata.pagination.add(new Date(date).getUTCFullYear());
39
40
// we add the category to the categories set
41
-
blogMetadata.categories.add(category);
42
43
// this is the url used for the blog post it based on the category and filename
44
const slug = `/blog/${category}/${basename(filename, extname(filename))}`;
45
46
-
return { title, author, date: new Date(date), category, slug };
47
};
48
49
/**
···
53
* @return {Promise<import('../../types').BlogData>}
54
*/
55
const generateBlogData = async () => {
56
-
// we retrieve all the filenames of all blog posts
57
const filenames = await getMarkdownFiles(process.cwd(), 'pages/en/blog', [
58
'**/index.md',
59
-
'**/pagination.md',
60
]);
61
62
return new Promise(resolve => {
63
-
const blogPosts = [];
64
const rawFrontmatter = [];
65
66
filenames.forEach(filename => {
···
95
// This allows us to only read the frontmatter part of each file
96
// and optimise the read-process as we have thousands of markdown files
97
_readLine.on('close', () => {
98
-
const frontmatter = getFrontMatter(
99
-
filename,
100
-
rawFrontmatter[filename][1]
101
-
);
102
-
103
-
blogPosts.push(frontmatter);
104
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
-
});
112
}
113
});
114
});
···
12
const blogPath = join(process.cwd(), 'pages/en/blog');
13
14
/**
15
+
* This contains the metadata of all available blog categories
16
*/
17
+
const blogCategories = new Set(['all']);
18
19
/**
20
* This method parses the source (raw) Markdown content into Frontmatter
···
31
category = 'uncategorized',
32
} = graymatter(source).data;
33
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}`);
43
44
// we add the category to the categories set
45
+
blogCategories.add(category);
46
47
// this is the url used for the blog post it based on the category and filename
48
const slug = `/blog/${category}/${basename(filename, extname(filename))}`;
49
50
+
return { title, author, date: new Date(date), categories, slug };
51
};
52
53
/**
···
57
* @return {Promise<import('../../types').BlogData>}
58
*/
59
const generateBlogData = async () => {
60
+
// We retrieve the full pathnames of all Blog Posts to read each file individually
61
const filenames = await getMarkdownFiles(process.cwd(), 'pages/en/blog', [
62
'**/index.md',
63
]);
64
65
return new Promise(resolve => {
66
+
const posts = [];
67
const rawFrontmatter = [];
68
69
filenames.forEach(filename => {
···
98
// This allows us to only read the frontmatter part of each file
99
// and optimise the read-process as we have thousands of markdown files
100
_readLine.on('close', () => {
101
+
posts.push(getFrontMatter(filename, rawFrontmatter[filename][1]));
102
103
+
if (posts.length === filenames.length) {
104
+
resolve({ categories: [...blogCategories], posts });
105
}
106
});
107
});
+30
-32
next-data/generators/websiteFeeds.mjs
+30
-32
next-data/generators/websiteFeeds.mjs
···
13
* This method generates RSS website feeds based on the current website configuration
14
* and the current blog data that is available
15
*
16
-
* @param {Promise<import('../../types').BlogDataRSC>} blogData
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
-
});
34
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
-
}));
44
45
-
blogFeedEntries.forEach(entry => feed.addItem(entry));
46
47
-
return [file, feed];
48
-
}
49
-
);
50
51
-
return new Map(websiteFeeds);
52
-
});
53
};
54
55
export default generateWebsiteFeeds;
···
13
* This method generates RSS website feeds based on the current website configuration
14
* and the current blog data that is available
15
*
16
+
* @param {import('../../types').BlogPostsRSC} blogData
17
*/
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
+
});
33
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
+
}));
43
44
+
blogFeedEntries.forEach(entry => feed.addItem(entry));
45
46
+
return [file, feed];
47
+
}
48
+
);
49
50
+
return new Map(websiteFeeds);
51
};
52
53
export default generateWebsiteFeeds;
+54
-33
next-data/providers/blogData.ts
+54
-33
next-data/providers/blogData.ts
···
1
import { cache } from 'react';
2
3
import generateBlogData from '@/next-data/generators/blogData.mjs';
4
-
import type { BlogDataRSC } from '@/types';
5
6
-
const blogData = generateBlogData();
7
8
-
const provideBlogData = cache(
9
-
async (category?: string): Promise<BlogDataRSC> => {
10
-
return blogData.then(({ posts, categories, pagination }) => {
11
-
const meta = { categories, pagination };
12
13
-
if (category && categories.includes(category)) {
14
-
return {
15
-
posts: posts.filter(post => post.category === category),
16
-
pagination: { next: null, prev: null },
17
-
meta,
18
-
};
19
-
}
20
21
-
if (category && category.startsWith('year-')) {
22
-
const paramYear = Number(category.replace('year-', ''));
23
24
-
const isEqualYear = (date: string) =>
25
-
new Date(date).getFullYear() === paramYear;
26
27
-
return {
28
-
posts: posts.filter(({ date }) => isEqualYear(date)),
29
-
pagination: {
30
-
next: pagination.includes(paramYear + 1) ? paramYear + 1 : null,
31
-
prev: pagination.includes(paramYear - 1) ? paramYear - 1 : null,
32
-
},
33
-
meta,
34
-
};
35
-
}
36
37
-
if (category && !categories.includes(category)) {
38
-
return { posts: [], pagination: { next: null, prev: null }, meta };
39
-
}
40
41
-
return { posts, pagination: { next: null, prev: null }, meta };
42
-
});
43
}
44
);
45
-
46
-
export default provideBlogData;
···
1
import { cache } from 'react';
2
3
import generateBlogData from '@/next-data/generators/blogData.mjs';
4
+
import { BLOG_POSTS_PER_PAGE } from '@/next.constants.mjs';
5
+
import type { BlogPostsRSC } from '@/types';
6
7
+
const { categories, posts } = await generateBlogData();
8
9
+
export const provideBlogCategories = cache(() => categories);
10
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());
15
16
+
// Total amount of possible pages given the amount of blog posts
17
+
const total = categoryPosts.length / BLOG_POSTS_PER_PAGE;
18
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
+
});
31
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;
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
+
}
56
57
+
return {
58
+
posts: [],
59
+
pagination: {
60
+
prev: pagination.total,
61
+
next: null,
62
+
pages: pagination.pages,
63
+
total: posts.length,
64
+
},
65
+
};
66
}
67
);
+2
-2
next-data/providers/releaseData.ts
+2
-2
next-data/providers/releaseData.ts
+7
-9
next-data/providers/websiteFeeds.ts
+7
-9
next-data/providers/websiteFeeds.ts
···
1
import { cache } from 'react';
2
3
import generateWebsiteFeeds from '@/next-data/generators/websiteFeeds.mjs';
4
-
import provideBlogData from '@/next-data/providers/blogData';
5
6
-
const websiteFeeds = generateWebsiteFeeds(provideBlogData());
7
8
-
const provideWebsiteFeeds = cache(async (feed: string) => {
9
-
return websiteFeeds.then(feeds => {
10
-
if (feed.includes('.xml') && feeds.has(feed)) {
11
-
return feeds.get(feed)?.rss2();
12
-
}
13
14
-
return undefined;
15
-
});
16
});
17
18
export default provideWebsiteFeeds;
···
1
import { cache } from 'react';
2
3
import generateWebsiteFeeds from '@/next-data/generators/websiteFeeds.mjs';
4
+
import { provideBlogPosts } from '@/next-data/providers/blogData';
5
6
+
const websiteFeeds = await generateWebsiteFeeds(provideBlogPosts('all'));
7
8
+
const provideWebsiteFeeds = cache((feed: string) => {
9
+
if (feed.includes('.xml') && websiteFeeds.has(feed)) {
10
+
return websiteFeeds.get(feed)?.rss2();
11
+
}
12
13
+
return undefined;
14
});
15
16
export default provideWebsiteFeeds;
+4
-2
next.config.mjs
+4
-2
next.config.mjs
···
9
BASE_PATH,
10
ENABLE_STATIC_EXPORT,
11
ENABLE_WEBSITE_REDESIGN,
12
SENTRY_DSN,
13
SENTRY_ENABLE,
14
SENTRY_EXTENSIONS,
15
SENTRY_TUNNEL,
16
-
} from './next.constants.mjs';
17
-
import { redirects, rewrites } from './next.rewrites.mjs';
18
19
/** @type {import('next').NextConfig} */
20
const nextConfig = {
···
9
BASE_PATH,
10
ENABLE_STATIC_EXPORT,
11
ENABLE_WEBSITE_REDESIGN,
12
+
} from './next.constants.mjs';
13
+
import { redirects, rewrites } from './next.rewrites.mjs';
14
+
import {
15
SENTRY_DSN,
16
SENTRY_ENABLE,
17
SENTRY_EXTENSIONS,
18
SENTRY_TUNNEL,
19
+
} from './sentry.constants.mjs';
20
21
/** @type {import('next').NextConfig} */
22
const nextConfig = {
+14
-58
next.constants.mjs
+14
-58
next.constants.mjs
···
1
'use strict';
2
3
/**
4
-
* This is used for the current Legacy Website Blog Pagination Generation
5
-
*
6
-
* @deperecated remove with website redesign
7
-
*/
8
-
export const CURRENT_YEAR = new Date().getFullYear();
9
-
10
-
/**
11
* This is used to verify if the current Website is running on a Development Environment
12
*/
13
export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development';
···
115
*/
116
export const MD_EXTENSION_REGEX = /((\/)?(index))?\.mdx?$/i;
117
118
/***
119
* This is a list of all external links that are used on website sitemap.
120
* @see https://github.com/nodejs/nodejs.org/issues/5813 for more context
···
128
'https://trademark-list.openjsf.org/',
129
'https://www.linuxfoundation.org/cookies',
130
];
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
-
};
···
1
'use strict';
2
3
/**
4
* This is used to verify if the current Website is running on a Development Environment
5
*/
6
export const IS_DEVELOPMENT = process.env.NODE_ENV === 'development';
···
108
*/
109
export const MD_EXTENSION_REGEX = /((\/)?(index))?\.mdx?$/i;
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
+
125
/***
126
* This is a list of all external links that are used on website sitemap.
127
* @see https://github.com/nodejs/nodejs.org/issues/5813 for more context
···
135
'https://trademark-list.openjsf.org/',
136
'https://www.linuxfoundation.org/cookies',
137
];
+29
-49
next.dynamic.constants.mjs
+29
-49
next.dynamic.constants.mjs
···
1
'use strict';
2
3
-
import { BASE_PATH, BASE_URL, CURRENT_YEAR } from './next.constants.mjs';
4
import { siteConfig } from './next.json.mjs';
5
import { defaultLocale } from './next.locales.mjs';
6
···
10
*
11
* @type {((route: import('./types').RouteSegment) => boolean)[]} A list of Ignored Routes by Regular Expressions
12
*/
13
-
export const STATIC_ROUTES_IGNORES = [
14
-
// Ignore the 404 route on Static Generation
15
({ pathname }) => pathname === '404',
16
-
// This is used to ignore is used to ignore all blog routes except for the English language
17
({ locale, pathname }) =>
18
locale !== defaultLocale.code && /^blog\//.test(pathname),
19
-
// This is used to ignore the blog/pagination meta route
20
-
// @deprecated remove with website redesign
21
-
({ pathname }) => /^blog\/pagination/.test(pathname),
22
-
];
23
-
24
-
/**
25
-
* This is a list of all dynamic routes or pages from the Website that we do not
26
-
* want to allow to be dynamically access by our Dynamic Route Engine
27
-
*
28
-
* @type {RegExp[]} A list of Ignored Routes by Regular Expressions
29
-
* @deprecated remove with website redesign
30
-
*/
31
-
export const DYNAMIC_ROUTES_IGNORES = [
32
-
// This is used to ignore the blog/pagination route
33
-
/^blog\/pagination/,
34
-
];
35
-
36
-
/**
37
-
* This is a list of all static routes that we want to rewrite their pathnames
38
-
* into something else. This is useful when you want to have the current pathname in the route
39
-
* but replace the actual Markdown file that is being loaded by the Dynamic Route to something else
40
-
*
41
-
* @type {[RegexExp, (pathname: string) => string][]}
42
-
* @deprecated remove with website redesign
43
-
*/
44
-
export const DYNAMIC_ROUTES_REWRITES = [
45
-
[/^blog\/year-/, () => 'blog/pagination'],
46
];
47
48
/**
49
-
* This is a constant that should be used during runtime by (`getStaticPaths`) on `pages/[...path].tsx`
50
-
*
51
-
* This function is used to provide an extra set of routes that are not provided by `next.dynamic.mjs`
52
-
* static route discovery. This can happen when we have dynamic routes that **must** be provided
53
-
* within the static export (static build) of the website. This constant usually would be used along
54
-
* with a matching pathname on `DYNAMIC_ROUTES_REWRITES`.
55
*
56
-
* @type {string[]} A list of all the Dynamic Routes that are generated by the Website
57
-
* @deprecated remove with website redesign
58
*/
59
-
export const DYNAMIC_GENERATED_ROUTES = [
60
-
...Array.from(
61
-
// Statically generate a List of Years from Current Year
62
-
// til 2011 which is the oldest year with blog posts
63
-
{ length: CURRENT_YEAR - 2011 },
64
-
(_, i) => CURRENT_YEAR - i
65
-
).map(year => `blog/year-${year}`),
66
-
];
67
68
/**
69
* This is the default Next.js Page Metadata for all pages
70
*
71
* @type {import('next').Metadata}
72
*/
73
-
export const DEFAULT_METADATA = {
74
metadataBase: new URL(`${BASE_URL}${BASE_PATH}`),
75
title: siteConfig.title,
76
description: siteConfig.description,
···
100
*
101
* @return {import('next').Viewport}
102
*/
103
-
export const DEFAULT_VIEWPORT = {
104
themeColor: siteConfig.accentColor,
105
width: 'device-width',
106
initialScale: 1,
···
1
'use strict';
2
3
+
import {
4
+
provideBlogCategories,
5
+
provideBlogPosts,
6
+
} from './next-data/providers/blogData';
7
+
import { BASE_PATH, BASE_URL } from './next.constants.mjs';
8
import { siteConfig } from './next.json.mjs';
9
import { defaultLocale } from './next.locales.mjs';
10
···
14
*
15
* @type {((route: import('./types').RouteSegment) => boolean)[]} A list of Ignored Routes by Regular Expressions
16
*/
17
+
export const IGNORED_ROUTES = [
18
+
// This is used to ignore the 404 route for the static generation (/404)
19
({ pathname }) => pathname === '404',
20
+
// This is used to ignore all blog routes except for the English language
21
({ locale, pathname }) =>
22
locale !== defaultLocale.code && /^blog\//.test(pathname),
23
];
24
25
/**
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
29
*
30
+
* @type {Map<string, import('./types').Layouts>} A Map of pathname and Layout Name
31
*/
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
+
]);
47
48
/**
49
* This is the default Next.js Page Metadata for all pages
50
*
51
* @type {import('next').Metadata}
52
*/
53
+
export const PAGE_METADATA = {
54
metadataBase: new URL(`${BASE_URL}${BASE_PATH}`),
55
title: siteConfig.title,
56
description: siteConfig.description,
···
80
*
81
* @return {import('next').Viewport}
82
*/
83
+
export const PAGE_VIEWPORT = {
84
themeColor: siteConfig.accentColor,
85
width: 'device-width',
86
initialScale: 1,
+11
-30
next.dynamic.mjs
+11
-30
next.dynamic.mjs
···
8
import { cache } from 'react';
9
import { VFile } from 'vfile';
10
11
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,
23
} from './next.dynamic.constants.mjs';
24
import { getMarkdownFiles } from './next.helpers.mjs';
25
import { siteConfig } from './next.json.mjs';
···
32
// This is a small utility that allows us to quickly separate locale from the remaning pathname
33
const getPathname = (path = []) => path.join('/');
34
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
// This maps a pathname into an actual route object that can be used
48
// we use a platform-specific separator to split the pathname
49
// since we're using filepaths here and not URL paths
···
79
);
80
81
websitePages.forEach(filename => {
82
-
let pathname = filename.replace(MD_EXTENSION_REGEX, '');
83
84
if (pathname.length > 1 && pathname.endsWith(sep)) {
85
pathname = pathname.substring(0, pathname.length - 1);
···
100
*/
101
const getRoutesByLanguage = async (locale = defaultLocale.code) => {
102
const shouldIgnoreStaticRoute = pathname =>
103
-
STATIC_ROUTES_IGNORES.every(e => !e({ pathname, locale }));
104
105
return [...pathnameToFilename.keys()]
106
.filter(shouldIgnoreStaticRoute)
107
-
.concat(DYNAMIC_GENERATED_ROUTES);
108
};
109
110
/**
···
207
* @returns {import('next').Metadata}
208
*/
209
const _getPageMetadata = async (locale = defaultLocale.code, path = '') => {
210
-
const pageMetadata = { ...DEFAULT_METADATA };
211
212
const { source = '' } = await getMarkdownFile(locale, path);
213
214
const { data } = matter(source);
215
216
pageMetadata.title = data.title
217
-
? `${data.title} | ${siteConfig.title}`
218
: siteConfig.title;
219
220
pageMetadata.twitter.title = pageMetadata.title;
···
246
247
return {
248
mapPathToRoute,
249
-
shouldIgnoreRoute,
250
getPathname,
251
-
getRouteRewrite,
252
getRoutesByLanguage,
253
getMDXContent,
254
getMarkdownFile,
···
8
import { cache } from 'react';
9
import { VFile } from 'vfile';
10
11
+
import { BASE_URL, BASE_PATH, IS_DEVELOPMENT } from './next.constants.mjs';
12
import {
13
+
IGNORED_ROUTES,
14
+
DYNAMIC_ROUTES,
15
+
PAGE_METADATA,
16
} from './next.dynamic.constants.mjs';
17
import { getMarkdownFiles } from './next.helpers.mjs';
18
import { siteConfig } from './next.json.mjs';
···
25
// This is a small utility that allows us to quickly separate locale from the remaning pathname
26
const getPathname = (path = []) => path.join('/');
27
28
// This maps a pathname into an actual route object that can be used
29
// we use a platform-specific separator to split the pathname
30
// since we're using filepaths here and not URL paths
···
60
);
61
62
websitePages.forEach(filename => {
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, '');
66
67
if (pathname.length > 1 && pathname.endsWith(sep)) {
68
pathname = pathname.substring(0, pathname.length - 1);
···
83
*/
84
const getRoutesByLanguage = async (locale = defaultLocale.code) => {
85
const shouldIgnoreStaticRoute = pathname =>
86
+
IGNORED_ROUTES.every(e => !e({ pathname, locale }));
87
88
return [...pathnameToFilename.keys()]
89
.filter(shouldIgnoreStaticRoute)
90
+
.concat([...DYNAMIC_ROUTES.keys()]);
91
};
92
93
/**
···
190
* @returns {import('next').Metadata}
191
*/
192
const _getPageMetadata = async (locale = defaultLocale.code, path = '') => {
193
+
const pageMetadata = { ...PAGE_METADATA };
194
195
const { source = '' } = await getMarkdownFile(locale, path);
196
197
const { data } = matter(source);
198
199
pageMetadata.title = data.title
200
+
? `${siteConfig.title} — ${data.title}`
201
: siteConfig.title;
202
203
pageMetadata.twitter.title = pageMetadata.title;
···
229
230
return {
231
mapPathToRoute,
232
getPathname,
233
getRoutesByLanguage,
234
getMDXContent,
235
getMarkdownFile,
+2
-1
next.mdx.compiler.mjs
+2
-1
next.mdx.compiler.mjs
···
19
* @returns {Promise<{
20
* MDXContent: import('mdx/types').MDXContent;
21
* headings: import('@vcarl/remark-headings').Heading[];
22
-
* frontmatter: Record<string, any>, readingTime: import('reading-time').ReadTimeResults
23
* }>}
24
*/
25
export async function compileMDX(source, fileExtension) {
···
19
* @returns {Promise<{
20
* MDXContent: import('mdx/types').MDXContent;
21
* headings: import('@vcarl/remark-headings').Heading[];
22
+
* frontmatter: Record<string, any>;
23
+
* readingTime: import('reading-time').ReadTimeResults;
24
* }>}
25
*/
26
export async function compileMDX(source, fileExtension) {
+50
-96
next.mdx.shiki.mjs
+50
-96
next.mdx.shiki.mjs
···
48
* @return {boolean} - True when it is a valid code element, false otherwise.
49
*/
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
-
}));
87
}
88
89
export default function rehypeShikiji() {
90
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 = [];
108
109
-
let defaultTab = '0';
110
111
-
visit(slicedTree, 'element', node => {
112
-
const codeElement = node.children[0];
113
114
-
const displayName = getMetaParameter(
115
-
codeElement.data?.meta,
116
-
'displayName'
117
-
);
118
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>.*)/);
123
124
-
languages.push(matches?.groups.language ?? 'text');
125
-
}
126
127
-
// Map the display names of each variant for the CodeTab
128
-
displayNames.push(displayName?.replaceAll('|', '') ?? '');
129
-
codeTabsChildren.push(node);
130
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
-
);
137
138
-
if (specificActive === 'true') {
139
-
defaultTab = String(codeTabsChildren.length - 1);
140
-
}
141
142
-
// Prevent visiting the code block children
143
-
return SKIP;
144
-
});
145
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
-
};
153
154
-
const deleteCount = compensatedRange.end - compensatedRange.start + 1;
155
156
-
// Replace the sequential code boxes with a code tabs element
157
-
children.splice(compensatedRange.start, deleteCount, {
158
type: 'element',
159
tagName: 'CodeTabs',
160
children: codeTabsChildren,
···
163
displayNames: displayNames.join('|'),
164
defaultTab,
165
},
166
-
});
167
-
}
168
169
-
// Update the tree with the transformed children
170
-
Object.assign(tree, { children: children });
171
-
}
172
173
visit(tree, 'element', (node, index, parent) => {
174
// We only want to process <pre>...</pre> elements
···
48
* @return {boolean} - True when it is a valid code element, false otherwise.
49
*/
50
function isCodeBlock(node) {
51
+
return Boolean(
52
+
node?.tagName === 'pre' && node?.children[0].tagName === 'code'
53
+
);
54
}
55
56
export default function rehypeShikiji() {
57
return async function (tree) {
58
+
visit(tree, 'element', (_, index, parent) => {
59
+
const languages = [];
60
+
const displayNames = [];
61
+
const codeTabsChildren = [];
62
63
+
let defaultTab = '0';
64
+
let currentIndex = index;
65
66
+
while (isCodeBlock(parent?.children[currentIndex])) {
67
+
const codeElement = parent?.children[currentIndex].children[0];
68
69
+
const displayName = getMetaParameter(
70
+
codeElement.data?.meta,
71
+
'displayName'
72
+
);
73
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>.*)/);
78
79
+
languages.push(matches?.groups.language ?? 'text');
80
+
}
81
82
+
// Map the display names of each variant for the CodeTab
83
+
displayNames.push(displayName?.replaceAll('|', '') ?? '');
84
85
+
codeTabsChildren.push(parent?.children[currentIndex]);
86
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
+
);
93
94
+
if (specificActive === 'true') {
95
+
defaultTab = String(codeTabsChildren.length - 1);
96
+
}
97
98
+
const nextNode = parent?.children[currentIndex + 1];
99
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
+
}
104
105
+
if (codeTabsChildren.length >= 2) {
106
+
const codeTabElement = {
107
type: 'element',
108
tagName: 'CodeTabs',
109
children: codeTabsChildren,
···
112
displayNames: displayNames.join('|'),
113
defaultTab,
114
},
115
+
};
116
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
+
});
126
127
visit(tree, 'element', (node, index, parent) => {
128
// We only want to process <pre>...</pre> elements
+4
-3
next.mdx.use.mjs
+4
-3
next.mdx.use.mjs
···
3
import Blockquote from './components/Common/Blockquote';
4
import Button from './components/Common/Button';
5
import DownloadButton from './components/Downloads/DownloadButton';
6
import DownloadReleasesTable from './components/Downloads/DownloadReleasesTable';
7
import HomeDownloadButton from './components/Home/HomeDownloadButton';
8
import Link from './components/Link';
···
33
CodeTabs: MDXCodeTabs,
34
// Renders a Download Button
35
DownloadButton: DownloadButton,
36
// Renders a Button Component for `button` tags
37
Button: Button,
38
};
···
51
? Blockquote
52
: ({ children }) => <div className="highlight-box">{children}</div>,
53
// Renders a CodeBox Component for `pre` tags
54
-
pre: ({ children, ...props }) => (
55
-
<MDXCodeBox {...props}>{children}</MDXCodeBox>
56
-
),
57
};
···
3
import Blockquote from './components/Common/Blockquote';
4
import Button from './components/Common/Button';
5
import DownloadButton from './components/Downloads/DownloadButton';
6
+
import DownloadLink from './components/Downloads/DownloadLink';
7
import DownloadReleasesTable from './components/Downloads/DownloadReleasesTable';
8
import HomeDownloadButton from './components/Home/HomeDownloadButton';
9
import Link from './components/Link';
···
34
CodeTabs: MDXCodeTabs,
35
// Renders a Download Button
36
DownloadButton: DownloadButton,
37
+
// Renders a Download Link
38
+
DownloadLink: DownloadLink,
39
// Renders a Button Component for `button` tags
40
Button: Button,
41
};
···
54
? Blockquote
55
: ({ children }) => <div className="highlight-box">{children}</div>,
56
// Renders a CodeBox Component for `pre` tags
57
+
pre: MDXCodeBox,
58
};
+9
package-lock.json
+9
package-lock.json
···
19
"@radix-ui/react-toast": "^1.1.5",
20
"@savvywombat/tailwindcss-grid-areas": "~3.1.0",
21
"@sentry/nextjs": "~7.86.0",
22
"@types/node": "20.10.6",
23
"@vcarl/remark-headings": "~0.1.0",
24
"@vercel/analytics": "~1.1.1",
···
6957
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz",
6958
"integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==",
6959
"dev": true
6960
},
6961
"node_modules/@testing-library/dom": {
6962
"version": "9.3.3",
···
19
"@radix-ui/react-toast": "^1.1.5",
20
"@savvywombat/tailwindcss-grid-areas": "~3.1.0",
21
"@sentry/nextjs": "~7.86.0",
22
+
"@tailwindcss/container-queries": "~0.1.1",
23
"@types/node": "20.10.6",
24
"@vcarl/remark-headings": "~0.1.0",
25
"@vercel/analytics": "~1.1.1",
···
6958
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz",
6959
"integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==",
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
+
}
6969
},
6970
"node_modules/@testing-library/dom": {
6971
"version": "9.3.3",
+1
-5
package.json
+1
-5
package.json
···
50
"@radix-ui/react-toast": "^1.1.5",
51
"@savvywombat/tailwindcss-grid-areas": "~3.1.0",
52
"@sentry/nextjs": "~7.86.0",
53
"@types/node": "20.10.6",
54
"@vcarl/remark-headings": "~0.1.0",
55
"@vercel/analytics": "~1.1.1",
···
123
"stylelint-order": "6.0.4",
124
"stylelint-selector-bem-pattern": "3.0.1",
125
"user-agent-data-types": "0.4.2"
126
-
},
127
-
"overrides": {
128
-
"stylelint-selector-bem-pattern": {
129
-
"stylelint": "16.1.0"
130
-
}
131
}
132
}
···
50
"@radix-ui/react-toast": "^1.1.5",
51
"@savvywombat/tailwindcss-grid-areas": "~3.1.0",
52
"@sentry/nextjs": "~7.86.0",
53
+
"@tailwindcss/container-queries": "~0.1.1",
54
"@types/node": "20.10.6",
55
"@vcarl/remark-headings": "~0.1.0",
56
"@vercel/analytics": "~1.1.1",
···
124
"stylelint-order": "6.0.4",
125
"stylelint-selector-bem-pattern": "3.0.1",
126
"user-agent-data-types": "0.4.2"
127
}
128
}
-4
pages/en/blog/advisory-board/index.md
-4
pages/en/blog/advisory-board/index.md
-4
pages/en/blog/announcements/index.md
-4
pages/en/blog/announcements/index.md
-4
pages/en/blog/community/index.md
-4
pages/en/blog/community/index.md
-4
pages/en/blog/feature/index.md
-4
pages/en/blog/feature/index.md
-4
pages/en/blog/module/index.md
-4
pages/en/blog/module/index.md
-54
pages/en/blog/nodejs-road-ahead.md
-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.
···
-5
pages/en/blog/pagination.md
-5
pages/en/blog/pagination.md
-4
pages/en/blog/release/index.md
-4
pages/en/blog/release/index.md
+1
-1
pages/en/blog/release/v0.6.3.md
+1
-1
pages/en/blog/release/v0.6.3.md
···
8
9
2011.11.25, Version 0.6.3 (stable)
10
11
-
- #2083 Land NPM in Node. It is included in packages/installers and installed on `make install`.
12
- #2076 Add logos to windows installer.
13
- #1711 Correctly handle http requests without headers. (Ben Noordhuis, Felix Geisendörfer)
14
- TLS: expose more openssl SSL context options and constants. (Ben Noordhuis)
···
8
9
2011.11.25, Version 0.6.3 (stable)
10
11
+
- #2083 Land npm in Node. It is included in packages/installers and installed on `make install`.
12
- #2076 Add logos to windows installer.
13
- #1711 Correctly handle http requests without headers. (Ben Noordhuis, Felix Geisendörfer)
14
- TLS: expose more openssl SSL context options and constants. (Ben Noordhuis)
-4
pages/en/blog/uncategorized/index.md
-4
pages/en/blog/uncategorized/index.md
-4
pages/en/blog/video/index.md
-4
pages/en/blog/video/index.md
-4
pages/en/blog/vulnerability/index.md
-4
pages/en/blog/vulnerability/index.md
-4
pages/en/blog/weekly-updates/index.md
-4
pages/en/blog/weekly-updates/index.md
+1
-1
pages/en/blog/weekly-updates/weekly-update.2015-04-17.md
+1
-1
pages/en/blog/weekly-updates/weekly-update.2015-04-17.md
···
31
## Community Updates
32
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/).
35
- Thoughts of Node.js Foundation on [Medium](https://medium.com/@programmer/thoughts-on-node-foundation-abcf86c72786).
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
- io.js mention on [Oracle's blog](https://blogs.oracle.com/java-platform-group/entry/node_js_and_io_js).
···
31
## Community Updates
32
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/).
35
- Thoughts of Node.js Foundation on [Medium](https://medium.com/@programmer/thoughts-on-node-foundation-abcf86c72786).
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
- 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
+1
-1
pages/en/blog/weekly-updates/weekly-update.2015-04-24.md
···
37
38
- Fedor Indutny opened discussion about removing TLS `newSession` and `resumeSession` event. [iojs/io.js#1462](https://github.com/nodejs/node/issues/1462)
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)
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
43
## Upcoming Events
···
37
38
- Fedor Indutny opened discussion about removing TLS `newSession` and `resumeSession` event. [iojs/io.js#1462](https://github.com/nodejs/node/issues/1462)
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)
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
43
## Upcoming Events
+1
-1
pages/en/download/package-manager.md
+1
-1
pages/en/download/package-manager.md
+2
-2
pages/en/learn/getting-started/an-introduction-to-the-npm-package-manager.md
+2
-2
pages/en/learn/getting-started/an-introduction-to-the-npm-package-manager.md
-1
pages/en/new-design/.gitkeep
-1
pages/en/new-design/.gitkeep
···
1
-
!.gitignore
···
+82
-32
pages/en/new-design/index.mdx
+82
-32
pages/en/new-design/index.mdx
···
1
---
2
layout: home.hbs
3
---
4
···
6
<WithBadge section="index" />
7
8
<div>
9
-
# Run JavaScript Everywhere
10
11
-
Node.js is a free, open-sourced, cross-platform JavaScript run-time
12
environment that lets developers write command line tools and server-side
13
scripts outside of a browser.
14
15
</div>
16
<div>
17
<WithNodeRelease status={['Active LTS', 'Maintenance LTS']}>
18
{({ release }) => (
19
-
<DownloadButton release={release}>Download Node.js (LTS)</DownloadButton>
20
)}
21
</WithNodeRelease>
22
-
23
-
<Button kind="secondary" href="/learn">Get Started</Button>
24
-
25
</div>
26
</section>
27
28
<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
38
39
-
# Install Node.js
40
-
nvm install --lts
41
42
-
# Check that Node is installed
43
-
node -v
44
45
-
# Check your NPM version
46
-
npm -v
47
```
48
49
-
```bash
50
-
# Install Chocolatey
51
-
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
52
53
-
# Install Node.js
54
-
choco install nodejs-lts
55
56
-
# Check that Node is installed
57
-
node -v
58
59
-
# Check your NPM version
60
-
npm -v
61
```
62
63
-
</CodeTabs>
64
-
Copy and paste this snippet to install Node.js LTS via a Package Manager
65
</section>
···
1
---
2
+
title: Run JavaScript Everywhere
3
layout: home.hbs
4
---
5
···
7
<WithBadge section="index" />
8
9
<div>
10
+
<h1 className="special">Run JavaScript Everywhere</h1>
11
12
+
Node.js is a free, open-source, cross-platform JavaScript runtime
13
environment that lets developers write command line tools and server-side
14
scripts outside of a browser.
15
16
</div>
17
+
18
<div>
19
<WithNodeRelease status={['Active LTS', 'Maintenance LTS']}>
20
{({ release }) => (
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>
36
)}
37
</WithNodeRelease>
38
</div>
39
</section>
40
41
<section>
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
+
});
50
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
+
```
56
57
+
```js displayName="Write Tests"
58
+
import assert from 'node:assert';
59
+
import test from 'node:test';
60
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
+
});
69
```
70
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');
93
94
+
readableStream.setEncoding('utf8');
95
96
+
readableStream.on('data', chunk => writableStream.write(chunk));
97
+
```
98
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));
111
```
112
113
+
</div>
114
+
Learn more what Node.js is able to offer with our [Learning materials](/learn).
115
</section>
+13
-20
providers/matterProvider.tsx
+13
-20
providers/matterProvider.tsx
···
1
'use client';
2
3
-
import type { Heading } from '@vcarl/remark-headings';
4
import { createContext } from 'react';
5
import type { FC, PropsWithChildren } from 'react';
6
-
import type { ReadTimeResults } from 'reading-time';
7
8
-
import type { LegacyFrontMatter } from '@/types';
9
10
-
type MatterContext = {
11
-
frontmatter: LegacyFrontMatter;
12
-
pathname: string;
13
-
headings: Array<Heading>;
14
-
readingTime: ReadTimeResults;
15
-
filename: string;
16
-
};
17
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>;
27
28
export const MatterProvider: FC<MatterProviderProps> = ({
29
children,
30
...data
31
-
}) => <MatterContext.Provider value={data}>{children}</MatterContext.Provider>;
···
1
'use client';
2
3
import { createContext } from 'react';
4
import type { FC, PropsWithChildren } from 'react';
5
6
+
import type { ClientSharedServerContext } from '@/types';
7
+
import { assignClientContext } from '@/util/assignClientContext';
8
9
+
export const MatterContext = createContext<ClientSharedServerContext>(
10
+
assignClientContext({})
11
+
);
12
13
+
type MatterProviderProps = PropsWithChildren<
14
+
Partial<ClientSharedServerContext>
15
+
>;
16
17
export const MatterProvider: FC<MatterProviderProps> = ({
18
children,
19
...data
20
+
}) => (
21
+
<MatterContext.Provider value={assignClientContext(data)}>
22
+
{children}
23
+
</MatterContext.Provider>
24
+
);
+1
-1
sentry.client.config.ts
+1
-1
sentry.client.config.ts
+45
sentry.constants.mjs
+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
+1
-1
sentry.edge.config.ts
+1
-1
sentry.server.config.ts
+1
-1
sentry.server.config.ts
+2
-1
site.json
+2
-1
site.json
+37
styles/new/effects.css
+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
+1
styles/new/index.css
+16
styles/new/markdown.css
+16
styles/new/markdown.css
···
1
main {
2
+
@apply flex
3
+
w-full
4
+
flex-col
5
+
gap-6;
6
+
7
hr {
8
@apply w-full
9
border-t
···
58
dark:text-white;
59
}
60
61
+
p {
62
+
@apply text-neutral-900
63
+
dark:text-white;
64
+
}
65
+
66
a {
67
@apply text-green-600
68
+
xs:underline
69
dark:text-green-400;
70
71
&:hover {
72
@apply text-green-900
73
dark:text-green-300;
74
+
}
75
+
76
+
&:has(code) {
77
+
@apply xs:decoration-neutral-800
78
+
dark:xs:decoration-neutral-200;
79
}
80
}
81
+6
-3
tailwind.config.ts
+6
-3
tailwind.config.ts
···
117
'ibm-plex-mono': ['var(--font-ibm-plex-mono)'],
118
},
119
extend: {
120
-
screens: { xs: { max: '670px' } },
121
backgroundImage: {
122
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
123
'gradient-subtle':
···
129
'gradient-subtle-white':
130
'linear-gradient(180deg, theme(colors.white) 0%, theme(colors.white / 80%) 100%)',
131
'gradient-glow-backdrop':
132
-
'radial-gradient(8em circle at calc(100% - 40px) 10px, theme(colors.green.500), transparent 30%)',
133
},
134
boxShadow: {
135
xs: '0px 1px 2px 0px theme(colors.shadow / 5%)',
···
141
},
142
},
143
darkMode: ['class', '[data-theme="dark"]'],
144
-
plugins: [require('@savvywombat/tailwindcss-grid-areas')],
145
} satisfies Config;
···
117
'ibm-plex-mono': ['var(--font-ibm-plex-mono)'],
118
},
119
extend: {
120
+
screens: { xs: { max: '670px', min: '0px' } },
121
backgroundImage: {
122
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
123
'gradient-subtle':
···
129
'gradient-subtle-white':
130
'linear-gradient(180deg, theme(colors.white) 0%, theme(colors.white / 80%) 100%)',
131
'gradient-glow-backdrop':
132
+
'radial-gradient(8em circle at calc(50%) 10px, theme(colors.green.500), transparent 30%)',
133
},
134
boxShadow: {
135
xs: '0px 1px 2px 0px theme(colors.shadow / 5%)',
···
141
},
142
},
143
darkMode: ['class', '[data-theme="dark"]'],
144
+
plugins: [
145
+
require('@savvywombat/tailwindcss-grid-areas'),
146
+
require('@tailwindcss/container-queries'),
147
+
],
148
} satisfies Config;
+13
-12
types/blog.ts
+13
-12
types/blog.ts
···
1
export interface BlogPost {
2
title: string;
3
author: string;
4
-
date: string;
5
-
category: string;
6
slug: string;
7
}
8
9
export interface BlogData {
10
posts: Array<BlogPost>;
11
-
pagination: Array<number>;
12
categories: Array<string>;
13
}
14
15
-
export interface BlogDataRSC {
16
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
}
···
1
+
export type BlogPreviewType = 'announcements' | 'release' | 'vulnerability';
2
+
3
export interface BlogPost {
4
title: string;
5
author: string;
6
+
date: Date;
7
+
categories: Array<string>;
8
slug: string;
9
}
10
11
export interface BlogData {
12
posts: Array<BlogPost>;
13
categories: Array<string>;
14
}
15
16
+
export interface BlogPagination {
17
+
next: number | null;
18
+
prev: number | null;
19
+
pages: number;
20
+
total: number;
21
+
}
22
+
23
+
export interface BlogPostsRSC {
24
posts: Array<BlogPost>;
25
+
pagination: BlogPagination;
26
}
+1
-1
types/features.ts
+1
-1
types/features.ts
+3
-1
types/layouts.ts
+3
-1
types/layouts.ts
+17
util/assignClientContext.ts
+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
+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
+
};