From ecb53da660149f53e22bee6c4f7d33b8c92ff7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Fri, 20 Feb 2026 16:55:14 -0300 Subject: [PATCH 1/3] feat: create announcements banner --- src/generators/jsx-ast/utils/buildContent.mjs | 1 + src/generators/web/constants.mjs | 4 ++ src/generators/web/index.mjs | 2 + src/generators/web/types.d.ts | 1 + .../components/AnnouncementBanner/index.jsx | 69 +++++++++++++++++++ .../components/AnnouncementBanner/types.d.ts | 11 +++ .../web/ui/utils/__tests__/banner.test.mjs | 63 +++++++++++++++++ src/generators/web/ui/utils/banner.mjs | 20 ++++++ src/generators/web/utils/data.mjs | 2 + 9 files changed, 173 insertions(+) create mode 100644 src/generators/web/ui/components/AnnouncementBanner/index.jsx create mode 100644 src/generators/web/ui/components/AnnouncementBanner/types.d.ts create mode 100644 src/generators/web/ui/utils/__tests__/banner.test.mjs create mode 100644 src/generators/web/ui/utils/banner.mjs diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index a938cb4f..01d338a4 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -287,6 +287,7 @@ export const createDocumentLayout = ( remark ) => createTree('root', [ + createJSXElement(JSX_IMPORTS.AnnouncementBanner.name), createJSXElement(JSX_IMPORTS.NavBar.name), createJSXElement(JSX_IMPORTS.Article.name, { children: [ diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 8a18f8d9..e36ab548 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -14,6 +14,10 @@ export const ROOT = dirname(fileURLToPath(import.meta.url)); * An object containing mappings for various JSX components to their import paths. */ export const JSX_IMPORTS = { + AnnouncementBanner: { + name: 'AnnouncementBanner', + source: resolve(ROOT, './ui/components/AnnouncementBanner'), + }, NavBar: { name: 'NavBar', source: resolve(ROOT, './ui/components/NavBar'), diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 70ebd7dc..6d4b6d9d 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -34,6 +34,8 @@ export default { imports: { '#config/Logo': '@node-core/ui-components/Common/NodejsLogo', }, + remoteConfig: + 'https://gist.githubusercontent.com/araujogui/8ea72ffaf574f58fca1482e764e8b5c8/raw/16af51e4efbf37da7b6aff9b7e5dd967d955aacf/api-docs.config.json', }, /** diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index 5feed60e..61d102f3 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -5,6 +5,7 @@ export type Generator = GeneratorMetadata< templatePath: string; title: string; imports: Record; + remoteConfig: string | null; }, Generate, AsyncGenerator<{ html: string; css: string }>> >; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx new file mode 100644 index 00000000..e526a41c --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -0,0 +1,69 @@ +import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; +import Banner from '@node-core/ui-components/Common/Banner'; +import { useEffect, useState } from 'preact/hooks'; + +import { STATIC_DATA } from '../../constants.mjs'; +import { isBannerActive } from '../../utils/banner.mjs'; + +/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ + +/** + * Asynchronously fetches and displays announcement banners from the remote config. + * Global banners are rendered above version-specific ones. + * Non-blocking: silently ignores fetch/parse failures. + */ +export default () => { + const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); + + useEffect(() => { + const { remoteConfig, versionMajor } = STATIC_DATA; + + if (!remoteConfig) { + return; + } + + fetch(remoteConfig, { + signal: AbortSignal.timeout(2500), + }) + .then(async res => { + if (!res.ok) { + return; + } + + /** @type {RemoteConfig} */ + const config = await res.json(); + + const active = []; + + const globalBanner = config.global?.banner; + if (globalBanner && isBannerActive(globalBanner)) { + active.push(globalBanner); + } + + const versionBanner = config[`v${versionMajor}`]?.banner; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } + + setBanners(active); + }) + .catch(error => { + console.error(error); + }); + }, []); + + if (!banners.length) { + return null; + } + + return ( +
+ {banners.map(banner => ( + + {banner.link ? {banner.text} : banner.text} + {banner.link && } + + ))} +
+ ); +}; diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts new file mode 100644 index 00000000..1c0a152d --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts @@ -0,0 +1,11 @@ +import type { BannerProps } from '@node-core/ui-components/Common/Banner'; + +export type BannerEntry = { + startDate?: string; + endDate?: string; + text: string; + link?: string; + type?: BannerProps['type']; +}; + +export type RemoteConfig = Record; diff --git a/src/generators/web/ui/utils/__tests__/banner.test.mjs b/src/generators/web/ui/utils/__tests__/banner.test.mjs new file mode 100644 index 00000000..262d2c9d --- /dev/null +++ b/src/generators/web/ui/utils/__tests__/banner.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { isBannerActive } from '../banner.mjs'; + +const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday +const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow + +const banner = (overrides = {}) => ({ + text: 'Test banner', + ...overrides, +}); + +describe('isBannerActive', () => { + describe('no startDate, no endDate', () => { + it('is always active', () => { + assert.equal(isBannerActive(banner()), true); + }); + }); + + describe('startDate only', () => { + it('is active when startDate is in the past', () => { + assert.equal(isBannerActive(banner({ startDate: PAST })), true); + }); + + it('is not active when startDate is in the future', () => { + assert.equal(isBannerActive(banner({ startDate: FUTURE })), false); + }); + }); + + describe('endDate only', () => { + it('is active when endDate is in the future', () => { + assert.equal(isBannerActive(banner({ endDate: FUTURE })), true); + }); + + it('is not active when endDate is in the past', () => { + assert.equal(isBannerActive(banner({ endDate: PAST })), false); + }); + }); + + describe('startDate and endDate', () => { + it('is active when now is within the range', () => { + assert.equal( + isBannerActive(banner({ startDate: PAST, endDate: FUTURE })), + true + ); + }); + + it('is not active when now is before the range', () => { + assert.equal( + isBannerActive(banner({ startDate: FUTURE, endDate: FUTURE })), + false + ); + }); + + it('is not active when now is after the range', () => { + assert.equal( + isBannerActive(banner({ startDate: PAST, endDate: PAST })), + false + ); + }); + }); +}); diff --git a/src/generators/web/ui/utils/banner.mjs b/src/generators/web/ui/utils/banner.mjs new file mode 100644 index 00000000..a3af015c --- /dev/null +++ b/src/generators/web/ui/utils/banner.mjs @@ -0,0 +1,20 @@ +/** @import { BannerEntry } from '../components/AnnouncementBanner/types' */ + +/** + * Checks whether a banner should be displayed based on its date range. + * Both `startDate` and `endDate` are optional; if omitted the banner is + * considered open-ended in that direction. + * + * @param {BannerEntry} banner + * @returns {boolean} + */ +export const isBannerActive = banner => { + const now = Date.now(); + if (banner.startDate && now < new Date(banner.startDate).getTime()) { + return false; + } + if (banner.endDate && now > new Date(banner.endDate).getTime()) { + return false; + } + return true; +}; diff --git a/src/generators/web/utils/data.mjs b/src/generators/web/utils/data.mjs index cd4fd465..4631aa3b 100644 --- a/src/generators/web/utils/data.mjs +++ b/src/generators/web/utils/data.mjs @@ -32,6 +32,8 @@ export const createStaticData = () => { shikiDisplayNameMap, title: config.title, repository: config.repository, + versionMajor: config.version?.major ?? null, + remoteConfig: config.remoteConfig ?? null, }; }; From 43fabd80acf8e74b9e27937614ea9ee968e6987f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sat, 21 Feb 2026 10:26:10 -0300 Subject: [PATCH 2/3] refactor: review --- src/generators/jsx-ast/utils/buildContent.mjs | 5 +++- src/generators/web/index.mjs | 2 +- src/generators/web/types.d.ts | 2 +- .../components/AnnouncementBanner/index.jsx | 29 ++++++++++++------- src/generators/web/utils/data.mjs | 2 -- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index 01d338a4..d7c45d14 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -287,7 +287,10 @@ export const createDocumentLayout = ( remark ) => createTree('root', [ - createJSXElement(JSX_IMPORTS.AnnouncementBanner.name), + createJSXElement(JSX_IMPORTS.AnnouncementBanner.name, { + remoteConfig: getConfig('web').remoteConfig, + versionMajor: getConfig('web').version?.major ?? null, + }), createJSXElement(JSX_IMPORTS.NavBar.name), createJSXElement(JSX_IMPORTS.Article.name, { children: [ diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 6d4b6d9d..883e8a89 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -35,7 +35,7 @@ export default { '#config/Logo': '@node-core/ui-components/Common/NodejsLogo', }, remoteConfig: - 'https://gist.githubusercontent.com/araujogui/8ea72ffaf574f58fca1482e764e8b5c8/raw/16af51e4efbf37da7b6aff9b7e5dd967d955aacf/api-docs.config.json', + 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', }, /** diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index 61d102f3..be3f458c 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -5,7 +5,7 @@ export type Generator = GeneratorMetadata< templatePath: string; title: string; imports: Record; - remoteConfig: string | null; + remoteConfig: string; }, Generate, AsyncGenerator<{ html: string; css: string }>> >; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index e526a41c..ebd83ef2 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -2,7 +2,6 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; import { useEffect, useState } from 'preact/hooks'; -import { STATIC_DATA } from '../../constants.mjs'; import { isBannerActive } from '../../utils/banner.mjs'; /** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ @@ -11,13 +10,13 @@ import { isBannerActive } from '../../utils/banner.mjs'; * Asynchronously fetches and displays announcement banners from the remote config. * Global banners are rendered above version-specific ones. * Non-blocking: silently ignores fetch/parse failures. + * + * @param {{ remoteConfig: string, versionMajor: number | null }} props */ -export default () => { +export default ({ remoteConfig, versionMajor }) => { const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); useEffect(() => { - const { remoteConfig, versionMajor } = STATIC_DATA; - if (!remoteConfig) { return; } @@ -40,9 +39,11 @@ export default () => { active.push(globalBanner); } - const versionBanner = config[`v${versionMajor}`]?.banner; - if (versionBanner && isBannerActive(versionBanner)) { - active.push(versionBanner); + if (versionMajor != null) { + const versionBanner = config[`v${versionMajor}`]?.banner; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } } setBanners(active); @@ -57,11 +58,17 @@ export default () => { } return ( -
+
{banners.map(banner => ( - - {banner.link ? {banner.text} : banner.text} - {banner.link && } + + {banner.link ? ( + + {banner.text} + + + ) : ( + banner.text + )} ))}
diff --git a/src/generators/web/utils/data.mjs b/src/generators/web/utils/data.mjs index 4631aa3b..cd4fd465 100644 --- a/src/generators/web/utils/data.mjs +++ b/src/generators/web/utils/data.mjs @@ -32,8 +32,6 @@ export const createStaticData = () => { shikiDisplayNameMap, title: config.title, repository: config.repository, - versionMajor: config.version?.major ?? null, - remoteConfig: config.remoteConfig ?? null, }; }; From 829c5879502cc10011c6ba5909b1b7836a48b27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sat, 21 Feb 2026 11:25:06 -0300 Subject: [PATCH 3/3] refactor: review --- src/generators/jsx-ast/index.mjs | 2 ++ src/generators/jsx-ast/utils/buildContent.mjs | 11 +++++++---- src/generators/web/index.mjs | 2 -- .../web/ui/components/AnnouncementBanner/index.jsx | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/generators/jsx-ast/index.mjs b/src/generators/jsx-ast/index.mjs index e1ee73a1..df011762 100644 --- a/src/generators/jsx-ast/index.mjs +++ b/src/generators/jsx-ast/index.mjs @@ -23,6 +23,8 @@ export default { defaultConfiguration: { ref: 'main', + remoteConfig: + 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', }, /** diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index d7c45d14..15ddf8d8 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -285,11 +285,13 @@ export const createDocumentLayout = ( sideBarProps, metaBarProps, remark -) => - createTree('root', [ +) => { + const config = getConfig('jsx-ast'); + + return createTree('root', [ createJSXElement(JSX_IMPORTS.AnnouncementBanner.name, { - remoteConfig: getConfig('web').remoteConfig, - versionMajor: getConfig('web').version?.major ?? null, + remoteConfig: config.remoteConfig, + versionMajor: config.version?.major ?? null, }), createJSXElement(JSX_IMPORTS.NavBar.name), createJSXElement(JSX_IMPORTS.Article.name, { @@ -312,6 +314,7 @@ export const createDocumentLayout = ( ], }), ]); +}; /** * @typedef {import('estree').Node & { data: ApiDocMetadataEntry }} JSXContent diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 883e8a89..70ebd7dc 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -34,8 +34,6 @@ export default { imports: { '#config/Logo': '@node-core/ui-components/Common/NodejsLogo', }, - remoteConfig: - 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', }, /** diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index ebd83ef2..b8bde491 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -34,13 +34,13 @@ export default ({ remoteConfig, versionMajor }) => { const active = []; - const globalBanner = config.global?.banner; + const globalBanner = config.websiteBanners?.index; if (globalBanner && isBannerActive(globalBanner)) { active.push(globalBanner); } if (versionMajor != null) { - const versionBanner = config[`v${versionMajor}`]?.banner; + const versionBanner = config.websiteBanners[`v${versionMajor}`]; if (versionBanner && isBannerActive(versionBanner)) { active.push(versionBanner); } @@ -64,11 +64,11 @@ export default ({ remoteConfig, versionMajor }) => { {banner.link ? ( {banner.text} - ) : ( banner.text )} + {banner.link && } ))}