/** * Code-first API docs: scan `@openapi` JSDoc in routes → OpenAPI JSON + Markdown under docs/api/. * * Usage: npm run docs:api */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { openApiOptions, getOpenApiSpec } from '../src/api/openapiSpec.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(__dirname, '..'); const docsDir = path.join(root, 'docs', 'api'); const routesDocsDir = path.join(docsDir, 'routes'); const PUBLIC_PATHS = new Set(['/auth/login', '/auth/logout', '/health', '/v1/client']); const OMITTED_ROUTES = new Set([ 'GET /article/export.zip', 'POST /article/{sku}/image', 'GET /auth/me', 'PUT /auth/selected-company', 'GET /category/export.zip', 'DELETE /company/{id}', 'POST /company', 'POST /category/{id}/image', 'POST /delivery/parse', 'GET /events', 'GET /health', 'GET /image/{fullHash}', 'POST /serper/image', 'POST /serper/images', 'GET /thumb/{fullHash}', 'GET /v1/category', 'GET /v1/client', 'GET /v1/init', 'GET /v1/product', ]); function isRouteOmitted(method, apiPath) { if (apiPath === '/posterminal' || apiPath.startsWith('/posterminal/')) { return true; } if (apiPath === '/modelconversation' || apiPath.startsWith('/modelconversation/')) { return true; } if (apiPath === '/public' || apiPath.startsWith('/public/')) { return true; } return OMITTED_ROUTES.has(`${method.toUpperCase()} ${apiPath}`); } /** * Routes omitted from the main overview because their payload is binary or streaming, which is * useless to the `dashback_request` tool — but which ARE reachable with `fetch()` from inside * `javascript_execute`, where the bytes can be decoded, transformed, written to the VFS and shown * as an image. These are documented separately so the model knows about them without cluttering * the request-shaped route table. * * Deliberately NOT listed: import/export (administrative bulk operations the model must not * trigger), `/v1/*` (legacy POS compat), `/events` and `/serper/images` (long-lived SSE), * `/modelconversation/*` and `/posterminal/*` (UI plumbing, recursion risk), `/public/*`, and every * auth/company mutation. */ const JS_FETCH_ONLY_ROUTES = [ 'GET /image/{fullHash}', 'GET /thumb/{fullHash}', 'POST /serper/image', ]; const spec = getOpenApiSpec(true); function ensureDir(dir) { fs.mkdirSync(dir, { recursive: true }); } function rmDirContents(dir) { if (!fs.existsSync(dir)) return; for (const name of fs.readdirSync(dir)) { fs.rmSync(path.join(dir, name), { recursive: true, force: true }); } } function schemaRefName(schema) { if (!schema) return null; if (schema.$ref) return schema.$ref.split('/').pop(); return null; } function resolveSchema(schema, components) { if (!schema) return null; if (schema.$ref) { const name = schemaRefName(schema); const resolved = components?.schemas?.[name]; return resolved ? resolveSchema(resolved, components) : schema; } if (Array.isArray(schema.allOf) && schema.allOf.length) { const merged = { type: 'object', properties: {}, required: [] }; for (const part of schema.allOf) { const resolvedPart = resolveSchema(part, components); if (resolvedPart?.properties) { Object.assign(merged.properties, resolvedPart.properties); } if (Array.isArray(resolvedPart?.required)) { merged.required.push(...resolvedPart.required); } } return merged; } return schema; } /** * Build a sample JSON value from an OpenAPI schema (expands $ref / allOf / array items). * @param {object} schema * @param {object} [components] * @param {number} [depth] * @returns {*} */ function toExample(schema, components, depth = 0) { if (!schema || depth > 8) return null; if (schema.example !== undefined) return schema.example; if (schema.$ref) { const resolved = components?.schemas?.[schemaRefName(schema)]; return resolved ? toExample(resolved, components, depth) : {}; } if (Array.isArray(schema.allOf) && schema.allOf.length) { const out = {}; for (const part of schema.allOf) { const sample = toExample(part, components, depth + 1); if (sample && typeof sample === 'object' && !Array.isArray(sample)) { Object.assign(out, sample); } } return out; } if (schema.type === 'array') { if (schema.items) { return [toExample(schema.items, components, depth + 1)]; } return []; } if (schema.type === 'object' || schema.properties) { const out = {}; for (const [key, prop] of Object.entries(schema.properties || {})) { out[key] = toExample(prop, components, depth + 1); } return out; } if (schema.type === 'string') { if (schema.enum?.length) return schema.enum[0]; if (schema.format === 'email') return 'user@example.com'; if (schema.format === 'date-time') return '2026-08-10T00:00:00.000Z'; if (schema.format === 'date') return '2026-08-10'; return '…'; } if (schema.type === 'integer') return 1; if (schema.type === 'number') return 1; if (schema.type === 'boolean') return true; if (schema.nullable) return null; return '…'; } function formatSchema(schema, components) { if (!schema) return '_none_'; const example = toExample(schema, components); return '```json\n' + JSON.stringify(example, null, 2) + '\n```'; } function contentSchema(content) { if (!content) return null; const json = content['application/json']; return json?.schema ?? null; } function renderSchemaPropertiesTable(schema, components) { const resolved = resolveSchema(schema, components); if (!resolved?.properties) return []; const lines = []; const required = new Set(resolved.required || []); const entries = Object.entries(resolved.properties); if (entries.length === 0) return []; lines.push('| Field | Required | Type | Description |'); lines.push('|-------|----------|------|-------------|'); for (const [name, prop] of entries) { const isReq = required.has(name) ? 'yes' : 'no'; let type = prop.type || ''; if (!type && prop.$ref) { type = schemaRefName(prop); } else if (type === 'array') { const itemType = prop.items?.type || schemaRefName(prop.items) || 'any'; type = `${itemType}[]`; } const desc = (prop.description || '').replace(/[\r\n|]+/g, ' ').trim(); lines.push(`| \`${name}\` | ${isReq} | ${type} | ${desc} |`); if (prop.type === 'array' && prop.items?.properties) { const itemReq = new Set(prop.items.required || []); for (const [subName, subProp] of Object.entries(prop.items.properties)) { const subIsReq = itemReq.has(subName) ? 'yes' : 'no'; const subType = subProp.type || schemaRefName(subProp) || ''; const subDesc = (subProp.description || '').replace(/[\r\n|]+/g, ' ').trim(); lines.push(`| \`${name}[].${subName}\` | ${subIsReq} | ${subType} | ${subDesc} |`); } } } lines.push(''); return lines; } function renderRequestBody(requestBody) { if (!requestBody?.content) return null; const lines = []; const types = Object.keys(requestBody.content); const jsonSchema = contentSchema(requestBody.content); if (jsonSchema) { const isOpt = requestBody.required === false; lines.push(`## Body (JSON)${isOpt ? ' — _optional_' : ''}`); lines.push(''); const propTable = renderSchemaPropertiesTable(jsonSchema, spec.components); if (propTable.length) { lines.push(...propTable); } lines.push(formatSchema(jsonSchema, spec.components)); lines.push(''); } // multipart/form-data — render fields as a table when properties are present const multipart = requestBody.content['multipart/form-data']; if (multipart?.schema?.properties) { const schema = multipart.schema; const required = new Set(schema.required || []); lines.push('## Body (`multipart/form-data`)'); lines.push(''); lines.push('| Field | Required | Type | Description |'); lines.push('|-------|----------|------|-------------|'); for (const [name, prop] of Object.entries(schema.properties)) { const type = prop.format === 'binary' || prop.items?.format === 'binary' ? 'file' : (prop.type || ''); lines.push(`| \`${name}\` | ${required.has(name) ? 'yes' : 'no'} | ${type} | ${prop.description || ''} |`); } lines.push(''); } if (!jsonSchema && !multipart?.schema?.properties) { lines.push('## Body'); lines.push(''); for (const type of types) { const schema = requestBody.content[type]?.schema; if (schema?.format === 'binary') { lines.push(`Raw binary (\`${type}\`).`); } else { lines.push(`\`${type}\``); if (schema) { lines.push(''); lines.push(formatSchema(schema, spec.components)); } } lines.push(''); } } return lines.length ? lines : null; } function docsFileFor(apiPath, method) { const custom = spec.paths[apiPath]?.[method]?.['x-markdown']; if (custom) return custom; const cleaned = apiPath .replace(/^\//, '') .replace(/\{([^}]+)\}/g, '_$1') .replace(/:([a-zA-Z0-9_]+)/g, '_$1'); const segments = cleaned.split('/').filter(Boolean); if (segments.length === 0) return `${method}.md`; const fileBase = segments.pop(); const dir = segments.join('/'); const methodsOnPath = Object.keys(spec.paths[apiPath]).filter((m) => !m.startsWith('x-')); const name = methodsOnPath.length > 1 ? `${fileBase}.${method}.md` : `${fileBase}.md`; return dir ? path.join(dir, name) : name; } function renderOperation(apiPath, method, operation) { const isPublic = PUBLIC_PATHS.has(apiPath) || operation.security?.length === 0; const lines = []; lines.push(`# \`${method.toUpperCase()} ${apiPath}\``); lines.push(''); if (isPublic) { lines.push('Public. No cookie required.'); } else { lines.push('Protected. Requires a valid session cookie (`credentials: \'include\'`).'); } lines.push(''); if (operation.summary) { lines.push(operation.summary); lines.push(''); } if (operation.description && operation.description !== operation.summary) { lines.push(operation.description); lines.push(''); } const params = operation.parameters || []; const pathParams = params.filter((p) => p.in === 'path'); const queryParams = params.filter((p) => p.in === 'query'); if (pathParams.length) { lines.push('## Path parameters'); lines.push(''); lines.push('| Param | Required | Type | Description |'); lines.push('|-------|----------|------|-------------|'); for (const p of pathParams) { lines.push( `| \`${p.name}\` | ${p.required ? 'yes' : 'no'} | ${p.schema?.type || ''} | ${p.description || ''} |`, ); } lines.push(''); } if (queryParams.length) { lines.push('## Query parameters'); lines.push(''); lines.push('| Param | Required | Type | Description |'); lines.push('|-------|----------|------|-------------|'); for (const p of queryParams) { lines.push( `| \`${p.name}\` | ${p.required ? 'yes' : 'no'} | ${p.schema?.type || ''} | ${p.description || ''} |`, ); } lines.push(''); } if (operation.requestBody) { const bodyLines = renderRequestBody(operation.requestBody); if (bodyLines) lines.push(...bodyLines); } const signals = operation['x-signals']; if (Array.isArray(signals) && signals.length) { lines.push('## Signals'); lines.push(''); lines.push( `On success, emits SSE signal${signals.length === 1 ? '' : 's'}: ${signals.map((s) => `\`${s}\``).join(', ')} (see [\`GET /events\`](../events.md)).`, ); lines.push(''); } const sseEvents = operation['x-sse-events']; if (Array.isArray(sseEvents) && sseEvents.length) { lines.push('## SSE Events'); lines.push(''); lines.push('The response is a `text/event-stream`. Events are emitted in order until the stream closes.'); lines.push(''); lines.push('| Event | Description | Payload |'); lines.push('|-------|-------------|---------|'); for (const ev of sseEvents) { lines.push(`| \`${ev.name}\` | ${ev.description || ''} | ${ev.payload ? `\`${ev.payload}\`` : ''} |`); } lines.push(''); } const responses = operation.responses || {}; const successCodes = Object.keys(responses) .filter((c) => c.startsWith('2')) .sort(); const errorCodes = Object.keys(responses) .filter((c) => !c.startsWith('2')) .sort(); for (const code of successCodes) { const resp = responses[code]; lines.push(`## Response \`${code}\``); lines.push(''); if (resp.description) { lines.push(resp.description); lines.push(''); } const schema = contentSchema(resp.content); if (schema) { lines.push(formatSchema(schema, spec.components)); lines.push(''); } else if (code === '204') { lines.push('_No content_'); lines.push(''); } } if (errorCodes.length) { lines.push('## Errors'); lines.push(''); lines.push('| Status | Description |'); lines.push('|--------|-------------|'); for (const code of errorCodes) { const resp = responses[code]; const schema = contentSchema(resp.content); let detail = resp.description || ''; if (schema?.example) { detail += (detail ? ' — ' : '') + `\`${JSON.stringify(schema.example)}\``; } else if (schemaRefName(schema) === 'Error') { detail += (detail ? ' — ' : '') + '`' + '{"error":"…"}' + '`'; } lines.push(`| ${code} | ${detail} |`); } lines.push(''); } lines.push('---'); lines.push(''); lines.push('_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._'); lines.push(''); return lines.join('\n'); } function renderReadme(operations) { const lines = []; lines.push('# Dash API'); lines.push(''); lines.push('```'); lines.push('http://localhost:4208/api'); lines.push('https://localhost:8443/api'); lines.push('https://localhost:8443/v1 (POS API)'); lines.push('```'); lines.push(''); lines.push('## CORS & cookies'); lines.push(''); lines.push('All browser requests that need auth must send cookies:'); lines.push(''); lines.push('```js'); lines.push("fetch('http://localhost:4208/api/auth/me', {"); lines.push(" credentials: 'include',"); lines.push('});'); lines.push('```'); lines.push(''); lines.push( 'Sessions use an HTTP-only `token` cookie (`Secure`, `SameSite=Strict`, 24h). The browser must talk to the API over **HTTPS** for login to work — `Secure` cookies are not sent on plain HTTP.', ); lines.push(''); lines.push('## Auth flow'); lines.push(''); lines.push('1. Call [`GET /auth/me`](routes/auth/me.md) to get `googleClientId` (included in 200 and 401 responses).'); lines.push('2. Sign in with Google on the frontend (Google Identity Services / One Tap).'); lines.push('3. POST the Google ID token to [`POST /auth/login`](routes/auth/login.md).'); lines.push('4. The API sets the session cookie.'); lines.push("5. Call protected routes with `credentials: 'include'`. The cookie is refreshed automatically when it has less than 2 hours left."); lines.push(''); lines.push('## Routes'); lines.push(''); lines.push('Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/routes/). Source of truth is the code; regenerate with `npm run docs:api`.'); lines.push(''); lines.push('| File | Endpoint |'); lines.push('|------|----------|'); for (const op of operations) { lines.push(`| [\`routes/${op.file}\`](routes/${op.file}) | \`${op.method.toUpperCase()} ${op.path}\` |`); } lines.push(''); lines.push('Route overview: [`routes.md`](routes.md) | All routes: [`routes-all.md`](routes-all.md).'); lines.push(''); lines.push('## Signals (SSE)'); lines.push(''); lines.push( 'Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). After a successful mutation, the API emits a named SSE event (signal) to subscribers of the same selected company.', ); lines.push(''); lines.push('| Signal | Emitted by |'); lines.push('|--------|------------|'); /** @type {Map} */ const bySignal = new Map(); for (const op of operations) { const signals = spec.paths[op.path]?.[op.method]?.['x-signals']; if (!Array.isArray(signals)) continue; const label = `\`${op.method.toUpperCase()} ${op.path}\``; for (const signal of signals) { if (!bySignal.has(signal)) bySignal.set(signal, []); bySignal.get(signal).push(label); } } const signalNames = [...bySignal.keys()].sort(); if (signalNames.length === 0) { lines.push('| — | _(none yet)_ |'); } else { for (const signal of signalNames) { lines.push(`| \`${signal}\` | ${bySignal.get(signal).join(', ')} |`); } } lines.push(''); lines.push('## DESIGN'); lines.push(''); lines.push('- for every route we have a file in /src/api/routes'); lines.push('- that should loosly bound to services in /src/services'); lines.push('- for every service we have a CLI tester in /scripts/'); lines.push('- all DB SQL code shall be in /src/db'); lines.push('- with SQL staements in /src/db/'); lines.push('- API docs: `@openapi` JSDoc on handlers → `npm run docs:api` → `docs/api/`'); lines.push(''); return lines.join('\n'); } /** * Compact one-line schema summary. Every leaf carries its explicit type * (`string`, `integer`, `number`, `boolean`, `object`), arrays render as `T[]`, * and objects render as `{ key: type, … }` at every nesting level. * * @param {object} [schema] * @param {object} [components] * @param {number} [depth] * @param {boolean} [markOptional] suffix non-required props with `?` (request bodies) * @returns {string} */ function summarizeSchema(schema, components = spec.components, depth = 0, markOptional = false) { if (!schema) return ''; if (depth > 6) return 'object'; const resolved = resolveSchema(schema, components); if (!resolved) return schemaRefName(schema) || ''; if (resolved.$ref) return schemaRefName(resolved) || ''; if (resolved.type === 'array') { const item = summarizeSchema(resolved.items, components, depth + 1, markOptional) || 'any'; return `${item}[]`; } if (resolved.type === 'object' || resolved.properties) { const required = new Set(resolved.required || []); let entries = Object.entries(resolved.properties || {}); if (entries.length === 0) return 'object'; // Required props first, then optional ones (stable within each group). if (markOptional) { entries = entries .map(([name], i) => ({ name, i })) .sort((a, b) => (required.has(a.name) ? 0 : 1) - (required.has(b.name) ? 0 : 1) || a.i - b.i) .map(({ name }) => [name, resolved.properties[name]]); } const parts = entries.map(([name, prop]) => { const opt = markOptional && !required.has(name) ? '?' : ''; const type = summarizeSchema(prop, components, depth + 1, markOptional) || 'any'; return `${name}${opt}: ${type}`; }); return `{ ${parts.join(', ')} }`; } if (resolved.type) return resolved.type; return schemaRefName(schema) || ''; } /** * Always-visible type for a path/query parameter, so declarations are uniform. * Falls back to `string` only when the parameter schema omits a type entirely. * @param {object} param * @returns {string} */ function paramType(param) { return summarizeSchema(param?.schema, spec.components) || 'string'; } function summarizeOperationParams(operation) { const parts = []; const params = operation.parameters || []; const pathParams = []; const queryParams = []; for (const p of params) { if (p.in === 'header' || p.in === 'cookie') continue; const isOpt = !p.required; const optSuffix = isOpt ? '?' : ''; if (p.in === 'path') { pathParams.push(`${p.name}: ${paramType(p)}`); } else if (p.in === 'query') { queryParams.push(`${p.name}${optSuffix}: ${paramType(p)}`); } } if (pathParams.length) { parts.push(...pathParams); } if (queryParams.length) { parts.push(`query: ${queryParams.join(', ')}`); } if (operation.requestBody?.content) { const isBodyRequired = operation.requestBody.required === true; const bodyPrefix = isBodyRequired ? 'body' : 'body?'; const content = operation.requestBody.content; if (content['application/json']?.schema) { const s = content['application/json'].schema; const sub = summarizeSchema(s, spec.components, 0, true); parts.push(`${bodyPrefix}: ${sub || 'json'}`); } else if (content['multipart/form-data']) { const formSchema = content['multipart/form-data'].schema; const formSub = summarizeSchema(formSchema, spec.components, 0, true); parts.push(`form: ${formSub || 'multipart'}`); } else { const ct = Object.keys(content)[0]; parts.push(isBodyRequired ? ct : `${ct}?`); } } return parts.length ? parts.join(', ') : '—'; } function summarizeOperationReturns(operation) { const responses = operation.responses || {}; const successCodes = Object.keys(responses) .filter((c) => c.startsWith('2')) .sort(); if (!successCodes.length) return '—'; const parts = []; for (const code of successCodes) { const resp = responses[code]; if (code === '204') { parts.push('204 void'); continue; } if (!resp?.content) { parts.push(code); continue; } const ct = Object.keys(resp.content)[0]; if (ct === 'text/event-stream') { parts.push(`${code} SSE`); continue; } if (ct.startsWith('image/')) { parts.push(`${code} ${ct.split('/')[1]}`); continue; } if (ct === 'application/zip') { parts.push(`${code} zip`); continue; } const s = resp.content[ct]?.schema; const sub = summarizeSchema(s); if (sub) { parts.push(`${code} ${sub}`); } else { parts.push(code); } } return parts.join(', '); } function renderRoutesOverview(operations, title = 'Dash API Routes Overview') { const lines = []; lines.push(`# ${title}`); lines.push(''); lines.push(`Overview of all ${operations.length} API endpoints across all modules.`); lines.push(''); lines.push('### Parameter Notation'); lines.push(''); lines.push('- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`.'); lines.push('- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`).'); lines.push('- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`).'); lines.push('- **Path parameters** match `{param}` in the route URL.'); lines.push('- **Query parameters** are prefixed with `query: ...`.'); lines.push('- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional).'); lines.push('- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property.'); lines.push(''); lines.push('### Routes'); lines.push(''); for (const op of operations) { const method = op.method.toUpperCase(); const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim(); const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``; lines.push(`#### ${heading}`); lines.push(op.params === '—' ? '_none_' : `- Params: \`${op.params}\``); lines.push(op.returns === '—' ? '_no response body_' : `- Returns: \`${op.returns}\``); } lines.push(''); return lines.join('\n'); } /** * Extra section appended to routes.md only. `all: false` renders it into the model-facing * overview; routes-all.md stays a complete listing with no behavioural advice. */ function renderJsFetchSection(allOperations) { const lines = []; lines.push('### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)'); lines.push(''); lines.push( 'These routes are NOT in the table above because their responses are binary or long-lived ' + 'streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from ' + 'inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write ' + 'them to the VFS and embed them in your reply:', ); lines.push(''); lines.push('| Method | Route | Returns | Notes |'); lines.push('|:---|:---|:---|:---|'); lines.push( '| `GET` | `/image/{fullHash}` | `image/avif` | Full-size article/brand/category image by SHA-256 hash. ' + 'Use for inspecting or re-encoding an existing image.', ); lines.push( '| `GET` | `/thumb/{fullHash}` | `image/avif` | Thumb AVIF by SHA-256 hash — cheaper than the full image. ' + 'Takes the same `fullHash` as `/image/{fullHash}`.', ); lines.push( '| `POST` | `/serper/image` | `image/avif` | Crawl an image from a URL, auto-crop transparent/white borders. ' + 'Body: `{ url }`.', ); lines.push(''); lines.push('Save the bytes to the VFS, then embed them in your reply as `![title](artifacts/thumb.avif)`:'); lines.push(''); lines.push('```js'); lines.push('module.exports = (async () => {'); lines.push(' const hash = "…64-char sha-256…";'); lines.push(' const res = await fetch(`/thumb/${hash}`); // authenticated automatically'); lines.push(' const buf = Buffer.from(await res.arrayBuffer());'); lines.push(' fs.writeFileSync("artifacts/thumb.avif", buf);'); lines.push(' return { bytes: buf.length };'); lines.push('})();'); lines.push('```'); lines.push(''); lines.push( '`fullHash` appears on articles, brands and categories. `thumbHash` is a base64 blur-up placeholder, ' + 'not a content hash, and is not accepted here.', ); lines.push(''); return lines.join('\n'); } // --- generate --- ensureDir(docsDir); rmDirContents(routesDocsDir); ensureDir(routesDocsDir); const operations = []; const paths = Object.keys(spec.paths || {}).sort(); for (const apiPath of paths) { const item = spec.paths[apiPath]; for (const method of Object.keys(item).sort()) { if (method.startsWith('x-')) continue; const operation = item[method]; const relFile = docsFileFor(apiPath, method); const absFile = path.join(routesDocsDir, relFile); ensureDir(path.dirname(absFile)); fs.writeFileSync(absFile, renderOperation(apiPath, method, operation)); operations.push({ path: apiPath, method, file: relFile.replace(/\\/g, '/'), summary: operation.summary || '', tags: operation.tags || [], params: summarizeOperationParams(operation), returns: summarizeOperationReturns(operation), omittedFromOverview: isRouteOmitted(method, apiPath), }); } } const overviewOperations = operations.filter((op) => !op.omittedFromOverview); fs.writeFileSync( path.join(docsDir, 'routes.md'), renderRoutesOverview(overviewOperations) + renderJsFetchSection(operations) ); fs.writeFileSync(path.join(docsDir, 'routes-all.md'), renderRoutesOverview(operations, 'Dash API Routes Overview (All)')); fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations)); fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2)); console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + ${operations.length} in routes-all.md + openapi.json → docs/api/`);