diff --git a/.changeset/opt-out-banners.md b/.changeset/opt-out-banners.md
new file mode 100644
index 00000000..652dee33
--- /dev/null
+++ b/.changeset/opt-out-banners.md
@@ -0,0 +1,5 @@
+---
+'@node-core/doc-kit': minor
+---
+
+Add banner opt-out
diff --git a/e2e/announcement-banner.spec.js b/e2e/announcement-banner.spec.js
index 2fecf4b3..95e77d8b 100644
--- a/e2e/announcement-banner.spec.js
+++ b/e2e/announcement-banner.spec.js
@@ -17,9 +17,12 @@ test.describe('Announcement Banner', () => {
await page.goto('/assert.html');
- const banner = page.getByRole('region', { name: 'Announcements' });
+ const banner = page.getByRole('region', { name: 'Announcement' });
await expect(banner).toBeVisible();
await expect(banner).toContainText('Important announcement for all users');
+ await expect(
+ banner.getByRole('button', { name: 'Close banner' })
+ ).toBeVisible();
});
test('renders a banner with a link', async ({ page }) => {
@@ -39,7 +42,7 @@ test.describe('Announcement Banner', () => {
await page.goto('/assert.html');
- const banner = page.getByRole('region', { name: 'Announcements' });
+ const banner = page.getByRole('region', { name: 'Announcement' });
await expect(banner).toBeVisible();
const link = banner.getByRole('link', { name: 'Read the release notes' });
@@ -60,7 +63,37 @@ test.describe('Announcement Banner', () => {
await page.waitForLoadState('networkidle');
await expect(
- page.getByRole('region', { name: 'Announcements' })
+ page.getByRole('region', { name: 'Announcement' })
).not.toBeAttached();
});
+
+ test('persists dismissal until the banner text changes', async ({ page }) => {
+ let text = 'Dismiss this announcement';
+
+ await page.route(REMOTE_CONFIG_URL, route =>
+ route.fulfill({
+ contentType: 'application/json',
+ body: JSON.stringify({
+ websiteBanners: { index: { text } },
+ }),
+ })
+ );
+
+ await page.goto('/assert.html');
+
+ const banner = page.getByRole('region', { name: 'Announcement' });
+ await banner.getByRole('button', { name: 'Close banner' }).click();
+ await expect(banner).not.toBeAttached();
+ await expect
+ .poll(() => page.evaluate(() => localStorage.getItem('banner-dismissal')))
+ .toBe(text);
+
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+ await expect(banner).not.toBeAttached();
+
+ text = 'A new announcement';
+ await page.reload();
+ await expect(banner).toContainText(text);
+ });
});
diff --git a/package-lock.json b/package-lock.json
index ec3d6bb1..15373b75 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,18 @@
{
"name": "@node-core/doc-kit",
- "version": "1.4.1",
+ "version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@node-core/doc-kit",
- "version": "1.4.1",
+ "version": "1.4.2",
"dependencies": {
"@actions/core": "^3.0.0",
"@heroicons/react": "^2.2.0",
"@minify-html/wasm": "^0.18.1",
"@node-core/rehype-shiki": "^1.4.2",
- "@node-core/ui-components": "^1.7.0",
+ "@node-core/ui-components": "^1.7.2",
"@orama/orama": "^3.1.18",
"@orama/ui": "^1.5.4",
"@rollup/plugin-virtual": "^3.0.2",
@@ -436,7 +436,6 @@
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
@@ -448,7 +447,6 @@
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -1324,6 +1322,7 @@
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -1477,9 +1476,9 @@
}
},
"node_modules/@node-core/ui-components": {
- "version": "1.7.1",
- "resolved": "https://registry.npmjs.org/@node-core/ui-components/-/ui-components-1.7.1.tgz",
- "integrity": "sha512-i1un8y0yth6WdT0OXpAQeL8XIkohcZ2P8khSC0oQstWG8H7PCRqX+mdZlJAuVUKq3MaQnC2fO34Q3j7L4TenXg==",
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@node-core/ui-components/-/ui-components-1.7.2.tgz",
+ "integrity": "sha512-lszPY9QTNNzTEtLr3ujQ1COpmqTm1BrHlN/mgiFM042ucDtrgjykotiLlstexiE7ZUHxamtg/LKP3sfzTsD9vw==",
"dependencies": {
"@heroicons/react": "^2.2.0",
"@orama/orama": "^3.1.18",
@@ -1554,6 +1553,7 @@
"resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz",
"integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@noble/hashes": "^1.1.5"
}
@@ -1571,7 +1571,8 @@
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz",
"integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==",
- "license": "AGPL-3.0"
+ "license": "AGPL-3.0",
+ "peer": true
},
"node_modules/@orama/stopwords": {
"version": "3.1.18",
@@ -2910,7 +2911,6 @@
"integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -3068,7 +3068,6 @@
"integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.62.1",
@@ -3533,7 +3532,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4230,7 +4228,8 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
@@ -4734,7 +4733,6 @@
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
"dev": true,
"license": "MIT",
- "peer": true,
"workspaces": [
"packages/*"
],
@@ -8532,7 +8530,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -8609,7 +8606,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.4.tgz",
"integrity": "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -8722,7 +8718,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9352,7 +9347,8 @@
"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"
+ "license": "MIT",
+ "peer": true
},
"node_modules/semver": {
"version": "7.8.5",
@@ -10379,7 +10375,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"napi-postinstall": "^0.3.4"
},
diff --git a/package.json b/package.json
index a95dc8b8..9421a236 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,7 @@
"@heroicons/react": "^2.2.0",
"@minify-html/wasm": "^0.18.1",
"@node-core/rehype-shiki": "^1.4.2",
- "@node-core/ui-components": "^1.7.0",
+ "@node-core/ui-components": "^1.7.2",
"@orama/orama": "^3.1.18",
"@orama/ui": "^1.5.4",
"@rollup/plugin-virtual": "^3.0.2",
diff --git a/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner.jsx b/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner.jsx
deleted file mode 100644
index e6116383..00000000
--- a/src/generators/web/ui/components/AnnouncementBanner/RemoteLoadableBanner.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { lazy, Suspense } from 'preact/compat';
-
-import AnnouncementBanner from './index.jsx';
-import { loadBanners } from './loadBanners.mjs';
-
-import { remoteConfigUrl, version } from '#theme/config';
-
-// TODO: Revisit SERVER global usage (https://github.com/nodejs/doc-kit/issues/353)
-const LazyBanners = SERVER
- ? null
- : lazy(async () => {
- const active = await loadBanners(remoteConfigUrl, version.major);
-
- if (!active.length) {
- return { default: () => null };
- }
-
- return { default: () => };
- });
-
-const RemoteLoadableBanner = SERVER
- ? () =>
- : () => (
-
-
-
-
-
- );
-
-export default RemoteLoadableBanner;
diff --git a/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs b/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs
deleted file mode 100644
index cb0560c4..00000000
--- a/src/generators/web/ui/components/AnnouncementBanner/__tests__/loadBanners.test.mjs
+++ /dev/null
@@ -1,174 +0,0 @@
-import assert from 'node:assert/strict';
-import { describe, it } from 'node:test';
-
-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
-
-const makeResponse = (banners, ok = true) => ({
- ok,
- json: async () => ({ websiteBanners: banners }),
-});
-
-describe('loadBanners', () => {
- describe('fetch behavior', () => {
- it('fetches from the given URL', async t => {
- t.mock.method(global, 'fetch', () => Promise.resolve(makeResponse({})));
-
- await loadBanners('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 loadBanners('https://example.com/site.json', null);
-
- assert.deepEqual(result, []);
- });
-
- it('handles fetch errors silently', async t => {
- t.mock.method(global, 'fetch', () =>
- Promise.reject(new Error('Network error'))
- );
-
- 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({})));
-
- const result = await loadBanners(undefined, null);
-
- assert.deepEqual(result, []);
- assert.equal(global.fetch.mock.calls.length, 0);
- });
- });
-
- 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 loadBanners('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 loadBanners('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 loadBanners('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 loadBanners('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 loadBanners('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 loadBanners('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 loadBanners('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 loadBanners('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 loadBanners('https://example.com/site.json', null);
-
- assert.deepEqual(result, [banner]);
- });
- });
-});
diff --git a/src/generators/web/ui/components/AnnouncementBanner/index.jsx b/src/generators/web/ui/components/AnnouncementBanner/index.jsx
deleted file mode 100644
index e6be507a..00000000
--- a/src/generators/web/ui/components/AnnouncementBanner/index.jsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
-import Banner from '@node-core/ui-components/Common/Banner';
-
-import styles from './index.module.css';
-
-/**
- * @param {import('./types.d.ts').AnnouncementBannerProps} 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.module.css b/src/generators/web/ui/components/AnnouncementBanner/index.module.css
deleted file mode 100644
index d8a38d10..00000000
--- a/src/generators/web/ui/components/AnnouncementBanner/index.module.css
+++ /dev/null
@@ -1,14 +0,0 @@
-.banners {
- animation: slideDown 300ms ease-out 400ms backwards;
-}
-
-@keyframes slideDown {
- from {
- opacity: 0;
- transform: translateY(-0.5rem);
- }
- to {
- opacity: 1;
- transform: translateY(0);
- }
-}
diff --git a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs b/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs
deleted file mode 100644
index 8e1cd4d3..00000000
--- a/src/generators/web/ui/components/AnnouncementBanner/loadBanners.mjs
+++ /dev/null
@@ -1,33 +0,0 @@
-import { isBannerActive } from '../../utils/banner.mjs';
-
-/**
- * Fetches and returns active banners for the given version.
- * Returns an empty array when remoteConfigUrl is absent, the response is not ok,
- * or on any fetch/parse failure.
- *
- * @param {string | undefined} remoteConfigUrl
- * @param {number | null} versionMajor
- * @returns {Promise>}
- */
-export const loadBanners = async (remoteConfigUrl, versionMajor) => {
- if (!remoteConfigUrl) {
- return [];
- }
-
- try {
- const res = await fetch(remoteConfigUrl);
-
- /** @type {import('./types.d.ts').RemoteConfig} */
- const { websiteBanners = {} } = await res.json();
-
- const keys = ['index', versionMajor != null && `v${versionMajor}`].filter(
- Boolean
- );
-
- return keys
- .map(key => websiteBanners[key])
- .filter(banner => banner && isBannerActive(banner));
- } catch {
- return [];
- }
-};
diff --git a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts b/src/generators/web/ui/components/AnnouncementBanner/types.d.ts
deleted file mode 100644
index 8ea873de..00000000
--- a/src/generators/web/ui/components/AnnouncementBanner/types.d.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-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 = {
- websiteBanners?: Record;
-};
-
-export type AnnouncementBannerProps = {
- banners: BannerEntry[];
-};
diff --git a/src/generators/web/ui/components/Banner.jsx b/src/generators/web/ui/components/Banner.jsx
new file mode 100644
index 00000000..1c5bb915
--- /dev/null
+++ b/src/generators/web/ui/components/Banner.jsx
@@ -0,0 +1,32 @@
+import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
+import Banner from '@node-core/ui-components/Common/Banner';
+
+import useBanners from '../hooks/useBanners.mjs';
+
+import { remoteConfigUrl, version } from '#theme/config';
+
+const Banners = () => {
+ const [banner, dismissBanner] = useBanners(remoteConfigUrl, version.major);
+
+ return (
+ banner && (
+ dismissBanner(banner)}
+ >
+ {banner.link ? (
+
+ {banner.text}
+
+ ) : (
+ banner.text
+ )}
+ {banner.link && }
+
+ )
+ );
+};
+
+export default Banners;
diff --git a/src/generators/web/ui/components/Layout/index.jsx b/src/generators/web/ui/components/Layout/index.jsx
index 8b2992c8..f7e9ce7f 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/RemoteLoadableBanner';
+import Banner from '../Banner';
import Footer from '#theme/Footer';
import MetaBar from '#theme/Metabar';
@@ -19,7 +19,7 @@ import SideBar from '#theme/Sidebar';
*/
export default ({ metadata, headings, readingTime, children }) => (
<>
-
+ {CLIENT && }
diff --git a/src/generators/web/ui/hooks/useBanners.mjs b/src/generators/web/ui/hooks/useBanners.mjs
new file mode 100644
index 00000000..a7f7b1b9
--- /dev/null
+++ b/src/generators/web/ui/hooks/useBanners.mjs
@@ -0,0 +1,127 @@
+import { useCallback, useEffect, useState } from 'react';
+
+const STORAGE_KEY = 'banner-dismissal';
+
+/**
+ * @typedef {object} BannerEntry
+ * @property {string} text
+ * @property {string} [startDate]
+ * @property {string} [endDate]
+ * @property {string} [link]
+ * @property {'default' | 'warning' | 'error'} [type]
+ */
+
+/**
+ * @typedef {BannerEntry & { section: string }} ActiveBanner
+ */
+
+/**
+ * The global banner uses the same key as nodejs.org.
+ *
+ * @param {string} section
+ */
+const getStorageKey = section =>
+ section === 'index' ? STORAGE_KEY : `${STORAGE_KEY}-${section}`;
+
+/**
+ * Checks whether a banner should be displayed based on its date range.
+ *
+ * @param {BannerEntry} banner
+ */
+export const isBannerActive = ({ startDate, endDate }) => {
+ const now = Date.now();
+
+ return (
+ (!startDate || now >= new Date(startDate)) &&
+ (!endDate || now <= new Date(endDate))
+ );
+};
+
+/**
+ * Fetches the first active banner, preferring the global banner over
+ * the version-specific one.
+ *
+ * @param {string | undefined} remoteConfigUrl
+ * @param {number | null} versionMajor
+ * @returns {Promise}
+ */
+export const loadBanner = async (remoteConfigUrl, versionMajor) => {
+ if (!remoteConfigUrl) {
+ return null;
+ }
+
+ const response = await fetch(remoteConfigUrl);
+
+ /** @type {{ websiteBanners?: Record }} */
+ const { websiteBanners = {} } = await response.json();
+
+ const sections =
+ versionMajor == null ? ['index'] : ['index', `v${versionMajor}`];
+
+ for (const section of sections) {
+ const banner = websiteBanners[section];
+
+ if (banner && isBannerActive(banner)) {
+ return { ...banner, section };
+ }
+ }
+
+ return null;
+};
+
+/**
+ * Checks whether the current text of a banner was dismissed.
+ *
+ * @param {ActiveBanner} banner
+ */
+export const isBannerDismissed = banner =>
+ localStorage.getItem(getStorageKey(banner.section)) === banner.text;
+
+/**
+ * Persists a banner dismissal.
+ *
+ * @param {ActiveBanner} banner
+ */
+export const saveBannerDismissal = banner =>
+ localStorage.setItem(getStorageKey(banner.section), banner.text);
+
+/**
+ * Loads, filters, and dismisses the announcement banner.
+ *
+ * @param {string | undefined} remoteConfigUrl
+ * @param {number | null} versionMajor
+ * @returns {[ActiveBanner | null, () => void]}
+ */
+export default (remoteConfigUrl, versionMajor) => {
+ const [banner, setBanner] = useState(
+ /** @type {ActiveBanner | null} */ (null)
+ );
+
+ useEffect(() => {
+ let mounted = true;
+
+ loadBanner(remoteConfigUrl, versionMajor)
+ .then(loaded => {
+ if (mounted) {
+ setBanner(loaded && !isBannerDismissed(loaded) ? loaded : null);
+ }
+ })
+ .catch(() => {});
+
+ return () => {
+ mounted = false;
+ };
+ }, [remoteConfigUrl, versionMajor]);
+
+ const dismissBanner = useCallback(() => {
+ setBanner(current => {
+ if (current) {
+ saveBannerDismissal(current);
+ }
+
+ return null;
+ });
+ }, []);
+
+ return [banner, dismissBanner];
+};
diff --git a/src/generators/web/ui/utils/__tests__/banner.test.mjs b/src/generators/web/ui/utils/__tests__/banner.test.mjs
deleted file mode 100644
index 262d2c9d..00000000
--- a/src/generators/web/ui/utils/__tests__/banner.test.mjs
+++ /dev/null
@@ -1,63 +0,0 @@
-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
deleted file mode 100644
index 4afa820a..00000000
--- a/src/generators/web/ui/utils/banner.mjs
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * 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 {import('../components/AnnouncementBanner/types').BannerEntry} banner
- * @returns {boolean}
- */
-export const isBannerActive = ({ startDate, endDate }) => {
- const now = Date.now();
- return (
- (!startDate || now >= new Date(startDate)) &&
- (!endDate || now <= new Date(endDate))
- );
-};