Skip to content

Commit 580f6a3

Browse files
feat(public): docs content contract (/api/public/docs) + decommission Redoc (#3878)
* feat(public): expose docs guides as a structured content tree Add a public, unauthenticated docs module serving: - GET /api/public/docs -> { categories: [{ id, label, order, guides: [{ slug, title, persona, order, summary }] }] } - GET /api/public/docs/:slug.md -> raw text/markdown (unknown slug -> 404) Guides are discovered from the existing config.files.guides glob (modules/*/doc/guides/*.md) and grouped via the config.docs.guideSections primitive (prefix-range sections, optional per-section persona; persona defaults to ['all']). Guides outside every range fall back to their capitalised module name so none is ever dropped. Rate-limited (limiters.api), ~5min in-process TTL cache. Ship two neutral sample guides (00-welcome, 01-quickstart) under modules/home/doc/guides and a neutral docs.guideSections default so the endpoint returns a working tree out of the box. * refactor(docs): decommission Redoc UI, keep /api/spec.json Remove the redoc-express dependency and the /api/docs Redoc UI mount, its theme + custom-CSS blocks, and the config.docs.redocTheme merge. Rename initSwagger -> initApiSpec; the OpenAPI JSON spec endpoint (/api/spec.json) is now the documentation surface and keeps the YAML merge, config-injected info/servers/x-logo, and the markdown guides merge into info.description. The config.swagger.enable + non-dev env gate is unchanged (now gates spec-serving only). Tests: drop the Redoc-UI assertions, strengthen the spec assertions, and add coverage asserting /api/docs is no longer mounted. * refactor(audit): drop dead /api/docs skip-prefix after Redoc decom * fix(public): warn on duplicate guide slugs + Cache-Control on docs endpoints M1: replace silent last-win Map construction in publicDocs.service compute() with an explicit loop that logger.warn()s on duplicate slugs. M2: add comment in load() explaining why no inflight guard is needed (compute() is fully synchronous — no stampede risk on the event loop). M3: set Cache-Control: public, max-age=300 on both the tree and raw-markdown 200 responses in publicDocs.controller (matches the 5-min in-process TTL); add controller unit tests asserting the header is present on 200 and absent on 404. L4: document NN-kebab-name.md filename convention in slugFromPath. L5: add comment in express.docs.unit.tests.js second describe block noting that every new test must stub loadGuides inline (no module-level mock). nit: trim docs.guideSections block comment in development.config.js to one line pointing at the helper JSDoc. * refactor(public): rename docs files to devkit convention + defensive copies + deterministic ordering F1: git mv all publicDocs.* files to public.docs.{type}.js (dot-separated, module-prefixed, routes plural); update every import and reference including test mocks and config comment. F2/F3: guideFiles() returns [...config.files.guides], guideSections() returns .map(s=>({...s})) so callers cannot mutate config state. F4: [...filePaths].sort() before .map() so fallback index for unprefixed guides is platform-stable (glob order is not guaranteed). F5: sort tiebreaker a.slug.localeCompare(b.slug) for equal numeric prefixes. F6: remove try/catch in beforeAll — let bootstrap() throw with its real stack trace. F7: update stale Redoc/reference-sidebar comments in public.docs.tree.js and development.config.js (guideSections groups public docs contract only). * test(public): strengthen /api/docs decom assertion + JSDoc helpers + async @returns
1 parent 3937953 commit 580f6a3

22 files changed

Lines changed: 1349 additions & 406 deletions

config/defaults/development.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ const config = {
1818
swagger: {
1919
enable: true,
2020
},
21+
docs: {
22+
// Grouping primitive for guide sections. See `public.docs.tree.js` JSDoc for full schema details.
23+
guideSections: [
24+
{ title: 'Get Started', prefixMin: 0, prefixMax: 9 },
25+
],
26+
},
2127
api: {
2228
protocol: 'http',
2329
port: 3000,

lib/helpers/guides.js

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
/**
2-
* Markdown guide loader for the Redoc API reference.
2+
* Markdown guide loader for the OpenAPI reference.
33
*
44
* Per-module markdown guides live under `modules/{name}/doc/guides/*.md`
55
* and are discovered by the same globbing mechanism as OpenAPI YAML files
66
* (see `config/assets.js` → `allGuides`).
77
*
8-
* Guides are merged into the OpenAPI spec via `info.description`, which
9-
* Redoc renders as a top-level "Introduction" section in the sidebar and
10-
* splits on markdown H1/H2 headings.
8+
* Guides are merged into the OpenAPI spec via `info.description`, which an
9+
* OpenAPI viewer renders as a top-level "Introduction" section, split on
10+
* markdown H1/H2 headings.
1111
*
1212
* 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.
13+
* are grouped under H1 section dividers (with each guide rendered as H2),
14+
* giving a sectioned IA structure instead of a flat list.
1615
*/
1716
import fs from 'fs';
1817
import path from 'path';
@@ -105,17 +104,16 @@ const prefixFromPath = (filePath) => {
105104
* Merge loaded guides into an OpenAPI spec's `info.description`.
106105
*
107106
* **Flat mode (default)** — each guide becomes a top-level H1 section.
108-
* Redoc renders each H1 as a sidebar entry.
107+
* An OpenAPI viewer renders each H1 as a sidebar entry.
109108
*
110109
* **Sectioned mode** — when `options.sections` is provided, guides are
111110
* 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.
111+
* H2, giving a compact sectioned IA instead of a flat list.
114112
* Guides whose filename prefix does not fall in any section range are
115113
* appended at the end as H2 (never silently dropped).
116114
*
117115
* The original spec is mutated (and returned) to match the merge style used
118-
* by `initSwagger` in `lib/services/express.js`.
116+
* by `initApiSpec` in `lib/services/express.js`.
119117
*
120118
* @param {object} spec - OpenAPI spec object (will be mutated).
121119
* @param {{ title: string, body: string, path?: string }[]} guides - Loaded guide entries.

lib/services/express.js

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import cors from 'cors';
1515
import morgan from 'morgan';
1616
import fs from 'fs';
1717
import YAML from 'js-yaml';
18-
import redoc from 'redoc-express';
1918

2019
import config from '../../config/index.js';
2120
import configHelper from '../helpers/config.js';
@@ -41,44 +40,14 @@ export const computeOpenApiServerUrl = (domain) => {
4140
};
4241

4342
/**
44-
* Default Redoc theme — Inter + JetBrains Mono, tighter sidebar, refined right panel.
45-
* No hardcoded brand color. Downstream projects override via config.docs.redocTheme
46-
* (deep-merged, zero devkit edits required).
47-
*/
48-
const defaultRedocTheme = {
49-
typography: {
50-
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
51-
headings: { fontFamily: '"Inter", -apple-system, sans-serif', fontWeight: '600' },
52-
code: { fontFamily: '"JetBrains Mono", Menlo, Consolas, monospace' },
53-
},
54-
sidebar: {
55-
width: '260px',
56-
textTransform: 'none',
57-
},
58-
rightPanel: { backgroundColor: '#1a1a1a' },
59-
spacing: { unit: 4 },
60-
};
61-
62-
/**
63-
* Custom CSS injected into the Redoc UI to cover what the theme schema cannot.
64-
* ≤ 30 lines.
65-
*/
66-
const redocCustomCss = `
67-
.menu-content { letter-spacing: 0; }
68-
.menu-content label, .menu-content .operation-type { text-transform: none !important; }
69-
pre, code { font-feature-settings: "calt" 0, "liga" 0; }
70-
.api-content blockquote { border-left: 3px solid #888; padding-left: 12px; color: #555; }
71-
`.trim();
72-
73-
/**
74-
* Initialize API documentation (Redoc UI + JSON spec endpoint)
43+
* Initialize API documentation (OpenAPI JSON spec endpoint)
7544
* @param {object} app - express application instance
7645
* @returns {void}
7746
*/
78-
const initSwagger = (app) => {
79-
// Secure-by-default: the API docs surface (/api/spec.json + /api/docs) is
80-
// UNAUTHENTICATED, so it is only mounted in dev-grade envs. Any production-grade
81-
// env (the literal `production` OR a deployment env label) skips it even when
47+
const initApiSpec = (app) => {
48+
// Secure-by-default: the API spec surface (/api/spec.json) is UNAUTHENTICATED,
49+
// so it is only mounted in dev-grade envs. Any production-grade env (the
50+
// literal `production` OR a deployment env label) skips it even when
8251
// config.swagger.enable is still truthy — opt-OUT by default in non-dev.
8352
if (config.swagger.enable && !configHelper.isProd()) {
8453
if (!config.files.swagger || config.files.swagger.length === 0) {
@@ -136,8 +105,8 @@ const initSwagger = (app) => {
136105
};
137106
spec.servers = [{ url: computeOpenApiServerUrl(config.domain) }];
138107

139-
// Merge per-module markdown guides into info.description so Redoc
140-
// renders them in its sidebar alongside the OpenAPI reference.
108+
// Merge per-module markdown guides into info.description so an OpenAPI
109+
// viewer renders them alongside the reference.
141110
const guides = guidesHelper.loadGuides(config.files.guides || []);
142111
guidesHelper.mergeGuidesIntoSpec(spec, guides);
143112
if (guides.length > 0) {
@@ -156,29 +125,6 @@ const initSwagger = (app) => {
156125

157126
// Serve the merged spec as JSON
158127
app.get('/api/spec.json', serveSpec);
159-
160-
// Deep-merge devkit default theme with optional per-project override from
161-
// config.docs.redocTheme — downstream projects need zero devkit edits.
162-
const theme = _.merge({}, defaultRedocTheme, (config.docs && config.docs.redocTheme) || {});
163-
164-
// Mount Redoc API reference UI — consumes the spec via URL (not inline).
165-
// Equivalents for the previous Scalar `hideModels` behavior: hide the
166-
// download button and schema titles, and expand common success responses
167-
// so the reference feels compact and consumer-focused.
168-
app.get(
169-
'/api/docs',
170-
redoc({
171-
title: config.app.title,
172-
specUrl: '/api/spec.json',
173-
redocOptions: {
174-
hideDownloadButton: true,
175-
hideSchemaTitles: true,
176-
expandResponses: '200,201',
177-
theme,
178-
customCss: redocCustomCss,
179-
},
180-
}),
181-
);
182128
}
183129
};
184130

@@ -371,8 +317,8 @@ const initErrorRoutes = (app) => {
371317
const init = async () => {
372318
// Initialize express app
373319
const app = express();
374-
// Initialize modules swagger doc
375-
initSwagger(app);
320+
// Initialize the OpenAPI JSON spec endpoint
321+
initApiSpec(app);
376322
// Initialize local variables
377323
initLocalVariables(app);
378324
// Assign a unique request ID before any route registration
@@ -415,7 +361,7 @@ const init = async () => {
415361
};
416362

417363
export default {
418-
initSwagger,
364+
initApiSpec,
419365
initLocalVariables,
420366
initPreParserRoutes,
421367
initMiddleware,

0 commit comments

Comments
 (0)