Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/generators/jsx-ast/utils/buildContent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ export const createDocumentLayout = (
remark
) =>
createTree('root', [
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: [
Expand Down
4 changes: 4 additions & 0 deletions src/generators/web/constants.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 2 additions & 0 deletions src/generators/web/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export default {
imports: {
'#config/Logo': '@node-core/ui-components/Common/NodejsLogo',
},
remoteConfig:
'https://raw.githubusercontent.com/nodejs/nodejs.org/main/apps/site/site.json',
},

/**
Expand Down
1 change: 1 addition & 0 deletions src/generators/web/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type Generator = GeneratorMetadata<
templatePath: string;
title: string;
imports: Record<string, string>;
remoteConfig: string;
},
Generate<Array<JSXContent>, AsyncGenerator<{ html: string; css: string }>>
>;
76 changes: 76 additions & 0 deletions src/generators/web/ui/components/AnnouncementBanner/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 { 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.
*
* @param {{ remoteConfig: string, versionMajor: number | null }} props
*/
export default ({ remoteConfig, versionMajor }) => {
const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([]));

useEffect(() => {
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);
}

if (versionMajor != null) {
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 (
<div role="region" aria-label="Announcements">
{banners.map(banner => (
<Banner key={banner.text ?? banner.text} type={banner.type}>
{banner.link ? (
<a href={banner.link} target="_blank" rel="noopener noreferrer">
{banner.text}
<ArrowUpRightIcon />
</a>
) : (
banner.text
)}
</Banner>
))}
</div>
);
};
11 changes: 11 additions & 0 deletions src/generators/web/ui/components/AnnouncementBanner/types.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, { banner?: BannerEntry } | undefined>;
63 changes: 63 additions & 0 deletions src/generators/web/ui/utils/__tests__/banner.test.mjs
Original file line number Diff line number Diff line change
@@ -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
);
});
});
});
20 changes: 20 additions & 0 deletions src/generators/web/ui/utils/banner.mjs
Original file line number Diff line number Diff line change
@@ -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;
};
Loading