docfy-ui
v0.11.0
Published
AI-first OpenAPI documentation UI — companion project to nestjs-docfy
Readme
AI-first OpenAPI documentation UI, a companion project to nestjs-docfy. A lean, modern API reference UI with a "Copy for AI" button on every endpoint: a one-click, pre-formatted text representation optimized for pasting into an LLM prompt, instead of dumping raw OpenAPI JSON or full HTML.
Table of contents
- Motivation
- Features
- Installation
- Quick start
- Configuration
- Copy for AI
- Try it out
- Deep-linking into a schema
- Favorites and recently viewed
- Guides
- Document Model
- Theming
- Accessibility
- Architecture notes
- Scripts
- Testing
- License
Motivation
Most OpenAPI UIs are built for humans skimming a page. That's the wrong shape for the other audience that reads documentation today: an LLM you're pasting context into. Copying an endpoint's details usually means grabbing raw JSON (verbose, full of $refs and noise) or copy-pasting rendered HTML (loses structure entirely).
Before: feeding an LLM a copy of the raw OpenAPI fragment.
{
"post": {
"operationId": "createUser",
"requestBody": {
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/CreateUserDto" }
}
}
},
"responses": {
"201": { "$ref": "#/components/responses/UserCreated" },
"400": { "description": "Bad Request" }
}
}
}After: one click of "Copy for AI" on the same endpoint.
## Create a user
POST /users
### Request
{
"name": "string",
"email": "string"
}
### Responses
201 Created — UserEntity
400 Bad Request
### Validation
- name: required, minLength 2
- email: required, format emaildocfy-ui renders that text deterministically from the same OpenAPI document any Swagger UI already serves, no extra annotations, no backend changes.
Features
- Copy for AI: every endpoint gets a one-click, LLM-ready plain-text summary (purpose, request, responses, validation rules) instead of raw JSON.
- Copy OpenAPI: copies the dereferenced, cycle-safe JSON fragment for just the selected endpoint.
- Copy MCP Reference: copies the endpoint's method/path plus the current page URL, so an agent with access to the live app (or an MCP server pointed at it) can look the operation up directly instead of you pasting the whole spec.
- Try it out: execute a real request against the API from the browser, with auth support (apiKey/bearer/basic/OAuth2 token), a "Live" response tab, a "Copy as curl" button that reproduces the exact request (real typed values and resolved auth, not placeholders), and a schema match badge that validates the live response against its declared schema — see Try it out.
- Compare specs: diff two OpenAPI documents and flag breaking vs. informational changes (new/removed endpoints, newly-required params, removed response codes).
- Multi-spec switcher: browse more than one service's documentation from a single deployed instance, without leaving the UI.
- Guides: narrative markdown pages (onboarding, tutorials) rendered alongside the generated API reference, listed in the sidebar — see Guides.
- Two-column endpoint view: documentation on the left (parameters, responses, navigable schema tree), code snippets (curl, JavaScript fetch, Axios, Python, PHP) on the right. Every request body / response has an Example and a Schema tab; deep-link straight into a nested property with a URL hash (e.g.
#response-200/address/city) — see Deep-linking into a schema. - Real-time search: filters the sidebar by path/summary/operationId on every keystroke, no debounce, no Enter key.
- Keyboard navigation: with focus on a sidebar link, ↑/↓ moves to the next/previous one (Favorites, Recent, Guides, the tag tree, and Compare specs all count as one continuous list, in DOM order) — clamped at the first/last link, never wraps. Tab still moves between every focusable element as usual; this is a shortcut on top, not a replacement.
- Favorites and recently viewed: star any endpoint to pin it in the sidebar, and the last 5 you opened show up in a "Recent" section automatically — both scoped per spec and persisted to
localStorage— see Favorites and recently viewed. - Dark/light theme: token-driven, switches instantly with no page reload and no flash on first paint.
- Zero backend coupling: fetches a plain OpenAPI 3.0/3.1 JSON document client-side; works with any server that exposes one, not just NestJS.
- Mobile-responsive: off-canvas sidebar drawer below the
lgbreakpoint, audited at 375/390/768px.
Installation
npm install docfy-uiThis package ships a pre-built static bundle (dist/); there is no server-side code to install on a Node backend. If you're using nestjs-docfy, its DocfyUiModule.setup() already wraps this for you (see below); otherwise, serve the dist/ folder with any static file server.
Quick start
Serving from the same NestJS app as the API
The simplest setup if your backend is NestJS: let nestjs-docfy mount this package for you.
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { DocfyUiModule } from 'nestjs-docfy';
const app = await NestFactory.create(AppModule);
DocfyUiModule.setup('/docs', app); // before SwaggerModule.setup
const document = SwaggerModule.createDocument(app, new DocumentBuilder().build());
SwaggerModule.setup('api', app, document); // exposes /api-json, which docfy-ui fetches by default
await app.listen(3000);Visit /docs: no further configuration needed, since docfy-ui fetches /api-json same-origin by default.
Pointing at a remote spec
Without nestjs-docfy, build and serve the static assets yourself and point them at any OpenAPI document: same-origin convention or an explicit URL.
npm run build # in this package, or use the prebuilt dist/ from npmServe dist/ with any static host (NestJS's ServeStaticModule, Nginx, S3 + CloudFront, etc.) alongside or in front of the API that exposes the spec.
Configuration
The UI has no build-time configuration. It resolves the spec to render entirely at runtime, via one rule with one override:
| Source | When | Example |
| ----------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| GET /api-json (same-origin) | Default: matches what @nestjs/swagger's SwaggerModule.setup() exposes alongside Swagger UI | https://api.example.com/docs → fetches https://api.example.com/api-json |
| ?spec=<url> query param | Takes precedence over the default when present | https://docs.example.com/?spec=https://api.example.com/api-json |
If the UI is deployed on a different origin than the API, use the ?spec= override and make sure the API's CORS configuration allows that origin to GET the JSON document.
This same origin question comes up again for Try it out's request execution — see that section for how the same-origin proxy sidesteps it without touching the API's own CORS config.
Copy for AI
operationToAiText(endpoint) (src/transformers/copy-for-ai.ts) is a pure function (no I/O, no React) that turns a normalized endpoint into the plain-text block behind the "Copy for AI" button, structured as: Purpose → Request → Responses → Error Responses → Validation. Edge cases are handled explicitly rather than guessed at:
- No
requestBody→ no Request section. - No declared
4xx/5xx→ no Error Responses section. oneOf/anyOfschemas → annotated as(one of N possible shapes)instead of picking one arbitrarily.- A schema with no constraints → no Validation section.
- A long description with no
summary→ truncated to two sentences for Purpose.
Generation is consistently well under 100ms (no spinner is ever shown) and recursive/circular DTOs are handled safely. See Document Model.
Try it out
Every endpoint's request panel has a Code / Try it out mode switch, next to the language tabs. "Code" is the snippet view described above; "Try it out" is an editable form (base URL, path/query/header params, request body) that executes a real request via executeRequest() (src/transformers/execute-request.ts) and shows the result in a "Live" tab alongside the declared example responses — pretty-printed when the body is JSON, with a friendly message instead of a raw error on a network/CORS failure.
- Base URL: defaults to the first entry in the OpenAPI document's
servers[]array when present, falling back to the page's own origin otherwise. Always freely editable. - Authentication: endpoints with a
securityrequirement get an inline auth form (AuthPanel) — one input per declared scheme.apiKeygoes to a header or query param per itsin;http bearer/oauth2/openIdConnectall accept a token you paste in directly (no OAuth dance is performed);http basicexpectsuser:pass. Credentials are global (shared across every endpoint using that scheme, like a real dev token) and persist tolocalStorageso they survive a reload. The same credentials can also be set from one place regardless of which endpoint you're on — a sidebar Authorize button (padlock icon, shown whenever the spec declares at least one security scheme) opens a dialog listing every scheme in the document, Swagger-UI-style (AuthorizeDialog.tsx). Like upstream Swagger UI, this doesn't perform a login — it's still "paste a credential you already have," just from a global entry point instead of a specific endpoint's form. - "Use as … token": when a successful Live response contains a token-shaped field (e.g. a login endpoint's
access_token, including nested under an envelope likedata.access_token), a button lets you reuse it as the Bearer credential for the rest of the session with one click — no manual copy/paste. - Copy as curl: sits next to "Send" — builds the exact
curlcommand "Try it out" is about to fire, sharingbuildRequestUrl()/applyAuth()with the real request so the two can never drift apart. Unlike the static "Code" tab snippets (which use placeholdername=typetokens and never see auth), this reflects whatever you actually typed into the form. - Schema match badge: on the "Live" tab, when the response status has a declared schema and the body parsed as JSON, a green "✓ Matches schema" or red "⚠ N schema mismatches" badge appears (hover for each offending path/reason) — via
docfy-core'svalidateAgainstSchema(). Catches contract drift (missing/renamed field, wrong type) at request time, not just in CI. - CORS: by default this is a direct
fetch()from the browser to the target, so it's subject to the target API's own CORS policy — same constraint as Configuration above. Whennestjs-docfy'sDocfyUiModule.setup()is configured withopenApiDocument(see its README),docfy-uidetects the injectedwindow.__DOCFY_PROXY_PATH__and routes the request through a same-origin server-side proxy instead, sidestepping CORS entirely for whatever origins the OpenAPI document declares inservers[].
Out of scope, deliberately: request history, multiple named environments, a full OAuth2 authorization-code/PKCE flow, and cookie-based auth (can't be set reliably cross-site from the browser).
Deep-linking into a schema
Every request body and response has an Example tab (the type-token JSON payload) and a Schema tab (SchemaTree — the navigable, expandable/collapsible property tree, src/components/SchemaTree.tsx). A URL hash on an endpoint page points straight at a nested property in one of them:
/{tag}/{operationId}#response-200/address/city
/{tag}/{operationId}#request-body/items/skuscopeisresponse-<status>orrequest-body; the rest of the hash is the property-key chain from the schema root, one segment per level (URL-encoded).- Opening a URL with a matching hash auto-opens the right response card (or the request body section), switches it to the Schema tab, expands every ancestor of the target property, and scrolls to it with a brief highlight.
- Built from
schemaToTreeNodes()'spath: string[]on eachSchemaTreeNode(the raw property-key chain, independent of the[]display suffix used for arrays) and the pure helpers insrc/document-model/schema-anchor.ts(buildSchemaAnchorHash/parseSchemaAnchorHash/buildSchemaAnchorId). - Every row in the Schema tree also has a hover "copy link" button (
SchemaTree.tsx) that copies the absolute URL — origin + path + the hash above — for that exact property, ready to paste into Slack/a PR comment/an agent prompt.
Favorites and recently viewed
The sidebar tracks endpoint usage per spec (keyed by the loaded spec URL, so switching specs via the multi-spec switcher doesn't mix unrelated APIs):
- Favorites — hover any endpoint row (in the tag tree, or in Favorites/Recent themselves) to reveal a star toggle; starred endpoints get pinned in a "Favorites" section at the top of the sidebar, in the order you starred them.
- Recently viewed — the last 5 distinct endpoints you opened, most-recent-first, shown in a "Recent" section below Favorites. An endpoint already in Favorites is left out of Recent to avoid showing it twice. A Clear button next to the section header empties it without touching Favorites.
Both persist to localStorage (useNavigationStore, src/state/navigation-store.ts) and survive a reload. Recording a visit happens automatically in EndpointRoute on navigation — no action needed beyond opening an endpoint.
Guides
Narrative markdown pages — onboarding, tutorials, anything that isn't "here's an endpoint" — rendered at /guides/:slug and listed in the sidebar above the endpoint tag tree. docfy-ui doesn't own the content; nestjs-docfy's DocfyUiModule.setup({ guides }) injects it (see its README):
DocfyUiModule.setup('/docs', app, {
guides: [
{
slug: 'getting-started',
title: 'Getting Started',
content: fs.readFileSync('./guides/getting-started.md', 'utf8'),
},
],
});Rendered via react-markdown + remark-gfm (tables, strikethrough, task lists) — no @tailwindcss/typography plugin, element styles are hand-mapped to this app's own design tokens instead. Fenced code blocks render through the same CodeBlock component used everywhere else (consistent styling, no second syntax highlighter). No guides configured → the sidebar section and /guides/* routes simply don't exist, zero visual change.
Embedded Try it out (docfy-try blocks)
A guide can embed a live, runnable request for any endpoint in the current spec — not just a link to its page. Use a fenced code block with the docfy-try language tag, containing a single METHOD /path line matching an endpoint exactly (same path template as the OpenAPI doc, e.g. /users/{id}):
```docfy-try
POST /auth/login
```Renders the same RequestPanel (Code/Try it out tabs, real auth, real request execution) used on the endpoint's own page, inline in the guide. No fuzzy matching: a typo'd method or path renders a small inline error instead of guessing, so broken references are obvious while writing the guide rather than failing silently.
Document Model
Before anything reaches a component, the raw OpenAPI document is normalized into an in-memory model (tagGroups → endpoints), implemented as pure, independently tested TypeScript with no React dependency:
src/document-model/normalize.ts: dereferences every$refvia@apidevtools/swagger-parserand groups endpoints by tag, preserving declared order.src/document-model/cap-depth.ts: makes a dereferenced (and possibly cyclic, for recursive DTOs) schema safe toJSON.stringifyfor the "Copy OpenAPI" button.src/document-model/example.ts/schema-tree.ts: build the type-token example payload and the navigable schema tree from the same schema, without fabricating fake data.src/document-model/filter.ts: the client-side search used by the sidebar.
All schema-walking functions (flattenSchema, schemaToTreeNodes, extractValidationRules) track visited nodes by object identity rather than a numeric depth cap, so a genuinely recursive DTO renders a single (circular reference) / ↩ circular marker instead of unrolling N times or crashing.
Theming
Dark/light theming is token-driven and reload-free:
src/styles/tokens.ts:getThemeTokens(theme)/deriveSurfaceTokens(bg, text), a small fixed set of base tokens (background, text, accent) plus derived surface/border tokens, obtained by mixingbgtowardtextand never introducing a new hue.src/styles/apply-theme.ts: writes the resulting CSS custom properties anddata-themeonto<html>; switching themes only changes variable values, no re-render of the component tree is required.src/state/theme-store.ts: a Zustand store that persists the chosen theme tolocalStorageand applies it synchronously before first paint (no flash of the wrong theme).
Accessibility
- Visible focus indicator on every interactive element (WCAG 2.4.7): a global
:focus-visibleoutline using the--ringdesign token (already existed, was never actually used before this) covers every plain button/link automatically. Elements that opt out of the native outline for a custom focus treatment (text inputs, the spec switcher<select>, the search command palette's input) get their ownfocus-visible:ring-2 focus-visible:ring-ringon top, via a box-shadow-based ring so it doesn't conflict withoutline-none. - Response/status switchers are real tabs:
ResponseViewer's status-code buttons and "Live" tab userole="tablist"/role="tab"/aria-selected, matching the patternRequestPanel's Code/Try it out and language switchers already used — a screen reader now announces these as a tab group with a current selection, not an unordered row of buttons. - Heading hierarchy: the endpoint detail page is a clean
h1 → h2 → h2 → h2(title, Parameters, Request Body, Responses) —ParametersSectionpreviously had no heading of its own above its Path/Query/Headersh3groups, which skipped straight fromh1toh3. - Keyboard-only navigation: every action reachable by mouse — auth, favorites, search, spec switching, sidebar navigation (see Favorites and recently viewed for the ↑/↓ shortcut) — is reachable by keyboard alone, with no traps (dialogs are Radix primitives, which handle focus trapping/return correctly).
- Color contrast: both themes' body/muted text pass WCAG AA comfortably (muted-foreground ≥ 5.0:1, primary text ≥ 15:1 against their respective backgrounds in both light and dark) — verified by converting the OKLCH design tokens to sRGB and computing actual contrast ratios, not eyeballing it.
Architecture notes
- Browser-only by design: the document model and "Copy for AI"/"Copy OpenAPI" transformers run entirely client-side; the UI has no server component beyond the static bundle.
@apidevtools/swagger-parserover@readme/openapi-parser: chosen for a smaller bundle, a workingbrowserfield, and equivalent OpenAPI 3.1 support (src/__tests__/parser-spike.spec.tsrecords this as a regression-protecting test). Its transitive dependency@apidevtools/json-schema-ref-parsercallsBuffer.isBuffer()unconditionally, which throws in a real browser. Worked around with a minimalbufferpolyfill imported first insrc/main.tsx(src/polyfills.ts).- Verified against real OpenAPI 3.0 and 3.1 documents (
public/sample-spec.json,public/sample-spec-31.json), exercisingoneOf/anyOf, a no-requestBodyendpoint, an endpoint with no declared error responses, an unconstrained schema, and a long multi-sentence description. Driven by Playwright against real Chrome, at desktop and mobile (375/390/768px) widths. - Route-level code-splitting:
GuidePageandComparePageare loaded viaReact.lazy, not bundled eagerly (Shell.tsx). Both pull real weight —GuidePagedrags inreact-markdown/remark-gfm,ComparePagethe diff engine — and neither is where most sessions land first, unlike the endpoint detail view. A session that never opens a guide or the compare view skips that JS entirely.
Scripts
npm run dev # start the Vite dev server
npm run build # typecheck + production build
npm run preview # preview the production build
npm test # run the test suite (vitest)
npm run typecheckTesting
npm testThe suite (Vitest + Testing Library) covers the document model, transformers, hooks, and every component, queried by role/text/label rather than implementation details, so visual changes don't require rewriting tests.
License
MIT © Marvin Rocha
