Skip to content

Add an OpenAPI blueprint that renders a spec into a docs site#3

Open
Jeehut wants to merge 21 commits into
mainfrom
wip/openapi-docs
Open

Add an OpenAPI blueprint that renders a spec into a docs site#3
Jeehut wants to merge 21 commits into
mainfrom
wip/openapi-docs

Conversation

@Jeehut

@Jeehut Jeehut commented Jun 20, 2026

Copy link
Copy Markdown
Member

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 a Content/openapi.yaml (or .json) and it renders a landing
page, 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 – all
driven 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 base SiteKit target
gains no new dependency – OpenAPIKit attaches only to SiteKitOpenAPI.

Pipeline, source → site:

  • OpenAPISpecLoader decodes the document (format auto-detected by extension; OpenAPI 3.0 and
    3.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.
  • Page renderers (landing / tag / operation / schema) consume only OpenAPISpec – no renderer
    imports OpenAPIKit – and wrap their content in an OpenAPIShell (the surface's own app-shell, like the
    DocC blueprint), so all of them share one nav rail, header, and footer and read the theme tokens.
  • A navigation tree (tag groups → operations, a Schemas group, method badges, deprecated dimming,
    active-item tracking) renders into every page; a multi-tag operation is cross-listed under each of its
    tags but keeps one canonical page.
  • System renderers (sitemap, nav-index, search index, llms.txt) include the API pages via a new,
    generic ContentSectionProviding seam in core SiteKit: a blueprint can register synthetic pages into
    the build context once, and every context-walking renderer picks them up. The seam is provably inert
    for sites that register no provider.
  • A stylesheet renderer emits openapi.css from the theme tokens plus a generated per-verb color
    block; the verb badges meet WCAG AA contrast in both light and dark (a per-verb label color travels
    with each background).
  • Client scripts (progressive-enhancement, gated behind a js class) 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 localStorage contract as the rest of SiteKit.

A consumer ships only Content/openapi.yaml, a SiteConfig, a Theme, and (optionally) an
api-intro.md; the blueprint renders everything else. The Plugin/ side adds a reference doc, a
use-case entry, and a ready-to-clone Plugin/blueprints/OpenAPI starter.

Why OpenAPIKit, and why not swift-openapi-generator

The renderers need a walkable document model at build time. The evaluated options:

  • apple/swift-openapi-generatorrejected. 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.
  • Hand-rolled Codable – rejected. OpenAPI 3.1's $ref/oneOf/anyOf/nullable/JSON-Schema-2020-12
    edge cases are dense; re-implementing that decoding is a large, bug-prone surface.
  • OpenAPIKit (OpenAPIKitCompat, MIT) – chosen. A mature runtime document model with first-class
    3.0→3.1 normalization, attached to the isolated SiteKitOpenAPI target only.

In scope

  • SiteBuilder.openAPI(config:projectDirectory:specPath:) (+ a configPath: convenience overload)
    producing a complete multi-page site from one spec, zero per-operation authoring.
  • OpenAPI 3.0 and 3.1, YAML and JSON (auto-detected); in-file component $ref resolution.
  • Landing / tag / operation / schema pages; nav rail; search; sitemap; llms.txt; nav-index; per-page
    SEO (title/description/canonical); footer; styled 404; theme toggle – all token-driven, light + dark,
    WCAG-AA verb badges.
  • Plugin reference doc, use-case entry, and an OpenAPI starter template.

Out of scope (deliberate)

  • Interactive "try it" request widget – this release renders request/response shapes + examples
    statically
    ; a // v1.2.0: mount seam is left for it. (Decision to confirm: ship static-first for
    this release, or hold for the try-it widget?)
  • Multi-file $ref across files – a single self-contained spec only (in-file component refs do
    resolve).
  • Auth/OAuth flows beyond rendering the spec's declared security.

Decisions worth a second look (reviewer's call)

  • Static-first vs try-it (above) – ship now, try-it in a follow-up.
  • Multi-tag operations are cross-listed in each tag's list but keep one canonical page/URL
    (matches OpenAPI tag semantics; avoids duplicate-content URLs).
  • Verb-badge palette keeps the recognizable Swagger hues but uses a per-verb AA-contrast label
    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.)
  • Theme toggle mirrors the base DocC theme mechanism (same localStorage "theme" + data-theme
    contract, OS-follow until a manual flip) so a reader's choice carries across every SiteKit surface.

How I verified

  • swift build clean; full swift test 940 tests / 89 suites pass, including red-green proofs for
    the two load-bearing pieces: component-$ref resolution (a $ref'd parameter renders blank without
    the 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 the
    sitemap, nav-index, search-index, and llms.txt each contain every operation and schema page.
  • Base SiteKit confirmed to gain no OpenAPIKit dependency (the SiteKitOpenAPI target carries it
    alone), and no renderer imports OpenAPIKit – the decoupling is structural, not aspirational.
  • Rendered five sample sites (Petstore in light + dark, plus deprecated-operation, no-tags, and
    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.
  • The Plugin/blueprints/OpenAPI starter 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 page in light mode with tag cards
Landing: the API title, an intro, and a card per tag.

Operation page in light mode
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 operation page in dark mode
The same page in a dark scheme – everything legible, verb badges keep their AA labels.

Schema page with a property table
Schema page: a property table with types, required markers, and descriptions.

A large 18-property schema
Edge case – a large schema: enums, int32/double/float formats, arrays, nullable, and $ref chips to other schemas.

A deprecated operation
Edge case – a deprecated operation: the nav row is dimmed and struck through, with a DEPRECATED badge in the header.

An untagged spec grouped under a general section
Edge case – a spec with no tags: every operation falls into a single synthetic "general" group.

Mobile off-canvas nav drawer over a dimmed backdrop
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.

Styled 404 page in light mode
A 404 renders through the full shell (header, nav rail, footer) with a link back to the API landing – not a dead end.

Styled 404 page in dark mode
The 404 in dark mode.

After clicking the appbar theme toggle
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

  • The decoupling guarantee (base SiteKit stays dependency-free; renderers never import OpenAPIKit) is the
    architectural backbone – the meat is in Sources/SiteKitOpenAPI/.
  • The one core-SiteKit change is the generic ContentSectionProviding seam (Sources/SiteKit/Pipeline/)
    – additive, and a no-op for every existing site type.
  • This is v1.1.0 material: a minor, additive release (new product, no breaking changes to base
    SiteKit).

Jeehut added 21 commits June 20, 2026 08:24
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant