diff --git a/apps/site/components/Downloads/Release/VersionDropdown.tsx b/apps/site/components/Downloads/Release/VersionDropdown.tsx index 3ca72e9b9b8ce..516e5fcbb359e 100644 --- a/apps/site/components/Downloads/Release/VersionDropdown.tsx +++ b/apps/site/components/Downloads/Release/VersionDropdown.tsx @@ -5,6 +5,7 @@ import { useLocale, useTranslations } from 'next-intl'; import { use } from 'react'; import { redirect, usePathname } from '#site/navigation'; +import { STATUS_KIND_MAP } from '#site/next.constants.mjs'; import { ReleaseContext, ReleasesContext, @@ -12,18 +13,6 @@ import { import type { FC } from 'react'; -const getDropDownStatus = (version: string, status: string) => { - if (status.endsWith('LTS')) { - return `${version} (LTS)`; - } - - if (status === 'Current') { - return `${version} (Current)`; - } - - return version; -}; - const VersionDropdown: FC = () => { const { releases } = use(ReleasesContext); const { release, setVersion } = use(ReleaseContext); @@ -38,7 +27,7 @@ const VersionDropdown: FC = () => { ({ versionWithPrefix }) => versionWithPrefix === version ); - if (release?.isLts && pathname.includes('current')) { + if (release?.status === 'LTS' && pathname.includes('current')) { redirect({ href: '/download', locale }); return; } @@ -56,7 +45,11 @@ const VersionDropdown: FC = () => { ariaLabel={t('layouts.download.dropdown.version')} values={releases.map(({ status, versionWithPrefix }) => ({ value: versionWithPrefix, - label: getDropDownStatus(versionWithPrefix, status), + label: versionWithPrefix, + badge: { + label: status, + kind: STATUS_KIND_MAP[status], + }, }))} defaultValue={release.versionWithPrefix} onChange={setVersionOrNavigate} diff --git a/apps/site/components/EOL/EOLReleaseTable/index.tsx b/apps/site/components/EOL/EOLReleaseTable/index.tsx index 9116bdf8dd5f3..bf00f75bfb6e2 100644 --- a/apps/site/components/EOL/EOLReleaseTable/index.tsx +++ b/apps/site/components/EOL/EOLReleaseTable/index.tsx @@ -3,7 +3,6 @@ import { getTranslations } from 'next-intl/server'; import provideReleaseData from '#site/next-data/providers/releaseData'; import provideVulnerabilities from '#site/next-data/providers/vulnerabilities'; -import { EOL_VERSION_IDENTIFIER } from '#site/next.constants.mjs'; import type { FC } from 'react'; @@ -15,9 +14,7 @@ const EOLReleaseTable: FC = async () => { const releaseData = await provideReleaseData(); const vulnerabilities = await provideVulnerabilities(); - const eolReleases = releaseData.filter( - release => release.status === EOL_VERSION_IDENTIFIER - ); + const eolReleases = releaseData.filter(release => release.status === 'EOL'); const t = await getTranslations(); diff --git a/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx b/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx index 0d91d28b00339..eb091a59acaad 100644 --- a/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx +++ b/apps/site/components/Releases/PreviousReleasesTable/TableBody.tsx @@ -7,20 +7,13 @@ import { Fragment, useState } from 'react'; import FormattedTime from '#site/components/Common/FormattedTime'; import LinkWithArrow from '#site/components/Common/LinkWithArrow'; import Link from '#site/components/Link'; +import { STATUS_KIND_MAP } from '#site/next.constants.mjs'; import type { NodeRelease } from '#site/types'; import type { FC } from 'react'; import ReleaseModal from '../ReleaseModal'; -const BADGE_KIND_MAP = { - 'End-of-life': 'warning', - 'Maintenance LTS': 'neutral', - 'Active LTS': 'info', - Current: 'default', - Pending: 'default', -} as const; - type PreviousReleasesTableBodyProps = { releaseData: Array; }; @@ -50,7 +43,7 @@ const PreviousReleasesTableBody: FC = ({ - + @@ -58,9 +51,8 @@ const PreviousReleasesTableBody: FC = ({ - + {release.status} - {release.status === 'End-of-life' ? ' (EoL)' : ''} diff --git a/apps/site/components/Releases/ReleaseOverview/index.tsx b/apps/site/components/Releases/ReleaseOverview/index.tsx index b3f5b3ed65b22..a77ddc1558695 100644 --- a/apps/site/components/Releases/ReleaseOverview/index.tsx +++ b/apps/site/components/Releases/ReleaseOverview/index.tsx @@ -28,7 +28,7 @@ const ReleaseOverview: FC = ({ release }) => {
} + title={} subtitle={t('components.releaseOverview.firstReleased')} /> diff --git a/apps/site/components/withDownloadSection.tsx b/apps/site/components/withDownloadSection.tsx index be825e39f2ef3..373eebaafb290 100644 --- a/apps/site/components/withDownloadSection.tsx +++ b/apps/site/components/withDownloadSection.tsx @@ -39,9 +39,7 @@ const WithDownloadSection: FC = async ({ .concat(localeSnippets); // Decides which initial release to use based on the current pathname - const initialRelease = pathname.endsWith('/current') - ? 'Current' - : ['Active LTS' as const, 'Maintenance LTS' as const]; + const initialRelease = pathname.endsWith('/current') ? 'Current' : 'LTS'; return ( diff --git a/apps/site/components/withFooter.tsx b/apps/site/components/withFooter.tsx index 72a200ccb18df..148aa7e65e08f 100644 --- a/apps/site/components/withFooter.tsx +++ b/apps/site/components/withFooter.tsx @@ -27,7 +27,7 @@ const WithFooter: FC = () => { const primary = (
- + {({ release }) => ( = ({ status }) => { const t = useTranslations(); switch (status) { - case 'End-of-life': + case 'EOL': return ( = ({ status }) => { })} ); - case 'Active LTS': - case 'Maintenance LTS': + case 'LTS': return ( { - it('generates release data with correct status', async t => { - t.mock.timers.enable({ now: new Date('2024-10-18') }); + let currentNodevuData = {}; + const nodevuMock = () => Promise.resolve(currentNodevuData); + + const runWithNodevuData = async (t, now, data) => { + currentNodevuData = data; + t.mock.timers.enable({ now: new Date(now) }); t.mock.module('@nodevu/core', { - defaultExport: () => - Promise.resolve({ - 14: { - releases: { - '14.0.0': { - semver: { major: 14, raw: '14.0.0' }, - dependencies: { npm: '6.14.10', v8: '8.0.276.20' }, - releaseDate: '2021-04-20', - modules: { version: '83' }, - }, - }, - support: { - phases: { - dates: { - start: '2021-10-26', - lts: '2022-10-18', - maintenance: '2023-10-18', - end: '2024-10-18', - }, - }, - }, - }, - }), + defaultExport: nodevuMock, }); const { default: generateReleaseData } = await import('#site/next-data/generators/releaseData.mjs'); - const result = await generateReleaseData(); + return generateReleaseData(); + }; + + it('returns EOL when release is on or past EOL date', async t => { + const result = await runWithNodevuData(t, '2024-10-18', { + 14: { + releases: { + '14.0.0': { + semver: { major: 14, raw: '14.0.0' }, + dependencies: { npm: '6.14.10', v8: '8.0.276.20' }, + releaseDate: '2021-04-20', + modules: { version: '83' }, + }, + }, + support: { + phases: { + dates: { + start: '2021-10-26', + lts: '2022-10-18', + maintenance: '2023-10-18', + end: '2024-10-18', + }, + }, + }, + }, + }); assert.equal(result.length, 1); assert.partialDeepStrictEqual(result[0], { @@ -42,12 +49,132 @@ describe('generateReleaseData', () => { version: '14.0.0', versionWithPrefix: 'v14.0.0', codename: '', - isLts: false, npm: '6.14.10', v8: '8.0.276.20', releaseDate: '2021-04-20', + initialDate: '2021-04-20', modules: '83', - status: 'End-of-life', + status: 'EOL', + }); + }); + + it('returns Current when release is not EOL and latest is not LTS', async t => { + const result = await runWithNodevuData(t, '2026-04-14', { + 20: { + releases: { + '20.12.0': { + semver: { major: 20, raw: '20.12.0' }, + dependencies: { npm: '10.8.2', v8: '11.3.244.8' }, + lts: { isLts: false }, + releaseDate: '2026-03-26', + modules: { version: '115' }, + }, + }, + support: { + phases: { + dates: { + start: '2025-10-22', + lts: '2026-10-22', + maintenance: '2027-10-22', + end: '2028-04-30', + }, + }, + }, + }, + }); + + assert.equal(result[0]?.status, 'Current'); + }); + + it('returns LTS when release is not EOL and latest is flagged as LTS', async t => { + const result = await runWithNodevuData(t, '2026-04-14', { + 22: { + releases: { + '22.7.0': { + semver: { major: 22, raw: '22.7.0' }, + dependencies: { npm: '10.9.0', v8: '12.4.254.10' }, + lts: { isLts: true }, + releaseDate: '2026-02-18', + modules: { version: '124' }, + }, + }, + support: { + phases: { + dates: { + start: '2026-04-23', + lts: '2026-10-21', + maintenance: '2027-10-20', + end: '2029-04-30', + }, + }, + }, + }, + }); + + assert.equal(result[0]?.status, 'LTS'); + }); + + it('returns Current when release is not EOL and LTS date has passed but latest is not LTS', async t => { + const result = await runWithNodevuData(t, '2026-04-14', { + 24: { + releases: { + '24.1.0': { + semver: { major: 24, raw: '24.1.0' }, + dependencies: { npm: '11.1.0', v8: '13.0.12.7' }, + lts: { isLts: false }, + releaseDate: '2026-03-10', + modules: { version: '130' }, + }, + }, + support: { + phases: { + dates: { + start: '2025-10-10', + lts: '2026-01-01', + maintenance: '2027-01-01', + end: '2028-10-01', + }, + }, + }, + }, }); + + assert.equal(result[0]?.status, 'Current'); + }); + + it('uses latest and earliest release dates for releaseDate and initialDate', async t => { + const result = await runWithNodevuData(t, '2026-04-14', { + 26: { + releases: { + '26.2.0': { + semver: { major: 26, raw: '26.2.0' }, + dependencies: { npm: '11.3.1', v8: '13.2.20.1' }, + lts: { isLts: false }, + releaseDate: '2026-04-01', + modules: { version: '132' }, + }, + '26.0.0': { + semver: { major: 26, raw: '26.0.0' }, + dependencies: { npm: '11.0.0', v8: '13.1.0.0' }, + lts: { isLts: false }, + releaseDate: '2025-10-21', + modules: { version: '131' }, + }, + }, + support: { + phases: { + dates: { + start: '2025-10-21', + lts: '2026-10-20', + maintenance: '2027-10-19', + end: '2029-04-30', + }, + }, + }, + }, + }); + + assert.equal(result[0]?.releaseDate, '2026-04-01'); + assert.equal(result[0]?.initialDate, '2025-10-21'); }); }); diff --git a/apps/site/next-data/generators/releaseData.mjs b/apps/site/next-data/generators/releaseData.mjs index b6db33b949f2d..c0b4c49005aae 100644 --- a/apps/site/next-data/generators/releaseData.mjs +++ b/apps/site/next-data/generators/releaseData.mjs @@ -3,31 +3,18 @@ import getMajorNodeReleases from './majorNodeReleases.mjs'; // Gets the appropriate release status for each major release -const getNodeReleaseStatus = (latest, support) => { +const getNodeReleaseStatus = (latest, eol) => { const now = new Date(); - const { endOfLife, maintenanceStart, ltsStart, currentStart } = support; - if (endOfLife && now >= new Date(endOfLife)) { - return 'End-of-life'; + if (eol && now >= new Date(eol)) { + return 'EOL'; } - if ( - latest.lts.isLts && - maintenanceStart && - now >= new Date(maintenanceStart) - ) { - return 'Maintenance LTS'; + if (latest.lts.isLts) { + return 'LTS'; } - if (latest.lts.isLts && ltsStart && now >= new Date(ltsStart)) { - return 'Active LTS'; - } - - if (currentStart && now >= new Date(currentStart)) { - return 'Current'; - } - - return 'Pending'; + return 'Current'; }; /** @@ -40,17 +27,15 @@ const generateReleaseData = async () => { const majors = await getMajorNodeReleases(); return majors.map(([, major]) => { - const [latestVersion] = Object.values(major.releases); - - const support = { - currentStart: major.support.phases.dates.start, - ltsStart: major.support.phases.dates.lts, - maintenanceStart: major.support.phases.dates.maintenance, - endOfLife: major.support.phases.dates.end, - }; + const versions = Object.values(major.releases); + const latestVersion = versions[0]; + const initialVersion = versions[versions.length - 1]; // Get the major release status based on our Release Schedule - const status = getNodeReleaseStatus(latestVersion, support); + const status = getNodeReleaseStatus( + latestVersion, + major.support.phases.dates.end + ); const minorVersions = Object.entries(major.releases).map(([, release]) => ({ modules: release.modules.version || '', @@ -62,16 +47,15 @@ const generateReleaseData = async () => { })); return { - ...support, status, major: latestVersion.semver.major, version: latestVersion.semver.raw, versionWithPrefix: `v${latestVersion.semver.raw}`, codename: major.support.codename || '', - isLts: status.endsWith('LTS'), npm: latestVersion.dependencies.npm || '', v8: latestVersion.dependencies.v8, releaseDate: latestVersion.releaseDate, + initialDate: initialVersion.releaseDate, modules: latestVersion.modules.version || '', minorVersions, }; diff --git a/apps/site/next.constants.mjs b/apps/site/next.constants.mjs index 1a4ca677ba8bc..f621472d3c309 100644 --- a/apps/site/next.constants.mjs +++ b/apps/site/next.constants.mjs @@ -205,9 +205,15 @@ export const SEVERITY_KIND_MAP = { }; /** - * Which Node.js versions do we want to display vulnerabilities for? + * Maps Node.js version status to UI Badge kinds + * + * @type {Record} */ -export const EOL_VERSION_IDENTIFIER = 'End-of-life'; +export const STATUS_KIND_MAP = { + EOL: 'warning', + LTS: 'info', + Current: 'default', +}; /** * The location of the Node.js Security Working Group Vulnerabilities data. diff --git a/apps/site/scripts/orama-search/get-documents.mjs b/apps/site/scripts/orama-search/get-documents.mjs index 0363ff4e2358c..bd374421520d5 100644 --- a/apps/site/scripts/orama-search/get-documents.mjs +++ b/apps/site/scripts/orama-search/get-documents.mjs @@ -13,17 +13,15 @@ const fetchOptions = process.env.GITHUB_TOKEN /** * Fetch Node.js API documentation directly from GitHub - * for the current Active LTS version. + * for the current LTS version. */ export const getAPIDocs = async () => { // Find the current Active LTS version const releaseData = await generateReleaseData(); - const ltsRelease = - releaseData.find(r => r.status === 'Active LTS') || - releaseData.find(r => r.status === 'Maintenance LTS'); + const ltsRelease = releaseData.find(r => r.status === 'LTS'); if (!ltsRelease) { - throw new Error('No Active LTS or Maintenance LTS release found'); + throw new Error('No LTS release found'); } // Get list of API docs from the Node.js repo diff --git a/apps/site/types/releases.ts b/apps/site/types/releases.ts index 37d9cec716e40..cb2cc25fdb651 100644 --- a/apps/site/types/releases.ts +++ b/apps/site/types/releases.ts @@ -1,21 +1,13 @@ -export type NodeReleaseStatus = - | 'Active LTS' - | 'Maintenance LTS' - | 'Current' - | 'End-of-life' - | 'Pending'; +export type NodeReleaseStatus = 'LTS' | 'Current' | 'EOL'; export type NodeReleaseSource = { major: number; version: string; codename?: string; - currentStart: string; - ltsStart?: string; - maintenanceStart?: string; - endOfLife: string; npm?: string; v8: string; releaseDate: string; + initialDate: string; modules?: string; }; @@ -30,7 +22,6 @@ export type MinorVersion = { export type NodeRelease = { versionWithPrefix: string; - isLts: boolean; status: NodeReleaseStatus; minorVersions: Array; } & NodeReleaseSource; diff --git a/apps/site/util/download/constants.json b/apps/site/util/download/constants.json index 6593acea69ec8..d5ccf208ccf5b 100644 --- a/apps/site/util/download/constants.json +++ b/apps/site/util/download/constants.json @@ -152,7 +152,7 @@ "name": "Brew", "compatibility": { "os": ["MAC", "LINUX"], - "releases": ["Current", "Active LTS", "Maintenance LTS"] + "releases": ["Current", "LTS"] }, "url": "https://brew.sh/", "info": "layouts.download.codeBox.platformInfo.brew" @@ -213,11 +213,5 @@ "bitness": ["64", "32"], "architecture": ["arm", "x86"] }, - "statusOrder": [ - "Current", - "Active LTS", - "Maintenance LTS", - "End-of-life", - "Pending" - ] + "statusOrder": ["Current", "LTS", "EOL"] } diff --git a/packages/ui-components/src/Common/Select/StatelessSelect/index.tsx b/packages/ui-components/src/Common/Select/StatelessSelect/index.tsx index 063cb1033b7e2..af0ec212f08fe 100644 --- a/packages/ui-components/src/Common/Select/StatelessSelect/index.tsx +++ b/packages/ui-components/src/Common/Select/StatelessSelect/index.tsx @@ -2,6 +2,8 @@ import { ChevronDownIcon } from '@heroicons/react/24/solid'; import classNames from 'classnames'; import { useId, useMemo } from 'react'; +import Badge from '#ui/Common/Badge'; + import type { SelectGroup, SelectProps } from '#ui/Common/Select'; import type { LinkLike } from '#ui/types'; @@ -66,6 +68,15 @@ const StatelessSelect = ({ {currentItem.iconImage} {currentItem.label} + {currentItem.badge && ( + + {currentItem.badge.label} + + )} )} {!currentItem && ( @@ -89,7 +100,13 @@ const StatelessSelect = ({ )} {items.map( - ({ value, label, iconImage, disabled: itemDisabled }) => ( + ({ + value, + label, + iconImage, + badge, + disabled: itemDisabled, + }) => ( ({ > {iconImage} {label} + {badge && ( + + {badge.label} + + )} ) )} diff --git a/packages/ui-components/src/Common/Select/index.module.css b/packages/ui-components/src/Common/Select/index.module.css index 7909fbca3b97d..98f528ae283c9 100644 --- a/packages/ui-components/src/Common/Select/index.module.css +++ b/packages/ui-components/src/Common/Select/index.module.css @@ -161,6 +161,10 @@ dark:text-neutral-200; } +.badge { + @apply ml-auto; +} + .noscript { @apply relative cursor-pointer; diff --git a/packages/ui-components/src/Common/Select/index.tsx b/packages/ui-components/src/Common/Select/index.tsx index 3d03f8026112a..66a7fb7d6859a 100644 --- a/packages/ui-components/src/Common/Select/index.tsx +++ b/packages/ui-components/src/Common/Select/index.tsx @@ -5,6 +5,7 @@ import * as SelectPrimitive from '@radix-ui/react-select'; import classNames from 'classnames'; import { useEffect, useId, useMemo, useState } from 'react'; +import Badge, { type BadgeKind } from '#ui/Common/Badge'; import Skeleton from '#ui/Common/Skeleton'; import type { FormattedMessage, LinkLike } from '#ui/types'; @@ -18,6 +19,10 @@ export type SelectValue = { label: FormattedMessage | string; value: T; iconImage?: ReactElement; + badge?: { + label: FormattedMessage | string; + kind?: BadgeKind; + }; disabled?: boolean; }; @@ -91,7 +96,7 @@ const Select = ({ )} - {items.map(({ value, label, iconImage, disabled }) => ( + {items.map(({ value, label, iconImage, badge, disabled }) => ( ({ {iconImage} {label} + {badge && ( + + {badge.label} + + )} ))} @@ -151,6 +161,15 @@ const Select = ({ <> {currentItem.iconImage} {currentItem.label} + {currentItem.badge && ( + + {currentItem.badge.label} + + )} )}