Skip to content

Commit da02947

Browse files
feat(docs): polish default Redoc theme — Inter + JetBrains Mono, optional override (#3687)
Closes #3686. Default Redoc theme now ships Inter (body + headings) + JetBrains Mono (code), tighter sidebar spacing, no uppercase group items, refined right panel. Logo populated from config.app.url (+ optional config.app.logo). Downstream can deep-merge their own theme via config.docs.redocTheme without devkit edits. Tests cover (1) default theme markers in served HTML and (2) override deep-merge applied.
1 parent dee7ea5 commit da02947

2 files changed

Lines changed: 335 additions & 0 deletions

File tree

lib/services/express.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,36 @@ import errorTracker from './errorTracker.js';
2626
import AnalyticsService from './analytics.js';
2727
import analyticsMiddleware from '../middlewares/analytics.js';
2828

29+
/**
30+
* Default Redoc theme — Inter + JetBrains Mono, tighter sidebar, refined right panel.
31+
* No hardcoded brand color. Downstream projects override via config.docs.redocTheme
32+
* (deep-merged, zero devkit edits required).
33+
*/
34+
const defaultRedocTheme = {
35+
typography: {
36+
fontFamily: '"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
37+
headings: { fontFamily: '"Inter", -apple-system, sans-serif', fontWeight: '600' },
38+
code: { fontFamily: '"JetBrains Mono", Menlo, Consolas, monospace' },
39+
},
40+
sidebar: {
41+
width: '260px',
42+
textTransform: 'none',
43+
},
44+
rightPanel: { backgroundColor: '#1a1a1a' },
45+
spacing: { unit: 4 },
46+
};
47+
48+
/**
49+
* Custom CSS injected into the Redoc UI to cover what the theme schema cannot.
50+
* ≤ 30 lines.
51+
*/
52+
const redocCustomCss = `
53+
.menu-content { letter-spacing: 0; }
54+
.menu-content label, .menu-content .operation-type { text-transform: none !important; }
55+
pre, code { font-feature-settings: "calt" 0, "liga" 0; }
56+
.api-content blockquote { border-left: 3px solid #888; padding-left: 12px; color: #555; }
57+
`.trim();
58+
2959
/**
3060
* Initialize API documentation (Redoc UI + JSON spec endpoint)
3161
* @param {object} app - express application instance
@@ -69,10 +99,22 @@ const initSwagger = (app) => {
6999

70100
// Inject runtime-resolved metadata from config so each downstream project
71101
// advertises its own title, description, and public domain in the spec.
102+
// x-logo wired from config.app.url (href) + optional config.app.logo (url).
103+
// If config.app.url is absent, x-logo is omitted (no regression vs current state).
104+
const xLogo =
105+
config.app && config.app.url
106+
? {
107+
url: config.app.logo || undefined,
108+
href: config.app.url,
109+
altText: config.app.title,
110+
}
111+
: undefined;
112+
72113
spec.info = {
73114
...(spec.info || {}),
74115
title: config.app.title,
75116
description: config.app.description,
117+
...(xLogo ? { 'x-logo': xLogo } : {}),
76118
};
77119
spec.servers = [{ url: config.domain || 'http://localhost:3000' }];
78120

@@ -97,6 +139,10 @@ const initSwagger = (app) => {
97139
// Serve the merged spec as JSON
98140
app.get('/api/spec.json', serveSpec);
99141

142+
// Deep-merge devkit default theme with optional per-project override from
143+
// config.docs.redocTheme — downstream projects need zero devkit edits.
144+
const theme = _.merge({}, defaultRedocTheme, (config.docs && config.docs.redocTheme) || {});
145+
100146
// Mount Redoc API reference UI — consumes the spec via URL (not inline).
101147
// Equivalents for the previous Scalar `hideModels` behavior: hide the
102148
// download button and schema titles, and expand common success responses
@@ -110,6 +156,8 @@ const initSwagger = (app) => {
110156
hideDownloadButton: true,
111157
hideSchemaTitles: true,
112158
expandResponses: '200,201',
159+
theme,
160+
customCss: redocCustomCss,
113161
},
114162
}),
115163
);
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for express.js initSwagger — Redoc theme polish (issue #3686).
8+
*
9+
* Two scenarios:
10+
* 1. Default theme markers (Inter + JetBrains Mono) appear in served HTML.
11+
* 2. config.docs.redocTheme deep-merge override (e.g. primary color) reaches served HTML.
12+
*/
13+
describe('express initSwagger — Redoc theme (issue #3686):', () => {
14+
let capturedHtml;
15+
16+
const mockYamlDoc = {
17+
openapi: '3.0.0',
18+
info: { title: 'Test API', version: '1.0.0', description: 'Test' },
19+
paths: {},
20+
};
21+
22+
const baseConfig = {
23+
swagger: { enable: true },
24+
files: { swagger: ['/fake/swagger.yaml'], guides: [] },
25+
app: { title: 'Test API', description: 'Test', url: 'https://example.com' },
26+
domain: 'https://example.com',
27+
};
28+
29+
/**
30+
* Build a mock Express app that captures responses for /api/docs and /api/spec.json
31+
*/
32+
const buildMockApp = () => {
33+
const routes = {};
34+
const app = {
35+
get: (path, handler) => {
36+
routes[path] = handler;
37+
},
38+
_routes: routes,
39+
};
40+
return app;
41+
};
42+
43+
/**
44+
* Trigger the /api/docs route and capture the HTML sent
45+
*/
46+
const callDocsRoute = (app) => {
47+
const handler = app._routes['/api/docs'];
48+
if (!handler) throw new Error('/api/docs route not registered');
49+
let html = null;
50+
const res = {
51+
type: jest.fn(),
52+
send: (body) => {
53+
html = body;
54+
},
55+
};
56+
handler({}, res);
57+
return html;
58+
};
59+
60+
/**
61+
* Trigger the /api/spec.json route and capture the JSON spec served
62+
*/
63+
const callSpecRoute = (app) => {
64+
const handler = app._routes['/api/spec.json'];
65+
if (!handler) throw new Error('/api/spec.json route not registered');
66+
let spec = null;
67+
const res = { json: (body) => { spec = body; } };
68+
handler({}, res);
69+
return spec;
70+
};
71+
72+
beforeEach(() => {
73+
jest.resetModules();
74+
capturedHtml = null;
75+
76+
// Mock fs + js-yaml so we don't need a real YAML file
77+
jest.unstable_mockModule('fs', () => ({
78+
default: { readFileSync: jest.fn().mockReturnValue('mocked') },
79+
readFileSync: jest.fn().mockReturnValue('mocked'),
80+
}));
81+
jest.unstable_mockModule('js-yaml', () => ({
82+
default: { load: jest.fn().mockReturnValue(mockYamlDoc) },
83+
load: jest.fn().mockReturnValue(mockYamlDoc),
84+
}));
85+
86+
// Mock guides helper — no guides
87+
jest.unstable_mockModule('../../helpers/guides.js', () => ({
88+
default: {
89+
loadGuides: jest.fn().mockReturnValue([]),
90+
mergeGuidesIntoSpec: jest.fn(),
91+
},
92+
}));
93+
94+
// Mock logger
95+
jest.unstable_mockModule('../logger.js', () => ({
96+
default: { warn: jest.fn(), info: jest.fn(), error: jest.fn() },
97+
}));
98+
});
99+
100+
afterEach(() => {
101+
jest.restoreAllMocks();
102+
});
103+
104+
describe('default theme (no config.docs override):', () => {
105+
test('should include Inter font family in serialised Redoc options', async () => {
106+
jest.unstable_mockModule('../../../config/index.js', () => ({
107+
default: baseConfig,
108+
}));
109+
110+
const { default: expressService } = await import('../express.js');
111+
const app = buildMockApp();
112+
expressService.initSwagger(app);
113+
capturedHtml = callDocsRoute(app);
114+
115+
expect(capturedHtml).toContain('Inter');
116+
});
117+
118+
test('should include JetBrains Mono in serialised Redoc options', async () => {
119+
jest.unstable_mockModule('../../../config/index.js', () => ({
120+
default: baseConfig,
121+
}));
122+
123+
const { default: expressService } = await import('../express.js');
124+
const app = buildMockApp();
125+
expressService.initSwagger(app);
126+
capturedHtml = callDocsRoute(app);
127+
128+
expect(capturedHtml).toContain('JetBrains Mono');
129+
});
130+
131+
test('should include tighter sidebar width (260px) in serialised Redoc options', async () => {
132+
jest.unstable_mockModule('../../../config/index.js', () => ({
133+
default: baseConfig,
134+
}));
135+
136+
const { default: expressService } = await import('../express.js');
137+
const app = buildMockApp();
138+
expressService.initSwagger(app);
139+
capturedHtml = callDocsRoute(app);
140+
141+
expect(capturedHtml).toContain('260px');
142+
});
143+
144+
test('should include dark right panel background (#1a1a1a) in serialised Redoc options', async () => {
145+
jest.unstable_mockModule('../../../config/index.js', () => ({
146+
default: baseConfig,
147+
}));
148+
149+
const { default: expressService } = await import('../express.js');
150+
const app = buildMockApp();
151+
expressService.initSwagger(app);
152+
capturedHtml = callDocsRoute(app);
153+
154+
expect(capturedHtml).toContain('#1a1a1a');
155+
});
156+
157+
test('should inject x-logo href in spec when config.app.url is set', async () => {
158+
jest.unstable_mockModule('../../../config/index.js', () => ({
159+
default: { ...baseConfig },
160+
}));
161+
162+
const { default: expressService } = await import('../express.js');
163+
const app = buildMockApp();
164+
expressService.initSwagger(app);
165+
const spec = callSpecRoute(app);
166+
167+
// x-logo is added to spec.info when config.app.url is present
168+
expect(spec.info['x-logo']).toBeDefined();
169+
expect(spec.info['x-logo'].href).toBe('https://example.com');
170+
});
171+
172+
test('should inject x-logo url in spec when config.app.logo is provided', async () => {
173+
jest.unstable_mockModule('../../../config/index.js', () => ({
174+
default: {
175+
...baseConfig,
176+
app: { ...baseConfig.app, logo: 'https://example.com/logo.png' },
177+
},
178+
}));
179+
180+
const { default: expressService } = await import('../express.js');
181+
const app = buildMockApp();
182+
expressService.initSwagger(app);
183+
const spec = callSpecRoute(app);
184+
185+
expect(spec.info['x-logo'].url).toBe('https://example.com/logo.png');
186+
});
187+
188+
test('should not inject x-logo when config.app.url is absent', async () => {
189+
jest.unstable_mockModule('../../../config/index.js', () => ({
190+
default: {
191+
...baseConfig,
192+
app: { title: 'Test API', description: 'Test' }, // no url
193+
},
194+
}));
195+
196+
const { default: expressService } = await import('../express.js');
197+
const app = buildMockApp();
198+
199+
// Must not throw and no x-logo in spec
200+
expect(() => expressService.initSwagger(app)).not.toThrow();
201+
const spec = callSpecRoute(app);
202+
expect(spec.info['x-logo']).toBeUndefined();
203+
});
204+
});
205+
206+
describe('config.docs.redocTheme deep-merge override:', () => {
207+
test('should apply custom primary color from config.docs.redocTheme override', async () => {
208+
jest.unstable_mockModule('../../../config/index.js', () => ({
209+
default: {
210+
...baseConfig,
211+
docs: {
212+
redocTheme: {
213+
colors: { primary: { main: '#ff0000' } },
214+
},
215+
},
216+
},
217+
}));
218+
219+
const { default: expressService } = await import('../express.js');
220+
const app = buildMockApp();
221+
expressService.initSwagger(app);
222+
capturedHtml = callDocsRoute(app);
223+
224+
// Override color reaches the HTML
225+
expect(capturedHtml).toContain('#ff0000');
226+
});
227+
228+
test('should preserve default font family when override adds a color', async () => {
229+
jest.unstable_mockModule('../../../config/index.js', () => ({
230+
default: {
231+
...baseConfig,
232+
docs: {
233+
redocTheme: {
234+
colors: { primary: { main: '#00ff00' } },
235+
},
236+
},
237+
},
238+
}));
239+
240+
const { default: expressService } = await import('../express.js');
241+
const app = buildMockApp();
242+
expressService.initSwagger(app);
243+
capturedHtml = callDocsRoute(app);
244+
245+
// Default font must still be present even when an override is applied
246+
expect(capturedHtml).toContain('Inter');
247+
expect(capturedHtml).toContain('#00ff00');
248+
});
249+
250+
test('should allow override to replace sidebar width', async () => {
251+
jest.unstable_mockModule('../../../config/index.js', () => ({
252+
default: {
253+
...baseConfig,
254+
docs: {
255+
redocTheme: {
256+
sidebar: { width: '300px' },
257+
},
258+
},
259+
},
260+
}));
261+
262+
const { default: expressService } = await import('../express.js');
263+
const app = buildMockApp();
264+
expressService.initSwagger(app);
265+
capturedHtml = callDocsRoute(app);
266+
267+
expect(capturedHtml).toContain('300px');
268+
});
269+
270+
test('should apply devkit defaults when config.docs is absent', async () => {
271+
jest.unstable_mockModule('../../../config/index.js', () => ({
272+
default: {
273+
...baseConfig,
274+
// no docs key at all
275+
},
276+
}));
277+
278+
const { default: expressService } = await import('../express.js');
279+
const app = buildMockApp();
280+
expressService.initSwagger(app);
281+
capturedHtml = callDocsRoute(app);
282+
283+
expect(capturedHtml).toContain('Inter');
284+
expect(capturedHtml).toContain('JetBrains Mono');
285+
});
286+
});
287+
});

0 commit comments

Comments
 (0)