Skip to content

Commit b56d3b6

Browse files
feat(guides): add sections option for grouped Redoc sidebar layout (#3771)
* feat(guides): add sections option for grouped Redoc sidebar layout Promote-up from trawl_node: mergeGuidesIntoSpec gains an optional { sections } argument that groups guides under H1 section dividers (each guide rendered as H2) using filename numeric prefix ranges. Adds prefixFromPath helper (exported). Flat mode unchanged when sections is absent (backward-compatible). Closes #3767 * test(guides): add direct prefixFromPath unit tests + orphan-order assertion Addresses DeepSeek Phase 0 medium finding: prefixFromPath is a new public export but had only indirect coverage. Adds 6 direct tests (dash separator, underscore separator, no-prefix, pure-digit, octal radix-10 correctness, empty input). Also adds orphan-position assertion (orphan appended after section markdown, per JSDoc spec). * fix(guides): null-safe options guard + JSDoc type fixes Address Copilot review threads: - options null-safety: `(options != null ? options : {})` prevents TypeError when null is passed explicitly as third arg - JSDoc: `path?` optional in guides entries (only needed for sections mode) - JSDoc: @returns reflects actual passthrough of falsy/non-object spec - Test: null options falls back to flat mode without throwing
1 parent 7ae3664 commit b56d3b6

2 files changed

Lines changed: 215 additions & 7 deletions

File tree

lib/helpers/guides.js

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
* Guides are merged into the OpenAPI spec via `info.description`, which
99
* Redoc renders as a top-level "Introduction" section in the sidebar and
1010
* splits on markdown H1/H2 headings.
11+
*
12+
* When `mergeGuidesIntoSpec` is called with a `{ sections }` option, guides
13+
* are grouped under H1 section dividers (with each guide rendered as H2).
14+
* Redoc auto-nests H2 entries under their parent H1 in the sidebar, giving
15+
* the 5-section IA structure instead of a flat list.
1116
*/
1217
import fs from 'fs';
1318
import path from 'path';
@@ -83,25 +88,75 @@ const loadGuides = (filePaths) => {
8388
});
8489
};
8590

91+
/**
92+
* Extract the leading numeric prefix from a guide file path.
93+
* E.g. `/foo/07-scheduling.md` → 7, `/foo/14-cli.md` → 14.
94+
* Returns null when the basename has no numeric prefix.
95+
* @param {string} filePath - Absolute or relative path to the guide file.
96+
* @returns {number|null} Numeric prefix, or null if not present.
97+
*/
98+
const prefixFromPath = (filePath) => {
99+
const base = path.basename(String(filePath), path.extname(String(filePath)));
100+
const m = base.match(/^(\d+)[-_]/);
101+
return m ? parseInt(m[1], 10) : null;
102+
};
103+
86104
/**
87105
* Merge loaded guides into an OpenAPI spec's `info.description`.
88-
* Each guide becomes a top-level H1 section, which Redoc renders as a
89-
* sidebar entry alongside the API reference.
106+
*
107+
* **Flat mode (default)** — each guide becomes a top-level H1 section.
108+
* Redoc renders each H1 as a sidebar entry.
109+
*
110+
* **Sectioned mode** — when `options.sections` is provided, guides are
111+
* grouped under H1 dividers (one per section) with each guide rendered as
112+
* H2. Redoc auto-nests H2 entries under their parent H1 in the sidebar,
113+
* giving a compact 5-section IA instead of a flat list of 18 guides.
114+
* Guides whose filename prefix does not fall in any section range are
115+
* appended at the end as H2 (never silently dropped).
90116
*
91117
* The original spec is mutated (and returned) to match the merge style used
92118
* by `initSwagger` in `lib/services/express.js`.
93119
*
94120
* @param {object} spec - OpenAPI spec object (will be mutated).
95-
* @param {{ title: string, body: string }[]} guides - Loaded guide entries.
96-
* @returns {object} The same spec, with guides appended to `info.description`.
121+
* @param {{ title: string, body: string, path?: string }[]} guides - Loaded guide entries.
122+
* `path` is only required when using sectioned mode (needed for prefix extraction).
123+
* @param {{ sections?: { title: string, prefixMin: number, prefixMax: number }[] } | null} [options]
124+
* @returns {object|*} The mutated spec (or the original value when `spec` is falsy / not an object).
97125
*/
98-
const mergeGuidesIntoSpec = (spec, guides) => {
126+
const mergeGuidesIntoSpec = (spec, guides, options = {}) => {
99127
if (!spec || typeof spec !== 'object') return spec;
100128
if (!Array.isArray(guides) || guides.length === 0) return spec;
101129

102-
const sections = guides.map(({ title, body }) => `# ${title}\n\n${body}`);
130+
const { sections } = (options != null ? options : {});
131+
let sectionsBlock;
132+
133+
if (Array.isArray(sections) && sections.length > 0) {
134+
// Sectioned mode: group by filename numeric prefix
135+
const groups = sections.map((sec) => ({ ...sec, guides: [] }));
136+
const orphans = [];
137+
for (const guide of guides) {
138+
const prefix = prefixFromPath(guide.path);
139+
const group = prefix !== null
140+
? groups.find((g) => prefix >= g.prefixMin && prefix <= g.prefixMax)
141+
: null;
142+
if (group) group.guides.push(guide);
143+
else orphans.push(guide);
144+
}
145+
const sectionMarkdown = groups
146+
.filter((g) => g.guides.length > 0)
147+
.map((g) => {
148+
const guideBlocks = g.guides.map(({ title, body }) => `## ${title}\n\n${body}`).join('\n\n');
149+
return `# ${g.title}\n\n${guideBlocks}`;
150+
});
151+
const orphanBlocks = orphans.map(({ title, body }) => `## ${title}\n\n${body}`);
152+
sectionsBlock = [...sectionMarkdown, ...orphanBlocks].join('\n\n');
153+
} else {
154+
// Flat mode (backward-compat): one H1 per guide
155+
sectionsBlock = guides.map(({ title, body }) => `# ${title}\n\n${body}`).join('\n\n');
156+
}
157+
103158
const existing = typeof spec.info?.description === 'string' ? spec.info.description.trim() : '';
104-
const merged = [existing, ...sections].filter(Boolean).join('\n\n');
159+
const merged = [existing, sectionsBlock].filter(Boolean).join('\n\n');
105160

106161
spec.info = { ...(spec.info || {}), description: merged };
107162
return spec;
@@ -112,4 +167,5 @@ export default {
112167
stripLeadingH1,
113168
loadGuides,
114169
mergeGuidesIntoSpec,
170+
prefixFromPath,
115171
};
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Unit tests for mergeGuidesIntoSpec — sections option — and prefixFromPath.
3+
*
4+
* Covers flat (backward-compat) mode and sectioned mode where guides
5+
* are grouped under H1 dividers (each guide rendered as H2) using
6+
* filename numeric prefix ranges. Also covers prefixFromPath edge cases
7+
* (exported helper, must handle all separator / no-prefix variants).
8+
*/
9+
import GuidesHelper from '../guides.js';
10+
11+
const { mergeGuidesIntoSpec, prefixFromPath } = GuidesHelper;
12+
13+
describe('mergeGuidesIntoSpec — sections option:', () => {
14+
const makeGuide = (filePath, body = 'guide body') => ({
15+
title: filePath.replace(/.*\//, '').replace(/\.md$/, '').replace(/^\d+[-_]/, '').replace(/[-_]/g, ' '),
16+
body,
17+
path: filePath,
18+
});
19+
20+
const loginGuide = makeGuide('/docs/01-login.md', 'Login content');
21+
const signupGuide = makeGuide('/docs/02-signup.md', 'Signup content');
22+
const subscribeGuide = makeGuide('/docs/10-subscribe.md', 'Subscribe content');
23+
24+
const baseSpec = () => ({ info: { description: 'overview' } });
25+
26+
test('without sections option: guides flatten into info.description as H1 entries', () => {
27+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide]);
28+
expect(out.info.description).toContain('login');
29+
expect(out.info.description).toContain('signup');
30+
// Flat mode → H1 headings, no section grouping
31+
expect(out.info.description).not.toMatch(/^# auth$/m);
32+
expect(out.info.description).not.toMatch(/^# billing$/m);
33+
// Flat mode → each guide is a top-level H1
34+
expect(out.info.description).toMatch(/^# /m);
35+
});
36+
37+
test('without sections option: existing description is preserved', () => {
38+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide]);
39+
expect(out.info.description).toContain('overview');
40+
});
41+
42+
test('with sections array: guides nest under H1 section dividers as H2', () => {
43+
const sections = [
44+
{ title: 'auth', prefixMin: 1, prefixMax: 9 },
45+
{ title: 'billing', prefixMin: 10, prefixMax: 19 },
46+
];
47+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide, subscribeGuide], { sections });
48+
// H1 section headers present
49+
expect(out.info.description).toMatch(/^# auth$/m);
50+
expect(out.info.description).toMatch(/^# billing$/m);
51+
// Guides appear as H2 under their section
52+
expect(out.info.description).toMatch(/^## /m);
53+
// login and signup under auth (prefixes 1,2 → prefixMin:1 prefixMax:9)
54+
expect(out.info.description).toContain('Login content');
55+
expect(out.info.description).toContain('Signup content');
56+
// subscribe under billing (prefix 10 → prefixMin:10 prefixMax:19)
57+
expect(out.info.description).toContain('Subscribe content');
58+
});
59+
60+
test('with sections: auth H1 appears before billing H1', () => {
61+
const sections = [
62+
{ title: 'auth', prefixMin: 1, prefixMax: 9 },
63+
{ title: 'billing', prefixMin: 10, prefixMax: 19 },
64+
];
65+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide, subscribeGuide], { sections });
66+
const authIdx = out.info.description.indexOf('# auth');
67+
const billingIdx = out.info.description.indexOf('# billing');
68+
expect(authIdx).toBeLessThan(billingIdx);
69+
});
70+
71+
test('with sections: guides without matching prefix range become orphan H2 entries (never silently dropped)', () => {
72+
const sections = [{ title: 'auth', prefixMin: 1, prefixMax: 9 }];
73+
// subscribeGuide has prefix 10, outside the only section range
74+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, subscribeGuide], { sections });
75+
// The orphan is still present (not dropped)
76+
expect(out.info.description).toContain('Subscribe content');
77+
});
78+
79+
test('with sections: sections with no matched guides are omitted from output', () => {
80+
const sections = [
81+
{ title: 'auth', prefixMin: 1, prefixMax: 9 },
82+
{ title: 'billing', prefixMin: 10, prefixMax: 19 },
83+
];
84+
// Only auth-range guides
85+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide], { sections });
86+
expect(out.info.description).toMatch(/^# auth$/m);
87+
// billing section has no guides → should NOT appear
88+
expect(out.info.description).not.toMatch(/^# billing$/m);
89+
});
90+
91+
test('returns spec unchanged when guides array is empty', () => {
92+
const spec = baseSpec();
93+
const out = mergeGuidesIntoSpec(spec, []);
94+
expect(out.info.description).toBe('overview');
95+
});
96+
97+
test('returns spec unchanged when spec is falsy', () => {
98+
expect(mergeGuidesIntoSpec(null, [loginGuide])).toBeNull();
99+
expect(mergeGuidesIntoSpec(undefined, [loginGuide])).toBeUndefined();
100+
});
101+
102+
test('null options does not throw — falls back to flat mode', () => {
103+
// Passing null explicitly as third arg must not throw (null-safe options guard)
104+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide], null);
105+
expect(out.info.description).toContain('Login content');
106+
expect(out.info.description).toMatch(/^# /m);
107+
});
108+
109+
test('with sections: orphan appears after section markdown (appended at end, not before)', () => {
110+
const sections = [{ title: 'auth', prefixMin: 1, prefixMax: 9 }];
111+
// subscribeGuide (prefix 10) is an orphan — should appear after the auth section
112+
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, subscribeGuide], { sections });
113+
const authIdx = out.info.description.indexOf('# auth');
114+
const orphanIdx = out.info.description.indexOf('Subscribe content');
115+
expect(authIdx).toBeGreaterThan(-1);
116+
expect(orphanIdx).toBeGreaterThan(authIdx);
117+
});
118+
});
119+
120+
describe('prefixFromPath:', () => {
121+
test('extracts numeric prefix with dash separator', () => {
122+
expect(prefixFromPath('/foo/07-scheduling.md')).toBe(7);
123+
expect(prefixFromPath('/foo/14-cli.md')).toBe(14);
124+
expect(prefixFromPath('/foo/01-getting-started.md')).toBe(1);
125+
});
126+
127+
test('extracts numeric prefix with underscore separator', () => {
128+
expect(prefixFromPath('/foo/03_intro.md')).toBe(3);
129+
expect(prefixFromPath('/foo/20_advanced.md')).toBe(20);
130+
});
131+
132+
test('returns null when no numeric prefix present', () => {
133+
expect(prefixFromPath('/foo/welcome.md')).toBeNull();
134+
expect(prefixFromPath('/foo/getting-started.md')).toBeNull();
135+
});
136+
137+
test('returns null for pure-digit basename (no separator after digits)', () => {
138+
// "42.md" has no dash or underscore after digits — not a prefix pattern
139+
expect(prefixFromPath('/foo/42.md')).toBeNull();
140+
});
141+
142+
test('handles octal-looking prefix correctly (parseInt radix 10)', () => {
143+
// "08-" would be invalid octal — must parse as decimal 8
144+
expect(prefixFromPath('/foo/08-webhooks.md')).toBe(8);
145+
expect(prefixFromPath('/foo/09-billing.md')).toBe(9);
146+
});
147+
148+
test('handles empty-ish inputs without throwing', () => {
149+
expect(prefixFromPath('')).toBeNull();
150+
expect(prefixFromPath('/foo/.md')).toBeNull();
151+
});
152+
});

0 commit comments

Comments
 (0)