Skip to content

Commit 67acceb

Browse files
feat(docs): embed markdown guides in Scalar API reference (#3453)
* feat(docs): embed markdown guides in Scalar API reference Add support for per-module markdown guides discovered via `modules/*/doc/guides/*.md` globbing. Guides are merged into the OpenAPI spec's `info.description` so Scalar renders them as sidebar sections alongside the API reference. Migrates the 3 guides previously shipped by the Vue docs module (getting-started, authentication, organizations) under `modules/core/doc/guides/` so they are always active regardless of downstream project configuration. Closes #3451 * fix(doc): correct scalar guides to match actual auth and org APIs - Auth guide uses cookie-based JWT (TOKEN cookie), no refresh token - Fix password reset payload (newPassword instead of password) - Organizations: /invites route and currentOrganization + /switch (not the X-Organization-Id header) - getting-started prerequisite bumped to Node 22+ - Include guides in module-activation filtering (fileKeys) - Expand initGlobalConfigFiles JSDoc * test(core): add integration coverage for scalar guides - Verify /api/spec.json exposes merged markdown guides in info.description (Getting Started, Authentication, Organizations) - Verify /api/docs serves the Scalar HTML reference page
1 parent c882495 commit 67acceb

10 files changed

Lines changed: 458 additions & 4 deletions

File tree

config/assets.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
export default {
33
allJS: ['server.js', 'config/**/*.js', 'lib/**/*.js', 'modules/*/**/*.js'],
44
allYaml: 'modules/*/doc/*.yml',
5+
allGuides: 'modules/*/doc/guides/*.md',
56
mongooseModels: 'modules/*/models/*.mongoose.js',
67
preRoutes: 'modules/*/routes/*.preroute.js',
78
routes: 'modules/*/routes/!(*.preroute).js',

config/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ const initGlobalConfig = async () => {
158158
// Initialize global globbed files
159159
config = deepMerge(config, { files: await configHelper.initGlobalConfigFiles(assets) });
160160
// Filter files by module activation (deactivated modules are excluded)
161-
const fileKeys = ['swagger', 'mongooseModels', 'preRoutes', 'routes', 'configs', 'policies'];
161+
const fileKeys = ['swagger', 'guides', 'mongooseModels', 'preRoutes', 'routes', 'configs', 'policies'];
162162
for (const key of fileKeys) {
163163
if (config.files[key]) {
164164
config.files[key] = configHelper.filterByActivation(config.files[key], config);

lib/helpers/config.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,20 @@ const filterByActivation = (files, config) => files.filter((file) => {
139139
});
140140

141141
/**
142-
* Initialize global configuration files
142+
* Initialize global configuration files by resolving asset glob patterns.
143+
*
144+
* Globs each category of files (swagger YAML, markdown guides, mongoose
145+
* models, routes, configs, policies) from the provided asset patterns and
146+
* returns them as a flat `files` object that is later merged into the
147+
* runtime config and filtered by module activation.
148+
*
149+
* @param {object} assets - Asset glob patterns (see `config/assets.js`).
150+
* @returns {Promise<object>} Object keyed by file category with resolved file paths.
143151
*/
144152
const initGlobalConfigFiles = async (assets) => {
145153
const files = {}; // Appending files
146154
files.swagger = await getGlobbedPaths(assets.allYaml); // Setting Globbed module yaml files
155+
files.guides = await getGlobbedPaths(assets.allGuides); // Setting Globbed module markdown guide files
147156
files.mongooseModels = await getGlobbedPaths(assets.mongooseModels); // Setting Globbed mongoose model files
148157
files.preRoutes = await getGlobbedPaths(assets.preRoutes); // Setting Globbed pre-parser route files
149158
files.routes = await getGlobbedPaths(assets.routes); // Setting Globbed route files

lib/helpers/guides.js

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Markdown guide loader for the Scalar API reference.
3+
*
4+
* Per-module markdown guides live under `modules/{name}/doc/guides/*.md`
5+
* and are discovered by the same globbing mechanism as OpenAPI YAML files
6+
* (see `config/assets.js` → `allGuides`).
7+
*
8+
* Guides are merged into the OpenAPI spec via `info.description`, which
9+
* Scalar renders as a top-level "Introduction" section in the sidebar and
10+
* splits on markdown H1/H2 headings.
11+
*/
12+
import fs from 'fs';
13+
import path from 'path';
14+
15+
import logger from '../services/logger.js';
16+
17+
/**
18+
* Derive a human-readable title from a guide file path.
19+
* E.g. `modules/auth/doc/guides/getting-started.md` → `Getting Started`
20+
* @param {string} filePath - Absolute or relative path to the guide file.
21+
* @returns {string} Title-cased guide name.
22+
*/
23+
const titleFromPath = (filePath) => {
24+
const base = path.basename(String(filePath), path.extname(String(filePath)));
25+
return base
26+
.split(/[-_\s]+/)
27+
.filter(Boolean)
28+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
29+
.join(' ');
30+
};
31+
32+
/**
33+
* Strip the first H1 heading from a markdown body (if present).
34+
* The loader injects its own H1 based on the file name so Scalar's sidebar
35+
* stays consistent even when guides omit a title or use a different one.
36+
* @param {string} markdown - Raw markdown content.
37+
* @returns {string} Markdown without the leading H1.
38+
*/
39+
const stripLeadingH1 = (markdown) => String(markdown).replace(/^\s*#\s+[^\n]*\n+/, '');
40+
41+
/**
42+
* Load markdown guides from disk and return normalized entries.
43+
* Invalid/unreadable files are skipped with a warning so one broken guide
44+
* cannot take down the whole API reference.
45+
* @param {string[]} filePaths - Absolute paths to `.md` guide files.
46+
* @returns {{ title: string, body: string, path: string }[]} Loaded guides.
47+
*/
48+
const loadGuides = (filePaths) => {
49+
if (!Array.isArray(filePaths) || filePaths.length === 0) return [];
50+
return filePaths
51+
.map((filePath) => {
52+
try {
53+
const raw = fs.readFileSync(filePath, 'utf8');
54+
const body = stripLeadingH1(raw).trim();
55+
if (!body) {
56+
logger.warn(`[guides] skipping ${filePath}: empty markdown content`);
57+
return null;
58+
}
59+
return { title: titleFromPath(filePath), body, path: filePath };
60+
} catch (err) {
61+
logger.warn(`[guides] failed to load ${filePath}: ${err.message}`);
62+
return null;
63+
}
64+
})
65+
.filter(Boolean)
66+
// Stable alphabetical order so the sidebar is deterministic across
67+
// filesystems (glob order varies on macOS vs Linux containers).
68+
.sort((a, b) => a.title.localeCompare(b.title));
69+
};
70+
71+
/**
72+
* Merge loaded guides into an OpenAPI spec's `info.description`.
73+
* Each guide becomes a top-level H1 section, which Scalar renders as a
74+
* sidebar entry alongside the API reference.
75+
*
76+
* The original spec is mutated (and returned) to match the merge style used
77+
* by `initSwagger` in `lib/services/express.js`.
78+
*
79+
* @param {object} spec - OpenAPI spec object (will be mutated).
80+
* @param {{ title: string, body: string }[]} guides - Loaded guide entries.
81+
* @returns {object} The same spec, with guides appended to `info.description`.
82+
*/
83+
const mergeGuidesIntoSpec = (spec, guides) => {
84+
if (!spec || typeof spec !== 'object') return spec;
85+
if (!Array.isArray(guides) || guides.length === 0) return spec;
86+
87+
const sections = guides.map(({ title, body }) => `# ${title}\n\n${body}`);
88+
const existing = typeof spec.info?.description === 'string' ? spec.info.description.trim() : '';
89+
const merged = [existing, ...sections].filter(Boolean).join('\n\n');
90+
91+
spec.info = { ...(spec.info || {}), description: merged };
92+
return spec;
93+
};
94+
95+
export default {
96+
titleFromPath,
97+
stripLeadingH1,
98+
loadGuides,
99+
mergeGuidesIntoSpec,
100+
};

lib/services/express.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import YAML from 'js-yaml';
1818
import { apiReference } from '@scalar/express-api-reference';
1919

2020
import config from '../../config/index.js';
21+
import guidesHelper from '../helpers/guides.js';
2122
import logger from './logger.js';
2223
import requestId from '../middlewares/requestId.js';
2324
import sentry from './sentry.js';
@@ -65,6 +66,14 @@ const initSwagger = (app) => {
6566
return;
6667
}
6768

69+
// Merge per-module markdown guides into info.description so Scalar
70+
// renders them in its sidebar alongside the OpenAPI reference.
71+
const guides = guidesHelper.loadGuides(config.files.guides || []);
72+
guidesHelper.mergeGuidesIntoSpec(spec, guides);
73+
if (guides.length > 0) {
74+
logger.info(`[swagger] merged ${guides.length} markdown guide(s) into API reference`);
75+
}
76+
6877
/**
6978
* Serve the merged OpenAPI spec as JSON.
7079
* @param {import('express').Request} _req - Incoming request (unused).
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Authentication
2+
3+
The API uses JWT authentication delivered via an `httpOnly` `TOKEN`
4+
cookie. Clients do not receive the token in the response body — they
5+
receive user metadata and a `tokenExpiresIn` timestamp, and subsequent
6+
requests are authenticated automatically as long as the cookie is sent.
7+
8+
## Sign up
9+
10+
Create a new account by sending a POST request:
11+
12+
```bash
13+
curl -X POST http://localhost:3000/api/auth/signup \
14+
-H "Content-Type: application/json" \
15+
-c cookies.txt \
16+
-d '{ "email": "user@example.com", "password": "YourPassword1!" }'
17+
```
18+
19+
If email verification is enabled, you will receive a confirmation link.
20+
On success the response sets the `TOKEN` cookie and returns a body like:
21+
22+
```json
23+
{ "user": { "id": "...", "email": "user@example.com" }, "tokenExpiresIn": 1735689600000 }
24+
```
25+
26+
## Log in
27+
28+
Authenticate with your credentials:
29+
30+
```bash
31+
curl -X POST http://localhost:3000/api/auth/signin \
32+
-H "Content-Type: application/json" \
33+
-c cookies.txt \
34+
-d '{ "email": "user@example.com", "password": "YourPassword1!" }'
35+
```
36+
37+
The response sets the `TOKEN` cookie and returns the user, their CASL
38+
abilities, and `tokenExpiresIn` (epoch ms at which the JWT expires).
39+
40+
## Using the token
41+
42+
Send the cookie on every protected request — the JWT is extracted from
43+
the `TOKEN` cookie by the passport strategy:
44+
45+
```bash
46+
curl http://localhost:3000/api/users/me \
47+
-b cookies.txt
48+
```
49+
50+
Browser clients get this for free: the cookie is `httpOnly`, `Secure`,
51+
and `SameSite`-configured, so it is attached automatically to same-site
52+
requests.
53+
54+
## Token lifetime
55+
56+
The JWT lifetime is controlled server-side by `config.jwt.expiresIn`.
57+
The signin/signup responses expose `tokenExpiresIn` so clients can
58+
proactively re-authenticate before expiry. There is no refresh-token
59+
endpoint — call `/api/auth/signin` again when the token expires.
60+
61+
## Password reset
62+
63+
Request a reset email, then confirm with the token received:
64+
65+
```bash
66+
# Request reset
67+
curl -X POST http://localhost:3000/api/auth/forgot \
68+
-H "Content-Type: application/json" \
69+
-d '{ "email": "user@example.com" }'
70+
71+
# Confirm reset
72+
curl -X POST http://localhost:3000/api/auth/reset \
73+
-H "Content-Type: application/json" \
74+
-d '{ "token": "<resetToken>", "newPassword": "NewPassword1!" }'
75+
```
76+
77+
## Next steps
78+
79+
- See the **Organizations** guide to create teams and manage roles.
80+
- Browse the endpoint reference for the full list of auth routes.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Getting Started
2+
3+
Welcome to the Devkit Node API. This guide walks you through running the
4+
backend locally and making your first API call.
5+
6+
## Prerequisites
7+
8+
- **Node.js** 22+ and npm
9+
- **MongoDB** running locally or accessible via a connection string
10+
- **Git** for cloning the repository
11+
12+
## Setup
13+
14+
1. Clone the Node backend repository.
15+
2. Copy `.env.example` to `.env` and fill in your values (mongo URI, JWT
16+
secret, mail provider, etc.).
17+
3. Install dependencies:
18+
19+
```bash
20+
npm install
21+
```
22+
23+
4. Start the development server:
24+
25+
```bash
26+
npm run dev
27+
```
28+
29+
The API listens on `http://localhost:3000` by default.
30+
31+
## Your first API call
32+
33+
Once the server is running, verify it responds:
34+
35+
```bash
36+
curl http://localhost:3000/api/core/status
37+
```
38+
39+
You should receive a JSON response confirming the server is healthy.
40+
41+
## Explore the API
42+
43+
- **Authentication** — sign up, log in, and manage tokens
44+
- **Organizations** — create teams and manage roles
45+
- Browse the endpoint reference in the sidebar for full request/response
46+
schemas and interactive examples
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Organizations
2+
3+
Organizations let you group users under a shared context with role-based
4+
access control.
5+
6+
All examples below assume you are already authenticated and send the
7+
`TOKEN` cookie set at signin (see the **Authentication** guide).
8+
9+
## Creating an organization
10+
11+
```bash
12+
curl -X POST http://localhost:3000/api/organizations \
13+
-H "Content-Type: application/json" \
14+
-b cookies.txt \
15+
-d '{ "name": "My Team" }'
16+
```
17+
18+
The creator is automatically assigned the **owner** role.
19+
20+
## Listing organizations
21+
22+
Retrieve all organizations you belong to:
23+
24+
```bash
25+
curl http://localhost:3000/api/organizations \
26+
-b cookies.txt
27+
```
28+
29+
## Inviting members
30+
31+
Invite a user by email. They receive an invitation they can accept or
32+
decline:
33+
34+
```bash
35+
curl -X POST http://localhost:3000/api/organizations/<orgId>/invites \
36+
-H "Content-Type: application/json" \
37+
-b cookies.txt \
38+
-d '{ "email": "teammate@example.com", "role": "member" }'
39+
```
40+
41+
The invitee then accepts (or declines) via the invite token they receive
42+
by email:
43+
44+
```bash
45+
curl -X POST http://localhost:3000/api/invites/<token>/accept \
46+
-b cookies.txt
47+
```
48+
49+
## Scoping requests to an organization
50+
51+
The API does not use an `X-Organization-Id` header. Org context is
52+
resolved in one of two ways:
53+
54+
1. **Route parameter** — org-scoped routes include `:organizationId` in
55+
the path, e.g. `/api/organizations/:organizationId/invites`. Pass the
56+
org id directly in the URL.
57+
2. **Current organization** — the authenticated user has a
58+
`currentOrganization` stored server-side. Switch it with:
59+
60+
```bash
61+
curl -X POST http://localhost:3000/api/organizations/<orgId>/switch \
62+
-b cookies.txt
63+
```
64+
65+
This updates `user.currentOrganization`, issues a fresh JWT cookie,
66+
and rebuilds abilities. Subsequent requests that rely on the current
67+
org (rather than a route param) use that value.
68+
69+
## Roles
70+
71+
| Role | Permissions |
72+
|------|-------------|
73+
| **owner** | Full access, manage billing, delete organization |
74+
| **admin** | Manage members, update settings |
75+
| **member** | Access shared resources |
76+
77+
Roles are enforced by CASL abilities on the backend — see each
78+
organization endpoint for the required ability.
79+
80+
## Next steps
81+
82+
- Browse the endpoint reference for the full list of organization routes.

0 commit comments

Comments
 (0)