From ecb53da660149f53e22bee6c4f7d33b8c92ff7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Fri, 20 Feb 2026 16:55:14 -0300 Subject: [PATCH 01/10] feat: create announcements banner --- src/generators/jsx-ast/utils/buildContent.mjs | 1 + src/generators/web/constants.mjs | 4 ++ src/generators/web/index.mjs | 2 + src/generators/web/types.d.ts | 1 + .../components/AnnouncementBanner/index.jsx | 69 +++++++++++++++++++ .../components/AnnouncementBanner/types.d.ts | 11 +++ .../web/ui/utils/__tests__/banner.test.mjs | 63 +++++++++++++++++ src/generators/web/ui/utils/banner.mjs | 20 ++++++ src/generators/web/utils/data.mjs | 2 + 9 files changed, 173 insertions(+) create mode 100644 src/generators/web/ui/components/AnnouncementBanner/index.jsx create mode 100644 src/generators/web/ui/components/AnnouncementBanner/types.d.ts create mode 100644 src/generators/web/ui/utils/__tests__/banner.test.mjs create mode 100644 src/generators/web/ui/utils/banner.mjs diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index a938cb4f..01d338a4 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -287,6 +287,7 @@ export const createDocumentLayout = ( remark ) => createTree('root', [ + createJSXElement(JSX_IMPORTS.AnnouncementBanner.name), createJSXElement(JSX_IMPORTS.NavBar.name), createJSXElement(JSX_IMPORTS.Article.name, { children: [ diff --git a/src/generators/web/constants.mjs b/src/generators/web/constants.mjs index 8a18f8d9..e36ab548 100644 --- a/src/generators/web/constants.mjs +++ b/src/generators/web/constants.mjs @@ -14,6 +14,10 @@ export const ROOT = dirname(fileURLToPath(import.meta.url)); * An object containing mappings for various JSX components to their import paths. */ export const JSX_IMPORTS = { + AnnouncementBanner: { + name: 'AnnouncementBanner', + source: resolve(ROOT, './ui/components/AnnouncementBanner'), + }, NavBar: { name: 'NavBar', source: resolve(ROOT, './ui/components/NavBar'), diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 70ebd7dc..6d4b6d9d 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -34,6 +34,8 @@ export default { imports: { '#config/Logo': '@node-core/ui-components/Common/NodejsLogo', }, + remoteConfig: + 'https://gist.githubusercontent.com/araujogui/8ea72ffaf574f58fca1482e764e8b5c8/raw/16af51e4efbf37da7b6aff9b7e5dd967d955aacf/api-docs.config.json', }, /** diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index 5feed60e..61d102f3 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -5,6 +5,7 @@ export type Generator = GeneratorMetadata< templatePath: string; title: string; imports: Record; + remoteConfig: string | null; }, Generate, AsyncGenerator<{ html: string; css: string }>> >; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx new file mode 100644 index 00000000..e526a41c --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -0,0 +1,69 @@ +import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; +import Banner from '@node-core/ui-components/Common/Banner'; +import { useEffect, useState } from 'preact/hooks'; + +import { STATIC_DATA } from '../../constants.mjs'; +import { isBannerActive } from '../../utils/banner.mjs'; + +/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ + +/** + * Asynchronously fetches and displays announcement banners from the remote config. + * Global banners are rendered above version-specific ones. + * Non-blocking: silently ignores fetch/parse failures. + */ +export default () => { + const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); + + useEffect(() => { + const { remoteConfig, versionMajor } = STATIC_DATA; + + if (!remoteConfig) { + return; + } + + fetch(remoteConfig, { + signal: AbortSignal.timeout(2500), + }) + .then(async res => { + if (!res.ok) { + return; + } + + /** @type {RemoteConfig} */ + const config = await res.json(); + + const active = []; + + const globalBanner = config.global?.banner; + if (globalBanner && isBannerActive(globalBanner)) { + active.push(globalBanner); + } + + const versionBanner = config[`v${versionMajor}`]?.banner; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } + + setBanners(active); + }) + .catch(error => { + console.error(error); + }); + }, []); + + if (!banners.length) { + return null; + } + + return ( +
+ {banners.map(banner => ( + + {banner.link ? {banner.text} : banner.text} + {banner.link && } + + ))} +
+ ); +}; diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts new file mode 100644 index 00000000..1c0a152d --- /dev/null +++ b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts @@ -0,0 +1,11 @@ +import type { BannerProps } from '@node-core/ui-components/Common/Banner'; + +export type BannerEntry = { + startDate?: string; + endDate?: string; + text: string; + link?: string; + type?: BannerProps['type']; +}; + +export type RemoteConfig = Record; diff --git a/src/generators/web/ui/utils/__tests__/banner.test.mjs b/src/generators/web/ui/utils/__tests__/banner.test.mjs new file mode 100644 index 00000000..262d2c9d --- /dev/null +++ b/src/generators/web/ui/utils/__tests__/banner.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { isBannerActive } from '../banner.mjs'; + +const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday +const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow + +const banner = (overrides = {}) => ({ + text: 'Test banner', + ...overrides, +}); + +describe('isBannerActive', () => { + describe('no startDate, no endDate', () => { + it('is always active', () => { + assert.equal(isBannerActive(banner()), true); + }); + }); + + describe('startDate only', () => { + it('is active when startDate is in the past', () => { + assert.equal(isBannerActive(banner({ startDate: PAST })), true); + }); + + it('is not active when startDate is in the future', () => { + assert.equal(isBannerActive(banner({ startDate: FUTURE })), false); + }); + }); + + describe('endDate only', () => { + it('is active when endDate is in the future', () => { + assert.equal(isBannerActive(banner({ endDate: FUTURE })), true); + }); + + it('is not active when endDate is in the past', () => { + assert.equal(isBannerActive(banner({ endDate: PAST })), false); + }); + }); + + describe('startDate and endDate', () => { + it('is active when now is within the range', () => { + assert.equal( + isBannerActive(banner({ startDate: PAST, endDate: FUTURE })), + true + ); + }); + + it('is not active when now is before the range', () => { + assert.equal( + isBannerActive(banner({ startDate: FUTURE, endDate: FUTURE })), + false + ); + }); + + it('is not active when now is after the range', () => { + assert.equal( + isBannerActive(banner({ startDate: PAST, endDate: PAST })), + false + ); + }); + }); +}); diff --git a/src/generators/web/ui/utils/banner.mjs b/src/generators/web/ui/utils/banner.mjs new file mode 100644 index 00000000..a3af015c --- /dev/null +++ b/src/generators/web/ui/utils/banner.mjs @@ -0,0 +1,20 @@ +/** @import { BannerEntry } from '../components/AnnouncementBanner/types' */ + +/** + * Checks whether a banner should be displayed based on its date range. + * Both `startDate` and `endDate` are optional; if omitted the banner is + * considered open-ended in that direction. + * + * @param {BannerEntry} banner + * @returns {boolean} + */ +export const isBannerActive = banner => { + const now = Date.now(); + if (banner.startDate && now < new Date(banner.startDate).getTime()) { + return false; + } + if (banner.endDate && now > new Date(banner.endDate).getTime()) { + return false; + } + return true; +}; diff --git a/src/generators/web/utils/data.mjs b/src/generators/web/utils/data.mjs index cd4fd465..4631aa3b 100644 --- a/src/generators/web/utils/data.mjs +++ b/src/generators/web/utils/data.mjs @@ -32,6 +32,8 @@ export const createStaticData = () => { shikiDisplayNameMap, title: config.title, repository: config.repository, + versionMajor: config.version?.major ?? null, + remoteConfig: config.remoteConfig ?? null, }; }; From 43fabd80acf8e74b9e27937614ea9ee968e6987f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sat, 21 Feb 2026 10:26:10 -0300 Subject: [PATCH 02/10] refactor: review --- src/generators/jsx-ast/utils/buildContent.mjs | 5 +++- src/generators/web/index.mjs | 2 +- src/generators/web/types.d.ts | 2 +- .../components/AnnouncementBanner/index.jsx | 29 ++++++++++++------- src/generators/web/utils/data.mjs | 2 -- 5 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index 01d338a4..d7c45d14 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -287,7 +287,10 @@ export const createDocumentLayout = ( remark ) => createTree('root', [ - createJSXElement(JSX_IMPORTS.AnnouncementBanner.name), + createJSXElement(JSX_IMPORTS.AnnouncementBanner.name, { + remoteConfig: getConfig('web').remoteConfig, + versionMajor: getConfig('web').version?.major ?? null, + }), createJSXElement(JSX_IMPORTS.NavBar.name), createJSXElement(JSX_IMPORTS.Article.name, { children: [ diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 6d4b6d9d..883e8a89 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -35,7 +35,7 @@ export default { '#config/Logo': '@node-core/ui-components/Common/NodejsLogo', }, remoteConfig: - 'https://gist.githubusercontent.com/araujogui/8ea72ffaf574f58fca1482e764e8b5c8/raw/16af51e4efbf37da7b6aff9b7e5dd967d955aacf/api-docs.config.json', + 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', }, /** diff --git a/src/generators/web/types.d.ts b/src/generators/web/types.d.ts index 61d102f3..be3f458c 100644 --- a/src/generators/web/types.d.ts +++ b/src/generators/web/types.d.ts @@ -5,7 +5,7 @@ export type Generator = GeneratorMetadata< templatePath: string; title: string; imports: Record; - remoteConfig: string | null; + remoteConfig: string; }, Generate, AsyncGenerator<{ html: string; css: string }>> >; diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index e526a41c..ebd83ef2 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -2,7 +2,6 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline'; import Banner from '@node-core/ui-components/Common/Banner'; import { useEffect, useState } from 'preact/hooks'; -import { STATIC_DATA } from '../../constants.mjs'; import { isBannerActive } from '../../utils/banner.mjs'; /** @import { BannerEntry, RemoteConfig } from './types.d.ts' */ @@ -11,13 +10,13 @@ import { isBannerActive } from '../../utils/banner.mjs'; * Asynchronously fetches and displays announcement banners from the remote config. * Global banners are rendered above version-specific ones. * Non-blocking: silently ignores fetch/parse failures. + * + * @param {{ remoteConfig: string, versionMajor: number | null }} props */ -export default () => { +export default ({ remoteConfig, versionMajor }) => { const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([])); useEffect(() => { - const { remoteConfig, versionMajor } = STATIC_DATA; - if (!remoteConfig) { return; } @@ -40,9 +39,11 @@ export default () => { active.push(globalBanner); } - const versionBanner = config[`v${versionMajor}`]?.banner; - if (versionBanner && isBannerActive(versionBanner)) { - active.push(versionBanner); + if (versionMajor != null) { + const versionBanner = config[`v${versionMajor}`]?.banner; + if (versionBanner && isBannerActive(versionBanner)) { + active.push(versionBanner); + } } setBanners(active); @@ -57,11 +58,17 @@ export default () => { } return ( -
+
{banners.map(banner => ( - - {banner.link ? {banner.text} : banner.text} - {banner.link && } + + {banner.link ? ( + + {banner.text} + + + ) : ( + banner.text + )} ))}
diff --git a/src/generators/web/utils/data.mjs b/src/generators/web/utils/data.mjs index 4631aa3b..cd4fd465 100644 --- a/src/generators/web/utils/data.mjs +++ b/src/generators/web/utils/data.mjs @@ -32,8 +32,6 @@ export const createStaticData = () => { shikiDisplayNameMap, title: config.title, repository: config.repository, - versionMajor: config.version?.major ?? null, - remoteConfig: config.remoteConfig ?? null, }; }; From 829c5879502cc10011c6ba5909b1b7836a48b27a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20Ara=C3=BAjo?= Date: Sat, 21 Feb 2026 11:25:06 -0300 Subject: [PATCH 03/10] refactor: review --- src/generators/jsx-ast/index.mjs | 2 ++ src/generators/jsx-ast/utils/buildContent.mjs | 11 +++++++---- src/generators/web/index.mjs | 2 -- .../web/ui/components/AnnouncementBanner/index.jsx | 6 +++--- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/generators/jsx-ast/index.mjs b/src/generators/jsx-ast/index.mjs index e1ee73a1..df011762 100644 --- a/src/generators/jsx-ast/index.mjs +++ b/src/generators/jsx-ast/index.mjs @@ -23,6 +23,8 @@ export default { defaultConfiguration: { ref: 'main', + remoteConfig: + 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', }, /** diff --git a/src/generators/jsx-ast/utils/buildContent.mjs b/src/generators/jsx-ast/utils/buildContent.mjs index d7c45d14..15ddf8d8 100644 --- a/src/generators/jsx-ast/utils/buildContent.mjs +++ b/src/generators/jsx-ast/utils/buildContent.mjs @@ -285,11 +285,13 @@ export const createDocumentLayout = ( sideBarProps, metaBarProps, remark -) => - createTree('root', [ +) => { + const config = getConfig('jsx-ast'); + + return createTree('root', [ createJSXElement(JSX_IMPORTS.AnnouncementBanner.name, { - remoteConfig: getConfig('web').remoteConfig, - versionMajor: getConfig('web').version?.major ?? null, + remoteConfig: config.remoteConfig, + versionMajor: config.version?.major ?? null, }), createJSXElement(JSX_IMPORTS.NavBar.name), createJSXElement(JSX_IMPORTS.Article.name, { @@ -312,6 +314,7 @@ export const createDocumentLayout = ( ], }), ]); +}; /** * @typedef {import('estree').Node & { data: ApiDocMetadataEntry }} JSXContent diff --git a/src/generators/web/index.mjs b/src/generators/web/index.mjs index 883e8a89..70ebd7dc 100644 --- a/src/generators/web/index.mjs +++ b/src/generators/web/index.mjs @@ -34,8 +34,6 @@ export default { imports: { '#config/Logo': '@node-core/ui-components/Common/NodejsLogo', }, - remoteConfig: - 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', }, /** diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx index ebd83ef2..b8bde491 100644 --- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx +++ b/src/generators/web/ui/components/AnnouncementBanner/index.jsx @@ -34,13 +34,13 @@ export default ({ remoteConfig, versionMajor }) => { const active = []; - const globalBanner = config.global?.banner; + const globalBanner = config.websiteBanners?.index; if (globalBanner && isBannerActive(globalBanner)) { active.push(globalBanner); } if (versionMajor != null) { - const versionBanner = config[`v${versionMajor}`]?.banner; + const versionBanner = config.websiteBanners[`v${versionMajor}`]; if (versionBanner && isBannerActive(versionBanner)) { active.push(versionBanner); } @@ -64,11 +64,11 @@ export default ({ remoteConfig, versionMajor }) => { {banner.link ? ( {banner.text} - ) : ( banner.text )} + {banner.link && } ))}
From a47a1a47f66b1e6e34532fc16c87ee019d9883d6 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/10] feat: update remote config url --- src/generators/jsx-ast/index.mjs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/generators/jsx-ast/index.mjs b/src/generators/jsx-ast/index.mjs index df011762..51f4639f 100644 --- a/src/generators/jsx-ast/index.mjs +++ b/src/generators/jsx-ast/index.mjs @@ -23,8 +23,7 @@ export default { defaultConfiguration: { ref: 'main', - remoteConfig: - 'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json', + remoteConfig: 'https://nodejs.org/site.json', }, /** From 71e53acb3146430059c5105c4dbe07ad9bf6360f 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/10] 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 baea0a64986caa85a4d4494a0afdbb22c780ee2e 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/10] 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 7fef7fba30addebcf45153e886ca713eb3a8daa8 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/10] 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 7a80e6d6ad4b885b93f5b9f590a3c7e15a215fe9 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/10] 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 5e93d895b07d4843924d2308df109c79a284f231 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/10] refactor: transform into hook --- npm-shrinkwrap.json | 685 ++++++++++++++++++ package.json | 6 +- ...chBanners.test.mjs => useBanners.test.mjs} | 130 +++- .../AnnouncementBanner/fetchBanners.mjs | 39 - .../components/AnnouncementBanner/index.jsx | 17 +- .../AnnouncementBanner/useBanners.mjs | 56 ++ 6 files changed, 849 insertions(+), 84 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/npm-shrinkwrap.json b/npm-shrinkwrap.json index a894096f..ad9a426c 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -57,6 +57,8 @@ "devDependencies": { "@eslint/js": "^9.39.2", "@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", @@ -66,7 +68,9 @@ "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-jsdoc": "^62.4.1", "eslint-plugin-react-x": "^2.8.1", + "global-jsdom": "^29.0.0", "husky": "^9.1.7", + "jsdom": "^29.0.1", "lint-staged": "^16.2.7", "prettier": "3.8.1" } @@ -118,6 +122,82 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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", @@ -128,6 +208,159 @@ "node": ">=18" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -451,6 +684,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -2997,6 +3248,54 @@ "tailwindcss": "4.1.18" } }, + "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", @@ -3007,6 +3306,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", @@ -3753,6 +4059,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", @@ -3779,6 +4095,16 @@ "dev": true, "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4171,6 +4497,20 @@ ], "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -4190,6 +4530,20 @@ "license": "MIT", "peer": true }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -4207,6 +4561,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", @@ -4278,6 +4639,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/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4982,6 +5350,19 @@ "node": ">=10.13.0" } }, + "node_modules/global-jsdom": { + "version": "29.0.0", + "resolved": "https://registry.npmjs.org/global-jsdom/-/global-jsdom-29.0.0.tgz", + "integrity": "sha512-S9Wl+EHDfkO8DYleqMYzf14hKfwIysEN/FvoVRdPif3uUGUlmiHs/gj1PVKXhmJ20DwUVACtdxxhNYYvvn9K7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "jsdom": ">=29 <30" + } + }, "node_modules/globals": { "version": "17.2.0", "resolved": "https://registry.npmjs.org/globals/-/globals-17.2.0.tgz", @@ -5230,6 +5611,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-entities": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", @@ -5469,6 +5863,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5524,6 +5925,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/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -5547,6 +5955,70 @@ "node": ">=20.0.0" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -5824,6 +6296,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lru-cache": { + "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", @@ -6146,6 +6638,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", @@ -7172,6 +7671,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/prism-react-renderer": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-1.3.5.tgz", @@ -7224,6 +7761,13 @@ "react": "^19.1.0" } }, + "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", @@ -7522,6 +8066,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/reserved-identifiers": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", @@ -7630,6 +8184,19 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", @@ -8009,6 +8576,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", @@ -8187,6 +8761,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8217,6 +8811,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -8686,6 +9306,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -8696,6 +9329,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8827,6 +9495,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 608e4bd1..e7a8cf68 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,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\"", @@ -27,6 +27,8 @@ "devDependencies": { "@eslint/js": "^9.39.2", "@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", @@ -36,7 +38,9 @@ "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-jsdoc": "^62.4.1", "eslint-plugin-react-x": "^2.8.1", + "global-jsdom": "^29.0.0", "husky": "^9.1.7", + "jsdom": "^29.0.1", "lint-staged": "^16.2.7", "prettier": "3.8.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 daab83667bc7bd326ff3dd9f125fbd2b2eba3e09 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/10] 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 +- src/generators/web/utils/processing.mjs | 2 +- 6 files changed, 136 insertions(+), 186 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'); }; diff --git a/src/generators/web/utils/processing.mjs b/src/generators/web/utils/processing.mjs index 902f1c22..13bc9141 100644 --- a/src/generators/web/utils/processing.mjs +++ b/src/generators/web/utils/processing.mjs @@ -69,7 +69,7 @@ export async function executeServerCode(serverCodeMap, requireFn) { const executedFunction = new Function('require', chunk.code); // Execute the function - result is the dehydrated HTML from server-side rendering - dehydratedMap.set(chunk.fileName, executedFunction(enhancedRequire)); + dehydratedMap.set(chunk.fileName, await executedFunction(enhancedRequire)); } return { pages: dehydratedMap, css };