Skip to content

Commit ecb53da

Browse files
committed
feat: create announcements banner
1 parent 32a824c commit ecb53da

9 files changed

Lines changed: 173 additions & 0 deletions

File tree

src/generators/jsx-ast/utils/buildContent.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ export const createDocumentLayout = (
287287
remark
288288
) =>
289289
createTree('root', [
290+
createJSXElement(JSX_IMPORTS.AnnouncementBanner.name),
290291
createJSXElement(JSX_IMPORTS.NavBar.name),
291292
createJSXElement(JSX_IMPORTS.Article.name, {
292293
children: [

src/generators/web/constants.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export const ROOT = dirname(fileURLToPath(import.meta.url));
1414
* An object containing mappings for various JSX components to their import paths.
1515
*/
1616
export const JSX_IMPORTS = {
17+
AnnouncementBanner: {
18+
name: 'AnnouncementBanner',
19+
source: resolve(ROOT, './ui/components/AnnouncementBanner'),
20+
},
1721
NavBar: {
1822
name: 'NavBar',
1923
source: resolve(ROOT, './ui/components/NavBar'),

src/generators/web/index.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export default {
3434
imports: {
3535
'#config/Logo': '@node-core/ui-components/Common/NodejsLogo',
3636
},
37+
remoteConfig:
38+
'https://gist.githubusercontent.com/araujogui/8ea72ffaf574f58fca1482e764e8b5c8/raw/16af51e4efbf37da7b6aff9b7e5dd967d955aacf/api-docs.config.json',
3739
},
3840

3941
/**

src/generators/web/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type Generator = GeneratorMetadata<
55
templatePath: string;
66
title: string;
77
imports: Record<string, string>;
8+
remoteConfig: string | null;
89
},
910
Generate<Array<JSXContent>, AsyncGenerator<{ html: string; css: string }>>
1011
>;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
2+
import Banner from '@node-core/ui-components/Common/Banner';
3+
import { useEffect, useState } from 'preact/hooks';
4+
5+
import { STATIC_DATA } from '../../constants.mjs';
6+
import { isBannerActive } from '../../utils/banner.mjs';
7+
8+
/** @import { BannerEntry, RemoteConfig } from './types.d.ts' */
9+
10+
/**
11+
* Asynchronously fetches and displays announcement banners from the remote config.
12+
* Global banners are rendered above version-specific ones.
13+
* Non-blocking: silently ignores fetch/parse failures.
14+
*/
15+
export default () => {
16+
const [banners, setBanners] = useState(/** @type {BannerEntry[]} */ ([]));
17+
18+
useEffect(() => {
19+
const { remoteConfig, versionMajor } = STATIC_DATA;
20+
21+
if (!remoteConfig) {
22+
return;
23+
}
24+
25+
fetch(remoteConfig, {
26+
signal: AbortSignal.timeout(2500),
27+
})
28+
.then(async res => {
29+
if (!res.ok) {
30+
return;
31+
}
32+
33+
/** @type {RemoteConfig} */
34+
const config = await res.json();
35+
36+
const active = [];
37+
38+
const globalBanner = config.global?.banner;
39+
if (globalBanner && isBannerActive(globalBanner)) {
40+
active.push(globalBanner);
41+
}
42+
43+
const versionBanner = config[`v${versionMajor}`]?.banner;
44+
if (versionBanner && isBannerActive(versionBanner)) {
45+
active.push(versionBanner);
46+
}
47+
48+
setBanners(active);
49+
})
50+
.catch(error => {
51+
console.error(error);
52+
});
53+
}, []);
54+
55+
if (!banners.length) {
56+
return null;
57+
}
58+
59+
return (
60+
<div>
61+
{banners.map(banner => (
62+
<Banner key={banner.link} type={banner.type}>
63+
{banner.link ? <a href={banner.link}>{banner.text}</a> : banner.text}
64+
{banner.link && <ArrowUpRightIcon />}
65+
</Banner>
66+
))}
67+
</div>
68+
);
69+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { BannerProps } from '@node-core/ui-components/Common/Banner';
2+
3+
export type BannerEntry = {
4+
startDate?: string;
5+
endDate?: string;
6+
text: string;
7+
link?: string;
8+
type?: BannerProps['type'];
9+
};
10+
11+
export type RemoteConfig = Record<string, { banner?: BannerEntry } | undefined>;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import assert from 'node:assert/strict';
2+
import { describe, it } from 'node:test';
3+
4+
import { isBannerActive } from '../banner.mjs';
5+
6+
const PAST = new Date(Date.now() - 86_400_000).toISOString(); // yesterday
7+
const FUTURE = new Date(Date.now() + 86_400_000).toISOString(); // tomorrow
8+
9+
const banner = (overrides = {}) => ({
10+
text: 'Test banner',
11+
...overrides,
12+
});
13+
14+
describe('isBannerActive', () => {
15+
describe('no startDate, no endDate', () => {
16+
it('is always active', () => {
17+
assert.equal(isBannerActive(banner()), true);
18+
});
19+
});
20+
21+
describe('startDate only', () => {
22+
it('is active when startDate is in the past', () => {
23+
assert.equal(isBannerActive(banner({ startDate: PAST })), true);
24+
});
25+
26+
it('is not active when startDate is in the future', () => {
27+
assert.equal(isBannerActive(banner({ startDate: FUTURE })), false);
28+
});
29+
});
30+
31+
describe('endDate only', () => {
32+
it('is active when endDate is in the future', () => {
33+
assert.equal(isBannerActive(banner({ endDate: FUTURE })), true);
34+
});
35+
36+
it('is not active when endDate is in the past', () => {
37+
assert.equal(isBannerActive(banner({ endDate: PAST })), false);
38+
});
39+
});
40+
41+
describe('startDate and endDate', () => {
42+
it('is active when now is within the range', () => {
43+
assert.equal(
44+
isBannerActive(banner({ startDate: PAST, endDate: FUTURE })),
45+
true
46+
);
47+
});
48+
49+
it('is not active when now is before the range', () => {
50+
assert.equal(
51+
isBannerActive(banner({ startDate: FUTURE, endDate: FUTURE })),
52+
false
53+
);
54+
});
55+
56+
it('is not active when now is after the range', () => {
57+
assert.equal(
58+
isBannerActive(banner({ startDate: PAST, endDate: PAST })),
59+
false
60+
);
61+
});
62+
});
63+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/** @import { BannerEntry } from '../components/AnnouncementBanner/types' */
2+
3+
/**
4+
* Checks whether a banner should be displayed based on its date range.
5+
* Both `startDate` and `endDate` are optional; if omitted the banner is
6+
* considered open-ended in that direction.
7+
*
8+
* @param {BannerEntry} banner
9+
* @returns {boolean}
10+
*/
11+
export const isBannerActive = banner => {
12+
const now = Date.now();
13+
if (banner.startDate && now < new Date(banner.startDate).getTime()) {
14+
return false;
15+
}
16+
if (banner.endDate && now > new Date(banner.endDate).getTime()) {
17+
return false;
18+
}
19+
return true;
20+
};

src/generators/web/utils/data.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const createStaticData = () => {
3232
shikiDisplayNameMap,
3333
title: config.title,
3434
repository: config.repository,
35+
versionMajor: config.version?.major ?? null,
36+
remoteConfig: config.remoteConfig ?? null,
3537
};
3638
};
3739

0 commit comments

Comments
 (0)