Add an OpenAPI blueprint that renders a spec into a docs site#3
Open
Jeehut wants to merge 21 commits into
Open
Add an OpenAPI blueprint that renders a spec into a docs site#3Jeehut wants to merge 21 commits into
Jeehut wants to merge 21 commits into
Conversation
Introduce an optional SiteKitOpenAPI library product and target that turns an OpenAPI document into a flattened, render-ready model. The parser (OpenAPIKit, via OpenAPIKitCompat) attaches only to this target, so a consumer depending on the base SiteKit product gains no new dependency (SE-0226 target-based resolution prunes it). Yams is reused from SiteKit, so this adds no new transitive runtime dependency either. OpenAPISpecLoader loads a spec from a file URL and handles the full input matrix on its own: format is auto-detected by extension (.json vs YAML) and version by the openapi field, with 3.0 documents normalized to 3.1 through OpenAPIKitCompat's convert(to:) so everything downstream sees one 3.1 shape. It projects the document into OpenAPISpec, a plain value model (info, servers, tags, operations, schemas) deliberately decoupled from OpenAPIKit so the renderers never import the parser. The openAPI(config:projectDirectory:) factory mirrors docc(...): it discovers the spec by convention at Content/openapi.yaml (with .yml/.json and an explicit specPath escape hatch), loads it up front, and returns a SiteBuilder. The page renderers that consume the model are a separate, follow-up piece of work. Cover the loader with red-green tests across the full 2x2 matrix, OpenAPI 3.0 and 3.1 each as YAML and JSON, using the canonical Petstore: info, servers, tags, the flattened operation list, a known operation's method, path, parameters and responses, the request body, and the component schemas with their properties and required fields.
Namespace every model type under OpenAPISpec to match Info/Server/Tag and, in particular, to stop OpenAPISpec.Operation from shadowing Foundation.Operation for a consumer that imports both. Operation, Parameter, RequestBody, MediaType, Response and SecurityRequirement (and the schema types SchemaObject, SchemaNode, SchemaProperty, Composition) move from top-level symbols into the OpenAPISpec namespace; the loader and tests are updated to the qualified names. This is a published API, so the rename lands now rather than after consumers reference the bare names. Make the version probe's openapi field optional so a document that omits it (a real Swagger 2.0 file) is rejected with the precise LoadError.unsupportedVersion rather than a raw decoding error, and correct the load doc comment about which errors name the file. Capture a oneOf/anyOf discriminator (propertyName plus any value mapping) on the composition model, and unify $ref handling so a referenced response or media-type entry is dropped consistently with referenced parameters and request bodies instead of emitting a degenerate empty entry. Soften the factory doc comment to describe the actual warn-and-continue behavior, mark the deferred decisions with notes, and make resolveSpecURL private. Add loader tests for the previously uncovered branches: the nullable normalization that proves 3.0 nullable:true and 3.1 ["T","null"] converge to one identical node, enum values, schema-level deprecated, oneOf with a discriminator, and the error paths (Swagger 2.0, empty, malformed, missing file).
Introduce the page-rendering foundation for the OpenAPI docs site, mirroring the DocC plugin set. OpenAPIShell wraps a page body in an app-shell (its own appbar, a slot for the navigation sidebar that a later slice fills) through PageShell with chrome .appShell, so the generic site header/footer are suppressed and the docs layout reads the theme tokens without touching any layout. OpenAPIRoutes is the single source of truth for the deep-linkable URL scheme (landing, tag, operation, schema) plus the slug and tag-grouping rules every renderer shares; an untagged operation groups under a synthetic "general" tag so none is dropped. OpenAPIHTML escapes interpolated values. OpenAPILandingPage renders the API title, an optional Content/api-intro.md prose block (rendered through SiteKit's Markdown loader, a no-op when absent), and a card per tag linking to that tag's page. The .openAPI factory now loads the spec once and injects it into the page renderers, which read only the OpenAPISpec model and never import OpenAPIKit. Semantic HTML with stable classes and data attributes only; the stylesheet that targets them is a later slice. The tag, operation, and schema pages and the HTML-structure tests follow in the next commits.
Complete the OpenAPI page set on top of the shell and landing page. The tag page lists a tag's operations (method badge + path + summary) linking to each operation page. The operation page is the leaf: method badge with data-method, full path, description, a parameters table (name/in/required/type), the request-body shape, responses per status code with their schemas and examples, and the security requirements. Static-first per the v1.1.0 decision: shapes and examples render as static HTML and a commented seam marks where a future try-it widget would mount, with no request-sending code. The schema page renders one page per component schema: the property table, composition (allOf/oneOf/anyOf with discriminator), enum values, and the nullable/deprecated facets. OpenAPISchemaHTML is shared by the operation and schema pages so they speak one schema language; a $ref renders as a link to that schema's page rather than expanding inline, keeping each schema documented once and deep-linkable. OpenAPIBadges emits the data-method verb pill the stylesheet slice will color. Capture a media type's inline example on the model (loader change), so the operation page can render request/response examples when the spec declares them. All four renderers read only the OpenAPISpec model and never import OpenAPIKit; the .openAPI factory registers the full set. Semantic HTML only; the stylesheet and the navigation sidebar are later slices. Tests follow in the next commit.
Cover the four page renderers with HTML-structure tests over the Petstore fixture and a sample-site assertion on the produced output paths. The sample-site test runs all four renderers and asserts the full OutputFile set: one landing, one page per tag, one per operation, and one per schema, each at its deep-linkable path under the api prefix. The per-page tests assert the semantic output the stylesheet slice will target: the landing has a card per tag linking to the tag page; the tag page lists its operations with data-method verb badges; the operation page carries data-method, the path, the parameter name and its data-in, the response status codes, a link to the response schema page, and the static-first try-it seam comment; the schema page lists the property names with their required markers and types.
The loader silently dropped every operation node defined as a component $ref: a $ref'd parameter, request body, or response (and a $ref'd path item) returned nil and was compactMapped away. Because the operation page guards on an empty section, a real spec that factors shared parameters (pagination, Authorization) or shared responses (401/404) into components/ rendered incomplete docs with no signal at all. Resolve each reference against document.components (parameters / responses / requestBodies / pathItems) and project the resolved value through the same flattening path the inline case uses, so a referenced node renders identically to an inline one. Schema $refs keep their existing behaviour (preserved by name as a link to the schema page, never inlined). A reference whose target is missing never drops silently: it becomes a visible placeholder carrying the reference name and emits a build warning, matching the factory's warn-and-continue posture. This unifies the emit-vs-drop behaviour across params, request bodies, responses, and content. Proof: new component-ref fixtures plus a red-green test suite asserting the resolved parameter name/in/type and the resolved response status + schema link appear on the operation page, and that an unresolvable $ref surfaces a placeholder instead of vanishing. The four tests fail on the old drop behaviour and pass once resolution lands. Full suite: 897 tests green. Resolution stays loader-side; the model and renderers remain OpenAPIKit-free.
OpenAPIRoutes.slugify was deterministic and lowercase but neither
ASCII-safe nor injective: an accented tag like "Café" kept non-ASCII
characters in its slug, and distinct inputs could fold to the same slug
("Get Pet" and "get-pet", a real tag "General" and the synthetic "general",
or a tag named "schemas" and the /schemas/ namespace), silently
overwriting one page with another at the same output path.
Fold slugs to [a-z0-9-]: map accented and diacritic characters to their
ASCII base through ICU (a clean character mapping, never a ue->ue-style
find-replace), so deep links and SEO canonicals stay percent-encoding-free
while display text keeps its real characters.
Add a deterministic collision guard. A new uniqueSlugs allocator assigns a
bare slug to the first claimant and suffixes later collisions (-2, -3, …),
warning which name was disambiguated. tagSections (replacing tagGroups)
allocates collision-safe tag slugs reserving the schema namespace, plus
operation slugs unique within each canonical tag; schemaSlugMap does the
same for component schema names. Both the schema pages and the $ref links
resolve through the one schema-slug map, so a link always lands on the page
it names. The result: no two pages ever resolve to the same file.
Proof: a slug-collisions fixture with two tags and two operationIds that
pre-fold to the same slug plus an accented tag, and tests asserting all
output paths are unique, the colliding tags/operations land at distinct
suffixed paths, and "Café" folds to "cafe". Full suite: 901 tests green.
Slug logic stays in the renderers' shared routing helper; no OpenAPIKit
dependency is introduced.
tagSections assigned each operation to exactly one group via its first tag, so an operation tagged [pets, admin] was listed only under pets and the admin tag page never showed it (and the admin landing card under-counted it). Readers browsing by the admin tag could not find an endpoint that legitimately belongs to it. List an operation in the section of every tag it carries, while keeping one canonical page per operation under its first tag (the canonical URL is unchanged, which preserves deep links and SEO canonicals). Each cross-listed entry links to that one canonical page rather than minting a per-tag duplicate, and only the canonical entry is flagged, so the operation-page renderer still emits a single page per operation. The tag order now includes any tag used purely as a secondary tag, so such a tag still gets its own section. Landing tag-card counts follow the same listing, so an operation is counted under each tag it carries. Proof: a multi-tag fixture with an operation tagged [pets, admin] and tests asserting it appears on both the pets and admin tag pages (both linking to the single /api/pets/banpet/ canonical URL, with no /api/admin/banpet/ variant), that exactly one operation page is emitted for it, and that the pets card counts 2 endpoints while the admin card counts 1. Full suite: 904 tests green. Grouping stays in the renderers' shared routing helper; no OpenAPIKit dependency is introduced.
Every OpenAPI page wrapped the app-shell but the sidebar seam was still a placeholder comment, so each page rendered without the persistent nav rail DocC pages get. A reader had no cross-page way to move between operations and schemas. Fill the seam with a real navigation tree, mirroring the DocC sidebar. OpenAPINavigationTree builds the model purely from OpenAPISpec (groups -> items): one group per tag via OpenAPIRoutes.tagSections, so the rail matches the tag pages and landing cards exactly, then a Schemas group listing every component schema. OpenAPISidebarRenderer emits the semantic HTML: a landing link, each group header linking to its tag page, and each operation item carrying the method badge plus a data-method hook and, when deprecated, a data-deprecated hook. The shell now builds the rail from the spec and the page being wrapped, marking the current page's item aria-current="page" with an is-active class; the page identity comes from the openAPIPath it already stashes (the landing page falls back to the landing path). The rail is wrapped in <nav aria-label="API navigation"> and emitted on landing, tag, operation, and schema pages alike, with chrome: .appShell unchanged. Cross-listing matches the page lists: a multi-tag operation appears under every tag it carries in the rail, each entry linking to its one canonical operation page. This slice is semantic HTML only - the stylesheet and the collapse/expand script are a later slice (the DocC parallel is DocCSidebarScriptRenderer); the emitted classes and data-* hooks are what they will target. Proof: a nav test suite asserting a group per tag with the right method hooks, the Schemas group listing every schema, the deprecated hook on a deprecated operation, the active item marked on the page being rendered, cross-listing consistency (a multi-tag op listed under every tag, all links canonical), and the rail present on every page type. Full suite: 910 tests green. Nav is built from the OpenAPISpec model only; no OpenAPIKit dependency is introduced.
The blueprint emitted correct, semantic, but unstyled HTML. Give it the Swagger/Stripe look with a token-driven stylesheet, generated verb colors, and progressive-enhancement nav interactivity. OpenAPIStylesheetRenderer (a .global Renderer) emits a bundled openapi.css and appends a generated [data-method] block: one rule per HTTP verb paints that verb's badge a semantic color (the Swagger-UI family), shared by the operation-header and in-rail badges. The stylesheet reads only theme token variables (colors, fonts, spacing) with component-scoped fallbacks, so an OpenAPI site inherits all color schemes and font pairings in light and dark, with the app-shell layout unchanged; the verb palette is the one place fixed hues are allowed. OpenAPINavScriptRenderer emits a bundled openapi-nav.js that adds collapse/expand twists, a live filter box, scrolls the active item into view, and wires a mobile drawer toggle - all progressive enhancement, the rail works without it. The shell links the stylesheet (after the critical head, non-blocking) and defers the script; both register in the .openAPI factory. The SiteKitOpenAPI target gains a processed Resources bundle. Folds in the S3 nav follow-ups: aria-current is now emitted only on a cross-listed operation's canonical occurrence (both occurrences keep the is-active visual, but a page advertises one current item); the nav label prefers the operation summary (matching the page H1) with an ellipsis + title tooltip for long summaries; the landing page stashes its openAPIPath explicitly rather than relying on the shell fallback; and the escape doc comment now lists all five escaped characters. Proof: styling tests assert the stylesheet and script render as output files, the CSS carries a [data-method] rule per verb, the shell head links the stylesheet and defers the script, and the three nav fixes hold (single aria-current on a cross-listed op page, summary labels, explicit landing path). Full suite: 916 tests green. Verified visually with light and dark screenshots of all four page types plus the deprecated / no-tags / large- schema edge cases and an alternate color scheme. Styling stays token-driven with no layout change; the nav and stylesheet are built from the OpenAPISpec model only and introduce no OpenAPIKit dependency.
The verb badges painted a blanket white label on the semantic verb backgrounds, which fails WCAG AA on the light hues (GET, POST, PUT, PATCH, DELETE all land below 4.5:1). Emit a per-verb label color from the same generator that emits the per-verb background, so the label color travels with the background and is verified at AA: near-black on the light verbs, white on the dark ones. All seven verbs now clear 4.5:1 (lowest is HEAD at 5.7:1). Make the mobile nav reachable with JavaScript disabled. The off-canvas drawer was the only way to reach the rail under 860px, and only the script could open it, so a no-JS narrow viewport trapped the rail off-screen. Add a cut-the-mustard "js" class to the document element as soon as the script runs, and gate the off-canvas transform behind html.js. Without the class the rail renders in normal document flow, stacked above the content and fully navigable. Also: derive the active-row method pill from the active token pair (instead of a hard-coded translucent white that assumes a dark accent), so a light-accent theme stays legible; move the collapse twist out of the group title's anchor into a sibling header row (a button nested in an anchor is invalid); add aria-controls to the twist and the mobile toggle and a section-named aria-label to the twist; and align the nav row radius to the theme --radius token so a sharp-corner theme flows through.
Blueprints whose pages are generated rather than loaded from files (the OpenAPI blueprint builds them from a spec) had no way to reach the machine-index renderers: sitemap, nav-index, search, and llms.txt all enumerate BuildContext.sections, which only ever held file-backed pages. Introduce ContentSectionProviding: a plugin registers one via SiteBuilder.contentSectionProvider(_:), and the pipeline merges its returned section into the context after file loading (single-language build and the multilingual global pass, where the site-wide indexes run). BuildContext gains an appendingSections helper that preserves every other field; provided pages do not re-contribute to the tag index. Also make the [PagePathResolving].pathResolution aggregate public so a blueprint-side index renderer can consult the same resolver chain the built-in sitemap and nav-index use, resolving generated pages to the paths they actually ship at.
The spec-derived OpenAPI pages were generated inside each renderer's pages(in:) and never entered BuildContext.sections, so sitemap.xml, nav-index.json, search-index.json, and llms.txt all missed every operation and schema page. Register them once through the new content-section provider and resolve their nested URLs from each page's stamped openAPIPath, and all four indexes pick them up. - OpenAPIContentProvider unions the four page renderers' pages(in:) into one section (the same PageModels that render, kept in lockstep); OpenAPIPagePathResolver returns each page's openAPIPath so the indexes link to the real OpenAPIRoutes URLs, never a recomputed path. - The .openAPI factory now registers the provider, adds NavIndexRenderer, and hands the resolver to the sitemap, nav-index, and search index. - OpenAPISearchIndexRenderer emits /assets/search-index.json (one record per page with kind / method / tag facets); OpenAPISearchScriptRenderer ships openapi-search.js, a progressive-enhancement appbar search that fetches the index and is revealed only behind html.js. - OpenAPILlmsTxtRenderer replaces the stock llms.txt with an API-shaped one listing every endpoint and schema individually under Endpoints and Schemas, plus the machine-index links. - Operation and schema pages get a never-blank, page-specific meta description fallback so the per-page SEO holds even when the spec omits a description. Tests assert sitemap, nav-index, search-index, and llms.txt each contain every operation and schema page, with a red-green proving the sitemap goes from missing to containing them once the pages are registered, and a full .openAPI build proving the registration cascade end to end.
Bring the OpenAPI docs surface to parity with the rest of a SiteKit site and close the accessibility polish items from the styling review. - Footer: render a config-driven footer (SiteConfig.footer – links plus a copyright line) at the bottom of the scroll area, token-styled like the DocC footer; omitted entirely when nothing is configured. - 404 and redirects: register ErrorPageRenderer plus the two redirect renderers in the factory, matching the .docc system-renderer set, so a 404 renders a styled page and redirects ship the _redirects map and the HTML stubs. - Skip link: clip the "Skip to content" link off-screen until it receives keyboard focus (the link had no styling and sat visible). Scoped to the OpenAPI surface in openapi.css; base SiteKit ships no skip-link CSS at all, a shared gap worth a separate fix. - Mobile drawer: add a backdrop shown behind the open drawer that closes it on tap, plus Escape-to-close and focus movement into and out of the rail, all gated behind html.js. - Theme toggle: add an appbar light/dark toggle consistent with the base DocC toggle – the same localStorage "theme" key and data-theme contract, following the OS until clicked. A flash-free inline head-init applies the stored or OS theme before first paint, skipped when the site's theme.yaml already provides a headInlineScript.
The public package source and tests carried internal development shorthand (slice ids, acceptance-criterion ids) in code comments. These mean nothing to a reader of the published repo and leak the internal workflow, so reword every one to keep the explanatory intent and drop the id. A comprehensive scan for slice / AC / phase / mission ids across the SiteKitOpenAPI source and tests now reports zero. Also note, on the schema page's SEO fallback, why it differs from the operation page's (a schema title is just its name, so an explicit "<Name> schema" reads better than the operation's title-as-last-resort).
Two follow-ups on the core seam from the registration review. - Fail loud, not silent: when the OpenAPI provider generates pages but no content section is configured, warn (matching the factory's other warnings) instead of silently dropping every API page from the machine indexes. In practice effectiveSections always synthesizes a default section, so the warning is a guard against that ever changing. - Lock the multilingual contract with a test: a registered provider's pages reach the build's global pass exactly once and never the per-locale passes, so an unlocalized synthetic page is not minted at a locale-prefixed URL it has no content for, and the site-wide outputs list it a single time.
With the off-canvas drawer open, keyboard focus was not contained: after tabbing past the rail, focus reached the page content hidden behind the scrim (landing cards, footer links). Mirror base SiteKit's docc-sidebar.js mechanism: on open, make the scrolling content region inert (a native focus trap plus aria-hidden rollup), lock the body scroll, and mark the rail a modal dialog (role + aria-modal); on close, undo all of it and return focus to the toggle. Gated behind html.js as before. The appbar is intentionally left interactive – it holds the hamburger, which is this drawer's close control – so closing by hamburger keeps working alongside Escape and a scrim tap. (Base DocC inerts its appbar because it ships a separate in-drawer close button.)
The 404 came from the base ErrorPageRenderer, which emits a footer-only page: a reader who landed on a missing URL had no appbar or nav rail and no way back into the docs. Mirror DocCMissingPage instead – render the 404 through OpenAPIShell so it carries the appbar (brand link to the landing, search, theme toggle), the nav rail, and the footer, with a clear "page not found" message and an explicit link back to the API landing in the content area. It still writes to 404.html, so the redirect renderers are unaffected, and it stays OpenAPIKit-free. Tests assert the rendered 404.html carries the full shell (brand, nav, footer) plus the not-found message and landing link, and that the open drawer marks the background inert / aria-modal and clears it on close.
Ship a ready-to-clone starter for the .openAPI blueprint, mirroring the DocC starter, so an author goes from an OpenAPI spec to a built site without per-endpoint authoring. - Plugin/blueprints/OpenAPI/: a Tasks API sample (Content/openapi.yaml + optional api-intro.md), a SiteConfig with the api section and a footer, a Theme, and a one-line Site executable. swift run Site build renders the landing, two tag pages, five operation pages, and five schema pages. - A short README plus the OpenAPI.md AI-instruction file, and the blueprint added to the catalog INDEX (row + decision tree). - SiteBuilder.openAPI(configPath:) overload: loads the SiteConfig and uses the current directory as the project root, so the starter's Main.swift is a one-liner like .docc(configPath:). It just loads the config and delegates to the existing openAPI(config:projectDirectory:). - BlueprintCatalogTests now expects the tenth blueprint.
Make the .openAPI blueprint discoverable for AI agents and humans, the same way the DocC blueprint is documented. - references/openapi.md: the reference – inputs the author provides (spec 3.0/3.1, SiteConfig, Theme, optional api-intro.md), the factory, what it renders (landing/tag/operation/schema, nav rail, search, SEO, sitemap/llms.txt/nav-index, footer, 404, theme toggle), the URL scheme under urlPrefix, the static-first note (no try-it widget this version), and the own-app-shell consistency note. - SKILL.md: a router row pointing API-docs intent at openapi.md, plus .openAPI() listed among the SiteBuilder factory methods. - blueprints.md: OpenAPI in the decision tree and the catalog table. - USE-CASES.md: a row for building API docs from a spec.
Curated light/dark, edge-case, mobile-drawer, 404, and theme-toggle captures of a rendered OpenAPI docs site, used in the blueprint docs and as visual reference for the new SiteKitOpenAPI product.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
SiteKit can now turn an OpenAPI specification into a complete, style-conforming, multi-page API
documentation site with a single factory call and zero per-operation authoring. Point the new
SiteBuilder.openAPI(...)blueprint at aContent/openapi.yaml(or.json) and it renders a landingpage, per-tag pages, per-operation pages, and per-schema pages – with a persistent navigation rail,
full-text search, SEO metadata, a sitemap,
llms.txt, a styled 404, and a light/dark theme toggle – alldriven by the site's theme tokens so it inherits every color scheme and font pairing with no layout
change.
The target quality bar is Swagger-UI / Stripe-docs: a bordered method/path header, scannable parameter
and response tables, semantic HTTP-verb color badges, and a collapsible tag → operation nav tree.
Design
The blueprint is a new, isolated library product,
SiteKitOpenAPI, so the baseSiteKittargetgains no new dependency – OpenAPIKit attaches only to
SiteKitOpenAPI.Pipeline, source → site:
OpenAPISpecLoaderdecodes the document (format auto-detected by extension; OpenAPI 3.0 and3.1 both supported, 3.0 normalized to 3.1 via
OpenAPIKitCompat) and projects it into a flattened,OpenAPIKit-free value model,
OpenAPISpec. In-file component$refs (parameters, request bodies,responses) are resolved; a missing target renders a visible placeholder plus a build warning rather
than vanishing.
OpenAPISpec– no rendererimports OpenAPIKit – and wrap their content in an
OpenAPIShell(the surface's own app-shell, like theDocC blueprint), so all of them share one nav rail, header, and footer and read the theme tokens.
active-item tracking) renders into every page; a multi-tag operation is cross-listed under each of its
tags but keeps one canonical page.
llms.txt) include the API pages via a new,generic
ContentSectionProvidingseam in core SiteKit: a blueprint can register synthetic pages intothe build context once, and every context-walking renderer picks them up. The seam is provably inert
for sites that register no provider.
openapi.cssfrom the theme tokens plus a generated per-verb colorblock; the verb badges meet WCAG AA contrast in both light and dark (a per-verb label color travels
with each background).
jsclass) add nav collapse/filter/scrollspy, a full-text search box, an off-canvas mobile drawer (with focus containment), and a theme
toggle that uses the exact same
localStoragecontract as the rest of SiteKit.A consumer ships only
Content/openapi.yaml, aSiteConfig, aTheme, and (optionally) anapi-intro.md; the blueprint renders everything else. ThePlugin/side adds a reference doc, ause-case entry, and a ready-to-clone
Plugin/blueprints/OpenAPIstarter.Why OpenAPIKit, and why not
swift-openapi-generatorThe renderers need a walkable document model at build time. The evaluated options:
apple/swift-openapi-generator– rejected. It is a build-time client/server code generator:it emits Swift types + stubs to call or implement an API, not a traversable description of the spec to
render docs from. It exposes no document model to walk, and it is itself built on OpenAPIKit. Using
it would mean generating client code and then reverse-engineering docs from the generated Swift –
strictly worse than reading the document directly.
Codable– rejected. OpenAPI 3.1's$ref/oneOf/anyOf/nullable/JSON-Schema-2020-12edge cases are dense; re-implementing that decoding is a large, bug-prone surface.
OpenAPIKitCompat, MIT) – chosen. A mature runtime document model with first-class3.0→3.1 normalization, attached to the isolated
SiteKitOpenAPItarget only.In scope
SiteBuilder.openAPI(config:projectDirectory:specPath:)(+ aconfigPath:convenience overload)producing a complete multi-page site from one spec, zero per-operation authoring.
$refresolution.llms.txt; nav-index; per-pageSEO (title/description/canonical); footer; styled 404; theme toggle – all token-driven, light + dark,
WCAG-AA verb badges.
OpenAPIstarter template.Out of scope (deliberate)
statically; a
// v1.2.0:mount seam is left for it. (Decision to confirm: ship static-first forthis release, or hold for the try-it widget?)
$refacross files – a single self-contained spec only (in-file component refs doresolve).
Decisions worth a second look (reviewer's call)
(matches OpenAPI tag semantics; avoids duplicate-content URLs).
color (dark text on the light verbs) rather than the widely-criticized white-on-light. (If you'd
prefer darkening the hues and keeping white labels, that's a one-line swap.)
localStorage "theme"+data-themecontract, OS-follow until a manual flip) so a reader's choice carries across every SiteKit surface.
How I verified
swift buildclean; fullswift test940 tests / 89 suites pass, including red-green proofs forthe two load-bearing pieces: component-
$refresolution (a$ref'd parameter renders blank withoutthe resolver, fully with it) and the sitemap registration keystone (0 → all API pages once the section
is registered). An end-to-end test builds the real
.openAPI(...)pipeline to disk and asserts thesitemap, nav-index, search-index, and
llms.txteach contain every operation and schema page.SiteKitconfirmed to gain no OpenAPIKit dependency (theSiteKitOpenAPItarget carries italone), and no renderer imports OpenAPIKit – the decoupling is structural, not aspirational.
large-schema edge specs) and reviewed them in a browser across light/dark, the nav interactions, the
mobile drawer, the styled 404, and the theme toggle. Verb-badge contrast measured at 5.7–13.1:1 in
both themes.
Plugin/blueprints/OpenAPIstarter builds to a working site (swift run Site build→_Site/api/...).Screenshots
A site rendered from the Petstore sample plus three edge-case specs.
Landing: the API title, an intro, and a card per tag.
Operation page: method/path header, a parameters table, and per-status response cards. The GET badge carries a dark label – the WCAG-AA verb-badge fix.
The same page in a dark scheme – everything legible, verb badges keep their AA labels.
Schema page: a property table with types, required markers, and descriptions.
Edge case – a large schema: enums,
int32/double/floatformats, arrays, nullable, and$refchips to other schemas.Edge case – a deprecated operation: the nav row is dimmed and struck through, with a DEPRECATED badge in the header.
Edge case – a spec with no tags: every operation falls into a single synthetic "general" group.
Narrow viewport: the nav becomes an off-canvas drawer over a backdrop; tap-outside or Escape closes it, and focus is contained while it is open.
A 404 renders through the full shell (header, nav rail, footer) with a link back to the API landing – not a dead end.
The 404 in dark mode.
The appbar theme toggle: a reader's light/dark choice persists across pages, using the same contract as the rest of SiteKit.
Notes for reviewers
architectural backbone – the meat is in
Sources/SiteKitOpenAPI/.ContentSectionProvidingseam (Sources/SiteKit/Pipeline/)– additive, and a no-op for every existing site type.
SiteKit).