diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index 4f672479b3586..75cfe199094b8 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -97,6 +97,7 @@ jobs: ${{ needs.get-vercel-preview.outputs.url }}/en/about ${{ needs.get-vercel-preview.outputs.url }}/en/about/previous-releases ${{ needs.get-vercel-preview.outputs.url }}/en/download + ${{ needs.get-vercel-preview.outputs.url }}/en/download/archive ${{ needs.get-vercel-preview.outputs.url }}/en/blog uploadArtifacts: true # save results as a action artifacts temporaryPublicStorage: true # upload lighthouse report to the temporary storage diff --git a/apps/site/app/[locale]/page.tsx b/apps/site/app/[locale]/page.tsx index cf9d20c1de3b3..d1673dbd05bd8 100644 --- a/apps/site/app/[locale]/page.tsx +++ b/apps/site/app/[locale]/page.tsx @@ -95,29 +95,6 @@ const getPage: FC = async props => { // Gets the current full pathname for a given path const pathname = dynamicRouter.getPathname(path); - const staticGeneratedLayout = DYNAMIC_ROUTES.get(pathname); - - // If the current pathname is a statically generated route - // it means it does not have a Markdown file nor exists under the filesystem - // but it is a valid route with an assigned layout that should be rendered - if (staticGeneratedLayout !== undefined) { - // Metadata and shared Context to be available through the lifecycle of the page - const sharedContext = { pathname: `/${pathname}` }; - - // Defines a shared Server Context for the Client-Side - // That is shared for all pages under the dynamic router - setClientContext(sharedContext); - - // The Matter Provider allows Client-Side injection of the data - // to a shared React Client Provider even though the page is rendered - // within a server-side context - return ( - - - - ); - } - // We retrieve the source of the Markdown file by doing an educated guess // of what possible files could be the source of the page, since the extension // context is lost from `getStaticProps` as a limitation of Next.js itself @@ -126,36 +103,38 @@ const getPage: FC = async props => { pathname ); - if (source.length && filename.length) { - // This parses the source Markdown content and returns a React Component and - // relevant context from the Markdown File - const { content, frontmatter, headings, readingTime } = - await dynamicRouter.getMDXContent(source, filename); - - // Metadata and shared Context to be available through the lifecycle of the page - const sharedContext = { - frontmatter: frontmatter, - headings: headings, - pathname: `/${pathname}`, - readingTime: readingTime, - filename: filename, - }; - - // Defines a shared Server Context for the Client-Side - // That is shared for all pages under the dynamic router - setClientContext(sharedContext); - - // The Matter Provider allows Client-Side injection of the data - // to a shared React Client Provider even though the page is rendered - // within a server-side context - return ( - - {content} - - ); + if (source === '' && filename === '' && !DYNAMIC_ROUTES.has(pathname)) { + return notFound(); } - return notFound(); + // This parses the source Markdown content and returns a React Component and + // relevant context from the Markdown File + const { content, frontmatter, headings, readingTime } = + await dynamicRouter.getMDXContent(source, filename); + + // Metadata and shared Context to be available through the lifecycle of the page + const sharedContext = { + frontmatter: frontmatter, + headings: headings, + pathname: `/${pathname}`, + readingTime: readingTime, + filename: filename, + }; + + const layout = frontmatter.layout || DYNAMIC_ROUTES.get(pathname); + + // Defines a shared Server Context for the Client-Side + // That is shared for all pages under the dynamic router + setClientContext(sharedContext); + + // The Matter Provider allows Client-Side injection of the data + // to a shared React Client Provider even though the page is rendered + // within a server-side context + return ( + + {content} + + ); }; // Enforces that this route is used as static rendering diff --git a/apps/site/components/Downloads/DownloadButton/index.tsx b/apps/site/components/Downloads/DownloadButton/index.tsx index d4f3e7a76dd4c..314baf96bdd9b 100644 --- a/apps/site/components/Downloads/DownloadButton/index.tsx +++ b/apps/site/components/Downloads/DownloadButton/index.tsx @@ -15,13 +15,13 @@ import styles from './index.module.css'; type DownloadButtonProps = { release: NodeRelease }; const DownloadButton: FC> = ({ - release: { versionWithPrefix }, + release: { versionWithPrefix: version }, children, }) => { const { os, bitness, architecture } = useClientContext(); const platform = getUserPlatform(architecture, bitness); - const downloadLink = getNodeDownloadUrl(versionWithPrefix, os, platform); + const downloadLink = getNodeDownloadUrl({ version, os, platform }); return ( <> diff --git a/apps/site/components/Downloads/DownloadLink.tsx b/apps/site/components/Downloads/DownloadLink.tsx index 905adbf352d8a..bdcc68e31b9d2 100644 --- a/apps/site/components/Downloads/DownloadLink.tsx +++ b/apps/site/components/Downloads/DownloadLink.tsx @@ -19,12 +19,12 @@ const DownloadLink: FC> = ({ const platform = getUserPlatform(architecture, bitness); - const downloadLink = getNodeDownloadUrl( - versionWithPrefix, - os, - platform, - kind - ); + const downloadLink = getNodeDownloadUrl({ + version: versionWithPrefix, + os: os, + platform: platform, + kind: kind, + }); return {children}; }; diff --git a/apps/site/components/Downloads/DownloadsTable/index.tsx b/apps/site/components/Downloads/DownloadsTable/index.tsx new file mode 100644 index 0000000000000..e4149ad85734c --- /dev/null +++ b/apps/site/components/Downloads/DownloadsTable/index.tsx @@ -0,0 +1,47 @@ +import { useTranslations } from 'next-intl'; +import type { FC } from 'react'; + +import Link from '#site/components/Link'; +import { OperatingSystemLabel } from '#site/util/download'; +import type { NodeDownloadArtifact } from '#site/util/download/archive'; + +type DownloadsTableProps = { + source: Array; +}; + +const DownloadsTable: FC = ({ source }) => { + const t = useTranslations(); + + return ( + + + + + + + + + + {source.map(release => ( + + + + + + ))} + +
{t('components.downloadsTable.fileName')} + {t('components.downloadsTable.operatingSystem')} + + {t('components.downloadsTable.architecture')} +
+ {release.file} + + {OperatingSystemLabel[release.os]} + + {release.architecture} +
+ ); +}; + +export default DownloadsTable; diff --git a/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx b/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx index fccff0462d69f..92f6fb2615a69 100644 --- a/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx +++ b/apps/site/components/Downloads/Release/PrebuiltDownloadButtons.tsx @@ -22,11 +22,21 @@ const PrebuiltDownloadButtons: FC = () => { const { release, os, platform } = useContext(ReleaseContext); const installerUrl = platform - ? getNodeDownloadUrl(release.versionWithPrefix, os, platform, 'installer') + ? getNodeDownloadUrl({ + version: release.versionWithPrefix, + os: os, + platform: platform, + kind: 'installer', + }) : ''; const binaryUrl = platform - ? getNodeDownloadUrl(release.versionWithPrefix, os, platform, 'binary') + ? getNodeDownloadUrl({ + version: release.versionWithPrefix, + os: os, + platform: platform, + kind: 'binary', + }) : ''; return ( diff --git a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx index 1258763784fba..6928a8c3b27ab 100644 --- a/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx +++ b/apps/site/components/Downloads/Release/ReleaseCodeBox.tsx @@ -10,6 +10,7 @@ import { useContext, useMemo } from 'react'; import CodeBox from '#site/components/Common/CodeBox'; import Link from '#site/components/Link'; import LinkWithArrow from '#site/components/LinkWithArrow'; +import WithReleaseAlertBox from '#site/components/withReleaseAlertBox'; import { createSval } from '#site/next.jsx.compiler.mjs'; import { ReleaseContext, @@ -107,7 +108,7 @@ const ReleaseCodeBox: FC = () => { > {t.rich('layouts.download.codeBox.noScriptDetected', { link: text => ( - + {text} ), @@ -115,29 +116,7 @@ const ReleaseCodeBox: FC = () => { - {release.status === 'End-of-life' && ( - - {t.rich('layouts.download.codeBox.unsupportedVersionWarning', { - link: text => {text}, - })} - - )} - - {release.isLts && ( - - {t.rich('layouts.download.codeBox.ltsVersionFeaturesNotice', { - link: text => {text}, - })} - - )} + {!currentPlatform || currentPlatform.recommended || ( ; }; -export const MinorReleasesTable: FC = ({ - releases, -}) => { +const MinorReleasesTable: FC = ({ releases }) => { const t = useTranslations(); return ( - - - - - - - - - - {releases.map(release => ( - - - + + + + ))} + +
{t('components.minorReleasesTable.version')}{t('components.minorReleasesTable.links')}
v{release.version} -
- - {t('components.minorReleasesTable.actions.release')} - - - - {t('components.minorReleasesTable.actions.changelog')} - - +
+ + + + + + + + + + {releases.map(release => ( + + - - ))} - -
{t('components.minorReleasesTable.version')} + {t('components.minorReleasesTable.information')} + + {t('components.minorReleasesTable.links')} +
- {t('components.minorReleasesTable.actions.docs')} + v{release.version} - -
+
+
+ {release.modules && ( + <> + + + + )} + {release.npm && ( + <> + + + + )} + +
+
+
+ + {t('components.minorReleasesTable.actions.docs')} + + + + {t('components.minorReleasesTable.actions.changelog')} + +
+
+ ); }; + +export default MinorReleasesTable; diff --git a/apps/site/components/Releases/PreviousReleasesTable.tsx b/apps/site/components/Releases/PreviousReleasesTable.tsx index 19705cf0958aa..674870bd66033 100644 --- a/apps/site/components/Releases/PreviousReleasesTable.tsx +++ b/apps/site/components/Releases/PreviousReleasesTable.tsx @@ -3,9 +3,10 @@ import Badge from '@node-core/ui-components/Common/Badge'; import { useTranslations } from 'next-intl'; import type { FC } from 'react'; -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import FormattedTime from '#site/components/Common/FormattedTime'; +import Link from '#site/components/Link'; import LinkWithArrow from '#site/components/LinkWithArrow'; import provideReleaseData from '#site/next-data/providers/releaseData'; @@ -41,9 +42,13 @@ const PreviousReleasesTable: FC = () => { {releaseData.map(release => ( - <> + - v{release.major} + + + v{release.major} + + {release.codename || '-'} @@ -77,7 +82,7 @@ const PreviousReleasesTable: FC = () => { open={currentModal === release.version} onOpenChange={open => open || setCurrentModal(undefined)} /> - + ))} diff --git a/apps/site/components/Releases/ReleaseModal.tsx b/apps/site/components/Releases/ReleaseModal.tsx index 0c908c6c204a0..c20ea30624158 100644 --- a/apps/site/components/Releases/ReleaseModal.tsx +++ b/apps/site/components/Releases/ReleaseModal.tsx @@ -1,11 +1,10 @@ -import AlertBox from '@node-core/ui-components/Common/AlertBox'; import { Modal, Title, Content } from '@node-core/ui-components/Common/Modal'; import { useTranslations } from 'next-intl'; import type { ComponentProps, FC } from 'react'; -import Link from '#site/components/Link'; -import { MinorReleasesTable } from '#site/components/Releases/MinorReleasesTable'; -import { ReleaseOverview } from '#site/components/Releases/ReleaseOverview'; +import MinorReleasesTable from '#site/components/Releases/MinorReleasesTable'; +import ReleaseOverview from '#site/components/Releases/ReleaseOverview'; +import WithReleaseAlertBox from '#site/components/withReleaseAlertBox'; import type { NodeRelease } from '#site/types'; type ReleaseModalProps = ComponentProps & { @@ -26,33 +25,7 @@ const ReleaseModal: FC = ({ release, ...props }) => { return ( - {release.status === 'End-of-life' && ( -
- - {t.rich('components.releaseModal.unsupportedVersionWarning', { - link: text => {text}, - })} - -
- )} - - {release.isLts && ( -
- - {t.rich('components.releaseModal.ltsVersionFeaturesNotice', { - link: text => {text}, - })} - -
- )} + {modalHeading} diff --git a/apps/site/components/Releases/ReleaseOverview/index.tsx b/apps/site/components/Releases/ReleaseOverview/index.tsx index db943b583d4d0..47f6026bdcef5 100644 --- a/apps/site/components/Releases/ReleaseOverview/index.tsx +++ b/apps/site/components/Releases/ReleaseOverview/index.tsx @@ -18,7 +18,7 @@ type ReleaseOverviewProps = { release: NodeRelease; }; -export const ReleaseOverview: FC = ({ release }) => { +const ReleaseOverview: FC = ({ release }) => { const t = useTranslations(); return ( @@ -67,3 +67,5 @@ export const ReleaseOverview: FC = ({ release }) => { ); }; + +export default ReleaseOverview; diff --git a/apps/site/components/withDownloadArchive.tsx b/apps/site/components/withDownloadArchive.tsx new file mode 100644 index 0000000000000..10c7b00855011 --- /dev/null +++ b/apps/site/components/withDownloadArchive.tsx @@ -0,0 +1,49 @@ +import type { FC } from 'react'; + +import { getClientContext } from '#site/client-context'; +import provideReleaseData from '#site/next-data/providers/releaseData'; +import { + buildReleaseArtifacts, + extractVersionFromPath, + findReleaseByVersion, +} from '#site/util/download/archive'; + +type DownloadArchive = ReturnType; + +type WithDownloadArchiveProps = { + children: FC; +}; + +/** + * Higher-order component that extracts version from pathname, + * fetches release data, and provides download artifacts to child component + */ +const WithDownloadArchive: FC = async ({ + children: Component, +}) => { + const { pathname } = getClientContext(); + + // Extract version from pathname + const version = extractVersionFromPath(pathname); + + if (version == null) { + throw new Error('Version could not be extracted from pathname'); + } + + // Find the release data for the given version + const releaseData = provideReleaseData(); + const release = findReleaseByVersion(releaseData, version); + + if (!release) { + return null; + } + + const releaseArtifacts = buildReleaseArtifacts( + release, + version === 'archive' ? release.versionWithPrefix : version + ); + + return ; +}; + +export default WithDownloadArchive; diff --git a/apps/site/components/withLayout.tsx b/apps/site/components/withLayout.tsx index ec553190e621d..c7f948a5946dc 100644 --- a/apps/site/components/withLayout.tsx +++ b/apps/site/components/withLayout.tsx @@ -5,6 +5,7 @@ import ArticlePageLayout from '#site/layouts/ArticlePage'; import BlogLayout from '#site/layouts/Blog'; import DefaultLayout from '#site/layouts/Default'; import DownloadLayout from '#site/layouts/Download'; +import DownloadArchiveLayout from '#site/layouts/DownloadArchive'; import GlowingBackdropLayout from '#site/layouts/GlowingBackdrop'; import LearnLayout from '#site/layouts/Learn'; import PostLayout from '#site/layouts/Post'; @@ -18,6 +19,7 @@ const layouts = { 'blog-post': PostLayout, 'blog-category': BlogLayout, download: DownloadLayout, + 'download-archive': DownloadArchiveLayout, article: ArticlePageLayout, } satisfies Record; diff --git a/apps/site/components/withReleaseAlertBox.tsx b/apps/site/components/withReleaseAlertBox.tsx new file mode 100644 index 0000000000000..ccab6943e76dd --- /dev/null +++ b/apps/site/components/withReleaseAlertBox.tsx @@ -0,0 +1,46 @@ +import AlertBox from '@node-core/ui-components/Common/AlertBox'; +import { useTranslations } from 'next-intl'; +import type { FC } from 'react'; + +import Link from '#site/components/Link'; +import type { NodeReleaseStatus } from '#site/types'; + +type WithReleaseAlertBoxProps = { + status: NodeReleaseStatus; +}; + +const WithReleaseAlertBox: FC = ({ status }) => { + const t = useTranslations(); + + switch (status) { + case 'End-of-life': + return ( + + {t.rich('layouts.download.codeBox.unsupportedVersionWarning', { + link: text => {text}, + })} + + ); + case 'Active LTS': + case 'Maintenance LTS': + return ( + + {t.rich('components.releaseModal.ltsVersionFeaturesNotice', { + link: text => {text}, + })} + + ); + default: + return null; + } +}; + +export default WithReleaseAlertBox; diff --git a/apps/site/components/withReleaseSelect.tsx b/apps/site/components/withReleaseSelect.tsx new file mode 100644 index 0000000000000..fea29286ba942 --- /dev/null +++ b/apps/site/components/withReleaseSelect.tsx @@ -0,0 +1,33 @@ +'use client'; + +import WithNoScriptSelect from '@node-core/ui-components/Common/Select/NoScriptSelect'; +import type { ComponentProps, FC } from 'react'; + +import Link from '#site/components/Link'; +import { useRouter } from '#site/navigation.mjs'; +import provideReleaseData from '#site/next-data/providers/releaseData'; +import { getDownloadArchiveNavigation } from '#site/util/download/archive'; + +type WithReleaseSelectProps = Omit< + ComponentProps, + 'values' | 'as' | 'onChange' +>; + +const WithReleaseSelect: FC = ({ ...props }) => { + const releaseData = provideReleaseData(); + const { push } = useRouter(); + const navigation = getDownloadArchiveNavigation(releaseData); + + return ( + + ); +}; + +export default WithReleaseSelect; diff --git a/apps/site/layouts/DownloadArchive.tsx b/apps/site/layouts/DownloadArchive.tsx new file mode 100644 index 0000000000000..12381a6d30268 --- /dev/null +++ b/apps/site/layouts/DownloadArchive.tsx @@ -0,0 +1,20 @@ +import type { FC, PropsWithChildren } from 'react'; + +import WithFooter from '#site/components/withFooter'; +import WithNavBar from '#site/components/withNavBar'; + +import styles from './layouts.module.css'; + +const DownloadArchiveLayout: FC = ({ children }) => ( + <> + + +
+
{children}
+
+ + + +); + +export default DownloadArchiveLayout; diff --git a/apps/site/next-data/generators/nodevuData.mjs b/apps/site/next-data/generators/nodevuData.mjs new file mode 100644 index 0000000000000..c3d3267d478d2 --- /dev/null +++ b/apps/site/next-data/generators/nodevuData.mjs @@ -0,0 +1,10 @@ +'use strict'; + +import nodevu from '@nodevu/core'; + +/** + * This file is used to fetch Node.js release data from Nodevu. + * It uses the `nodevu` package to fetch the data and export it for use in + * other parts of the site. + */ +export default nodevu({ fetch }); diff --git a/apps/site/next-data/generators/releaseData.mjs b/apps/site/next-data/generators/releaseData.mjs index c26ee62508982..9de9a616b94e3 100644 --- a/apps/site/next-data/generators/releaseData.mjs +++ b/apps/site/next-data/generators/releaseData.mjs @@ -1,6 +1,6 @@ 'use strict'; -import nodevu from '@nodevu/core'; +import nodevuData from './nodevuData.mjs'; // Gets the appropriate release status for each major release const getNodeReleaseStatus = (now, support) => { @@ -32,9 +32,7 @@ const getNodeReleaseStatus = (now, support) => { * @returns {Promise>} */ const generateReleaseData = async () => { - const nodevuOutput = await nodevu({ fetch }); - - const majors = Object.entries(nodevuOutput).filter( + const majors = Object.entries(await nodevuData).filter( ([version, { support }]) => { // Filter out those without documented support // Basically those not in schedule.json @@ -70,8 +68,12 @@ const generateReleaseData = async () => { const status = getNodeReleaseStatus(new Date(), support); const minorVersions = Object.entries(major.releases).map(([, release]) => ({ - version: release.semver.raw, + modules: release.modules.version || '', + npm: release.dependencies.npm || '', releaseDate: release.releaseDate, + v8: release.dependencies.v8, + version: release.semver.raw, + versionWithPrefix: `v${release.semver.raw}`, })); return { diff --git a/apps/site/next-data/generators/releaseVersions.mjs b/apps/site/next-data/generators/releaseVersions.mjs new file mode 100644 index 0000000000000..6b273124898d5 --- /dev/null +++ b/apps/site/next-data/generators/releaseVersions.mjs @@ -0,0 +1,45 @@ +'use strict'; + +import nodevuData from './nodevuData.mjs'; + +/** + * This method is used to generate all Node.js versions + * for self-consumption during RSC and Static Builds + * + * @returns {Promise>} + */ +const generateAllVersionsData = async () => { + const majors = Object.entries(await nodevuData).filter( + ([version, { support }]) => { + // Filter out those without documented support + // Basically those not in schedule.json + if (!support) { + return false; + } + + // nodevu returns duplicated v0.x versions (v0.12, v0.10, ...). + // This behavior seems intentional as the case is hardcoded in nodevu, + // see https://github.com/cutenode/nodevu/blob/0c8538c70195fb7181e0a4d1eeb6a28e8ed95698/core/index.js#L24. + // This line ignores those duplicated versions and takes the latest + // v0.x version (v0.12.18). It is also consistent with the legacy + // nodejs.org implementation. + if (version.startsWith('v0.') && version !== 'v0.12') { + return false; + } + + return true; + } + ); + + const allVersions = []; + + majors.forEach(([, major]) => { + Object.entries(major.releases).forEach(([, release]) => { + allVersions.push(`v${release.semver.raw}`); + }); + }); + + return allVersions; +}; + +export default generateAllVersionsData; diff --git a/apps/site/next-data/providers/releaseVersions.ts b/apps/site/next-data/providers/releaseVersions.ts new file mode 100644 index 0000000000000..f7ac4d86d010d --- /dev/null +++ b/apps/site/next-data/providers/releaseVersions.ts @@ -0,0 +1,9 @@ +import { cache } from 'react'; + +import generateAllVersionsData from '#site/next-data/generators/releaseVersions.mjs'; + +const releaseVersions = await generateAllVersionsData(); + +const provideReleaseVersions = cache(() => releaseVersions); + +export default provideReleaseVersions; diff --git a/apps/site/next.dynamic.constants.mjs b/apps/site/next.dynamic.constants.mjs index 294344b09ea15..60511ffd4b577 100644 --- a/apps/site/next.dynamic.constants.mjs +++ b/apps/site/next.dynamic.constants.mjs @@ -3,6 +3,7 @@ import { blogData } from '#site/next.json.mjs'; import { provideBlogPosts } from './next-data/providers/blogData'; +import provideReleaseVersions from './next-data/providers/releaseVersions'; import { BASE_PATH, BASE_URL } from './next.constants.mjs'; import { siteConfig } from './next.json.mjs'; import { defaultLocale } from './next.locales.mjs'; @@ -29,6 +30,12 @@ export const IGNORED_ROUTES = [ * @type {Map} A Map of pathname and Layout Name */ export const DYNAMIC_ROUTES = new Map([ + // Creates dynamic routes for downloads archive pages for each version + // (e.g., /download/archive/v18.20.8, /download/archive/v20.19.2) + ...provideReleaseVersions().map(version => [ + `download/archive/${version}`, + 'download-archive', + ]), // Provides Routes for all Blog Categories ...blogData.categories.map(c => [`blog/${c}`, 'blog-category']), // Provides Routes for all Blog Categories w/ Pagination @@ -44,6 +51,18 @@ export const DYNAMIC_ROUTES = new Map([ .flat(), ]); +/** + * A Map that stores file paths for Markdown files to be dynamically generated + * in a dynamic route, keyed by their corresponding layout names. + * + * @type {Map>} A Map of Layout Name and paths + */ +export const DYNAMIC_MARKDOWN_ROUTES = new Map([ + // Pages that use the download-archive layout map to the /{locale}/download/archive/index.mdx + // markdown file. + ['download-archive', ['download', 'archive']], +]); + /** * This is the default Next.js Page Metadata for all pages * diff --git a/apps/site/next.dynamic.mjs b/apps/site/next.dynamic.mjs index eddacdaaeaebc..638e6faf5050b 100644 --- a/apps/site/next.dynamic.mjs +++ b/apps/site/next.dynamic.mjs @@ -16,6 +16,7 @@ import { } from './next.constants.mjs'; import { DYNAMIC_ROUTES, + DYNAMIC_MARKDOWN_ROUTES, IGNORED_ROUTES, PAGE_METADATA, } from './next.dynamic.constants.mjs'; @@ -106,6 +107,15 @@ const getDynamicRouter = async () => { * @returns {Promise<{ source: string; filename: string }>} */ const _getMarkdownFile = async (locale = '', pathname = '') => { + const layout = DYNAMIC_ROUTES.get(pathname); + + if (DYNAMIC_MARKDOWN_ROUTES.has(layout)) { + // If the current pathname is a dynamic route that does not have a Markdown + // file we simply return the pathname of the dynamic route so that the page + // can be rendered with the correct layout + pathname = getPathname(DYNAMIC_MARKDOWN_ROUTES.get(layout)); + } + const normalizedPathname = normalize(pathname).replace('.', ''); // This verifies if the given pathname actually exists on our Map diff --git a/apps/site/next.mdx.use.client.mjs b/apps/site/next.mdx.use.client.mjs index 67bdc3b4b16d4..94e04232bee72 100644 --- a/apps/site/next.mdx.use.client.mjs +++ b/apps/site/next.mdx.use.client.mjs @@ -17,6 +17,7 @@ import ReleaseVersionDropdown from './components/Downloads/Release/VersionDropdo import Link from './components/Link'; import MDXCodeBox from './components/MDX/CodeBox'; import MDXImage from './components/MDX/Image'; +import WithReleaseSelect from './components/withReleaseSelect'; import { ReleaseProvider } from './providers/releaseProvider'; /** @@ -29,6 +30,8 @@ export const clientMdxComponents = { CodeTabs: MDXCodeTabs, // Renders a Download Button DownloadButton: DownloadButton, + // Renders a stateless Release Select Component + WithReleaseSelect: WithReleaseSelect, // Group of components that enable you to select versions for Node.js // releases and download selected versions. Uses `releaseProvider` as a provider Release: { diff --git a/apps/site/next.mdx.use.mjs b/apps/site/next.mdx.use.mjs index 39590e493f5b6..343980a90fae9 100644 --- a/apps/site/next.mdx.use.mjs +++ b/apps/site/next.mdx.use.mjs @@ -3,15 +3,20 @@ import BadgeGroup from '@node-core/ui-components/Common/BadgeGroup'; import Button from './components/Common/Button'; +import DownloadsTable from './components/Downloads/DownloadsTable'; import EOLAlertBox from './components/EOL/EOLAlert'; import EOLReleaseTable from './components/EOL/EOLReleaseTable'; import Link from './components/Link'; import LinkWithArrow from './components/LinkWithArrow'; import UpcomingMeetings from './components/MDX/Calendar/UpcomingMeetings'; +import MinorReleasesTable from './components/Releases/MinorReleasesTable'; import PreviousReleasesTable from './components/Releases/PreviousReleasesTable'; +import ReleaseOverview from './components/Releases/ReleaseOverview'; import WithBadgeGroup from './components/withBadgeGroup'; import WithBanner from './components/withBanner'; +import WithDownloadArchive from './components/withDownloadArchive'; import WithNodeRelease from './components/withNodeRelease'; +import WithReleaseAlertBox from './components/withReleaseAlertBox'; /** * A full list of React Components that we want to pass through to MDX @@ -19,15 +24,25 @@ import WithNodeRelease from './components/withNodeRelease'; * @satisfies {import('mdx/types').MDXComponents} */ export const mdxComponents = { + // HOC for providing the Download Archive Page properties + WithDownloadArchive, + // Renders a table with Node.js Releases with different platforms and architectures + DownloadsTable, PreviousReleasesTable, // HOC for getting Node.js Release Metadata WithNodeRelease, + // Renders an alert box with the given release status + WithReleaseAlertBox, // HOC for providing Banner Data WithBanner, // HOC for providing Badge Data WithBadgeGroup, // Standalone Badge Group BadgeGroup, + // Renders the Release Overview for a specified version + ReleaseOverview, + // Renders a table with all the Minor Releases for a Major Version + MinorReleasesTable, // Renders an container for Upcoming Node.js Meetings UpcomingMeetings, // Renders an EOL alert diff --git a/apps/site/pages/en/download/archive/index.mdx b/apps/site/pages/en/download/archive/index.mdx new file mode 100644 index 0000000000000..480a1564e7c5e --- /dev/null +++ b/apps/site/pages/en/download/archive/index.mdx @@ -0,0 +1,57 @@ +--- +title: Download Node.js® +layout: download-archive +--- + + + {({ binaries, installers, version, release, sources }) => ( + <> +

Node.js® Download Archive

+ +

+ Node.js Logo + {version} + {release.codename && ` (${release.codename})`} +

+ + + + + +
    + +
  • + Learn more about Node.js releases, including the release schedule and LTS status. +
  • + +
  • + Signed SHASUMS for release files. How to verify signed SHASUMS. +
  • + +
  • + Download a signed Node.js {version} source tarball. +
  • + +
+ +

Other releases

+ + +

Binary Downloads

+ + +

Installer Packages

+ + +

Minor versions

+ + + +)} + +
diff --git a/apps/site/pages/en/download/current.mdx b/apps/site/pages/en/download/current.mdx index 16e78d6a8e1d7..a3c838e5f70e3 100644 --- a/apps/site/pages/en/download/current.mdx +++ b/apps/site/pages/en/download/current.mdx @@ -30,7 +30,7 @@ Learn how to Node.js source tarball. Check out our nightly binaries or -all previous releases +all previous releases or the unofficial binaries for other platforms. diff --git a/apps/site/pages/en/download/index.mdx b/apps/site/pages/en/download/index.mdx index 16e78d6a8e1d7..a3c838e5f70e3 100644 --- a/apps/site/pages/en/download/index.mdx +++ b/apps/site/pages/en/download/index.mdx @@ -30,7 +30,7 @@ Learn how to Node.js source tarball. Check out our nightly binaries or -all previous releases +all previous releases or the unofficial binaries for other platforms. diff --git a/apps/site/types/download.ts b/apps/site/types/download.ts index 3be983a9466f2..a7f86c049a849 100644 --- a/apps/site/types/download.ts +++ b/apps/site/types/download.ts @@ -4,4 +4,4 @@ export interface DownloadSnippet { content: string; } -export type DownloadKind = 'installer' | 'binary' | 'source'; +export type DownloadKind = 'installer' | 'binary' | 'source' | 'shasum'; diff --git a/apps/site/types/layouts.ts b/apps/site/types/layouts.ts index a3e81f69132f7..ff6ad599eca79 100644 --- a/apps/site/types/layouts.ts +++ b/apps/site/types/layouts.ts @@ -6,4 +6,5 @@ export type Layouts = | 'blog-category' | 'blog-post' | 'download' + | 'download-archive' | 'article'; diff --git a/apps/site/types/releases.ts b/apps/site/types/releases.ts index 1c98008cec210..2e54681d132e8 100644 --- a/apps/site/types/releases.ts +++ b/apps/site/types/releases.ts @@ -20,8 +20,12 @@ export interface NodeReleaseSource { } export interface MinorVersion { - version: string; + npm?: string; + modules?: string; releaseDate: string; + v8: string; + version: string; + versionWithPrefix: string; } export interface NodeRelease extends NodeReleaseSource { diff --git a/apps/site/util/__tests__/url.test.mjs b/apps/site/util/__tests__/url.test.mjs index 8f6cd5be3f302..4f24164e6b680 100644 --- a/apps/site/util/__tests__/url.test.mjs +++ b/apps/site/util/__tests__/url.test.mjs @@ -56,48 +56,58 @@ describe('getNodeApiUrl', () => { describe('getNodeDownloadUrl', () => { it('should return the correct download URL for Mac', () => { const os = 'MAC'; - const bitness = 86; + const platform = 86; const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0.pkg'; - assert.equal(getNodeDownloadUrl(version, os, bitness), expectedUrl); + assert.equal(getNodeDownloadUrl({ version, os, platform }), expectedUrl); }); it('should return the correct download URL for Windows (32-bit)', () => { const os = 'WIN'; - const bitness = 86; + const platform = 86; const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x86.msi'; - assert.equal(getNodeDownloadUrl(version, os, bitness), expectedUrl); + assert.equal(getNodeDownloadUrl({ version, os, platform }), expectedUrl); }); it('should return the correct download URL for Windows (64-bit)', () => { const os = 'WIN'; - const bitness = 64; + const platform = 64; const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0-x64.msi'; - assert.equal(getNodeDownloadUrl(version, os, bitness), expectedUrl); + assert.equal(getNodeDownloadUrl({ version, os, platform }), expectedUrl); }); it('should return the default download URL for other operating systems', () => { const os = 'OTHER'; - const bitness = 86; + const platform = 86; const expectedUrl = 'https://nodejs.org/dist/v18.16.0/node-v18.16.0.tar.gz'; - assert.equal(getNodeDownloadUrl(version, os, bitness), expectedUrl); + assert.equal(getNodeDownloadUrl({ version, os, platform }), expectedUrl); }); describe('MAC', () => { it('should return .pkg link for installer', () => { - const url = getNodeDownloadUrl('v18.0.0', 'MAC', 'x64', 'installer'); + const url = getNodeDownloadUrl({ + version: 'v18.0.0', + os: 'MAC', + platform: 'x64', + kind: 'installer', + }); assert.ok(url.includes('.pkg')); }); }); describe('WIN', () => { it('should return an MSI link for installer', () => { - const url = getNodeDownloadUrl('v18.0.0', 'WIN', 'x64', 'installer'); + const url = getNodeDownloadUrl({ + version: 'v18.0.0', + os: 'WIN', + platform: 'x64', + kind: 'installer', + }); assert.ok(url.includes('.msi')); }); }); diff --git a/apps/site/util/download/archive.tsx b/apps/site/util/download/archive.tsx new file mode 100644 index 0000000000000..475235e6a8b20 --- /dev/null +++ b/apps/site/util/download/archive.tsx @@ -0,0 +1,201 @@ +import semVer from 'semver'; + +import type { DownloadKind, OperatingSystem, Platform } from '#site/types'; +import type { NodeRelease } from '#site/types/releases'; +import type { DownloadDropdownItem } from '#site/util/download'; +import { OS_NOT_SUPPORTING_INSTALLERS, PLATFORMS } from '#site/util/download'; +import { getNodeDownloadUrl } from '#site/util/url'; + +import { DIST_URL } from '#site/next.constants'; + +export type NodeDownloadArtifact = { + file: string; + kind: DownloadKind; + os: OperatingSystem; + architecture: string; + url: string; + version: string; +}; + +/** + * Checks if a download item is compatible with the given OS, platform, and version. + */ +function isCompatible( + compatibility: DownloadDropdownItem['compatibility'], + os: OperatingSystem, + platform: Platform, + version: string +): boolean { + const { + os: osList, + platform: platformList, + semver: versions, + } = compatibility; + + return ( + (osList?.includes(os) ?? true) && + (platformList?.includes(platform) ?? true) && + (versions?.every(r => semVer.satisfies(version, r)) ?? true) + ); +} + +type CompatibleArtifactOptions = { + platforms?: Record>>; + exclude?: Array; + version: string; + kind?: DownloadKind; +}; + +/** + * Returns a list of compatible artifacts for the given options. + */ +const getCompatibleArtifacts = ({ + platforms = PLATFORMS, + exclude = [], + version, + kind = 'binary', +}: CompatibleArtifactOptions): Array => { + return Object.entries(platforms).flatMap(([os, items]) => { + if (exclude.includes(os)) return []; + + return items + .filter(({ compatibility, value }) => + isCompatible(compatibility, os as OperatingSystem, value, version) + ) + .map(({ value, label }) => { + const url = getNodeDownloadUrl({ + version: version, + os: os as OperatingSystem, + platform: value, + kind: kind, + }); + + return { + file: url.replace(`${DIST_URL}${version}/`, ''), + kind: kind, + os: os as OperatingSystem, + architecture: label, + url: url, + version: version, + }; + }); + }); +}; + +// Define status order priority +const statusOrder = [ + 'Current', + 'Active LTS', + 'Maintenance LTS', + 'End-of-life', + 'Pending', +]; + +type Navigations = Record>; + +/** + * Generates the navigation links for the Node.js download archive + * It creates a list of links for each major release, grouped by status, + * formatted with the major version and codename if available. + */ +export const getDownloadArchiveNavigation = (releases: Array) => { + // Group releases by status + const groupedByStatus = releases.reduce((acc, release) => { + const { status, major, codename, versionWithPrefix } = release; + + if (!acc[status]) { + acc[status] = []; + } + + acc[status].push({ + label: `Node.js v${major}.x ${codename ? `(${codename})` : ''}`, + value: `/download/archive/${versionWithPrefix}`, + }); + + return acc; + }, {} as Navigations); + + return statusOrder + .filter(status => groupedByStatus[status]) + .map(status => ({ + label: status, + items: groupedByStatus[status], + })); +}; + +/** + * Builds the release artifacts for a given Node.js release and version. + * It retrieves binaries, installers, and source files based on the version. + */ +export const buildReleaseArtifacts = ( + release: NodeRelease, + version: string +) => { + const minorVersion = release.minorVersions.find( + ({ versionWithPrefix }) => versionWithPrefix === version + ); + + const enrichedRelease = { + ...release, + ...minorVersion, + }; + + return { + binaries: getCompatibleArtifacts({ + version: version, + kind: 'binary', + }), + installers: getCompatibleArtifacts({ + exclude: OS_NOT_SUPPORTING_INSTALLERS, + version: version, + kind: 'installer', + }), + sources: { + shasum: getNodeDownloadUrl({ + version: version, + kind: 'shasum', + }), + tarball: getNodeDownloadUrl({ + version: version, + kind: 'source', + }), + }, + version: version, + release: enrichedRelease, + }; +}; + +/** + * Extracts the version from the pathname. + * It expects the version to be in the format 'v22.0.4' or 'archive'. + */ +export const extractVersionFromPath = (pathname: string | undefined) => { + if (!pathname) { + return null; + } + + const segments = pathname.split('/').filter(Boolean); + const version = segments.pop(); + + // Check version format like (v22.0.4 or 'archive') + if (!version || !version.match(/^v\d+(\.\d+)*|archive$/)) { + return null; + } + + return version; +}; + +/** + * Finds the appropriate release based on version, if 'archive' is passed, + * it returns the latest LTS release. + */ +export const findReleaseByVersion = ( + releaseData: Array, + version: string | 'archive' +) => { + if (version === 'archive') { + return releaseData.find(release => release.status === 'Current'); + } + + return releaseData.find(release => semVer.major(version) === release.major); +}; diff --git a/apps/site/util/download/constants.json b/apps/site/util/download/constants.json index 4834573ee7337..c76e57c636b75 100644 --- a/apps/site/util/download/constants.json +++ b/apps/site/util/download/constants.json @@ -8,7 +8,10 @@ "platforms": [ { "label": "x64", - "value": "x64" + "value": "x64", + "compatibility": { + "semver": [">= 4.0.0"] + } }, { "label": "x86", @@ -40,7 +43,7 @@ "label": "ARM64", "value": "arm64", "compatibility": { - "semver": [">= 19.9.0"] + "semver": [">= 16.0.0"] } } ] diff --git a/apps/site/util/download/index.tsx b/apps/site/util/download/index.tsx index 9b31281e4c1f8..0f0579e49c8c9 100644 --- a/apps/site/util/download/index.tsx +++ b/apps/site/util/download/index.tsx @@ -36,7 +36,7 @@ type DownloadCompatibility = { releases?: Array; }; -type DownloadDropdownItem = { +export type DownloadDropdownItem = { label: IntlMessageKeys; recommended?: boolean; url?: string; diff --git a/apps/site/util/url.ts b/apps/site/util/url.ts index 9fde24b05294e..0bf3c48822a73 100644 --- a/apps/site/util/url.ts +++ b/apps/site/util/url.ts @@ -17,73 +17,98 @@ export const getNodeApiUrl = (version: string) => { : `${DIST_URL}${version}/docs/api/`; }; -export const getNodeDownloadUrl = ( - versionWithPrefix: string, - os: OperatingSystem | 'LOADING', - platform: Platform = 'x64', - kind: DownloadKind = 'installer' -) => { - const baseURL = `${DIST_URL}${versionWithPrefix}`; +type DownloadOptions = { + version: string; + os?: OperatingSystem | 'LOADING'; + platform?: Platform; + kind?: DownloadKind; +}; + +/** + * Generates a Node.js download URL for the given options. + * + * @param options - The download options. + * @param options.version - The Node.js version string, must include the 'v' prefix (e.g., 'v20.12.2'). + * @param options.os - The target operating system. Defaults to 'LOADING'. + * @param options.platform - The target platform/architecture (e.g., 'x64', 'arm64'). Defaults to 'x64'. + * @param options.kind - The type of download artifact. Can be 'installer', 'binary', 'source', or 'shasum'. Defaults to 'installer'. + * @returns The fully qualified URL to the requested Node.js artifact. + * + * @example + * getNodeDownloadUrl({ version: 'v20.12.2', os: 'MAC', platform: 'arm64', kind: 'binary' }); + * // => 'https://nodejs.org/dist/v20.12.2/node-v20.12.2-darwin-arm64.tar.gz' + */ +export const getNodeDownloadUrl = ({ + version, + os = 'LOADING', + platform = 'x64', + kind = 'installer', +}: DownloadOptions) => { + const baseURL = `${DIST_URL}${version}`; if (kind === 'source') { - return `${baseURL}/node-${versionWithPrefix}.tar.gz`; + return `${baseURL}/node-${version}.tar.gz`; + } + + if (kind === 'shasum') { + return `${baseURL}/SHASUMS256.txt.asc`; } switch (os) { case 'MAC': // Prepares a downloadable Node.js installer link for the x64, ARM64 platforms if (kind === 'installer') { - return `${baseURL}/node-${versionWithPrefix}.pkg`; + return `${baseURL}/node-${version}.pkg`; } // Prepares a downloadable Node.js link for the ARM64 platform if (typeof platform === 'string') { - return `${baseURL}/node-${versionWithPrefix}-darwin-${platform}.tar.gz`; + return `${baseURL}/node-${version}-darwin-${platform}.tar.gz`; } // Prepares a downloadable Node.js link for the x64 platform. // Since the x86 platform is not officially supported, returns the x64 // link as the default value. - return `${baseURL}/node-${versionWithPrefix}-darwin-x64.tar.gz`; + return `${baseURL}/node-${version}-darwin-x64.tar.gz`; case 'WIN': { if (kind === 'installer') { // Prepares a downloadable Node.js installer link for the ARM platforms if (typeof platform === 'string') { - return `${baseURL}/node-${versionWithPrefix}-${platform}.msi`; + return `${baseURL}/node-${version}-${platform}.msi`; } // Prepares a downloadable Node.js installer link for the x64 and x86 platforms - return `${baseURL}/node-${versionWithPrefix}-x${platform}.msi`; + return `${baseURL}/node-${version}-x${platform}.msi`; } // Prepares a downloadable Node.js link for the ARM64 platform if (typeof platform === 'string') { - return `${baseURL}/node-${versionWithPrefix}-win-${platform}.zip`; + return `${baseURL}/node-${version}-win-${platform}.zip`; } // Prepares a downloadable Node.js link for the x64 and x86 platforms - return `${baseURL}/node-${versionWithPrefix}-win-x${platform}.zip`; + return `${baseURL}/node-${version}-win-x${platform}.zip`; } case 'LINUX': // Prepares a downloadable Node.js link for the ARM platforms such as // ARMv7 and ARMv8 if (typeof platform === 'string') { - return `${baseURL}/node-${versionWithPrefix}-linux-${platform}.tar.xz`; + return `${baseURL}/node-${version}-linux-${platform}.tar.xz`; } // Prepares a downloadable Node.js link for the x64 platform. // Since the x86 platform is not officially supported, returns the x64 // link as the default value. - return `${baseURL}/node-${versionWithPrefix}-linux-x64.tar.xz`; + return `${baseURL}/node-${version}-linux-x64.tar.xz`; case 'AIX': // Prepares a downloadable Node.js link for AIX if (typeof platform === 'string') { - return `${baseURL}/node-${versionWithPrefix}-aix-${platform}.tar.gz`; + return `${baseURL}/node-${version}-aix-${platform}.tar.gz`; } - return `${baseURL}/node-${versionWithPrefix}-aix-ppc64.tar.gz`; + return `${baseURL}/node-${version}-aix-ppc64.tar.gz`; default: // Prepares a downloadable Node.js source code link - return `${baseURL}/node-${versionWithPrefix}.tar.gz`; + return `${baseURL}/node-${version}.tar.gz`; } }; diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index ec85d0e3a0b1e..3fec6ccf78e87 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -166,6 +166,11 @@ "status": "Status", "details": "Details" }, + "downloadsTable": { + "fileName": "File Name", + "operatingSystem": "OS", + "architecture": "Architecture" + }, "releaseModal": { "title": "Node.js v{version} ({codename})", "titleWithoutCodename": "Node.js v{version}", @@ -212,6 +217,7 @@ "minorReleasesTable": { "version": "Version", "links": "Links", + "information": "Version Informations", "actions": { "release": "Release", "changelog": "Changelog", @@ -362,7 +368,7 @@ "ltsVersionFeaturesNotice": "Want new features sooner? Get the latest Node.js version instead and try the latest improvements!", "communityPlatformInfo": "Installation methods that involve community software are supported by the teams maintaining that software.", "externalSupportInfo": "If you encounter any issues please visit {platform}'s website", - "noScriptDetected": "This page requires JavaScript. You can download Node.js without JavaScript by visiting the releases page directly.", + "noScriptDetected": "This page requires JavaScript. You can download Node.js without JavaScript by visiting the downloads archive page directly.", "platformInfo": { "default": "{platform} and their installation scripts are not maintained by the Node.js project.", "nvm": "\"nvm\" is a cross-platform Node.js version manager.", diff --git a/packages/ui-components/src/Common/Modal/index.module.css b/packages/ui-components/src/Common/Modal/index.module.css index d5e385f7228bd..c9da0809358e1 100644 --- a/packages/ui-components/src/Common/Modal/index.module.css +++ b/packages/ui-components/src/Common/Modal/index.module.css @@ -27,6 +27,7 @@ focus:outline-none sm:my-20 xl:p-12 + dark:border-neutral-800 dark:bg-neutral-950; } diff --git a/packages/ui-components/src/Common/Select/index.module.css b/packages/ui-components/src/Common/Select/index.module.css index 5e048f9b4be71..605b9fa866445 100644 --- a/packages/ui-components/src/Common/Select/index.module.css +++ b/packages/ui-components/src/Common/Select/index.module.css @@ -161,12 +161,17 @@ } .noscript { - @apply relative; + @apply relative + cursor-pointer; summary { @apply flex w-full justify-between; + + span { + @apply pl-0; + } } .trigger { diff --git a/packages/ui-components/src/Common/Separator/index.module.css b/packages/ui-components/src/Common/Separator/index.module.css index 61d7dc140faa0..49ef6d6896fec 100644 --- a/packages/ui-components/src/Common/Separator/index.module.css +++ b/packages/ui-components/src/Common/Separator/index.module.css @@ -2,7 +2,8 @@ .root { @apply shrink-0 - bg-neutral-800; + bg-neutral-200 + dark:bg-neutral-800; &.horizontal { @apply h-px