From 022bfa95f1075e9ac6324b321261ead6f620c772 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 01/27] feat: create announcements banner --- src/generators/jsx-ast/utils/buildContent.mjs | 1 + src/generators/web/constants.mjs | 4 ++ src/generators/web/index.mjs | 2 + .../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 ++++++ 7 files changed, 170 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 12f54825..4650061c 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -279,6 +279,7 @@ export const processEntry = entry => { */ export const createDocumentLayout = (entries, metadata) => createTree('root', [ + createJSXElement(JSX_IMPORTS.AnnouncementBanner.name), createJSXElement(JSX_IMPORTS.Layout.name, { metadata, headings: extractHeadings(entries), diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 5cbfe760..4fed5b54 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'), + }, Layout: { name: 'Layout', source: '#theme/Layout', diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 2bb92841..303c1a7d 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -43,5 +43,7 @@ export default createLazyGenerator({ '#theme/Layout': join(import.meta.dirname, './ui/components/Layout'), }, virtualImports: {}, + remoteConfig: + 'https://gist.githubusercontent.com/araujogui/8ea72ffaf574f58fca1482e764e8b5c8/raw/16af51e4efbf37da7b6aff9b7e5dd967d955aacf/api-docs.config.json', }, }); 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; +}; From c3d7a611646a508c76314568a352fb165ab060a7 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 02/27] refactor: review --- src/generators/web/constants.mjs | 20 +++++++++++++ src/generators/web/index.mjs | 2 +- .../components/AnnouncementBanner/index.jsx | 29 ++++++++++++------- 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 4fed5b54..3171bd57 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -22,6 +22,26 @@ export const JSX_IMPORTS = { name: 'Layout', source: '#theme/Layout', }, + NavBar: { + name: 'NavBar', + source: resolve(ROOT, './ui/components/NavBar'), + }, + Article: { + name: 'Article', + source: '@node-core/ui-components/Containers/Article', + }, + SideBar: { + name: 'SideBar', + source: resolve(ROOT, './ui/components/SideBar'), + }, + TableOfContents: { + name: 'TableOfContents', + source: '@node-core/ui-components/Common/TableOfContents', + }, + MetaBar: { + name: 'MetaBar', + source: resolve(ROOT, './ui/components/MetaBar'), + }, CodeBox: { name: 'CodeBox', source: resolve(ROOT, './ui/components/CodeBox'), diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 303c1a7d..e28e281c 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -44,6 +44,6 @@ export default createLazyGenerator({ }, virtualImports: {}, 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/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 + )} ))}
From d45ba5ab1d76925b15cde7d2d21ab9fd082311a2 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 03/27] refactor: review --- .../web/ui/components/AnnouncementBanner/index.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 && } ))}
From f85c9fa98b880e0f3ab2f51ee2609ad4ccad1e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 12 Mar 2026 19:28:44 -0300 Subject: [PATCH 04/27] feat: update remote config url --- src/generators/jsx-ast/index.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/generators/jsx-ast/index.mjs b/src/generators/jsx-ast/index.mjs index da61cb56..b90c3f06 100644 --- a/src/generators/jsx-ast/index.mjs +++ b/src/generators/jsx-ast/index.mjs @@ -18,6 +18,7 @@ export default createLazyGenerator({ defaultConfiguration: { ref: 'main', + remoteConfig: 'https://nodejs.org/site.json', }, hasParallelProcessor: true, From 8a886b25e49bf31cb31adef6021b9c2f3b160aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 12 Mar 2026 19:29:09 -0300 Subject: [PATCH 05/27] refactor: split fetch util --- .../__tests__/fetchBanners.test.mjs | 166 ++++++++++++++++++ .../AnnouncementBanner/fetchBanners.mjs | 38 ++++ .../components/AnnouncementBanner/index.jsx | 37 +--- .../components/AnnouncementBanner/types.d.ts | 4 +- 4 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs create mode 100644 src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs diff --git a/src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs b/src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs new file mode 100644 index 00000000..89dbbe94 --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs @@ -0,0 +1,166 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { fetchBanners } from '../fetchBanners.mjs'; + +const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday +const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow + +const makeResponse = (banners, ok = true) => ({ + ok, + json: async () => ({ websiteBanners: banners }), +}); + +describe('fetchBanners', () => { + describe('fetch behavior', () => { + it('fetches from the given URL', async t => { + t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({}))); + + await fetchBanners('https://example.com/site.json', null); + + assert.equal(global.fetch.mock.calls.length, 1); + assert.equal( + global.fetch.mock.calls[0].arguments[0], + 'https://example.com/site.json' + ); + }); + + it('returns an empty array on non-ok response', async t => { + t.mock.method(global, 'fetch', () => + Promise.resolve(makeResponse({}, false)) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, []); + }); + + it('propagates fetch errors to the caller', async t => { + t.mock.method(global, 'fetch', () => + Promise.reject(new Error('Network error')) + ); + + await assert.rejects( + () => fetchBanners('https://example.com/site.json', null), + { message: 'Network error' } + ); + }); + }); + + describe('banner selection', () => { + it('returns the active global (index) banner', async t => { + const banner = { text: 'Global banner', type: 'warning' }; + t.mock.method(global, 'fetch', () => + Promise.resolve(makeResponse({ index: banner })) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, [banner]); + }); + + it('returns the active version-specific banner', async t => { + const banner = { text: 'v20 banner', type: 'warning' }; + t.mock.method(global, 'fetch', () => + Promise.resolve(makeResponse({ v20: banner })) + ); + + const result = await fetchBanners('https://example.com/site.json', 20); + + assert.deepEqual(result, [banner]); + }); + + it('returns both global and version banners when both are active', async t => { + const globalBanner = { text: 'Global banner', type: 'warning' }; + const versionBanner = { text: 'v20 banner', type: 'error' }; + t.mock.method(global, 'fetch', () => + Promise.resolve( + makeResponse({ index: globalBanner, v20: versionBanner }) + ) + ); + + const result = await fetchBanners('https://example.com/site.json', 20); + + assert.deepEqual(result, [globalBanner, versionBanner]); + }); + + it('returns global banner first, version banner second', async t => { + const globalBanner = { text: 'Global', type: 'warning' }; + const versionBanner = { text: 'v22', type: 'error' }; + t.mock.method(global, 'fetch', () => + Promise.resolve( + makeResponse({ index: globalBanner, v22: versionBanner }) + ) + ); + + const result = await fetchBanners('https://example.com/site.json', 22); + + assert.equal(result[0], globalBanner); + assert.equal(result[1], versionBanner); + }); + + it('does not include the version banner when versionMajor is null', async t => { + const globalBanner = { text: 'Global banner', type: 'warning' }; + const versionBanner = { text: 'v20 banner', type: 'error' }; + t.mock.method(global, 'fetch', () => + Promise.resolve( + makeResponse({ index: globalBanner, v20: versionBanner }) + ) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, [globalBanner]); + }); + + it('returns an empty array when websiteBanners is absent', async t => { + t.mock.method(global, 'fetch', () => + Promise.resolve({ ok: true, json: async () => ({}) }) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, []); + }); + }); + + describe('date filtering', () => { + it('excludes a banner whose endDate has passed', async t => { + const banner = { text: 'Expired', type: 'warning', endDate: PAST }; + t.mock.method(global, 'fetch', () => + Promise.resolve(makeResponse({ index: banner })) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, []); + }); + + it('excludes a banner whose startDate is in the future', async t => { + const banner = { text: 'Upcoming', type: 'warning', startDate: FUTURE }; + t.mock.method(global, 'fetch', () => + Promise.resolve(makeResponse({ index: banner })) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, []); + }); + + it('includes a banner within its active date range', async t => { + const banner = { + text: 'Active', + type: 'warning', + startDate: PAST, + endDate: FUTURE, + }; + t.mock.method(global, 'fetch', () => + Promise.resolve(makeResponse({ index: banner })) + ); + + const result = await fetchBanners('https://example.com/site.json', null); + + assert.deepEqual(result, [banner]); + }); + }); +}); diff --git a/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs new file mode 100644 index 00000000..48d1ff7e --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs @@ -0,0 +1,38 @@ +/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ + +import { isBannerActive } from '../../utils/banner.mjs'; + +/** + * Fetches and returns active banners for the given version from the remote config. + * Returns an empty array on any fetch or parse failure. + * + * @param {string} remoteConfig + * @param {number | null} versionMajor + * @returns {Promise} + */ +export const fetchBanners = async (remoteConfig, versionMajor) => { + const res = await fetch(remoteConfig, { signal: AbortSignal.timeout(2500) }); + + if (!res.ok) { + return []; + } + + /** @type {RemoteConfig} */ + const config = await res.json(); + + const active = []; + + const globalBanner = config.websiteBanners?.index; + if (globalBanner && isBannerActive(globalBanner)) { + active.push(globalBanner); + } + + if (versionMajor != null) { + const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } + } + + return active; +}; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index b8bde491..0561730c 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -2,9 +2,9 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; import { useEffect, useState } from 'preact/hooks'; -import { isBannerActive } from '../../utils/banner.mjs'; +import { fetchBanners } from './fetchBanners.mjs'; -/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ +/** @import { BannerEntry } from './types.d.ts' */ /** * Asynchronously fetches and displays announcement banners from the remote config. @@ -21,36 +21,9 @@ export default ({ remoteConfig, versionMajor }) => { 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.websiteBanners?.index; - if (globalBanner && isBannerActive(globalBanner)) { - active.push(globalBanner); - } - - if (versionMajor != null) { - const versionBanner = config.websiteBanners[`v${versionMajor}`]; - if (versionBanner && isBannerActive(versionBanner)) { - active.push(versionBanner); - } - } - - setBanners(active); - }) - .catch(error => { - console.error(error); - }); + fetchBanners(remoteConfig, versionMajor) + .then(setBanners) + .catch(console.error); }, []); if (!banners.length) { diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts index 1c0a152d..bdd3b605 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts +++ b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts @@ -8,4 +8,6 @@ export type BannerEntry = { type?: BannerProps['type']; }; -export type RemoteConfig = Record; +export type RemoteConfig = { + websiteBanners?: Record; +}; From 2b5603e0de745176f11fbfadce451c7f388b4218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Fri, 20 Mar 2026 21:25:55 -0300 Subject: [PATCH 06/27] refactor: simplify --- src/generators/web/ui/utils/banner.mjs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/generators/web/ui/utils/banner.mjs b/src/generators/web/ui/utils/banner.mjs index a3af015c..b8721152 100644 --- a/src/generators/web/ui/utils/banner.mjs +++ b/src/generators/web/ui/utils/banner.mjs @@ -8,13 +8,10 @@ * @param {BannerEntry} banner * @returns {boolean} */ -export const isBannerActive = banner => { +export const isBannerActive = ({ startDate, endDate }) => { 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; + return ( + (!startDate || now >= new Date(startDate)) && + (!endDate || now <= new Date(endDate)) + ); }; From cc48c6a1a16ac3bb444414eac07ecbaf24e2ddb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Fri, 20 Mar 2026 21:30:06 -0300 Subject: [PATCH 07/27] refactor: add version skip comment --- .../web/ui/components/AnnouncementBanner/fetchBanners.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs index 48d1ff7e..8b34cfb6 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs @@ -27,6 +27,7 @@ export const fetchBanners = async (remoteConfig, versionMajor) => { active.push(globalBanner); } + // no version info available, skip version-specific banner if (versionMajor != null) { const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; if (versionBanner && isBannerActive(versionBanner)) { From a7434e457c6b58b1f0f4e259a060be8eef181980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 23 Mar 2026 19:26:48 -0300 Subject: [PATCH 08/27] fix: cls --- .../web/ui/components/AnnouncementBanner/index.jsx | 3 ++- .../components/AnnouncementBanner/index.module.css | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 src/generators/web/ui/components/AnnouncementBanner/index.module.css diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index 0561730c..2c704c98 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -3,6 +3,7 @@ import Banner from '@node-core/ui-components/Common/Banner'; import { useEffect, useState } from 'preact/hooks'; import { fetchBanners } from './fetchBanners.mjs'; +import styles from './index.module.css'; /** @import { BannerEntry } from './types.d.ts' */ @@ -31,7 +32,7 @@ export default ({ remoteConfig, versionMajor }) => { } return ( -
+
{banners.map(banner => ( {banner.link ? ( diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.module.css b/src/generators/web/ui/components/AnnouncementBanner/index.module.css new file mode 100644 index 00000000..d8a38d10 --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/index.module.css @@ -0,0 +1,14 @@ +.banners { + animation: slideDown 300ms ease-out 400ms backwards; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-0.5rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} From bce9501b62c42461960ab2d50ae11e59009bdaab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 23 Mar 2026 19:36:20 -0300 Subject: [PATCH 09/27] refactor: transform into hook --- package-lock.json | 443 ++++++++++++------ package.json | 4 +- ...chBanners.test.mjs => useBanners.test.mjs} | 130 +++-- .../AnnouncementBanner/fetchBanners.mjs | 39 -- .../components/AnnouncementBanner/index.jsx | 17 +- .../AnnouncementBanner/useBanners.mjs | 56 +++ 6 files changed, 458 insertions(+), 231 deletions(-) rename src/generators/web/ui/components/AnnouncementBanner/__tests__/{fetchBanners.test.mjs => useBanners.test.mjs} (51%) delete mode 100644 src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs create mode 100644 src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs diff --git a/package-lock.json b/package-lock.json index e9c588fd..6aaca907 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@reporters/github": "^1.12.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/mdast": "^4.0.4", "@types/node": "^24.10.1", "@types/semver": "^7.7.1", @@ -121,6 +123,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -881,16 +918,21 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@noble/hashes": { @@ -2719,64 +2761,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -2828,6 +2812,54 @@ "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2838,6 +2870,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2939,14 +2978,14 @@ "license": "MIT" }, "node_modules/@typescript-eslint/project-service": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.2.tgz", - "integrity": "sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.57.2", - "@typescript-eslint/types": "^8.57.2", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "engines": { @@ -2957,18 +2996,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2979,9 +3018,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.2.tgz", - "integrity": "sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -2992,21 +3031,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3017,13 +3056,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -3035,21 +3074,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3059,20 +3098,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3083,17 +3122,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3130,6 +3169,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -3143,6 +3183,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" @@ -3169,6 +3210,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -3182,6 +3224,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" @@ -3195,6 +3238,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3208,6 +3252,7 @@ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3221,6 +3266,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3234,6 +3280,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3247,6 +3294,7 @@ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3260,6 +3308,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3273,6 +3322,7 @@ "riscv64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3286,6 +3336,7 @@ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3299,6 +3350,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3312,6 +3364,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -3325,6 +3378,7 @@ "wasm32" ], "dev": true, + "license": "MIT", "optional": true, "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" @@ -3333,6 +3387,19 @@ "node": ">=14.0.0" } }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", @@ -3341,6 +3408,7 @@ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3354,6 +3422,7 @@ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3367,6 +3436,7 @@ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -3569,6 +3639,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -3617,9 +3697,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4111,6 +4191,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -4648,9 +4735,9 @@ } }, "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true, "license": "ISC" }, @@ -5307,6 +5394,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdoc-type-pratt-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", @@ -5873,15 +5967,25 @@ } }, "node_modules/lru-cache": { - "version": "11.2.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", - "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6781,13 +6885,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -7067,9 +7171,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -7300,6 +7404,44 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", @@ -7339,27 +7481,34 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "peer": true, "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.0" + "react": "^19.2.5" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -7779,9 +7928,9 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT", "peer": true }, @@ -8213,9 +8362,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -8274,9 +8423,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -8353,9 +8502,9 @@ } }, "node_modules/undici": { - "version": "6.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", - "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/package.json b/package.json index 2768520c..72596477 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "format": "prettier .", "format:write": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test --experimental-test-module-mocks \"src/**/*.test.mjs\"", + "test": "node --test --experimental-test-module-mocks --import=global-jsdom/register \"src/**/*.test.mjs\"", "test:coverage": "c8 node --test --experimental-test-module-mocks \"src/**/*.test.mjs\"", "test:ci": "c8 --reporter=lcov node --test --experimental-test-module-mocks \"src/**/*.test.mjs\" --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml --test-reporter=spec --test-reporter-destination=stdout", "test:update-snapshots": "node --test --experimental-test-module-mocks --test-update-snapshots \"src/**/*.test.mjs\"", @@ -28,6 +28,8 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@reporters/github": "^1.12.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", "@types/mdast": "^4.0.4", "@types/node": "^24.10.1", "@types/semver": "^7.7.1", diff --git a/src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs b/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs similarity index 51% rename from src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs rename to src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs index 89dbbe94..552dc87d 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/__tests__/fetchBanners.test.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { fetchBanners } from '../fetchBanners.mjs'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useBanners } from '../useBanners.mjs'; const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow @@ -11,14 +13,19 @@ const makeResponse = (banners, ok = true) => ({ json: async () => ({ websiteBanners: banners }), }); -describe('fetchBanners', () => { +describe('useBanners', () => { describe('fetch behavior', () => { it('fetches from the given URL', async t => { t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({}))); - await fetchBanners('https://example.com/site.json', null); + renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.equal(global.fetch.mock.calls.length, 1); + await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); assert.equal( global.fetch.mock.calls[0].arguments[0], 'https://example.com/site.json' @@ -30,20 +37,31 @@ describe('fetchBanners', () => { Promise.resolve(makeResponse({}, false)) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, []); + await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); + assert.deepEqual(result.current.banners, []); }); - it('propagates fetch errors to the caller', async t => { + it('handles fetch errors silently', async t => { t.mock.method(global, 'fetch', () => Promise.reject(new Error('Network error')) ); - await assert.rejects( - () => fetchBanners('https://example.com/site.json', null), - { message: 'Network error' } + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) ); + + await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); + assert.deepEqual(result.current.banners, []); }); }); @@ -54,9 +72,14 @@ describe('fetchBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, [banner]); + await waitFor(() => assert.deepEqual(result.current.banners, [banner])); }); it('returns the active version-specific banner', async t => { @@ -65,9 +88,14 @@ describe('fetchBanners', () => { Promise.resolve(makeResponse({ v20: banner })) ); - const result = await fetchBanners('https://example.com/site.json', 20); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: 20, + }) + ); - assert.deepEqual(result, [banner]); + await waitFor(() => assert.deepEqual(result.current.banners, [banner])); }); it('returns both global and version banners when both are active', async t => { @@ -79,9 +107,16 @@ describe('fetchBanners', () => { ) ); - const result = await fetchBanners('https://example.com/site.json', 20); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: 20, + }) + ); - assert.deepEqual(result, [globalBanner, versionBanner]); + await waitFor(() => + assert.deepEqual(result.current.banners, [globalBanner, versionBanner]) + ); }); it('returns global banner first, version banner second', async t => { @@ -93,10 +128,17 @@ describe('fetchBanners', () => { ) ); - const result = await fetchBanners('https://example.com/site.json', 22); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: 22, + }) + ); - assert.equal(result[0], globalBanner); - assert.equal(result[1], versionBanner); + await waitFor(() => { + assert.equal(result.current.banners[0], globalBanner); + assert.equal(result.current.banners[1], versionBanner); + }); }); it('does not include the version banner when versionMajor is null', async t => { @@ -108,9 +150,16 @@ describe('fetchBanners', () => { ) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, [globalBanner]); + await waitFor(() => + assert.deepEqual(result.current.banners, [globalBanner]) + ); }); it('returns an empty array when websiteBanners is absent', async t => { @@ -118,9 +167,15 @@ describe('fetchBanners', () => { Promise.resolve({ ok: true, json: async () => ({}) }) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, []); + await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); + assert.deepEqual(result.current.banners, []); }); }); @@ -131,9 +186,15 @@ describe('fetchBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, []); + await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); + assert.deepEqual(result.current.banners, []); }); it('excludes a banner whose startDate is in the future', async t => { @@ -142,9 +203,15 @@ describe('fetchBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, []); + await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); + assert.deepEqual(result.current.banners, []); }); it('includes a banner within its active date range', async t => { @@ -158,9 +225,14 @@ describe('fetchBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const result = await fetchBanners('https://example.com/site.json', null); + const { result } = renderHook(() => + useBanners({ + remoteConfig: 'https://example.com/site.json', + versionMajor: null, + }) + ); - assert.deepEqual(result, [banner]); + await waitFor(() => assert.deepEqual(result.current.banners, [banner])); }); }); }); diff --git a/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs deleted file mode 100644 index 8b34cfb6..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/fetchBanners.mjs +++ /dev/null @@ -1,39 +0,0 @@ -/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ - -import { isBannerActive } from '../../utils/banner.mjs'; - -/** - * Fetches and returns active banners for the given version from the remote config. - * Returns an empty array on any fetch or parse failure. - * - * @param {string} remoteConfig - * @param {number | null} versionMajor - * @returns {Promise} - */ -export const fetchBanners = async (remoteConfig, versionMajor) => { - const res = await fetch(remoteConfig, { signal: AbortSignal.timeout(2500) }); - - if (!res.ok) { - return []; - } - - /** @type {RemoteConfig} */ - const config = await res.json(); - - const active = []; - - const globalBanner = config.websiteBanners?.index; - if (globalBanner && isBannerActive(globalBanner)) { - active.push(globalBanner); - } - - // no version info available, skip version-specific banner - if (versionMajor != null) { - const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; - if (versionBanner && isBannerActive(versionBanner)) { - active.push(versionBanner); - } - } - - return active; -}; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index 2c704c98..fc3481cc 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -1,11 +1,8 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; -import { useEffect, useState } from 'preact/hooks'; -import { fetchBanners } from './fetchBanners.mjs'; import styles from './index.module.css'; - -/** @import { BannerEntry } from './types.d.ts' */ +import { useBanners } from './useBanners.mjs'; /** * Asynchronously fetches and displays announcement banners from the remote config. @@ -15,17 +12,7 @@ import styles from './index.module.css'; * @param {{ remoteConfig: string, versionMajor: number | null }} props */ export default ({ remoteConfig, versionMajor }) => { - const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); - - useEffect(() => { - if (!remoteConfig) { - return; - } - - fetchBanners(remoteConfig, versionMajor) - .then(setBanners) - .catch(console.error); - }, []); + const { banners } = useBanners({ remoteConfig, versionMajor }); if (!banners.length) { return null; diff --git a/src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs new file mode 100644 index 00000000..f6c957c7 --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +import { isBannerActive } from '../../utils/banner.mjs'; + +/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ + +/** + * Fetches and returns active banners for the given version. + * Returns an empty array until loaded or on any failure. + * + * @param {{ remoteConfig: string, versionMajor: number | null }} props + * @returns {{ banners: BannerEntry[] }} + */ +export const useBanners = ({ remoteConfig, versionMajor }) => { + const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); + + useEffect(() => { + if (!remoteConfig) { + return; + } + + /** @returns {Promise} */ + const load = async () => { + const res = await fetch(remoteConfig, { + signal: AbortSignal.timeout(2500), + }); + + if (!res.ok) { + return; + } + + /** @type {RemoteConfig} */ + const config = await res.json(); + const active = []; + + const globalBanner = config.websiteBanners?.index; + if (globalBanner && isBannerActive(globalBanner)) { + active.push(globalBanner); + } + + // no version info available, skip version-specific banner + if (versionMajor != null) { + const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } + } + + setBanners(active); + }; + + load().catch(console.error); + }, []); + + return { banners }; +}; From 38e168be0710b7ce194704198e50ffe87b228664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 23 Mar 2026 20:12:18 -0300 Subject: [PATCH 10/27] feat: set lazy loading --- .../__tests__/useBanners.test.mjs | 136 +++++------------- .../components/AnnouncementBanner/index.jsx | 71 +++++---- .../AnnouncementBanner/loadBanners.mjs | 47 ++++++ .../AnnouncementBanner/useBanners.mjs | 56 -------- src/generators/web/utils/generate.mjs | 10 +- 5 files changed, 135 insertions(+), 185 deletions(-) create mode 100644 src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs delete mode 100644 src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs diff --git a/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs b/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs index 552dc87d..cb0560c4 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs @@ -1,9 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { renderHook, waitFor } from '@testing-library/react'; - -import { useBanners } from '../useBanners.mjs'; +import { loadBanners } from '../loadBanners.mjs'; const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow @@ -13,19 +11,14 @@ const makeResponse = (banners, ok = true) => ({ json: async () => ({ websiteBanners: banners }), }); -describe('useBanners', () => { +describe('loadBanners', () => { describe('fetch behavior', () => { it('fetches from the given URL', async t => { t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({}))); - renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); + assert.equal(global.fetch.mock.calls.length, 1); assert.equal( global.fetch.mock.calls[0].arguments[0], 'https://example.com/site.json' @@ -37,15 +30,9 @@ describe('useBanners', () => { Promise.resolve(makeResponse({}, false)) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); - assert.deepEqual(result.current.banners, []); + assert.deepEqual(result, []); }); it('handles fetch errors silently', async t => { @@ -53,15 +40,18 @@ describe('useBanners', () => { Promise.reject(new Error('Network error')) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); + + assert.deepEqual(result, []); + }); + + it('returns an empty array when remoteConfig is absent', async t => { + t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({}))); - await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); - assert.deepEqual(result.current.banners, []); + const result = await loadBanners(undefined, null); + + assert.deepEqual(result, []); + assert.equal(global.fetch.mock.calls.length, 0); }); }); @@ -72,14 +62,9 @@ describe('useBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.deepEqual(result.current.banners, [banner])); + assert.deepEqual(result, [banner]); }); it('returns the active version-specific banner', async t => { @@ -88,14 +73,9 @@ describe('useBanners', () => { Promise.resolve(makeResponse({ v20: banner })) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: 20, - }) - ); + const result = await loadBanners('https://example.com/site.json', 20); - await waitFor(() => assert.deepEqual(result.current.banners, [banner])); + assert.deepEqual(result, [banner]); }); it('returns both global and version banners when both are active', async t => { @@ -107,16 +87,9 @@ describe('useBanners', () => { ) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: 20, - }) - ); + const result = await loadBanners('https://example.com/site.json', 20); - await waitFor(() => - assert.deepEqual(result.current.banners, [globalBanner, versionBanner]) - ); + assert.deepEqual(result, [globalBanner, versionBanner]); }); it('returns global banner first, version banner second', async t => { @@ -128,17 +101,10 @@ describe('useBanners', () => { ) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: 22, - }) - ); + const result = await loadBanners('https://example.com/site.json', 22); - await waitFor(() => { - assert.equal(result.current.banners[0], globalBanner); - assert.equal(result.current.banners[1], versionBanner); - }); + assert.equal(result[0], globalBanner); + assert.equal(result[1], versionBanner); }); it('does not include the version banner when versionMajor is null', async t => { @@ -150,16 +116,9 @@ describe('useBanners', () => { ) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => - assert.deepEqual(result.current.banners, [globalBanner]) - ); + assert.deepEqual(result, [globalBanner]); }); it('returns an empty array when websiteBanners is absent', async t => { @@ -167,15 +126,9 @@ describe('useBanners', () => { Promise.resolve({ ok: true, json: async () => ({}) }) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); - assert.deepEqual(result.current.banners, []); + assert.deepEqual(result, []); }); }); @@ -186,15 +139,9 @@ describe('useBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); - assert.deepEqual(result.current.banners, []); + assert.deepEqual(result, []); }); it('excludes a banner whose startDate is in the future', async t => { @@ -203,15 +150,9 @@ describe('useBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.equal(global.fetch.mock.calls.length, 1)); - assert.deepEqual(result.current.banners, []); + assert.deepEqual(result, []); }); it('includes a banner within its active date range', async t => { @@ -225,14 +166,9 @@ describe('useBanners', () => { Promise.resolve(makeResponse({ index: banner })) ); - const { result } = renderHook(() => - useBanners({ - remoteConfig: 'https://example.com/site.json', - versionMajor: null, - }) - ); + const result = await loadBanners('https://example.com/site.json', null); - await waitFor(() => assert.deepEqual(result.current.banners, [banner])); + assert.deepEqual(result, [banner]); }); }); }); diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index fc3481cc..83eca72f 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -1,37 +1,56 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; +import { lazy, Suspense } from 'preact/compat'; +import { useMemo } from 'preact/hooks'; import styles from './index.module.css'; -import { useBanners } from './useBanners.mjs'; +import { loadBanners } from './loadBanners.mjs'; + +/** @import { BannerEntry } 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. - * - * @param {{ remoteConfig: string, versionMajor: number | null }} props - */ export default ({ remoteConfig, versionMajor }) => { - const { banners } = useBanners({ remoteConfig, versionMajor }); + const LazyBanners = useMemo( + () => + lazy(async () => { + const active = await loadBanners(remoteConfig, versionMajor); + + if (!active.length) { + return { default: () => null }; + } - if (!banners.length) { - return null; - } + return { + default: () => ( +
+ {active.map(banner => ( + + {banner.link ? ( + + {banner.text} + + ) : ( + banner.text + )} + {banner.link && } + + ))} +
+ ), + }; + }), + [] + ); return ( -
- {banners.map(banner => ( - - {banner.link ? ( - - {banner.text} - - ) : ( - banner.text - )} - {banner.link && } - - ))} -
+ + + ); }; diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs new file mode 100644 index 00000000..234c0adb --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs @@ -0,0 +1,47 @@ +import { isBannerActive } from '../../utils/banner.mjs'; + +/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ + +/** + * Fetches and returns active banners for the given version. + * Returns an empty array when remoteConfig is absent, the response is not ok, + * or on any fetch/parse failure. + * + * @param {string | undefined} remoteConfig + * @param {number | null} versionMajor + * @returns {Promise} + */ +export const loadBanners = async (remoteConfig, versionMajor) => { + try { + if (!remoteConfig) { + return []; + } + + const res = await fetch(remoteConfig, { + signal: AbortSignal.timeout(2500), + }); + if (!res.ok) { + return []; + } + + /** @type {RemoteConfig} */ + const config = await res.json(); + const active = []; + + const globalBanner = config.websiteBanners?.index; + if (globalBanner && isBannerActive(globalBanner)) { + active.push(globalBanner); + } + + if (versionMajor != null) { + const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } + } + + return active; + } catch { + return []; + } +}; diff --git a/src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs deleted file mode 100644 index f6c957c7..00000000 --- a/src/generators/web/ui/components/AnnouncementBanner/useBanners.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { isBannerActive } from '../../utils/banner.mjs'; - -/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ - -/** - * Fetches and returns active banners for the given version. - * Returns an empty array until loaded or on any failure. - * - * @param {{ remoteConfig: string, versionMajor: number | null }} props - * @returns {{ banners: BannerEntry[] }} - */ -export const useBanners = ({ remoteConfig, versionMajor }) => { - const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); - - useEffect(() => { - if (!remoteConfig) { - return; - } - - /** @returns {Promise} */ - const load = async () => { - const res = await fetch(remoteConfig, { - signal: AbortSignal.timeout(2500), - }); - - if (!res.ok) { - return; - } - - /** @type {RemoteConfig} */ - const config = await res.json(); - const active = []; - - const globalBanner = config.websiteBanners?.index; - if (globalBanner && isBannerActive(globalBanner)) { - active.push(globalBanner); - } - - // no version info available, skip version-specific banner - if (versionMajor != null) { - const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; - if (versionBanner && isBannerActive(versionBanner)) { - active.push(versionBanner); - } - } - - setBanners(active); - }; - - load().catch(console.error); - }, []); - - return { banners }; -}; diff --git a/src/generators/web/utils/generate.mjs b/src/generators/web/utils/generate.mjs index ee10ec3d..3c4069f6 100644 --- a/src/generators/web/utils/generate.mjs +++ b/src/generators/web/utils/generate.mjs @@ -80,11 +80,15 @@ export default () => { // Import all JSX components ...baseImports, - // Import Preact's SSR render function (named import) - createImportDeclaration('render', 'preact-render-to-string', false), + // Import Preact's async SSR render function (named import) + createImportDeclaration( + 'renderToStringAsync', + 'preact-render-to-string', + false + ), // Render component to HTML string and return it - `return render(${componentCode});`, + `return renderToStringAsync(${componentCode});`, ].join('\n'); }; From 36a4a2187804effd27136446fd6c68e2a8ea01fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 25 Mar 2026 19:54:11 -0300 Subject: [PATCH 11/27] refactor: use layout component --- src/generators/web/constants.mjs | 24 ------------------- .../web/ui/components/Layout/index.jsx | 19 +++++++++++++-- 2 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 3171bd57..5cbfe760 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -14,34 +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'), - }, Layout: { name: 'Layout', source: '#theme/Layout', }, - NavBar: { - name: 'NavBar', - source: resolve(ROOT, './ui/components/NavBar'), - }, - Article: { - name: 'Article', - source: '@node-core/ui-components/Containers/Article', - }, - SideBar: { - name: 'SideBar', - source: resolve(ROOT, './ui/components/SideBar'), - }, - TableOfContents: { - name: 'TableOfContents', - source: '@node-core/ui-components/Common/TableOfContents', - }, - MetaBar: { - name: 'MetaBar', - source: resolve(ROOT, './ui/components/MetaBar'), - }, CodeBox: { name: 'CodeBox', source: resolve(ROOT, './ui/components/CodeBox'), diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index 9978fc0c..87973dfc 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -1,6 +1,8 @@ import TableOfContents from '@node-core/ui-components/Common/TableOfContents'; import Article from '@node-core/ui-components/Containers/Article'; +import AnnouncementBanner from '../AnnouncementBanner'; + import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; import NavBar from '#theme/Navigation'; @@ -13,10 +15,23 @@ import SideBar from '#theme/Sidebar'; * main content, meta bar, and footer. Override via `#theme/Layout` in your * configuration's `imports` to customize the entire page structure. * - * @param {{ metadata: import('../../types').SerializedMetadata, headings: Array, readingTime: string, children: import('preact').ComponentChildren }} props + * @param {{ + * metadata: import('../../types').SerializedMetadata, + * headings: Array, + * readingTime: string, + * children: import('preact').ComponentChildren, + * announcementBannerProps: object + * }} props */ -export default ({ metadata, headings, readingTime, children }) => ( +export default ({ + metadata, + headings, + readingTime, + announcementBannerProps, + children, +}) => ( <> +
From 149c1e1fbb1a23c77f053e20ff39f0edc885871f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 25 Mar 2026 19:59:02 -0300 Subject: [PATCH 12/27] feat: separate components --- .../components/AnnouncementBanner/index.jsx | 54 +++++++++---------- .../components/AnnouncementBanner/types.d.ts | 9 ++++ .../web/ui/components/Layout/index.jsx | 4 +- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index 83eca72f..51d89729 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -8,7 +8,32 @@ import { loadBanners } from './loadBanners.mjs'; /** @import { BannerEntry } from './types.d.ts' */ -export default ({ remoteConfig, versionMajor }) => { +/** + * @param {{ banners: BannerEntry[] }} props + */ +const AnnouncementBanner = ({ banners }) => ( +
+ {banners.map(banner => ( + + {banner.link ? ( + + {banner.text} + + ) : ( + banner.text + )} + {banner.link && } + + ))} +
+); + +export default AnnouncementBanner; + +/** + * @param {{ remoteConfig: string, versionMajor: number | null }} props + */ +export const RemoteLoadableBanner = ({ remoteConfig, versionMajor }) => { const LazyBanners = useMemo( () => lazy(async () => { @@ -18,32 +43,7 @@ export default ({ remoteConfig, versionMajor }) => { return { default: () => null }; } - return { - default: () => ( -
- {active.map(banner => ( - - {banner.link ? ( - - {banner.text} - - ) : ( - banner.text - )} - {banner.link && } - - ))} -
- ), - }; + return { default: () => }; }), [] ); diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts index bdd3b605..5423b6df 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts +++ b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts @@ -11,3 +11,12 @@ export type BannerEntry = { export type RemoteConfig = { websiteBanners?: Record; }; + +export type AnnouncementBannerProps = { + banners: BannerEntry[]; +}; + +export type RemoteLoadableBannerProps = { + remoteConfig: string; + versionMajor: number | null; +}; diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index 87973dfc..b9e14613 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -1,7 +1,7 @@ import TableOfContents from '@node-core/ui-components/Common/TableOfContents'; import Article from '@node-core/ui-components/Containers/Article'; -import AnnouncementBanner from '../AnnouncementBanner'; +import { RemoteLoadableBanner } from '../AnnouncementBanner'; import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; @@ -31,7 +31,7 @@ export default ({ children, }) => ( <> - +
From 44259a0de299131837262c324e70cb5eef7d53c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 25 Mar 2026 20:02:52 -0300 Subject: [PATCH 13/27] refactor: clean loadBanners --- .../AnnouncementBanner/loadBanners.mjs | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs index 234c0adb..567537bb 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs @@ -12,11 +12,11 @@ import { isBannerActive } from '../../utils/banner.mjs'; * @returns {Promise} */ export const loadBanners = async (remoteConfig, versionMajor) => { - try { - if (!remoteConfig) { - return []; - } + if (!remoteConfig) { + return []; + } + try { const res = await fetch(remoteConfig, { signal: AbortSignal.timeout(2500), }); @@ -25,22 +25,15 @@ export const loadBanners = async (remoteConfig, versionMajor) => { } /** @type {RemoteConfig} */ - const config = await res.json(); - const active = []; - - const globalBanner = config.websiteBanners?.index; - if (globalBanner && isBannerActive(globalBanner)) { - active.push(globalBanner); - } + const { websiteBanners = {} } = await res.json(); - if (versionMajor != null) { - const versionBanner = config.websiteBanners?.[`v${versionMajor}`]; - if (versionBanner && isBannerActive(versionBanner)) { - active.push(versionBanner); - } - } + const keys = ['index', versionMajor != null && `v${versionMajor}`].filter( + Boolean + ); - return active; + return keys + .map(key => websiteBanners[key]) + .filter(banner => banner && isBannerActive(banner)); } catch { return []; } From f426f4fd8279876a3b2b231cf458d4e987bf6ec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 30 Mar 2026 16:32:37 -0300 Subject: [PATCH 14/27] refactor: split component files --- .../AnnouncementBanner/AnnouncementBanner.jsx | 28 ++++++++++++++++ .../components/AnnouncementBanner/index.jsx | 32 +++---------------- .../web/ui/components/Layout/index.jsx | 2 +- 3 files changed, 33 insertions(+), 29 deletions(-) create mode 100644 src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx diff --git a/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx b/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx new file mode 100644 index 00000000..645e9ea1 --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx @@ -0,0 +1,28 @@ +import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; +import Banner from '@node-core/ui-components/Common/Banner'; + +import styles from './index.module.css'; + +/** @import { BannerEntry } from './types.d.ts' */ + +/** + * @param {{ banners: BannerEntry[] }} props + */ +const AnnouncementBanner = ({ banners }) => ( +
+ {banners.map(banner => ( + + {banner.link ? ( + + {banner.text} + + ) : ( + banner.text + )} + {banner.link && } + + ))} +
+); + +export default AnnouncementBanner; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index 51d89729..d9d34338 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -1,39 +1,13 @@ -import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; -import Banner from '@node-core/ui-components/Common/Banner'; import { lazy, Suspense } from 'preact/compat'; import { useMemo } from 'preact/hooks'; -import styles from './index.module.css'; +import AnnouncementBanner from './AnnouncementBanner.jsx'; import { loadBanners } from './loadBanners.mjs'; -/** @import { BannerEntry } from './types.d.ts' */ - -/** - * @param {{ banners: BannerEntry[] }} props - */ -const AnnouncementBanner = ({ banners }) => ( -
- {banners.map(banner => ( - - {banner.link ? ( - - {banner.text} - - ) : ( - banner.text - )} - {banner.link && } - - ))} -
-); - -export default AnnouncementBanner; - /** * @param {{ remoteConfig: string, versionMajor: number | null }} props */ -export const RemoteLoadableBanner = ({ remoteConfig, versionMajor }) => { +const RemoteLoadableBanner = ({ remoteConfig, versionMajor }) => { const LazyBanners = useMemo( () => lazy(async () => { @@ -54,3 +28,5 @@ export const RemoteLoadableBanner = ({ remoteConfig, versionMajor }) => { ); }; + +export default RemoteLoadableBanner; diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index b9e14613..83904378 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -1,7 +1,7 @@ import TableOfContents from '@node-core/ui-components/Common/TableOfContents'; import Article from '@node-core/ui-components/Containers/Article'; -import { RemoteLoadableBanner } from '../AnnouncementBanner'; +import RemoteLoadableBanner from '../AnnouncementBanner'; import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; From 7e496697f3238cd99af8746aace3bed48ad1441e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 30 Mar 2026 16:36:16 -0300 Subject: [PATCH 15/27] test: fix test name --- .../__tests__/{useBanners.test.mjs => loadBanners.test.mjs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/generators/web/ui/components/AnnouncementBanner/__tests__/{useBanners.test.mjs => loadBanners.test.mjs} (100%) diff --git a/src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs b/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs similarity index 100% rename from src/generators/web/ui/components/AnnouncementBanner/__tests__/useBanners.test.mjs rename to src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs From cf2b7daa5865eb8c4dc8e82be727cf818668e469 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 30 Mar 2026 18:10:56 -0300 Subject: [PATCH 16/27] fix: review --- .../web/ui/components/AnnouncementBanner/loadBanners.mjs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs index 567537bb..be868024 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs @@ -17,12 +17,7 @@ export const loadBanners = async (remoteConfig, versionMajor) => { } try { - const res = await fetch(remoteConfig, { - signal: AbortSignal.timeout(2500), - }); - if (!res.ok) { - return []; - } + const res = await fetch(remoteConfig); /** @type {RemoteConfig} */ const { websiteBanners = {} } = await res.json(); From 9b101327d2e86defff7ab85866604fd4133ee304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Mon, 30 Mar 2026 18:11:56 -0300 Subject: [PATCH 17/27] chore: remove testing library --- package-lock.json | 195 +--------------------------------------------- package.json | 4 +- 2 files changed, 4 insertions(+), 195 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6aaca907..f5de2e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,8 +59,6 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@reporters/github": "^1.12.0", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", "@types/mdast": "^4.0.4", "@types/node": "^24.10.1", "@types/semver": "^7.7.1", @@ -123,41 +121,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -918,9 +881,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", "license": "MIT", "optional": true, "dependencies": { @@ -2312,24 +2275,6 @@ "node": ">=14.0.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.0-rc.12", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", @@ -2812,54 +2757,6 @@ "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -2870,13 +2767,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3639,16 +3529,6 @@ "node": ">=10" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -4191,13 +4071,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -5394,13 +5267,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, "node_modules/jsdoc-type-pratt-parser": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.1.tgz", @@ -5976,16 +5842,6 @@ "node": "20 || >=22" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7404,44 +7260,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/pretty-hrtime": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", @@ -7502,13 +7320,6 @@ "react": "^19.2.5" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT" - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 72596477..2768520c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "format": "prettier .", "format:write": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test --experimental-test-module-mocks --import=global-jsdom/register \"src/**/*.test.mjs\"", + "test": "node --test --experimental-test-module-mocks \"src/**/*.test.mjs\"", "test:coverage": "c8 node --test --experimental-test-module-mocks \"src/**/*.test.mjs\"", "test:ci": "c8 --reporter=lcov node --test --experimental-test-module-mocks \"src/**/*.test.mjs\" --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml --test-reporter=spec --test-reporter-destination=stdout", "test:update-snapshots": "node --test --experimental-test-module-mocks --test-update-snapshots \"src/**/*.test.mjs\"", @@ -28,8 +28,6 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@reporters/github": "^1.12.0", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", "@types/mdast": "^4.0.4", "@types/node": "^24.10.1", "@types/semver": "^7.7.1", From ad9ae89b4e1e751aaccc8622efbbf5fe32a7dcf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Tue, 31 Mar 2026 20:23:10 -0300 Subject: [PATCH 18/27] refactor: remove type import --- .../ui/components/AnnouncementBanner/AnnouncementBanner.jsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx b/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx index 645e9ea1..e6be507a 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/AnnouncementBanner.jsx @@ -3,10 +3,8 @@ import Banner from '@node-core/ui-components/Common/Banner'; import styles from './index.module.css'; -/** @import { BannerEntry } from './types.d.ts' */ - /** - * @param {{ banners: BannerEntry[] }} props + * @param {import('./types.d.ts').AnnouncementBannerProps} props */ const AnnouncementBanner = ({ banners }) => (
From 300d562a41f9c2ee9446f338ce8cb603739851da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Wed, 1 Apr 2026 19:01:17 -0300 Subject: [PATCH 19/27] refactor: inline type import --- .../web/ui/components/AnnouncementBanner/loadBanners.mjs | 6 ++---- src/generators/web/ui/utils/banner.mjs | 4 +--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs index be868024..13043c78 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs @@ -1,7 +1,5 @@ import { isBannerActive } from '../../utils/banner.mjs'; -/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ - /** * Fetches and returns active banners for the given version. * Returns an empty array when remoteConfig is absent, the response is not ok, @@ -9,7 +7,7 @@ import { isBannerActive } from '../../utils/banner.mjs'; * * @param {string | undefined} remoteConfig * @param {number | null} versionMajor - * @returns {Promise} + * @returns {Promise} */ export const loadBanners = async (remoteConfig, versionMajor) => { if (!remoteConfig) { @@ -19,7 +17,7 @@ export const loadBanners = async (remoteConfig, versionMajor) => { try { const res = await fetch(remoteConfig); - /** @type {RemoteConfig} */ + /** @type {import('./types.d.ts').RemoteConfig} */ const { websiteBanners = {} } = await res.json(); const keys = ['index', versionMajor != null && `v${versionMajor}`].filter( diff --git a/src/generators/web/ui/utils/banner.mjs b/src/generators/web/ui/utils/banner.mjs index b8721152..4afa820a 100644 --- a/src/generators/web/ui/utils/banner.mjs +++ b/src/generators/web/ui/utils/banner.mjs @@ -1,11 +1,9 @@ -/** @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 + * @param {import('../components/AnnouncementBanner/types').BannerEntry} banner * @returns {boolean} */ export const isBannerActive = ({ startDate, endDate }) => { From f1b7d56471a3937e2b9e07981668dcd3b8aa1c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 2 Apr 2026 12:13:31 -0300 Subject: [PATCH 20/27] fix: remove duplicated banner --- src/generators/jsx-ast/utils/buildContent.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index 4650061c..12f54825 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -279,7 +279,6 @@ export const processEntry = entry => { */ export const createDocumentLayout = (entries, metadata) => createTree('root', [ - createJSXElement(JSX_IMPORTS.AnnouncementBanner.name), createJSXElement(JSX_IMPORTS.Layout.name, { metadata, headings: extractHeadings(entries), From 85a32451d66a09d281d3f54cadde936b65866fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 2 Apr 2026 14:41:20 -0300 Subject: [PATCH 21/27] fix: web config --- src/generators/jsx-ast/index.mjs | 1 - src/generators/web/index.mjs | 1 + .../components/AnnouncementBanner/index.jsx | 1 + .../web/ui/components/Layout/index.jsx | 22 ++++++------------- src/generators/web/ui/types.d.ts | 2 ++ src/generators/web/utils/processing.mjs | 2 +- 6 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/generators/jsx-ast/index.mjs b/src/generators/jsx-ast/index.mjs index b90c3f06..da61cb56 100644 --- a/src/generators/jsx-ast/index.mjs +++ b/src/generators/jsx-ast/index.mjs @@ -18,7 +18,6 @@ export default createLazyGenerator({ defaultConfiguration: { ref: 'main', - remoteConfig: 'https://nodejs.org/site.json', }, hasParallelProcessor: true, diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index e28e281c..348f92db 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -34,6 +34,7 @@ export default createLazyGenerator({ useAbsoluteURLs: false, editURL: `${GITHUB_EDIT_URL}/doc/api{path}.md`, pageURL: '{baseURL}/latest-{version}/api{path}.html', + remoteConfig: 'https://nodejs.org/site.json', imports: { '#theme/Logo': '@node-core/ui-components/Common/NodejsLogo', '#theme/Navigation': join(import.meta.dirname, './ui/components/NavBar'), diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index d9d34338..93c6f6a4 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -19,6 +19,7 @@ const RemoteLoadableBanner = ({ remoteConfig, versionMajor }) => { return { default: () => }; }), + // eslint-disable-next-line react-x/exhaustive-deps [] ); diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index 83904378..5444aefa 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -3,6 +3,7 @@ import Article from '@node-core/ui-components/Containers/Article'; import RemoteLoadableBanner from '../AnnouncementBanner'; +import { remoteConfig, versionMajor } from '#theme/config'; import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; import NavBar from '#theme/Navigation'; @@ -15,23 +16,14 @@ import SideBar from '#theme/Sidebar'; * main content, meta bar, and footer. Override via `#theme/Layout` in your * configuration's `imports` to customize the entire page structure. * - * @param {{ - * metadata: import('../../types').SerializedMetadata, - * headings: Array, - * readingTime: string, - * children: import('preact').ComponentChildren, - * announcementBannerProps: object - * }} props + * @param {{ metadata: import('../../types').SerializedMetadata, headings: Array, readingTime: string, children: import('preact').ComponentChildren }} props */ -export default ({ - metadata, - headings, - readingTime, - announcementBannerProps, - children, -}) => ( +export default ({ metadata, headings, readingTime, children }) => ( <> - +
diff --git a/src/generators/web/ui/types.d.ts b/src/generators/web/ui/types.d.ts index 081061db..e200135a 100644 --- a/src/generators/web/ui/types.d.ts +++ b/src/generators/web/ui/types.d.ts @@ -24,6 +24,7 @@ declare module '#theme/config' { // From config generation export const version: string; + export const versionMajor: number; export const versions: Array<{ url: string; label: string; @@ -32,6 +33,7 @@ declare module '#theme/config' { export const editURL: string; export const pages: Array<[string, string]>; export const languageDisplayNameMap: Map; + export const remoteConfig: string; } // Omit Primitives from Metadata diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index e4a66f23..184163ea 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -84,7 +84,7 @@ async function executeServerCode(serverCodeMap, requireFn, virtualImports) { // Execute each bundled entry and collect dehydrated HTML results for (const chunk of entryChunks) { const executedFunction = new Function('require', chunk.code); - pages.set(chunk.fileName, executedFunction(enhancedRequire)); + pages.set(chunk.fileName, await executedFunction(enhancedRequire)); } return { pages, css }; From 25c763cec7fff2fcbb01db176f79775a0ddfbe8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 2 Apr 2026 15:34:26 -0300 Subject: [PATCH 22/27] fix: simplify lazy --- .../components/AnnouncementBanner/index.jsx | 35 +++++++------------ .../web/ui/components/Layout/index.jsx | 6 +--- 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index 93c6f6a4..5a8d9ed7 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -1,33 +1,22 @@ -import { lazy, Suspense } from 'preact/compat'; -import { useMemo } from 'preact/hooks'; +import { useState, useEffect } from 'preact/hooks'; import AnnouncementBanner from './AnnouncementBanner.jsx'; import { loadBanners } from './loadBanners.mjs'; -/** - * @param {{ remoteConfig: string, versionMajor: number | null }} props - */ -const RemoteLoadableBanner = ({ remoteConfig, versionMajor }) => { - const LazyBanners = useMemo( - () => - lazy(async () => { - const active = await loadBanners(remoteConfig, versionMajor); +import { remoteConfig, versionMajor } from '#theme/config'; - if (!active.length) { - return { default: () => null }; - } +const RemoteLoadableBanner = () => { + const [banners, setBanners] = useState([]); - return { default: () => }; - }), - // eslint-disable-next-line react-x/exhaustive-deps - [] - ); + useEffect(() => { + loadBanners(remoteConfig, versionMajor).then(active => { + if (active.length) { + setBanners(active); + } + }); + }, []); - return ( - - - - ); + return banners.length ? : null; }; export default RemoteLoadableBanner; diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx index 5444aefa..b4416363 100644 --- a/src/generators/web/ui/components/Layout/index.jsx +++ b/src/generators/web/ui/components/Layout/index.jsx @@ -3,7 +3,6 @@ import Article from '@node-core/ui-components/Containers/Article'; import RemoteLoadableBanner from '../AnnouncementBanner'; -import { remoteConfig, versionMajor } from '#theme/config'; import Footer from '#theme/Footer'; import MetaBar from '#theme/Metabar'; import NavBar from '#theme/Navigation'; @@ -20,10 +19,7 @@ import SideBar from '#theme/Sidebar'; */ export default ({ metadata, headings, readingTime, children }) => ( <> - +
From 298cbf5bf90923e9a831f71fce1ac010325cdc65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 2 Apr 2026 15:42:47 -0300 Subject: [PATCH 23/27] fix: lazy loading --- .../components/AnnouncementBanner/index.jsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index 5a8d9ed7..eaf82d55 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -1,22 +1,30 @@ -import { useState, useEffect } from 'preact/hooks'; +import { lazy, Suspense } from 'preact/compat'; import AnnouncementBanner from './AnnouncementBanner.jsx'; import { loadBanners } from './loadBanners.mjs'; import { remoteConfig, versionMajor } from '#theme/config'; -const RemoteLoadableBanner = () => { - const [banners, setBanners] = useState([]); +const LazyBanners = SERVER + ? null + : lazy(async () => { + const active = await loadBanners(remoteConfig, versionMajor); - useEffect(() => { - loadBanners(remoteConfig, versionMajor).then(active => { - if (active.length) { - setBanners(active); + if (!active.length) { + return { default: () => null }; } + + return { default: () => }; }); - }, []); - return banners.length ? : null; -}; +const RemoteLoadableBanner = SERVER + ? () =>
+ : () => ( +
+ + + +
+ ); export default RemoteLoadableBanner; From e2a76a143c87a325cac3b20a5d66a9fd7710f19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sun, 5 Apr 2026 23:19:24 -0300 Subject: [PATCH 24/27] fix: add major version config --- src/generators/web/index.mjs | 2 -- src/generators/web/ui/components/AnnouncementBanner/index.jsx | 1 + src/generators/web/utils/config.mjs | 1 + 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 348f92db..ca0bb6a9 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -44,7 +44,5 @@ export default createLazyGenerator({ '#theme/Layout': join(import.meta.dirname, './ui/components/Layout'), }, virtualImports: {}, - 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 eaf82d55..932037e2 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -5,6 +5,7 @@ import { loadBanners } from './loadBanners.mjs'; import { remoteConfig, versionMajor } from '#theme/config'; +// TODO: Revisit SERVER global usage. const LazyBanners = SERVER ? null : lazy(async () => { diff --git a/src/generators/web/utils/config.mjs b/src/generators/web/utils/config.mjs index 00d7ae15..414e6f8f 100644 --- a/src/generators/web/utils/config.mjs +++ b/src/generators/web/utils/config.mjs @@ -86,6 +86,7 @@ export default function createConfigSource(input) { ['changelog', 'index', 'imports', 'virtualImports'] ), version, + versionMajor: configVersion.major ?? null, versions: buildVersionEntries(config.changelog, pageURL), editURL, pages: buildPageList(input), From cc5383d961308d895633f7bd2747fcca9843e4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sun, 5 Apr 2026 23:33:06 -0300 Subject: [PATCH 25/27] refactor: nit --- .../web/ui/components/AnnouncementBanner/loadBanners.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs index 13043c78..818cab8f 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs +++ b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs @@ -7,7 +7,7 @@ import { isBannerActive } from '../../utils/banner.mjs'; * * @param {string | undefined} remoteConfig * @param {number | null} versionMajor - * @returns {Promise} + * @returns {Promise>} */ export const loadBanners = async (remoteConfig, versionMajor) => { if (!remoteConfig) { From 9aa4e45f31d17421b4b2a1d6135bd1735045e688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 9 Apr 2026 19:55:07 -0300 Subject: [PATCH 26/27] refactor: remove unused type --- .../web/ui/components/AnnouncementBanner/types.d.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts index 5423b6df..8ea873de 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts +++ b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts @@ -15,8 +15,3 @@ export type RemoteConfig = { export type AnnouncementBannerProps = { banners: BannerEntry[]; }; - -export type RemoteLoadableBannerProps = { - remoteConfig: string; - versionMajor: number | null; -}; From 248c2789cd7814a142c5eeab88f7a95f32d23580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Thu, 9 Apr 2026 21:30:53 -0300 Subject: [PATCH 27/27] refactor: assign promise --- src/generators/web/utils/processing.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 184163ea..7228ad6f 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -84,7 +84,8 @@ async function executeServerCode(serverCodeMap, requireFn, virtualImports) { // Execute each bundled entry and collect dehydrated HTML results for (const chunk of entryChunks) { const executedFunction = new Function('require', chunk.code); - pages.set(chunk.fileName, await executedFunction(enhancedRequire)); + const dehydratedHtml = await executedFunction(enhancedRequire); + pages.set(chunk.fileName, dehydratedHtml); } return { pages, css };