Skip to content

Commit b9b0ca6

Browse files
committed
chore: extracting utilities and adding more unit tests
1 parent 616c7a2 commit b9b0ca6

5 files changed

Lines changed: 342 additions & 160 deletions

File tree

src/generators/web/ui/components/SideBar/index.jsx

Lines changed: 9 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,54 +2,28 @@ 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 { relative } from '../../../../../utils/url.mjs';
6-
import { buildSideBarGroups } from '../../utils/sidebar.mjs';
5+
import {
6+
buildSideBarGroups,
7+
getCompatibleVersions,
8+
redirect,
9+
} from './utils/index.mjs';
710

811
import { title, version, versions, pages } from '#theme/config';
912

10-
/**
11-
* Extracts the major version number from a version string.
12-
* @param {string} v - Version string (e.g., 'v14.0.0', '14.0.0')
13-
* @returns {number}
14-
*/
15-
const getMajorVersion = v => parseInt(String(v).match(/\d+/)?.[0] ?? '0', 10);
16-
17-
/**
18-
* Redirect to a URL
19-
* @param {string} url URL
20-
*/
21-
const redirect = url => (window.location.href = url);
22-
2313
/**
2414
* Sidebar component for MDX documentation with version selection and page navigation
2515
* @param {{ metadata: import('../../types').SerializedMetadata }} props
2616
*/
2717
export default ({ metadata }) => {
28-
const introducedMajor = getMajorVersion(
29-
metadata.added ?? metadata.introduced_in
30-
);
31-
18+
// Build sidebar groups from metadata, categorizing pages and preserving order
19+
const groups = buildSideBarGroups(pages, metadata);
3220
// Filter pre-computed versions by compatibility and resolve per-page URL
33-
const compatibleVersions = versions
34-
.filter(v => v.major >= introducedMajor)
35-
.map(({ url, label }) => ({
36-
value: url.replace('{path}', metadata.path),
37-
label,
38-
}));
39-
40-
const items = pages.map(([heading, path, category]) => ({
41-
label: heading,
42-
link:
43-
metadata.path === path
44-
? `${metadata.basename}.html`
45-
: `${relative(path, metadata.path)}.html`,
46-
category,
47-
}));
21+
const compatibleVersions = getCompatibleVersions(versions, metadata);
4822

4923
return (
5024
<SideBar
5125
pathname={`${metadata.basename}.html`}
52-
groups={buildSideBarGroups(items)}
26+
groups={groups}
5327
onSelect={redirect}
5428
as={props => <a {...props} rel="prefetch" />}
5529
title="Navigation"
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
'use strict';
2+
3+
import assert from 'node:assert/strict';
4+
import { describe, it } from 'node:test';
5+
6+
const {
7+
buildSideBarGroups,
8+
getSidebarItems,
9+
getMajorVersion,
10+
getCompatibleVersions,
11+
} = await import('../index.mjs');
12+
13+
const pages = [
14+
['File System API', 'fs', 'File System'],
15+
['HTTP API', 'http', 'Networking'],
16+
['Path API', 'path', 'File System'],
17+
['Index', 'index'],
18+
];
19+
20+
const versions = [
21+
{ major: 14, url: '/api/v14/{path}.html', label: 'v14' },
22+
{ major: 16, url: '/api/v16/{path}.html', label: 'v16' },
23+
{ major: 18, url: '/api/v18/{path}.html', label: 'v18' },
24+
];
25+
26+
describe('buildSideBarGroups', () => {
27+
it('groups entries by category and preserves insertion order', () => {
28+
const metadata = { path: 'fs', basename: 'fs' };
29+
30+
const result = buildSideBarGroups(pages, metadata);
31+
32+
assert.deepStrictEqual(result, [
33+
{
34+
groupName: 'File System',
35+
items: [
36+
{ label: 'File System API', link: 'fs.html' },
37+
{ label: 'Path API', link: 'path.html' },
38+
],
39+
},
40+
{
41+
groupName: 'Networking',
42+
items: [{ label: 'HTTP API', link: 'http.html' }],
43+
},
44+
]);
45+
});
46+
47+
it('puts entries without category into an Others group at the end by default', () => {
48+
const uncategorizedPages = [
49+
['Buffer', 'buffer', 'Binary'],
50+
['Unknown', 'unknown'],
51+
['Config', 'config', ''],
52+
];
53+
const metadata = { path: 'buffer', basename: 'buffer' };
54+
55+
const result = buildSideBarGroups(uncategorizedPages, metadata);
56+
57+
assert.equal(result.at(-1).groupName, 'Others');
58+
assert.deepStrictEqual(result.at(-1).items, [
59+
{ label: 'Unknown', link: 'unknown.html' },
60+
{ label: 'Config', link: 'config.html' },
61+
]);
62+
});
63+
64+
it('uses a custom default group name when provided', () => {
65+
const metadata = { path: 'unknown', basename: 'unknown' };
66+
const result = buildSideBarGroups(
67+
[['Unknown', 'unknown']],
68+
metadata,
69+
'General'
70+
);
71+
72+
assert.deepStrictEqual(result, [
73+
{
74+
groupName: 'General',
75+
items: [{ label: 'Unknown', link: 'unknown.html' }],
76+
},
77+
]);
78+
});
79+
80+
it('returns an empty array when given no entries', () => {
81+
assert.deepStrictEqual(
82+
buildSideBarGroups([], { path: 'fs', basename: 'fs' }),
83+
[]
84+
);
85+
});
86+
});
87+
88+
describe('getSidebarItems', () => {
89+
it('maps pages to sidebar items and keeps category values', () => {
90+
const metadata = { path: 'fs', basename: 'fs' };
91+
const result = getSidebarItems(pages.slice(0, 3), metadata);
92+
93+
assert.deepStrictEqual(result, [
94+
{
95+
label: 'File System API',
96+
link: 'fs.html',
97+
category: 'File System',
98+
},
99+
{
100+
label: 'HTTP API',
101+
link: 'http.html',
102+
category: 'Networking',
103+
},
104+
{
105+
label: 'Path API',
106+
link: 'path.html',
107+
category: 'File System',
108+
},
109+
]);
110+
});
111+
112+
it('uses basename html for the current page and relative links for others', () => {
113+
const metadata = { path: 'guide/fs', basename: 'fs' };
114+
const result = getSidebarItems(
115+
[
116+
['File System API', 'guide/fs', 'File System'],
117+
['HTTP API', 'guide/http', 'Networking'],
118+
['Child API', 'guide/sub/child'],
119+
],
120+
metadata
121+
);
122+
123+
assert.deepStrictEqual(result, [
124+
{
125+
label: 'File System API',
126+
link: 'fs.html',
127+
category: 'File System',
128+
},
129+
{
130+
label: 'HTTP API',
131+
link: 'http.html',
132+
category: 'Networking',
133+
},
134+
{
135+
label: 'Child API',
136+
link: 'sub/child.html',
137+
category: undefined,
138+
},
139+
]);
140+
});
141+
});
142+
143+
describe('getMajorVersion', () => {
144+
it('extracts major version from "v" prefixed string', () => {
145+
assert.strictEqual(getMajorVersion('v14.0.0'), 14);
146+
assert.strictEqual(getMajorVersion('v18.12.0'), 18);
147+
});
148+
149+
it('extracts major version without "v" prefix', () => {
150+
assert.strictEqual(getMajorVersion('16.0.0'), 16);
151+
assert.strictEqual(getMajorVersion('20.1.0'), 20);
152+
});
153+
154+
it('handles single digit versions', () => {
155+
assert.strictEqual(getMajorVersion('v4'), 4);
156+
assert.strictEqual(getMajorVersion('9'), 9);
157+
});
158+
159+
it('returns integer only', () => {
160+
const result = getMajorVersion('v14.5.3');
161+
assert.strictEqual(typeof result, 'number');
162+
assert.strictEqual(result % 1, 0);
163+
});
164+
});
165+
166+
describe('getCompatibleVersions', () => {
167+
it('includes versions with equal or greater major version', () => {
168+
const metadata = { added: 'v14.0.0', path: 'fs.md' };
169+
const result = getCompatibleVersions(versions, metadata);
170+
171+
assert.deepStrictEqual(result, [
172+
{ value: '/api/v14/fs.md.html', label: 'v14' },
173+
{ value: '/api/v16/fs.md.html', label: 'v16' },
174+
{ value: '/api/v18/fs.md.html', label: 'v18' },
175+
]);
176+
});
177+
178+
it('filters out versions with lower major version', () => {
179+
const metadata = { added: 'v18.0.0', path: 'fs.md' };
180+
const result = getCompatibleVersions(versions, metadata);
181+
182+
assert.deepStrictEqual(result, [
183+
{ value: '/api/v18/fs.md.html', label: 'v18' },
184+
]);
185+
});
186+
187+
it('uses introduced_in as fallback when added is missing', () => {
188+
const metadata = { introduced_in: 'v16.0.0', path: 'fs.md' };
189+
const result = getCompatibleVersions(versions, metadata);
190+
191+
assert.deepStrictEqual(result, [
192+
{ value: '/api/v16/fs.md.html', label: 'v16' },
193+
{ value: '/api/v18/fs.md.html', label: 'v18' },
194+
]);
195+
});
196+
197+
it('defaults to v0 when no version info provided', () => {
198+
const metadata = { path: 'fs.md' };
199+
const result = getCompatibleVersions(versions, metadata);
200+
201+
assert.deepStrictEqual(result, [
202+
{ value: '/api/v14/fs.md.html', label: 'v14' },
203+
{ value: '/api/v16/fs.md.html', label: 'v16' },
204+
{ value: '/api/v18/fs.md.html', label: 'v18' },
205+
]);
206+
});
207+
208+
it('replaces {path} placeholder in URL', () => {
209+
const metadata = { added: 'v14.0.0', path: 'file/system' };
210+
const result = getCompatibleVersions(versions, metadata);
211+
212+
result.forEach(item => {
213+
assert.ok(!item.value.includes('{path}'));
214+
assert.ok(item.value.includes(metadata.path));
215+
});
216+
});
217+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { relative } from '../../../../../../utils/url.mjs';
2+
import { SIDEBAR_GROUPS } from '../../../constants.mjs';
3+
4+
/**
5+
* @deprecated This is being exported temporarily during the transition period.
6+
* Reverse lookup: filename (e.g. 'fs.html') -> groupName, used as category
7+
* fallback for pages without explicit category in metadata.
8+
*/
9+
export const fileToGroup = new Map(
10+
SIDEBAR_GROUPS.flatMap(({ groupName, items }) =>
11+
items.map(item => [item, groupName])
12+
)
13+
);
14+
15+
/**
16+
* Builds grouped sidebar navigation from categorized page entries.
17+
* Pages without a category are placed under the provided default group.
18+
*
19+
* @param {Array<[string, string, string?]>} pages - Array of page entries as [heading, path, category?]
20+
* @param {{ path: string, basename: string }} metadata - Metadata for the current page, used to resolve links
21+
* @param {string} [defaultGroupName='Others'] - Name for the default group containing uncategorized pages
22+
* @returns {Array<{ groupName: string, items: Array<{ label: string, link: string }> }>}
23+
*/
24+
export const buildSideBarGroups = (
25+
pages,
26+
metadata,
27+
defaultGroupName = 'Others'
28+
) => {
29+
const items = getSidebarItems(pages, metadata);
30+
const groups = new Map();
31+
const others = [];
32+
33+
// Group entries by category while preserving insertion order
34+
for (const { label, link, category } of items) {
35+
const linkFilename = link.split('/').at(-1);
36+
37+
// Skip index pages as they are typically the main entry point for a section
38+
// and may not need to be listed separately in the sidebar.
39+
if (linkFilename === 'index.html') {
40+
continue;
41+
}
42+
43+
const resolvedCategory = category ?? fileToGroup.get(linkFilename);
44+
45+
if (!resolvedCategory) {
46+
others.push({ label, link });
47+
continue;
48+
}
49+
50+
const groupItems = groups.get(resolvedCategory) ?? [];
51+
groupItems.push({ label, link });
52+
groups.set(resolvedCategory, groupItems);
53+
}
54+
55+
// Convert the groups map to an array while preserving the original order of categories
56+
const orderedGroups = [...groups.entries()].map(([groupName, items]) => ({
57+
groupName,
58+
items,
59+
}));
60+
61+
if (others.length > 0) {
62+
orderedGroups.push({ groupName: defaultGroupName, items: others });
63+
}
64+
65+
return orderedGroups;
66+
};
67+
68+
/**
69+
* Converts page entries to sidebar items with resolved links based on current page metadata.
70+
* @param {Array<[string, string, string?]>} pages
71+
* @param {{ path: string, basename: string }} metadata
72+
* @returns {Array<{ label: string, link: string, category?: string }>}
73+
*/
74+
export const getSidebarItems = (pages, metadata) =>
75+
pages.map(([heading, path, category]) => ({
76+
label: heading,
77+
link:
78+
metadata.path === path
79+
? `${metadata.basename}.html`
80+
: `${relative(path, metadata.path)}.html`,
81+
category,
82+
}));
83+
84+
/**
85+
* Extracts the major version number from a version string.
86+
* @param {string} v - Version string (e.g., 'v14.0.0', '14.0.0')
87+
* @returns {number}
88+
*/
89+
export const getMajorVersion = v =>
90+
parseInt(String(v).match(/\d+/)?.[0] ?? '0', 10);
91+
92+
/**
93+
* Filters pre-computed versions by compatibility and resolves per-page URL based on metadata.
94+
* @param {Array<{ major: number, url: string, label: string }>} versions
95+
* @param {{ added?: string, introduced_in?: string, path: string }} metadata
96+
* @returns {Array<{ value: string, label: string }>}
97+
*/
98+
export const getCompatibleVersions = (versions, metadata) => {
99+
const introducedMajor = getMajorVersion(
100+
metadata.added ?? metadata.introduced_in
101+
);
102+
103+
// Filter pre-computed versions by compatibility and resolve per-page URL
104+
return versions
105+
.filter(v => v.major >= introducedMajor)
106+
.map(({ url, label }) => ({
107+
value: url.replace('{path}', metadata.path),
108+
label,
109+
}));
110+
};
111+
112+
/**
113+
* Redirect to a URL
114+
* @param {string} url URL
115+
*/
116+
export const redirect = url => (window.location.href = url);

0 commit comments

Comments
 (0)