A modern OpenAPI reference and API client. One script tag, zero dependencies, 79 KB.
<div id="app"></div>
<script src="https://unpkg.com/@sillo/atlas"></script>
<script>
Atlas.createApiReference('#app', { url: '/openapi.json' })
</script>Or with no JavaScript at all:
<script src="https://unpkg.com/@sillo/atlas" data-url="/openapi.json"></script>| Atlas | Swagger UI | Scalar | |
|---|---|---|---|
| Standalone bundle | 79 KB | ~1.4 MB | ~1 MB |
| As a module import | 53 KB | n/a | n/a |
| Runtime dependencies | none | many | many |
| Styles | inlined | separate CSS | inlined |
| Recursive schemas | rendered | expands forever | partial |
Dangling $ref |
reported | ignored | blank page |
Nothing is bundled that you did not ask for. Atlas builds DOM nodes directly — no framework, no virtual DOM, no runtime dependency of any kind. That is also why an OpenAPI description cannot inject script into the page: there is no code path that parses a string as markup.
Three-pane reference. Sidebar grouped by tag, operation detail in the middle, request builder on the right. Light and dark, following the system by default.
The whole info block. Licence, terms of service, contact, and external
documentation as links; every base URL; every security scheme with its type
and its OAuth scopes; a heading and description per tag. These are the parts
of a document that say who owns the API and what you may do with it, and a
reference that drops them silently discards the only legal and human
metadata in the file.
Search. ⌘K or /. Ranked, not filtered — typing user puts
GET /users above a passing mention twelve operations down.
API client. Build a request from the schema-seeded form and send it. Real timing, status, size, headers, and a response viewer. Credentials persist across reloads.
Nine-language snippets. cURL, HTTPie, Python (httpx and requests), JavaScript fetch, Node axios, Go, PHP, Ruby. Generated from the same prepared request the Send button uses, so a snippet cannot drift from what the client actually does.
npm install @sillo/atlasimport { createApiReference } from '@sillo/atlas'
import '@sillo/atlas/dist/atlas.css'
createApiReference('#app', { url: '/openapi.json' })Atlas.createApiReference('#app', {
url: '/openapi.json', // or: spec: {...} / spec: '{"openapi":...}'
theme: 'auto', // 'light' | 'dark' | 'auto'
deepLinking: true, // update location.hash while reading
fetchHeaders: { Authorization: 'Bearer …' }, // for a private document
servers: [{ url: 'https://api.example.com' }], // override the document
onLoaded: (spec) => console.log(spec.operations.length),
onError: (error) => console.error(error),
})Returns { spec, destroy }.
<script
src="atlas.standalone.js"
data-url="/openapi.json"
data-target="#docs"
data-theme="dark"
data-deep-linking="false"
></script>Everything is exported, so the pieces work outside the page:
import { parseSpec, prepareRequest, SNIPPET_LANGUAGES } from '@sillo/atlas'
const spec = parseSpec(JSON.parse(await fs.readFile('openapi.json', 'utf8')))
const request = prepareRequest(spec.operations[0], 'https://api.example.com',
{ path: {}, query: {}, header: {}, cookie: {} }, { values: {} }, spec.securitySchemes)
console.log(SNIPPET_LANGUAGES.find((l) => l.id === 'curl').generate(request))Also exported: AtlasApp, renderOperation, renderSchema, renderInfo,
renderTagHeading, createPanel, createSearch, codeBlock, markdown,
highlighted; and RefResolver, walkSchema, exampleFromSchema,
typeLabel, prepareRequest, sendRequest, SNIPPET_LANGUAGES,
searchOperations, resolveServers, parseMarkdown, safeHref,
tokenize.
Recursive schemas. Comment.replies: Comment[] is a cycle. Eagerly
inlining $ref does not terminate — the tab hangs. Atlas resolves lazily
and reports a cycle as a named, readable reference. Mutual recursion
(A → B → A) too.
Dangling references. A $ref pointing at nothing is shown as a
diagnostic naming the pointer, instead of a blank section.
Path-level parameters. Merged into every operation under that path, with operation-level parameters winning on a name-and-location clash.
Converter-style paths. {widget_id:int} — sillo's form — is substituted
like {widget_id}. Substituting only the first leaves a literal
{widget_id:int} in the URL and a confusing 404.
readOnly and writeOnly. Excluded from request and response examples
respectively, so a seeded body is one the API will actually accept.
Colliding operationIds. Deduplicated, because a duplicate anchor makes
a deep link land on the wrong operation.
Escaped JSON pointers. #/components/schemas/a~1b resolves to the key
a/b, in the right order — decoding ~0 first would turn ~01 into /.
Which server a request goes to. A document declaring
http://localhost:8000 is correct on the author's machine and wrong on a
colleague's port 8001, wrong on a LAN address, and wrong behind a proxy.
When the document was fetched from the same origin as the page, the API is
serving its own documentation — so that origin is offered as This server
and selected by default. Nothing declared is hidden; the document's servers
stay in the dropdown. Documentation hosted apart from the API keeps the
declared server, because there the page origin is not the API.
The OpenAPI document is untrusted input. Someone else may write the descriptions in the API you are hosting docs for.
- Markdown is parsed to a block tree and rendered one element at a
time — nothing ever reaches
innerHTML. - Link schemes are an allowlist;
javascript:anddata:render as inert text. - Syntax highlighting tokenises to data, rendered as
<span>children, so a JSON string in a response body cannot become markup. - The request panel updates in place rather than rebuilding, so typing into a parameter never replaces the focused element.
- The bundle needs no external origins, so it works under a strict
Content-Security-Policy.
There are tests for each of these, verified by reintroducing the bug: swapping the paragraph renderer to raw HTML turns two of them red.
npm install
npm run dev # demo at http://localhost:5173, rebuilds on change
npm test # 79 tests
npm run check # typecheck + test + builddist/ is not committed to main — it is built locally and ignored.
CI fails the build if it ever becomes tracked again.
Bump the version in package.json on main, then run the Release
workflow with the same version. It typechecks, tests, builds, and commits
dist/ onto a detached commit that only the tag points at.
That is what lets
https://cdn.jsdelivr.net/gh/sillohq/atlas@v0.4.0/dist/atlas.standalone.js
work while main stays free of ~2 MB of build output per release. The
release commit's parent is main's HEAD, so git log v0.4.0 shows the
full history — the commit is a real point in the project, just not on the
branch.
The tag is created by the workflow rather than pushed by hand. If a human tagged first, CI would have to force-move the tag onto the build commit, rewriting a ref other people may already have fetched.
The application is mounted into a real DOM (linkedom) and queried, so the
tree under test is the tree a browser would get — nothing is serialised to
markup and re-parsed.
npm test builds first, because every test imports from dist/.
- JSON only. A YAML document is detected and reported with a message
saying so, rather than failing as invalid JSON. Convert it, or parse it
yourself and pass
spec. $refwithin one document. External file and URL references are reported, not followed.- OpenAPI 3.x. A Swagger 2.0 document is flagged and rendered on a best-effort basis.
- The API client runs in the browser, so it is subject to CORS. A cross-origin failure is reported as such rather than as a bare "Failed to fetch", but it still needs the server to allow the origin.
MIT