Skip to content

Commit 8fd0c13

Browse files
authored
feat: allow for specifying versions in config (#1110)
1 parent 6a73fd2 commit 8fd0c13

6 files changed

Lines changed: 100 additions & 49 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@doc-kit/generator-react': minor
3+
---
4+
5+
feat(html): fetch the remote config once through a `useRemoteConfig` hook, and let its `versions` key replace the build-time version selector entries

‎packages/react/src/html/README.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ from the module pages' compiled content rather than built again from scratch.
3030
- `pageURL` {string} URL template for documentation page links.
3131
**Default:** `'{baseURL}{path}.html'`.
3232
- `remoteConfigUrl` {string} URL fetched client-side at runtime for remote site
33-
config (currently used to power the announcement banner).
33+
config. Its `websiteBanners` power the announcement banner, and its
34+
`versions` (same shape as the `versions` export below) replace the build-time
35+
version selector entries, so docs built for an older release still list the
36+
releases that came after it.
3437
**Default:** none — no runtime fetch, no banner.
3538
- `head` {Object} Configurable `<meta>`, `<link>`, and raw markup for the
3639
document head. See [`head`](#head).
@@ -434,7 +437,7 @@ path, items? }` entries nested by heading depth, in document order. Empty
434437
- `languageDisplayNameMap` {Map<string, string>} Shiki language alias → display
435438
name map for code blocks.
436439
- `remoteConfigUrl` {string} Mirrors the configured `remoteConfigUrl` (fetched
437-
client-side by `RemoteLoadableBanner` to load announcement banners).
440+
client-side by the `useRemoteConfig` hook for the banner and version selector).
438441
- `server` {boolean} Whether the current bundle is the server build.
439442
440443
### Usage in custom components

‎packages/react/src/html/ui/components/Banner.jsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@ import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
22
import Banner from '@node-core/ui-components/Common/Banner';
33

44
import useBanners from '../hooks/useBanners.mjs';
5+
import useRemoteConfig from '../hooks/useRemoteConfig.mjs';
56
import withIsland from '../islands/withIsland.jsx';
67

7-
import { remoteConfigUrl, version } from '#theme/config';
8+
import { version } from '#theme/config';
89

910
const Banners = () => {
10-
const [banner, dismissBanner] = useBanners(remoteConfigUrl, version.major);
11+
const remote = useRemoteConfig();
12+
const [banner, dismissBanner] = useBanners(
13+
remote?.websiteBanners,
14+
version.major
15+
);
1116

1217
return (
1318
banner && (

‎packages/react/src/html/ui/components/SideBar/index.jsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Select from '@node-core/ui-components/Common/Select';
22
import SideBar from '@node-core/ui-components/Containers/Sidebar';
33

44
import styles from './index.module.css';
5+
import useRemoteConfig from '../../hooks/useRemoteConfig.mjs';
56
import withIsland from '../../islands/withIsland.jsx';
67
import { relativeOrAbsolute } from '../../utils/relativeOrAbsolute.mjs';
78
import { renderLabel } from '../../utils/renderLabel.jsx';
@@ -109,8 +110,12 @@ const Sidebar = ({ metadata }) => {
109110
metadata.added ?? metadata.introduced_in
110111
);
111112

112-
// Filter pre-computed versions by compatibility and resolve per-page URL
113-
const compatibleVersions = versions
113+
// A remote config's `versions` supersede the build-time list
114+
const remote = useRemoteConfig();
115+
const availableVersions = remote?.versions ?? versions;
116+
117+
// Filter versions by compatibility and resolve per-page URL
118+
const compatibleVersions = availableVersions
114119
.filter(v => v.major >= introducedMajor)
115120
.map(({ url, label }) => ({
116121
value: url.replace('{path}', metadata.path),
@@ -126,7 +131,7 @@ const Sidebar = ({ metadata }) => {
126131
title="Navigation"
127132
>
128133
{/* A site built without a `changelog` has no versions to switch between. */}
129-
{versions.length > 0 && (
134+
{availableVersions.length > 0 && (
130135
<div>
131136
<Select
132137
label={`${project} version`}

‎packages/react/src/html/ui/hooks/useBanners.mjs‎

Lines changed: 23 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useMemo, useState } from 'react';
22

33
const STORAGE_KEY = 'banner-dismissal';
44

@@ -38,23 +38,14 @@ export const isBannerActive = ({ startDate, endDate }) => {
3838
};
3939

4040
/**
41-
* Fetches the first active banner, preferring the global banner over
41+
* Finds the first active banner, preferring the global banner over
4242
* the version-specific one.
4343
*
44-
* @param {string | undefined} remoteConfigUrl
44+
* @param {Record<string, BannerEntry> | undefined} websiteBanners
4545
* @param {number | null} versionMajor
46-
* @returns {Promise<ActiveBanner | null>}
46+
* @returns {ActiveBanner | null}
4747
*/
48-
export const loadBanner = async (remoteConfigUrl, versionMajor) => {
49-
if (!remoteConfigUrl) {
50-
return null;
51-
}
52-
53-
const response = await fetch(remoteConfigUrl);
54-
55-
/** @type {{ websiteBanners?: Record<string, BannerEntry> }} */
56-
const { websiteBanners = {} } = await response.json();
57-
48+
export const findBanner = (websiteBanners = {}, versionMajor) => {
5849
const sections =
5950
versionMajor == null ? ['index'] : ['index', `v${versionMajor}`];
6051

@@ -86,42 +77,32 @@ export const saveBannerDismissal = banner =>
8677
localStorage.setItem(getStorageKey(banner.section), banner.text);
8778

8879
/**
89-
* Loads, filters, and dismisses the announcement banner.
80+
* Selects, filters, and dismisses the announcement banner.
9081
*
91-
* @param {string | undefined} remoteConfigUrl
82+
* @param {Record<string, BannerEntry> | undefined} websiteBanners
9283
* @param {number | null} versionMajor
9384
* @returns {[ActiveBanner | null, () => void]}
9485
*/
95-
export default (remoteConfigUrl, versionMajor) => {
96-
const [banner, setBanner] = useState(
97-
/** @type {ActiveBanner | null} */ (null)
98-
);
86+
export default (websiteBanners, versionMajor) => {
87+
// Only re-renders the banner away; the dismissal itself lives in storage
88+
const [dismissed, setDismissed] = useState(false);
9989

100-
useEffect(() => {
101-
let mounted = true;
102-
103-
loadBanner(remoteConfigUrl, versionMajor)
104-
.then(loaded => {
105-
if (mounted) {
106-
setBanner(loaded && !isBannerDismissed(loaded) ? loaded : null);
107-
}
108-
})
109-
.catch(() => {});
90+
const found = useMemo(
91+
() => (websiteBanners ? findBanner(websiteBanners, versionMajor) : null),
92+
[websiteBanners, versionMajor]
93+
);
11094

111-
return () => {
112-
mounted = false;
113-
};
114-
}, [remoteConfigUrl, versionMajor]);
95+
// `found` is only set client-side, once the remote config has loaded
96+
const banner =
97+
found && !dismissed && !isBannerDismissed(found) ? found : null;
11598

11699
const dismissBanner = useCallback(() => {
117-
setBanner(current => {
118-
if (current) {
119-
saveBannerDismissal(current);
120-
}
121-
122-
return null;
123-
});
124-
}, []);
100+
if (found) {
101+
saveBannerDismissal(found);
102+
}
103+
104+
setDismissed(true);
105+
}, [found]);
125106

126107
return [banner, dismissBanner];
127108
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { remoteConfigUrl } from '#theme/config';
4+
5+
/**
6+
* The site configuration fetched at runtime from `remoteConfigUrl`.
7+
*
8+
* Every key is optional: a remote config only overrides what it provides.
9+
*
10+
* @typedef {object} RemoteConfig
11+
* @property {Record<string, import('./useBanners.mjs').BannerEntry>} [websiteBanners]
12+
* Announcement banners, keyed by `index` (global) or `v{major}`.
13+
* @property {typeof import('#theme/config').versions} [versions]
14+
* Version entries for the version selector, in the same shape as the
15+
* build-time `versions` export. When present, they replace the build-time
16+
* list so that docs built for an older release still list current releases.
17+
*/
18+
19+
/**
20+
* Fetches the remote site configuration once the component mounts.
21+
*
22+
* @returns {RemoteConfig | null} `null` until loaded, or when there is no
23+
* `remoteConfigUrl` or the fetch fails.
24+
*/
25+
export default () => {
26+
const [config, setConfig] = useState(
27+
/** @type {RemoteConfig | null} */ (null)
28+
);
29+
30+
useEffect(() => {
31+
if (!remoteConfigUrl) {
32+
return;
33+
}
34+
35+
let mounted = true;
36+
37+
fetch(remoteConfigUrl)
38+
.then(response => response.json())
39+
.then(loaded => {
40+
if (mounted) {
41+
setConfig(loaded);
42+
}
43+
})
44+
.catch(() => {});
45+
46+
return () => {
47+
mounted = false;
48+
};
49+
}, []);
50+
51+
return config;
52+
};

0 commit comments

Comments
 (0)