Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
70 changes: 63 additions & 7 deletions lib/helpers/guides.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
* Guides are merged into the OpenAPI spec via `info.description`, which
* Redoc renders as a top-level "Introduction" section in the sidebar and
* splits on markdown H1/H2 headings.
*
* When `mergeGuidesIntoSpec` is called with a `{ sections }` option, guides
* are grouped under H1 section dividers (with each guide rendered as H2).
* Redoc auto-nests H2 entries under their parent H1 in the sidebar, giving
* the 5-section IA structure instead of a flat list.
*/
import fs from 'fs';
import path from 'path';
Expand Down Expand Up @@ -83,25 +88,75 @@ const loadGuides = (filePaths) => {
});
};

/**
* Extract the leading numeric prefix from a guide file path.
* E.g. `/foo/07-scheduling.md` → 7, `/foo/14-cli.md` → 14.
* Returns null when the basename has no numeric prefix.
* @param {string} filePath - Absolute or relative path to the guide file.
* @returns {number|null} Numeric prefix, or null if not present.
*/
const prefixFromPath = (filePath) => {
const base = path.basename(String(filePath), path.extname(String(filePath)));
const m = base.match(/^(\d+)[-_]/);
return m ? parseInt(m[1], 10) : null;
};

/**
* Merge loaded guides into an OpenAPI spec's `info.description`.
* Each guide becomes a top-level H1 section, which Redoc renders as a
* sidebar entry alongside the API reference.
*
* **Flat mode (default)** — each guide becomes a top-level H1 section.
* Redoc renders each H1 as a sidebar entry.
*
* **Sectioned mode** — when `options.sections` is provided, guides are
* grouped under H1 dividers (one per section) with each guide rendered as
* H2. Redoc auto-nests H2 entries under their parent H1 in the sidebar,
* giving a compact 5-section IA instead of a flat list of 18 guides.
* Guides whose filename prefix does not fall in any section range are
* appended at the end as H2 (never silently dropped).
*
* The original spec is mutated (and returned) to match the merge style used
* by `initSwagger` in `lib/services/express.js`.
*
* @param {object} spec - OpenAPI spec object (will be mutated).
* @param {{ title: string, body: string }[]} guides - Loaded guide entries.
* @returns {object} The same spec, with guides appended to `info.description`.
* @param {{ title: string, body: string, path?: string }[]} guides - Loaded guide entries.
* `path` is only required when using sectioned mode (needed for prefix extraction).
* @param {{ sections?: { title: string, prefixMin: number, prefixMax: number }[] } | null} [options]
* @returns {object|*} The mutated spec (or the original value when `spec` is falsy / not an object).
*/
const mergeGuidesIntoSpec = (spec, guides) => {
const mergeGuidesIntoSpec = (spec, guides, options = {}) => {
if (!spec || typeof spec !== 'object') return spec;
if (!Array.isArray(guides) || guides.length === 0) return spec;

const sections = guides.map(({ title, body }) => `# ${title}\n\n${body}`);
const { sections } = (options != null ? options : {});
let sectionsBlock;

if (Array.isArray(sections) && sections.length > 0) {
// Sectioned mode: group by filename numeric prefix
const groups = sections.map((sec) => ({ ...sec, guides: [] }));
const orphans = [];
for (const guide of guides) {
const prefix = prefixFromPath(guide.path);
const group = prefix !== null
? groups.find((g) => prefix >= g.prefixMin && prefix <= g.prefixMax)
: null;
if (group) group.guides.push(guide);
else orphans.push(guide);
}
const sectionMarkdown = groups
.filter((g) => g.guides.length > 0)
.map((g) => {
const guideBlocks = g.guides.map(({ title, body }) => `## ${title}\n\n${body}`).join('\n\n');
return `# ${g.title}\n\n${guideBlocks}`;
});
const orphanBlocks = orphans.map(({ title, body }) => `## ${title}\n\n${body}`);
sectionsBlock = [...sectionMarkdown, ...orphanBlocks].join('\n\n');
} else {
// Flat mode (backward-compat): one H1 per guide
sectionsBlock = guides.map(({ title, body }) => `# ${title}\n\n${body}`).join('\n\n');
}

const existing = typeof spec.info?.description === 'string' ? spec.info.description.trim() : '';
const merged = [existing, ...sections].filter(Boolean).join('\n\n');
const merged = [existing, sectionsBlock].filter(Boolean).join('\n\n');

spec.info = { ...(spec.info || {}), description: merged };
return spec;
Expand All @@ -112,4 +167,5 @@ export default {
stripLeadingH1,
loadGuides,
mergeGuidesIntoSpec,
prefixFromPath,
};
152 changes: 152 additions & 0 deletions lib/helpers/tests/guides.sections.unit.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/**
* Unit tests for mergeGuidesIntoSpec — sections option — and prefixFromPath.
*
* Covers flat (backward-compat) mode and sectioned mode where guides
* are grouped under H1 dividers (each guide rendered as H2) using
* filename numeric prefix ranges. Also covers prefixFromPath edge cases
* (exported helper, must handle all separator / no-prefix variants).
*/
import GuidesHelper from '../guides.js';

const { mergeGuidesIntoSpec, prefixFromPath } = GuidesHelper;

describe('mergeGuidesIntoSpec — sections option:', () => {
const makeGuide = (filePath, body = 'guide body') => ({
title: filePath.replace(/.*\//, '').replace(/\.md$/, '').replace(/^\d+[-_]/, '').replace(/[-_]/g, ' '),
body,
path: filePath,
});

const loginGuide = makeGuide('/docs/01-login.md', 'Login content');
const signupGuide = makeGuide('/docs/02-signup.md', 'Signup content');
const subscribeGuide = makeGuide('/docs/10-subscribe.md', 'Subscribe content');

const baseSpec = () => ({ info: { description: 'overview' } });

test('without sections option: guides flatten into info.description as H1 entries', () => {
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide]);
expect(out.info.description).toContain('login');
expect(out.info.description).toContain('signup');
// Flat mode → H1 headings, no section grouping
expect(out.info.description).not.toMatch(/^# auth$/m);
expect(out.info.description).not.toMatch(/^# billing$/m);
// Flat mode → each guide is a top-level H1
expect(out.info.description).toMatch(/^# /m);
});

test('without sections option: existing description is preserved', () => {
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide]);
expect(out.info.description).toContain('overview');
});

test('with sections array: guides nest under H1 section dividers as H2', () => {
const sections = [
{ title: 'auth', prefixMin: 1, prefixMax: 9 },
{ title: 'billing', prefixMin: 10, prefixMax: 19 },
];
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide, subscribeGuide], { sections });
// H1 section headers present
expect(out.info.description).toMatch(/^# auth$/m);
expect(out.info.description).toMatch(/^# billing$/m);
// Guides appear as H2 under their section
expect(out.info.description).toMatch(/^## /m);
// login and signup under auth (prefixes 1,2 → prefixMin:1 prefixMax:9)
expect(out.info.description).toContain('Login content');
expect(out.info.description).toContain('Signup content');
// subscribe under billing (prefix 10 → prefixMin:10 prefixMax:19)
expect(out.info.description).toContain('Subscribe content');
});

test('with sections: auth H1 appears before billing H1', () => {
const sections = [
{ title: 'auth', prefixMin: 1, prefixMax: 9 },
{ title: 'billing', prefixMin: 10, prefixMax: 19 },
];
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide, subscribeGuide], { sections });
const authIdx = out.info.description.indexOf('# auth');
const billingIdx = out.info.description.indexOf('# billing');
expect(authIdx).toBeLessThan(billingIdx);
});

test('with sections: guides without matching prefix range become orphan H2 entries (never silently dropped)', () => {
const sections = [{ title: 'auth', prefixMin: 1, prefixMax: 9 }];
// subscribeGuide has prefix 10, outside the only section range
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, subscribeGuide], { sections });
// The orphan is still present (not dropped)
expect(out.info.description).toContain('Subscribe content');
});

test('with sections: sections with no matched guides are omitted from output', () => {
const sections = [
{ title: 'auth', prefixMin: 1, prefixMax: 9 },
{ title: 'billing', prefixMin: 10, prefixMax: 19 },
];
// Only auth-range guides
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, signupGuide], { sections });
expect(out.info.description).toMatch(/^# auth$/m);
// billing section has no guides → should NOT appear
expect(out.info.description).not.toMatch(/^# billing$/m);
});

test('returns spec unchanged when guides array is empty', () => {
const spec = baseSpec();
const out = mergeGuidesIntoSpec(spec, []);
expect(out.info.description).toBe('overview');
});

test('returns spec unchanged when spec is falsy', () => {
expect(mergeGuidesIntoSpec(null, [loginGuide])).toBeNull();
expect(mergeGuidesIntoSpec(undefined, [loginGuide])).toBeUndefined();
});

test('null options does not throw — falls back to flat mode', () => {
// Passing null explicitly as third arg must not throw (null-safe options guard)
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide], null);
expect(out.info.description).toContain('Login content');
expect(out.info.description).toMatch(/^# /m);
});

test('with sections: orphan appears after section markdown (appended at end, not before)', () => {
const sections = [{ title: 'auth', prefixMin: 1, prefixMax: 9 }];
// subscribeGuide (prefix 10) is an orphan — should appear after the auth section
const out = mergeGuidesIntoSpec(baseSpec(), [loginGuide, subscribeGuide], { sections });
const authIdx = out.info.description.indexOf('# auth');
const orphanIdx = out.info.description.indexOf('Subscribe content');
expect(authIdx).toBeGreaterThan(-1);
expect(orphanIdx).toBeGreaterThan(authIdx);
});
});

describe('prefixFromPath:', () => {
test('extracts numeric prefix with dash separator', () => {
expect(prefixFromPath('/foo/07-scheduling.md')).toBe(7);
expect(prefixFromPath('/foo/14-cli.md')).toBe(14);
expect(prefixFromPath('/foo/01-getting-started.md')).toBe(1);
});

test('extracts numeric prefix with underscore separator', () => {
expect(prefixFromPath('/foo/03_intro.md')).toBe(3);
expect(prefixFromPath('/foo/20_advanced.md')).toBe(20);
});

test('returns null when no numeric prefix present', () => {
expect(prefixFromPath('/foo/welcome.md')).toBeNull();
expect(prefixFromPath('/foo/getting-started.md')).toBeNull();
});

test('returns null for pure-digit basename (no separator after digits)', () => {
// "42.md" has no dash or underscore after digits — not a prefix pattern
expect(prefixFromPath('/foo/42.md')).toBeNull();
});

test('handles octal-looking prefix correctly (parseInt radix 10)', () => {
// "08-" would be invalid octal — must parse as decimal 8
expect(prefixFromPath('/foo/08-webhooks.md')).toBe(8);
expect(prefixFromPath('/foo/09-billing.md')).toBe(9);
});

test('handles empty-ish inputs without throwing', () => {
expect(prefixFromPath('')).toBeNull();
expect(prefixFromPath('/foo/.md')).toBeNull();
});
});
Loading