diff --git a/Docs/Screenshots/OpenAPI/01-landing-light.png b/Docs/Screenshots/OpenAPI/01-landing-light.png new file mode 100644 index 0000000..06ab6a6 Binary files /dev/null and b/Docs/Screenshots/OpenAPI/01-landing-light.png differ diff --git a/Docs/Screenshots/OpenAPI/02-operation-light.png b/Docs/Screenshots/OpenAPI/02-operation-light.png new file mode 100644 index 0000000..890cdd5 Binary files /dev/null and b/Docs/Screenshots/OpenAPI/02-operation-light.png differ diff --git a/Docs/Screenshots/OpenAPI/03-operation-dark.png b/Docs/Screenshots/OpenAPI/03-operation-dark.png new file mode 100644 index 0000000..2ccc732 Binary files /dev/null and b/Docs/Screenshots/OpenAPI/03-operation-dark.png differ diff --git a/Docs/Screenshots/OpenAPI/04-schema.png b/Docs/Screenshots/OpenAPI/04-schema.png new file mode 100644 index 0000000..27c7a5b Binary files /dev/null and b/Docs/Screenshots/OpenAPI/04-schema.png differ diff --git a/Docs/Screenshots/OpenAPI/05-edge-large-schema.png b/Docs/Screenshots/OpenAPI/05-edge-large-schema.png new file mode 100644 index 0000000..0ef1e60 Binary files /dev/null and b/Docs/Screenshots/OpenAPI/05-edge-large-schema.png differ diff --git a/Docs/Screenshots/OpenAPI/06-edge-deprecated.png b/Docs/Screenshots/OpenAPI/06-edge-deprecated.png new file mode 100644 index 0000000..2af6fdf Binary files /dev/null and b/Docs/Screenshots/OpenAPI/06-edge-deprecated.png differ diff --git a/Docs/Screenshots/OpenAPI/07-edge-no-tags.png b/Docs/Screenshots/OpenAPI/07-edge-no-tags.png new file mode 100644 index 0000000..672ad2f Binary files /dev/null and b/Docs/Screenshots/OpenAPI/07-edge-no-tags.png differ diff --git a/Docs/Screenshots/OpenAPI/08-mobile-drawer.png b/Docs/Screenshots/OpenAPI/08-mobile-drawer.png new file mode 100644 index 0000000..6b5be9b Binary files /dev/null and b/Docs/Screenshots/OpenAPI/08-mobile-drawer.png differ diff --git a/Docs/Screenshots/OpenAPI/09-404-light.png b/Docs/Screenshots/OpenAPI/09-404-light.png new file mode 100644 index 0000000..3024535 Binary files /dev/null and b/Docs/Screenshots/OpenAPI/09-404-light.png differ diff --git a/Docs/Screenshots/OpenAPI/10-404-dark.png b/Docs/Screenshots/OpenAPI/10-404-dark.png new file mode 100644 index 0000000..c255562 Binary files /dev/null and b/Docs/Screenshots/OpenAPI/10-404-dark.png differ diff --git a/Docs/Screenshots/OpenAPI/11-theme-toggle.png b/Docs/Screenshots/OpenAPI/11-theme-toggle.png new file mode 100644 index 0000000..2cfae8f Binary files /dev/null and b/Docs/Screenshots/OpenAPI/11-theme-toggle.png differ diff --git a/Package.resolved b/Package.resolved index e426ad1..6e2287c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "8ed9a9d9e721da71dfc2e44baf106c5f006158b5665518d22d352e32f671eb79", + "originHash" : "2b9d923b5d7e8abc1f30661689f1b5357acb38b7ef9a53f4381782b72be09c27", "pins" : [ + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit.git", + "state" : { + "revision" : "57b6318128e3f901c93f4fbf98d1c1464ec168d3", + "version" : "6.2.0" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 1e45e29..7b6806e 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,11 @@ let package = Package( // ONLY into builds that actually use it. A consumer depending only on the `SiteKit` product never // compiles swift-syntax (SE-0226 target-based dependency resolution prunes it). .library(name: "SiteKitSyntaxHighlighting", targets: ["SiteKitSyntaxHighlighting"]), + // Optional add-on library: renders an OpenAPI 3.0/3.1 spec (YAML or JSON) into a multi-page, + // style-conforming API-documentation site. It lives in its own product+target so the OpenAPIKit + // parser pulls in ONLY for builds that use it. A consumer depending solely on the `SiteKit` + // product never compiles OpenAPIKit (SE-0226 target-based dependency resolution prunes it). + .library(name: "SiteKitOpenAPI", targets: ["SiteKitOpenAPI"]), // The executable *product* is `sitekit` (the durable public command name); its *target* // is `SiteKitCLI` because a target literally named `sitekit` collides with the `SiteKit` // library target on a case-insensitive filesystem – at both the `Sources/` directory and @@ -26,6 +31,13 @@ let package = Package( .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), + // OpenAPIKit powers ONLY the optional SiteKitOpenAPI target: it parses an OpenAPI 3.0/3.1 + // document into a runtime model the blueprint walks to render API-documentation pages. It + // attaches solely to SiteKitOpenAPI, so a consumer depending only on the base `SiteKit` + // product never compiles it (SE-0226 target-based dependency resolution prunes it). OpenAPIKit + // declares Yams only for ITS test targets, so this adds zero new transitive runtime deps. + .package(url: "https://github.com/mattpolzin/OpenAPIKit.git", from: "6.2.0"), + // swift-syntax powers ONLY the optional SiteKitSyntaxHighlighting target. The 6xx.x line tracks // the Swift toolchain (603.x = Swift 6.3, the toolchain SiteKit builds with). Only the parser + // tree + syntactic-classification modules are used; the macro/compiler-plugin modules (the heavy, @@ -47,7 +59,7 @@ let package = Package( .executableTarget( name: "SiteKitCLI", dependencies: [ - .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ArgumentParser", package: "swift-argument-parser") ] ), // Optional SwiftSyntax-based highlighter. Depends on the base `SiteKit` library (for the @@ -74,6 +86,24 @@ let package = Package( name: "SiteKitSyntaxHighlightingTests", dependencies: ["SiteKitSyntaxHighlighting"] ), + // Optional OpenAPI blueprint. Depends on the base `SiteKit` library (for the pipeline, + // SiteBuilder and PageShell seams), OpenAPIKitCompat (which re-exports both the 3.0 and 3.1 + // parsers and the 3.0→3.1 conversion used to normalize every spec to one 3.1 shape), and Yams + // (already a SiteKit dependency – reused here to decode YAML specs, no new transitive dep). + .target( + name: "SiteKitOpenAPI", + dependencies: [ + "SiteKit", + .product(name: "OpenAPIKitCompat", package: "OpenAPIKit"), + "Yams", + ], + resources: [.process("Resources")] + ), + .testTarget( + name: "SiteKitOpenAPITests", + dependencies: ["SiteKitOpenAPI"], + resources: [.copy("Fixtures")] + ), .testTarget( name: "SiteKitCLITests", dependencies: ["SiteKitCLI"] diff --git a/Plugin/blueprints/INDEX.md b/Plugin/blueprints/INDEX.md index 073c80d..27b1c6d 100644 --- a/Plugin/blueprints/INDEX.md +++ b/Plugin/blueprints/INDEX.md @@ -27,6 +27,7 @@ Read `.md` first, then copy files from `/`. | `Newsletter` | Email newsletter with issue archive, signup forms, email rendering | Topic newsletters, curated digests, weekly/monthly roundups | [Newsletter.md](Newsletter.md) | [evolutionkit.dev](https://evolutionkit.dev) | | `AppLanding` | Single product landing page with hero, features, pricing, reviews | App marketing pages, SaaS products | [AppLanding.md](AppLanding.md) | [translatekit.pages.dev](https://translatekit.pages.dev) | | `DocC` | DocC catalog → static, AI-fetchable HTML with a sidebar + full-text search | Documentation sites, API/guide docs | [DocC.md](DocC.md) | [wwdcnotes.com](https://wwdcnotes.com) | +| `OpenAPI` | OpenAPI/Swagger spec → static, searchable API-reference docs (operations, schemas, nav rail) | API reference docs generated from a spec | [OpenAPI.md](OpenAPI.md) | – | | `Plain` | Minimal structure, no opinions | Experimentation, custom pipelines | [Plain.md](Plain.md) | – | --- @@ -35,9 +36,10 @@ Read `.md` first, then copy files from `/`. Use this decision tree: -0. **Do you have a DocC catalog (`.docc` – Markdown notes with DocC directives)?** - - Yes → **`DocC`** (renders it to static, AI-fetchable HTML with a sidebar + full-text search) - - No, continue ↓ +0. **Do you have a machine-readable description to render as-is?** + - An OpenAPI/Swagger spec (`.yaml`/`.json`, 3.0 or 3.1) → **`OpenAPI`** (renders it to a static, searchable API-reference site) + - A DocC catalog (`.docc` – Markdown notes with DocC directives) → **`DocC`** (static, AI-fetchable HTML with a sidebar + full-text search) + - Neither, continue ↓ 1. **Is your content audio episodes (a podcast)?** - Yes → **`Podcast`** diff --git a/Plugin/blueprints/OpenAPI.md b/Plugin/blueprints/OpenAPI.md new file mode 100644 index 0000000..40aba54 --- /dev/null +++ b/Plugin/blueprints/OpenAPI.md @@ -0,0 +1,42 @@ +# Blueprint: OpenAPI + +Generate a complete, static API-documentation site from an OpenAPI spec. The author provides one spec file; the blueprint renders every page. + +## Quick Start + +1. Copy the `OpenAPI/` template into a new project directory. +2. Replace `Content/openapi.yaml` with the user's spec (OpenAPI 3.0 or 3.1, YAML or JSON, auto-detected). +3. Edit `SiteConfig.yaml` (name, base URL, footer) and `Theme/theme.yaml` (color scheme, fonts). +4. `swift run Site build` → `_Site/`. + +`Sources/Site/Main.swift` is one line: `try SiteBuilder.openAPI(configPath: "SiteConfig.yaml").run()`. + +## When to Choose This + +Choose `OpenAPI` when the user has an OpenAPI/Swagger spec (3.0 or 3.1) and wants browsable, linkable, search- and AI-indexed reference docs for it. If they want hand-written guides or a DocC catalog instead, use `DocC`; for narrative articles, use `Blog`. + +The spec must be a single self-contained file. In-file component `$ref`s resolve; multi-file `$ref`s that point across files are out of scope. + +## How It Works + +One spec in, a full site out, all under the section's `urlPrefix` (default `api`): + +- **Landing** (`/api/`) – the API title, version, the optional `Content/api-intro.md` prose, and a card per tag. +- **Tag pages** (`/api//`) – each tag's operations, with method badges. +- **Operation pages** (`/api///`) – method + path, parameters, request body, per-status responses, referenced schemas, examples, and security. Static-first: shapes and examples, no interactive request widget in this version. +- **Schema pages** (`/api/schemas//`) – properties, composition (allOf/oneOf/anyOf), enums, and nullable/deprecated markers. + +Slugs are ASCII-folded and collision-guarded, so non-ASCII tag/operation names and name clashes still produce distinct, stable URLs. + +Around the pages the blueprint ships: a persistent **nav rail** (collapsible groups, a live filter, deprecated dimming, active-page tracking), full-text **search** (`/assets/search-index.json` + an appbar search box), per-page **SEO** (title, description, canonical), **sitemap.xml**, **llms.txt**, and **nav-index.json**, a config-driven **footer**, a styled **404**, and a light/dark **theme toggle** consistent with the rest of SiteKit. + +## Style + +The OpenAPI surface owns its own app-shell (the persistent rail + content area), so it looks consistent across all 15 color schemes in light and dark with no layout change. The layout *templates* (Classic / Sidebar / Minimal) do not alter it – only the chosen color scheme and font pairing do. + +## Questions to Ask + +- Where is the spec? (Default `Content/openapi.yaml`; pass `specPath:` to `.openAPI(...)` if it lives elsewhere.) +- What URL prefix? (Default `api`; set the section's `urlPrefix` in `SiteConfig.yaml`.) +- Any intro prose for the landing page? (Optional `Content/api-intro.md`.) +- Which color scheme and fonts? (Any of the 15 schemes / 6 font pairings in `Theme/theme.yaml`.) diff --git a/Plugin/blueprints/OpenAPI/.gitignore b/Plugin/blueprints/OpenAPI/.gitignore new file mode 100644 index 0000000..498032c --- /dev/null +++ b/Plugin/blueprints/OpenAPI/.gitignore @@ -0,0 +1,4 @@ +.build/ +_Site/ +Package.resolved +.DS_Store diff --git a/Plugin/blueprints/OpenAPI/Content/api-intro.md b/Plugin/blueprints/OpenAPI/Content/api-intro.md new file mode 100644 index 0000000..9a36a54 --- /dev/null +++ b/Plugin/blueprints/OpenAPI/Content/api-intro.md @@ -0,0 +1,3 @@ +Welcome to the Tasks API. It lets you create tasks, track their status through their lifecycle, and assign them to people. + +This prose block is optional: it comes from `Content/api-intro.md` and renders above the tag cards on the landing page. Delete the file to omit it, or edit it to introduce your own API. Everything below is generated from `Content/openapi.yaml`. diff --git a/Plugin/blueprints/OpenAPI/Content/openapi.yaml b/Plugin/blueprints/OpenAPI/Content/openapi.yaml new file mode 100644 index 0000000..c47c259 --- /dev/null +++ b/Plugin/blueprints/OpenAPI/Content/openapi.yaml @@ -0,0 +1,205 @@ +openapi: 3.1.0 +info: + title: Tasks API + version: 1.0.0 + description: A small task-tracking API, used as the sample spec for the SiteKit OpenAPI blueprint. +servers: + - url: https://api.example.com/v1 +tags: + - name: tasks + description: Create, inspect, and complete tasks. + - name: users + description: Look up the people a task can be assigned to. +paths: + /tasks: + get: + operationId: listTasks + summary: List tasks + description: Returns the tasks, most recently created first, optionally filtered by status. + tags: + - tasks + parameters: + - name: status + in: query + required: false + description: Only return tasks in this status. + schema: + $ref: "#/components/schemas/TaskStatus" + - name: limit + in: query + required: false + description: Maximum number of tasks to return (1-100). + schema: + type: integer + format: int32 + default: 20 + responses: + "200": + description: A page of tasks. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Task" + post: + operationId: createTask + summary: Create a task + description: Creates a new task and returns it with its assigned id. + tags: + - tasks + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NewTask" + responses: + "201": + description: The created task. + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + "400": + description: The request body was invalid. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /tasks/{taskId}: + get: + operationId: getTask + summary: Fetch a task + tags: + - tasks + parameters: + - name: taskId + in: path + required: true + description: The task identifier. + schema: + type: string + responses: + "200": + description: The task. + content: + application/json: + schema: + $ref: "#/components/schemas/Task" + "404": + description: No task with that id exists. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + delete: + operationId: deleteTask + summary: Delete a task + tags: + - tasks + parameters: + - name: taskId + in: path + required: true + description: The task identifier. + schema: + type: string + responses: + "204": + description: The task was deleted. + /users/{userId}: + get: + operationId: getUser + summary: Fetch a user + tags: + - users + parameters: + - name: userId + in: path + required: true + description: The user identifier. + schema: + type: string + responses: + "200": + description: The user. + content: + application/json: + schema: + $ref: "#/components/schemas/User" +components: + schemas: + TaskStatus: + type: string + description: The lifecycle status of a task. + enum: + - open + - in_progress + - done + - archived + NewTask: + type: object + description: The fields needed to create a task. + required: + - title + properties: + title: + type: string + description: A short summary of the work. + details: + type: string + description: Optional longer description. + assigneeId: + type: [string, "null"] + description: The id of the user the task is assigned to, if any. + Task: + type: object + description: A unit of work. + required: + - id + - title + - status + - createdAt + properties: + id: + type: string + description: Stable unique identifier. + title: + type: string + details: + type: string + status: + $ref: "#/components/schemas/TaskStatus" + assignee: + $ref: "#/components/schemas/User" + createdAt: + type: string + format: date-time + description: When the task was created. + User: + type: object + description: A person a task can be assigned to. + required: + - id + - name + properties: + id: + type: string + name: + type: string + email: + type: string + format: email + Error: + type: object + description: A problem response. + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/Plugin/blueprints/OpenAPI/Package.swift b/Plugin/blueprints/OpenAPI/Package.swift new file mode 100644 index 0000000..e61f76c --- /dev/null +++ b/Plugin/blueprints/OpenAPI/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 6.2 +import PackageDescription + +let package = Package( + name: "Site", + platforms: [.macOS(.v26)], + dependencies: [ + // The OpenAPI blueprint ships in the optional SiteKitOpenAPI product (it pulls the + // OpenAPI parser only into builds that use it). + .package(url: "https://github.com/FlineDev/SiteKit.git", from: "1.1.0") + ], + targets: [ + .executableTarget( + name: "Site", + dependencies: [ + .product(name: "SiteKit", package: "SiteKit"), + .product(name: "SiteKitOpenAPI", package: "SiteKit"), + ] + ) + ] +) diff --git a/Plugin/blueprints/OpenAPI/README.md b/Plugin/blueprints/OpenAPI/README.md new file mode 100644 index 0000000..a849576 --- /dev/null +++ b/Plugin/blueprints/OpenAPI/README.md @@ -0,0 +1,26 @@ +# Tasks API – SiteKit OpenAPI starter + +A ready-to-build API-documentation site generated from an OpenAPI spec with SiteKit's `.openAPI` blueprint. + +## Build it + +```bash +swift run Site build # renders the site into _Site/ +swift run Site serve # local preview at http://localhost:8080 +``` + +Open `_Site/api/index.html` (via `serve`) to see the landing page, then the per-tag, per-operation, and per-schema pages. + +## What's here + +- `Content/openapi.yaml` – the spec (OpenAPI 3.1; 3.0 works too, auto-detected). Replace it with yours. +- `Content/api-intro.md` – optional prose shown above the landing tag cards. Delete it to omit. +- `SiteConfig.yaml` – site name, base URL, the single `api` section, and the footer. +- `Theme/theme.yaml` – the color scheme and font pairing. The API surface brings its own layout, so any of the 15 schemes work in light and dark. +- `Sources/Site/Main.swift` – one line: `SiteBuilder.openAPI(configPath: "SiteConfig.yaml").run()`. + +## Make it yours + +Drop your own `Content/openapi.yaml` in place (keep the file name, or point `specPath` at another location), update `SiteConfig.yaml`, and rebuild. Every operation and schema page, the nav rail, search, sitemap, and `llms.txt` come from the spec – there is nothing to author per endpoint. + +The pages render request/response shapes and examples (static-first); there is no interactive "try it" request widget in this version. diff --git a/Plugin/blueprints/OpenAPI/SiteConfig.yaml b/Plugin/blueprints/OpenAPI/SiteConfig.yaml new file mode 100644 index 0000000..c22c1df --- /dev/null +++ b/Plugin/blueprints/OpenAPI/SiteConfig.yaml @@ -0,0 +1,28 @@ +name: "Tasks API" +baseURL: "https://example.com" +outputDirectory: "_Site" +# The spec lives at Content/openapi.yaml. The section below groups the generated pages; +# its own contentDirectory resolves to Content/Content (no Markdown articles to load), so +# the API pages come entirely from the spec. +contentDirectory: "Content" +defaultLanguage: "en" + +author: + name: "Your Name" + email: "you@example.com" + +footer: + copyright: "© 2026 Your Company" + links: + - title: "Privacy" + url: "/privacy/" + - title: "Imprint" + url: "/imprint/" + +# One section for the API docs. `urlPrefix` is the URL root for every page +# (/api/, /api//, /api///, /api/schemas//). +sections: + - name: "API" + slug: "api" + contentDirectory: "Content" + urlPrefix: "api" diff --git a/Plugin/blueprints/OpenAPI/Sources/Site/Main.swift b/Plugin/blueprints/OpenAPI/Sources/Site/Main.swift new file mode 100644 index 0000000..d983456 --- /dev/null +++ b/Plugin/blueprints/OpenAPI/Sources/Site/Main.swift @@ -0,0 +1,9 @@ +import SiteKit +import SiteKitOpenAPI + +@main +struct Site { + static func main() throws { + try SiteBuilder.openAPI(configPath: "SiteConfig.yaml").run() + } +} diff --git a/Plugin/blueprints/OpenAPI/Theme/theme.yaml b/Plugin/blueprints/OpenAPI/Theme/theme.yaml new file mode 100644 index 0000000..4d3d03d --- /dev/null +++ b/Plugin/blueprints/OpenAPI/Theme/theme.yaml @@ -0,0 +1,3 @@ +name: "API Docs" +colorScheme: "ocean" +fontPairing: "modern" diff --git a/Plugin/skills/sitekit/SKILL.md b/Plugin/skills/sitekit/SKILL.md index 99ba762..26254c9 100644 --- a/Plugin/skills/sitekit/SKILL.md +++ b/Plugin/skills/sitekit/SKILL.md @@ -7,7 +7,7 @@ metadata: # SiteKit -SiteKit is an AI-first Swift static site generator built around a **phase-oriented pipeline**: Discovery → Loading → Enrichment → Page rendering → System rendering → Output processing, plus content-independent asset teleporting. Each phase is one Swift protocol; sites are composed with `SiteBuilder` factory methods (`.blog()`, `.podcast()`, `.newsletter()`, `.portfolio()`, `.docs()`, `.docc()`) and customized fluently by swapping or appending plugins. +SiteKit is an AI-first Swift static site generator built around a **phase-oriented pipeline**: Discovery → Loading → Enrichment → Page rendering → System rendering → Output processing, plus content-independent asset teleporting. Each phase is one Swift protocol; sites are composed with `SiteBuilder` factory methods (`.blog()`, `.podcast()`, `.newsletter()`, `.portfolio()`, `.docs()`, `.docc()`, `.openAPI()`) and customized fluently by swapping or appending plugins. ## Process @@ -17,7 +17,8 @@ Route the user's intent to the right reference. Read only the references you nee |---|---| | Installing SiteKit + scaffolding the first site (the `sitekit` CLI) | `references/bootstrap.md` | | Setting up a new site from scratch | `references/onboarding.md` | -| Picking a blueprint (Blog, Newsletter, Podcast, Portfolio, AppLanding, Snippets, IndieDev, DocC, Plain) | `references/blueprints.md` | +| Picking a blueprint (Blog, Newsletter, Podcast, Portfolio, AppLanding, Snippets, IndieDev, DocC, OpenAPI, Plain) | `references/blueprints.md` | +| Building API docs from an OpenAPI / Swagger spec (3.0 or 3.1) | `references/openapi.md` | | Writing content (blog posts, newsletter issues, static pages) | `references/content-writing.md` | | Imprint / privacy / legal pages (country-dependent, GDPR, cookies) | `references/legal-pages.md` | | DocC / Markdown directive extensions (`@Metadata`, `@Row`, `@TabNavigator`, `@Video`, `@Image`, `@Links`, …) + the graceful-degradation contract | `references/markdown-extensions.md` | diff --git a/Plugin/skills/sitekit/references/blueprints.md b/Plugin/skills/sitekit/references/blueprints.md index 54ab66b..98af2a2 100644 --- a/Plugin/skills/sitekit/references/blueprints.md +++ b/Plugin/skills/sitekit/references/blueprints.md @@ -9,6 +9,9 @@ When a user installs the SiteKit plugin and asks Claude Code to "build me a webs Walk top-to-bottom; pick the first match. ``` +Do you have an OpenAPI / Swagger spec (.yaml/.json, 3.0 or 3.1)? + yes ──► OpenAPI + no Do you have a DocC catalog (.docc with DocC directives)? yes ──► DocC no @@ -45,6 +48,7 @@ Is there any time-based content (articles, posts, snippets)? | `Podcast` | stable | `.podcast(...)` | Podcast shows, interview series (iTunes RSS, audio player, chapters) | | `AppLanding` | beta | site-custom Swift | Single-product marketing pages (hero, features, pricing, reviews) | | `DocC` | stable | `.docc(...)` | Documentation sites from a `.docc` catalog – sidebar, full-text search, AI-fetchable static HTML | +| `OpenAPI` | stable | `.openAPI(...)` | API reference docs from an OpenAPI 3.0/3.1 spec – operation + schema pages, nav rail, search, sitemap/llms.txt. Deep: [openapi.md](openapi.md) | ### Beta status – `AppLanding` diff --git a/Plugin/skills/sitekit/references/openapi.md b/Plugin/skills/sitekit/references/openapi.md new file mode 100644 index 0000000..25d7b99 --- /dev/null +++ b/Plugin/skills/sitekit/references/openapi.md @@ -0,0 +1,54 @@ +# OpenAPI API-documentation sites + +The `.openAPI` blueprint turns one OpenAPI spec into a complete, static, searchable API-reference site. The author supplies the spec; the blueprint renders every page. Zero per-operation authoring. + +Starter template: `Plugin/blueprints/OpenAPI/` (clone it, swap in the user's spec). AI-instruction file: `Plugin/blueprints/OpenAPI.md`. + +## Inputs the author provides + +- **`Content/openapi.yaml`** (or `.json`) – the spec. **OpenAPI 3.0 or 3.1**, YAML or JSON, auto-detected by the loader. It must be a **single self-contained file**: in-file component `$ref`s resolve; multi-file `$ref`s pointing across files are out of scope. +- **`SiteConfig.yaml`** – site name, base URL, and one section (slug `api` by convention) whose `urlPrefix` is the URL root for the docs. +- **`Theme/theme.yaml`** – a color scheme and font pairing. +- **`Content/api-intro.md`** (optional) – Markdown prose rendered above the tag cards on the landing page. + +Spec elsewhere? Pass `specPath:` to `.openAPI(...)` to override the conventional `Content/openapi.yaml` discovery. + +## The factory + +```swift +import SiteKit +import SiteKitOpenAPI + +try SiteBuilder.openAPI(configPath: "SiteConfig.yaml").run() +``` + +`.openAPI` is in the optional **`SiteKitOpenAPI`** product (the OpenAPI parser pulls in only for builds that use it). `Package.swift` depends on both `SiteKit` and `SiteKitOpenAPI`. The `configPath:` overload loads the config and uses the current directory as the project root; the explicit form is `.openAPI(config:projectDirectory:specPath:)`. + +## What it renders + +All under the section's `urlPrefix` (default `api`): + +| Page | URL | Contents | +|---|---|---| +| Landing | `/api/` | API title + version, optional `api-intro.md` prose, a card per tag | +| Tag | `/api//` | the tag's operations, with method badges | +| Operation | `/api///` | method + path, parameters, request body, per-status responses, referenced schemas, examples, security | +| Schema | `/api/schemas//` | properties, composition (allOf/oneOf/anyOf), enums, nullable / deprecated markers | + +A multi-tag operation appears under each of its tags but has one canonical page. Slugs are **ASCII-folded and collision-guarded**, so non-ASCII names and clashes still produce distinct, stable URLs. + +Around the pages, the blueprint also ships: + +- A persistent **nav rail** – collapsible tag groups, a live filter, deprecated dimming, active-page tracking. +- Full-text **search** – `/assets/search-index.json` plus an appbar search box. +- **SEO** – per-page ``, `<meta name="description">`, and `<link rel="canonical">`. +- Machine indexes – **sitemap.xml**, **llms.txt**, and **nav-index.json**, each listing every operation and schema page. +- A config-driven **footer**, a styled **404** rendered in the full shell (with a link back to the landing), and a light/dark **theme toggle** consistent with the rest of SiteKit (follows the OS until the reader picks a mode). + +## Static-first (no "try it" widget) + +This version renders request and response **shapes and examples** – it does **not** include an interactive "try it" request widget that fires live calls. State this plainly so authors are not surprised; a seam exists for adding one later. + +## Style and layout + +The OpenAPI surface owns its own app-shell (the persistent rail + content area), so it looks consistent across all 15 color schemes in light and dark with **no layout change**. The layout *templates* (Classic / Sidebar / Minimal) do not alter the API surface – only the chosen color scheme and font pairing do. The whole surface stays decoupled from the parser: only the spec loader touches OpenAPIKit. diff --git a/Sources/SiteKit/Pipeline/BuildContext.swift b/Sources/SiteKit/Pipeline/BuildContext.swift index 9cf7eb8..45108b3 100644 --- a/Sources/SiteKit/Pipeline/BuildContext.swift +++ b/Sources/SiteKit/Pipeline/BuildContext.swift @@ -121,4 +121,30 @@ public struct BuildContext { self.projectDirectory = projectDirectory self.draftPages = draftPages } + + /// Returns a copy with `extraSections` appended to `sections`, preserving every other + /// field (router, uiStrings, tags, …) unchanged. + /// + /// The pipeline uses this to merge synthetic sections from `ContentSectionProviding` + /// plugins (e.g. the OpenAPI blueprint, whose pages are generated from a spec rather + /// than loaded from files) into the context after file-backed loading, so the + /// machine-index renderers (sitemap, nav-index, search, llms.txt) enumerate the + /// provided pages alongside the file-backed ones. The provided pages do not + /// re-contribute to `tags` – synthetic API pages are untagged content. + func appendingSections(_ extraSections: [ContentSection]) -> BuildContext { + guard !extraSections.isEmpty else { return self } + return BuildContext( + config: self.config, + themeConfig: self.themeConfig, + sections: self.sections + extraSections, + staticPages: self.staticPages, + tags: self.tags, + homeContent: self.homeContent, + router: self.router, + uiStrings: self.uiStrings, + outputDirectory: self.outputDirectory, + projectDirectory: self.projectDirectory, + draftPages: self.draftPages + ) + } } diff --git a/Sources/SiteKit/Pipeline/BuildPipeline.swift b/Sources/SiteKit/Pipeline/BuildPipeline.swift index 3bfd74f..b785215 100644 --- a/Sources/SiteKit/Pipeline/BuildPipeline.swift +++ b/Sources/SiteKit/Pipeline/BuildPipeline.swift @@ -27,7 +27,8 @@ extension BuildPipelineError: CustomStringConvertible { case .fileWriteFailed(let url, let error): return "Could not write output file \(url.path): \(error)" case .renderersFailed(let failures): - let details = failures + let details = + failures .map { "\($0.renderer): \($0.error)" } .joined(separator: "; ") return "\(failures.count) renderer(s) failed – \(details)" @@ -56,6 +57,7 @@ public struct BuildPipeline { private let enrichers: [any Enricher] private let renderers: [any Renderer] private let processors: [any OutputProcessor] + private let contentSectionProviders: [any ContentSectionProviding] private let logger: Logger private let cleanBeforeBuild: Bool private let themeConfig: ThemeConfig? @@ -76,7 +78,8 @@ public struct BuildPipeline { additionalTeleporters: [any Teleporter] = [], enrichers: [any Enricher] = [], renderers: [any Renderer]? = nil, - processors: [any OutputProcessor]? = nil + processors: [any OutputProcessor]? = nil, + contentSectionProviders: [any ContentSectionProviding] = [] ) { self.config = config self.projectDirectory = projectDirectory @@ -88,6 +91,7 @@ public struct BuildPipeline { self.assetCopier = teleporter ?? AssetCopier() self.additionalTeleporters = additionalTeleporters self.enrichers = enrichers + self.contentSectionProviders = contentSectionProviders self.logger = Logger(label: "SiteKit.build") self.cleanBeforeBuild = cleanBeforeBuild @@ -115,13 +119,14 @@ public struct BuildPipeline { // CSSBackgroundImageProcessor must run BEFORE AssetMinifier, because the // minifier rewrites the CSS file (stripping whitespace) which would make // our regex-based declaration scanner harder to match reliably. - self.processors = processors ?? [ - ImageResizer(), - FontAwesomeInliner(), - CSSBackgroundImageProcessor(), - AssetMinifier(), - AssetFingerprinter(), - ] + self.processors = + processors ?? [ + ImageResizer(), + FontAwesomeInliner(), + CSSBackgroundImageProcessor(), + AssetMinifier(), + AssetFingerprinter(), + ] // Default generators if none provided. The canonical list lives on // SiteBuilder.blogRenderers so SiteBuilder.blog(...) and a direct @@ -185,6 +190,16 @@ public struct BuildPipeline { } } + /// Returns `context` with the synthetic sections from every registered + /// `ContentSectionProviding` plugin merged in, so the machine-index renderers enumerate + /// generated pages (e.g. the OpenAPI blueprint's spec-derived pages) alongside file-backed + /// ones. A no-op when no providers are registered. Providers see the file-backed context. + private func mergingProvidedSections(into context: BuildContext) -> BuildContext { + guard !self.contentSectionProviders.isEmpty else { return context } + let provided = self.contentSectionProviders.compactMap { $0.contentSection(in: context) } + return context.appendingSections(provided) + } + /// Standard single-language build (backward compatible). private func buildSingleLanguage() throws { // 4. Load content sections @@ -208,13 +223,25 @@ public struct BuildPipeline { var ext = page.extensions ext["sectionSlug"] = sectionConfig.slug return PageModel( - id: page.id, title: page.title, date: page.date, slug: page.slug, - htmlContent: page.htmlContent, sourcePath: page.sourcePath, - category: page.category, tags: page.tags, summary: page.summary, - description: page.description, author: page.author, image: page.image, - imageAlt: page.imageAlt, draft: page.draft, pageType: page.pageType, - locale: page.locale, originalLanguage: page.originalLanguage, - legalDocument: page.legalDocument, extensions: ext + id: page.id, + title: page.title, + date: page.date, + slug: page.slug, + htmlContent: page.htmlContent, + sourcePath: page.sourcePath, + category: page.category, + tags: page.tags, + summary: page.summary, + description: page.description, + author: page.author, + image: page.image, + imageAlt: page.imageAlt, + draft: page.draft, + pageType: page.pageType, + locale: page.locale, + originalLanguage: page.originalLanguage, + legalDocument: page.legalDocument, + extensions: ext ) } @@ -273,7 +300,9 @@ public struct BuildPipeline { draftPages: allDraftPages ) - try self.runRenderers(context: context) + // Merge in any synthetic sections (e.g. the OpenAPI blueprint's spec-derived pages) + // so the machine-index renderers enumerate them like file-backed pages. + try self.runRenderers(context: self.mergingProvidedSections(into: context)) } /// Multi-language build: discovers content per locale, builds per locale, then global assets. @@ -391,13 +420,25 @@ public struct BuildPipeline { ext["sectionSlug"] = sectionConfig.slug ext["translationMap"] = translationMap return PageModel( - id: page.id, title: page.title, date: page.date, slug: page.slug, - htmlContent: page.htmlContent, sourcePath: page.sourcePath, - category: page.category, tags: page.tags, summary: page.summary, - description: page.description, author: page.author, image: page.image, - imageAlt: page.imageAlt, draft: page.draft, pageType: page.pageType, - locale: page.locale, originalLanguage: page.originalLanguage, - legalDocument: page.legalDocument, extensions: ext + id: page.id, + title: page.title, + date: page.date, + slug: page.slug, + htmlContent: page.htmlContent, + sourcePath: page.sourcePath, + category: page.category, + tags: page.tags, + summary: page.summary, + description: page.description, + author: page.author, + image: page.image, + imageAlt: page.imageAlt, + draft: page.draft, + pageType: page.pageType, + locale: page.locale, + originalLanguage: page.originalLanguage, + legalDocument: page.legalDocument, + extensions: ext ) } @@ -429,8 +470,11 @@ public struct BuildPipeline { let staticSources = (allStaticContent[locale] ?? []) .filter { !$0.filePath.lastPathComponent.hasPrefix("home") || $0.filePath.lastPathComponent != "home.md" } .filter { !$0.filePath.lastPathComponent.hasPrefix("home.") } - let defaultStaticSources = locale == defaultLang ? [] : ((allStaticContent[defaultLang] ?? []) - .filter { !$0.filePath.lastPathComponent.hasPrefix("home") }) + let defaultStaticSources = + locale == defaultLang + ? [] + : ((allStaticContent[defaultLang] ?? []) + .filter { !$0.filePath.lastPathComponent.hasPrefix("home") }) let translatedStaticBases = Set(staticSources.map { localizedDiscovery.baseFilename(for: $0.filePath) }) let fallbackStaticSources = defaultStaticSources.filter { !translatedStaticBases.contains(localizedDiscovery.baseFilename(for: $0.filePath)) @@ -443,13 +487,25 @@ public struct BuildPipeline { var ext = page.extensions ext["translationMap"] = translationMap return PageModel( - id: page.id, title: page.title, date: page.date, slug: page.slug, - htmlContent: page.htmlContent, sourcePath: page.sourcePath, - category: page.category, tags: page.tags, summary: page.summary, - description: page.description, author: page.author, image: page.image, - imageAlt: page.imageAlt, draft: page.draft, pageType: page.pageType, - locale: page.locale, originalLanguage: page.originalLanguage, - legalDocument: page.legalDocument, extensions: ext + id: page.id, + title: page.title, + date: page.date, + slug: page.slug, + htmlContent: page.htmlContent, + sourcePath: page.sourcePath, + category: page.category, + tags: page.tags, + summary: page.summary, + description: page.description, + author: page.author, + image: page.image, + imageAlt: page.imageAlt, + draft: page.draft, + pageType: page.pageType, + locale: page.locale, + originalLanguage: page.originalLanguage, + legalDocument: page.legalDocument, + extensions: ext ) } for enricher in self.enrichers { @@ -511,7 +567,9 @@ public struct BuildPipeline { projectDirectory: self.projectDirectory ) - try self.runRenderers(context: globalContext, renderers: globalRenderers) + // Synthetic provider sections live in the global pass (where the site-wide machine + // indexes run), not duplicated per-locale – their pages are not localized. + try self.runRenderers(context: self.mergingProvidedSections(into: globalContext), renderers: globalRenderers) // Generate translation status JSON for AI agents let translationStatusGenerator = TranslationStatusRenderer( @@ -535,7 +593,7 @@ public struct BuildPipeline { let effectivePath = FileManager.default.fileExists(atPath: homePath.path) ? homePath : fallbackPath guard FileManager.default.fileExists(atPath: effectivePath.path), - let content = try? String(contentsOf: effectivePath, encoding: .utf8) + let content = try? String(contentsOf: effectivePath, encoding: .utf8) else { return nil } let source = MarkdownSource(filePath: effectivePath, content: content) @@ -567,7 +625,9 @@ public struct BuildPipeline { } } - private func loadPages(from sources: [MarkdownSource], using loader: any Loader<MarkdownSource, PageModel>, locale: String? = nil) throws -> [PageModel] { + private func loadPages(from sources: [MarkdownSource], using loader: any Loader<MarkdownSource, PageModel>, locale: String? = nil) throws + -> [PageModel] + { var pages: [PageModel] = [] for source in sources { do { diff --git a/Sources/SiteKit/Pipeline/ContentSectionProviding.swift b/Sources/SiteKit/Pipeline/ContentSectionProviding.swift new file mode 100644 index 0000000..7b5b7bc --- /dev/null +++ b/Sources/SiteKit/Pipeline/ContentSectionProviding.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Supplies a synthetic content section – pages generated by a plugin rather than loaded +/// from source files – into the build so the standard machine-index renderers enumerate +/// them like any other page. +/// +/// File-backed sections flow through Discovery → Loading into `BuildContext.sections`, and +/// every `context`-walking system renderer (`SitemapRenderer`, `NavIndexRenderer`, search +/// index, `LlmsTxtRenderer`) reads them from there. A blueprint whose pages are *generated* +/// (the OpenAPI blueprint builds them from a spec; they never touch the content directory) +/// would otherwise be invisible to those renderers. Conform a provider, register it via +/// `SiteBuilder.contentSectionProvider(_:)`, and the pipeline merges the returned section +/// into the context after file loading – one registration, and sitemap + nav-index + search +/// + llms.txt all include the pages. +/// +/// The provider receives the file-backed context (config, router, output directory), so it +/// can compute URLs and slugs the same way the page renderers do; it must not assume any +/// other provider's pages are present. Pair the provider with a `PagePathResolving` when the +/// generated pages live at paths the `URLRouter` cannot derive, so the index renderers link +/// to the URLs that actually ship. +public protocol ContentSectionProviding: Sendable { + /// Builds the synthetic section to merge into `context`, or `nil` when the provider has + /// nothing to contribute (e.g. the source spec failed to load). + func contentSection(in context: BuildContext) -> ContentSection? +} diff --git a/Sources/SiteKit/Pipeline/SiteBuilder.swift b/Sources/SiteKit/Pipeline/SiteBuilder.swift index 8d46aaf..0be12eb 100644 --- a/Sources/SiteKit/Pipeline/SiteBuilder.swift +++ b/Sources/SiteKit/Pipeline/SiteBuilder.swift @@ -29,6 +29,7 @@ public struct SiteBuilder { private var enrichers: [any Enricher] = [] private var renderers: [any Renderer] = [] private var processors: [any OutputProcessor]? + private var contentSectionProviders: [any ContentSectionProviding] = [] /// Creates a bare builder with no plugins registered – the starting point for /// fully custom pipelines. Phases left unconfigured fall back to the pipeline @@ -102,6 +103,18 @@ public struct SiteBuilder { return copy } + /// Returns a copy that registers `provider` to contribute a synthetic content section + /// (pages generated rather than loaded from files) to the build. The pipeline merges the + /// provided section into `BuildContext.sections` after file loading, so the machine-index + /// renderers (sitemap, nav-index, search, llms.txt) enumerate the generated pages. The + /// OpenAPI blueprint uses this to register its spec-derived pages once and have all four + /// indexes include them. + public func contentSectionProvider(_ provider: any ContentSectionProviding) -> SiteBuilder { + var copy = self + copy.contentSectionProviders.append(provider) + return copy + } + /// Returns a copy using `teleporter` to copy content and theme assets into /// the output directory (phase 0) instead of the default `AssetCopier`. /// Replaces the primary teleporter – use `additionalTeleporter(_:)` to add @@ -267,61 +280,66 @@ public struct SiteBuilder { additionalTeleporters: self.additionalTeleporters, enrichers: self.enrichers, renderers: self.renderers.isEmpty ? nil : self.renderers, - processors: self.processors + processors: self.processors, + contentSectionProviders: self.contentSectionProviders ) } // MARK: - Default Renderer Lists /// The standard set of renderers for a blog site. - public static var blogRenderers: [any Renderer] { [ - SectionPageRenderer(), - SectionListingRenderer(), - CategoryListingRenderer(), - TagListingRenderer(), - StaticPageRenderer(), - HomePageRenderer(), - ErrorPageRenderer(), - RSSFeedRenderer(), - SitemapRenderer(), - RobotsTxtRenderer(), - NavIndexRenderer(), - TokenCSSOutputRenderer(), - BaseCSSOutputRenderer(), - FontsFaceCSSRenderer(), - CloudflareHeadersRenderer(), - HTMLRedirectPageRenderer(), - CloudflareRedirectsRenderer(), - LanguageRedirectRenderer(), - FaviconRenderer(), - LlmsTxtRenderer(), - ContentIndexRenderer(), - DraftPreviewRenderer(), - ] } + public static var blogRenderers: [any Renderer] { + [ + SectionPageRenderer(), + SectionListingRenderer(), + CategoryListingRenderer(), + TagListingRenderer(), + StaticPageRenderer(), + HomePageRenderer(), + ErrorPageRenderer(), + RSSFeedRenderer(), + SitemapRenderer(), + RobotsTxtRenderer(), + NavIndexRenderer(), + TokenCSSOutputRenderer(), + BaseCSSOutputRenderer(), + FontsFaceCSSRenderer(), + CloudflareHeadersRenderer(), + HTMLRedirectPageRenderer(), + CloudflareRedirectsRenderer(), + LanguageRedirectRenderer(), + FaviconRenderer(), + LlmsTxtRenderer(), + ContentIndexRenderer(), + DraftPreviewRenderer(), + ] + } /// The standard set of renderers for a podcast site. - public static var podcastRenderers: [any Renderer] { [ - PodcastEpisodeRenderer(), - PodcastListingRenderer(), - PodcastHomePageRenderer(), - PodcastRSSRenderer(), - TemplateStaticPageRenderer(), - TagListingRenderer(), - ErrorPageRenderer(), - SitemapRenderer(), - RobotsTxtRenderer(), - NavIndexRenderer(), - TokenCSSOutputRenderer(), - BaseCSSOutputRenderer(), - FontsFaceCSSRenderer(), - CloudflareHeadersRenderer(), - HTMLRedirectPageRenderer(), - CloudflareRedirectsRenderer(), - FaviconRenderer(), - LlmsTxtRenderer(), - ContentIndexRenderer(), - DraftPreviewRenderer(), - ] } + public static var podcastRenderers: [any Renderer] { + [ + PodcastEpisodeRenderer(), + PodcastListingRenderer(), + PodcastHomePageRenderer(), + PodcastRSSRenderer(), + TemplateStaticPageRenderer(), + TagListingRenderer(), + ErrorPageRenderer(), + SitemapRenderer(), + RobotsTxtRenderer(), + NavIndexRenderer(), + TokenCSSOutputRenderer(), + BaseCSSOutputRenderer(), + FontsFaceCSSRenderer(), + CloudflareHeadersRenderer(), + HTMLRedirectPageRenderer(), + CloudflareRedirectsRenderer(), + FaviconRenderer(), + LlmsTxtRenderer(), + ContentIndexRenderer(), + DraftPreviewRenderer(), + ] + } /// Populates the builder with all default podcast renderers. public func defaultPodcastRenderers() -> SiteBuilder { @@ -387,7 +405,8 @@ public struct SiteBuilder { builder = builder.enricher(HreflangEnricher(config: config)) } - return builder + return + builder .renderer(StaticPageRenderer()) .renderer(HomePageRenderer()) .renderer(ErrorPageRenderer()) @@ -415,10 +434,12 @@ public struct SiteBuilder { ) -> SiteBuilder { var builder = SiteBuilder(config: config, projectDirectory: projectDirectory) .cleanBeforeBuild(cleanBeforeBuild) - .articleLoader(MarkdownLoader( - requiredFields: ["title", "date", "audioURL", "duration"], - language: config.language - )) + .articleLoader( + MarkdownLoader( + requiredFields: ["title", "date", "audioURL", "duration"], + language: config.language + ) + ) for enricher in enrichers { builder = builder.enricher(enricher) @@ -472,7 +493,8 @@ public struct SiteBuilder { builder = builder.enricher(HreflangEnricher(config: config)) } - return builder + return + builder .renderer(StaticPageRenderer()) .renderer(HomePageRenderer()) .renderer(ErrorPageRenderer()) @@ -517,7 +539,8 @@ public struct SiteBuilder { if let mapPath = config.docc?.sessionFrameworksPath { let mapURL = projectDirectory.appendingPathComponent(mapPath) if let data = try? Data(contentsOf: mapURL), - let map = try? JSONDecoder().decode([String: String].self, from: data) { + let map = try? JSONDecoder().decode([String: String].self, from: data) + { builder = builder.enricher(DocCFrameworkEnricher(map: map)) } else { // Misconfigured or missing path – skip gracefully rather than crashing the build. @@ -542,7 +565,8 @@ public struct SiteBuilder { pathResolvers.append(DocCContributorPage()) } - builder = builder + builder = + builder .renderer(DocCHomePage()) .renderer(DocCYearListingPage()) @@ -552,7 +576,8 @@ public struct SiteBuilder { } // Missing-sessions feature: the /missingnotes/ coverage page + its show-more script. if features.missingSessionsEnabled { - builder = builder + builder = + builder .renderer(DocCMissingPage()) .renderer(DocCMissingScriptRenderer()) } @@ -564,7 +589,8 @@ public struct SiteBuilder { builder = builder.renderer(DocCSearchPage()) } - builder = builder + builder = + builder .renderer(DocCArticlePage()) .renderer(DocCStylesheetRenderer()) .renderer(ErrorPageRenderer()) @@ -574,13 +600,15 @@ public struct SiteBuilder { // Search index + client scripts ship only when search is enabled. if features.searchEnabled { - builder = builder + builder = + builder .renderer(DocCSearchIndexRenderer(pathResolvers: pathResolvers)) .renderer(DocCSearchScriptRenderer()) .renderer(DocCSearchPageScriptRenderer()) } - return builder + return + builder .renderer(DocCSidebarScriptRenderer()) .renderer(DocCSidebarNavRenderer()) .renderer(DocCFilterScriptRenderer()) @@ -902,10 +930,16 @@ extension SiteBuilder { signal(SIGTERM, SIG_IGN) signal(SIGINT, SIG_IGN) let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .global()) - sigtermSource.setEventHandler { process.terminate(); exit(0) } + sigtermSource.setEventHandler { + process.terminate() + exit(0) + } sigtermSource.resume() let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .global()) - sigintSource.setEventHandler { process.terminate(); exit(0) } + sigintSource.setEventHandler { + process.terminate() + exit(0) + } sigintSource.resume() process.waitUntilExit() @@ -950,23 +984,25 @@ extension SiteBuilder { } private static func printSiteKitUsage() { - print(""" - SiteKit - Static Site Generator - - USAGE: - swift run Site <command> - - COMMANDS: - build Build the site (default) - serve Build then start a local development server - validate Check translations and other quality rules - help Show this help message - - OPTIONS: - --no-clean Skip cleaning output directory before build - --port <number> Port for serve command (default: 8080) - --base-url <url> Override the SiteConfig.yaml baseURL for this build (build/serve). - Absolute http(s) URL, e.g. https://staging.example.com - """) + print( + """ + SiteKit - Static Site Generator + + USAGE: + swift run Site <command> + + COMMANDS: + build Build the site (default) + serve Build then start a local development server + validate Check translations and other quality rules + help Show this help message + + OPTIONS: + --no-clean Skip cleaning output directory before build + --port <number> Port for serve command (default: 8080) + --base-url <url> Override the SiteConfig.yaml baseURL for this build (build/serve). + Absolute http(s) URL, e.g. https://staging.example.com + """ + ) } } diff --git a/Sources/SiteKit/Pipeline/URLRouter.swift b/Sources/SiteKit/Pipeline/URLRouter.swift index 6a4cb45..15dbf3a 100644 --- a/Sources/SiteKit/Pipeline/URLRouter.swift +++ b/Sources/SiteKit/Pipeline/URLRouter.swift @@ -200,8 +200,10 @@ public protocol PagePathResolving { extension [any PagePathResolving] { /// The first non-default resolution across the resolvers, in order; - /// `.routerDefault` when no resolver claims the page. - func pathResolution(for page: PageModel, context: BuildContext) -> PagePathResolution { + /// `.routerDefault` when no resolver claims the page. Public so blueprint-side index + /// renderers (e.g. SiteKitOpenAPI's search index) consult the same resolver chain the + /// built-in sitemap and nav-index do. + public func pathResolution(for page: PageModel, context: BuildContext) -> PagePathResolution { for resolver in self { let resolution = resolver.pathResolution(for: page, context: context) if resolution != .routerDefault { diff --git a/Sources/SiteKitOpenAPI/OpenAPIBadges.swift b/Sources/SiteKitOpenAPI/OpenAPIBadges.swift new file mode 100644 index 0000000..697fd28 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIBadges.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Shared chips for the OpenAPI pages: the HTTP method badge (the verb-colored +/// pill) and the deprecated marker. +/// +/// The method badge carries `data-method="<verb>"` (lowercased) so a later slice's +/// stylesheet can paint each verb its semantic color, the same way the DocC plugin +/// targets `data-framework`. This slice only emits the semantic markup. +enum OpenAPIBadges { + /// The verb pill, for example `<span class="sk-openapi-method" data-method="get">GET</span>`. + static func methodBadge(_ method: String) -> String { + let lower = OpenAPIHTML.escape(method.lowercased()) + let label = OpenAPIHTML.escape(method.uppercased()) + return "<span class=\"sk-openapi-method\" data-method=\"\(lower)\">\(label)</span>" + } + + /// The "Deprecated" marker, or an empty string when `isDeprecated` is false. + static func deprecatedBadge(_ isDeprecated: Bool) -> String { + isDeprecated ? "<span class=\"sk-openapi-deprecated\" data-deprecated=\"true\">Deprecated</span>" : "" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIContentRegistration.swift b/Sources/SiteKitOpenAPI/OpenAPIContentRegistration.swift new file mode 100644 index 0000000..7a38856 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIContentRegistration.swift @@ -0,0 +1,62 @@ +import Foundation +import SiteKit + +/// Registers the OpenAPI blueprint's spec-derived pages into `BuildContext.sections` so the +/// standard machine-index renderers (sitemap, nav-index, search, llms.txt) enumerate them. +/// +/// The OpenAPI page renderers generate their pages from the spec inside `pages(in:)`; those +/// pages never pass through Discovery/Loading, so without this provider they are absent from +/// `context.sections` and every `context`-walking system renderer misses them. The provider +/// returns exactly the union of the four page renderers' `pages(in:)` – the same `PageModel`s +/// that get rendered, each already stamped with its `openAPIPath` – so the indexes and the +/// rendered pages stay in lockstep. Pair it with ``OpenAPIPagePathResolver`` so those indexes +/// resolve each page to its real ``OpenAPIRoutes`` URL. +public struct OpenAPIContentProvider: ContentSectionProviding { + private let spec: OpenAPISpec + + /// Creates a provider for `spec`. + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func contentSection(in context: BuildContext) -> ContentSection? { + let pages = + OpenAPILandingPage(spec: self.spec).pages(in: context) + + OpenAPITagPage(spec: self.spec).pages(in: context) + + OpenAPIOperationPage(spec: self.spec).pages(in: context) + + OpenAPISchemaPage(spec: self.spec).pages(in: context) + + guard !pages.isEmpty else { return nil } + + // Reuse the configured API section so llms.txt / nav-index group the pages under it and + // the search index picks up its URL prefix. With no section configured there is nothing + // to attach to – warn loudly (matching the factory's spec-missing warnings) rather than + // silently dropping every API page from every machine index. + guard let sectionConfig = context.config.effectiveSections.first else { + print( + "[SiteKit] Warning: \(pages.count) OpenAPI page(s) were generated but no content section is configured, so they are omitted from the sitemap, nav-index, search index, and llms.txt. Configure at least one section in SiteConfig." + ) + return nil + } + return ContentSection(config: sectionConfig, pages: pages) + } +} + +/// Resolves every OpenAPI page to the `OpenAPIRoutes` path it actually ships at, for the +/// machine-index renderers (sitemap, nav-index, search) that otherwise trust the URL router. +/// +/// The OpenAPI pages live at nested paths (`/api/pets/showpetbyid/`, `/api/schemas/pet/`, …) +/// the default router cannot derive from slug + section, so each page stamps its canonical +/// path into the `openAPIPath` extension at creation. This resolver reads that stamp – the +/// single ``OpenAPIRoutes`` source of truth, never a recomputed path – and returns it; pages +/// without the stamp fall through to the router default. +public struct OpenAPIPagePathResolver: PagePathResolving { + public init() {} + + public func pathResolution(for page: PageModel, context: BuildContext) -> PagePathResolution { + guard let path: String = page.extensionValue("openAPIPath") else { + return .routerDefault + } + return .path(path) + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIHTML.swift b/Sources/SiteKitOpenAPI/OpenAPIHTML.swift new file mode 100644 index 0000000..8acd27d --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIHTML.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Small HTML helpers shared by the OpenAPI page renderers. +/// +/// The renderers assemble HTML by string concatenation (like the DocC plugin +/// set), so every value interpolated from the spec passes through `escape(_:)` +/// to neutralize `&`, `"`, `'`, `<`, `>`. +enum OpenAPIHTML { + /// Escapes the five characters that would otherwise break out of text or an + /// attribute value in the assembled HTML. + static func escape(_ string: String) -> String { + string + .replacing("&", with: "&") + .replacing("\"", with: """) + .replacing("'", with: "'") + .replacing("<", with: "<") + .replacing(">", with: ">") + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPILandingPage.swift b/Sources/SiteKitOpenAPI/OpenAPILandingPage.swift new file mode 100644 index 0000000..ed5b2f2 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPILandingPage.swift @@ -0,0 +1,114 @@ +import Foundation +import SiteKit + +/// The landing page of the OpenAPI docs site: the API title and description, an +/// optional `Content/api-intro.md` prose block, and a card per tag linking to that +/// tag's page. +/// +/// Mirrors `DocCHomePage`: it emits a single synthetic `PageModel` (not backed by a +/// Markdown file) and wraps its body in the `OpenAPIShell`. The cards come from +/// ``OpenAPIRoutes/tagSections(_:)`` so the landing, tag pages, and operation URLs +/// agree on which operations belong to which tag. +public struct OpenAPILandingPage: Page { + private let spec: OpenAPISpec + + /// Creates the landing renderer for `spec`. + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func pages(in context: BuildContext) -> [PageModel] { + [ + PageModel( + title: self.spec.info.title, + slug: OpenAPIRoutes.prefix(context), + htmlContent: "", + sourcePath: context.projectDirectory.appendingPathComponent("\(context.config.contentDirectory)/openapi.yaml"), + summary: self.spec.info.description, + description: self.spec.info.description, + pageType: .staticPage, + // Stash the landing path explicitly so the shell marks the landing nav item + // active by identity, not by the `?? landingPath` fallback (which a future + // page lacking the extension could otherwise trip). + extensions: ["openAPIPath": OpenAPIRoutes.landingPath(context)] + ) + ] + } + + public func renderHTML(_ page: PageModel, context: BuildContext) -> String { + let path = OpenAPIRoutes.landingPath(context) + let renderer = OutputFileRenderer(context: context) + let head = renderer.buildHead( + title: "\(self.spec.info.title) – \(context.config.name)", + description: self.spec.info.description, + canonicalURL: "\(context.config.baseURL)\(path)", + ogType: "website" + ) + + let body = + "<article class=\"sk-openapi-landing\">" + + self.headerHTML() + + self.introHTML(context: context) + + self.tagCardsHTML(context: context) + + "</article>" + + return OpenAPIShell.wrap(content: body, page: page, context: context, head: head, spec: self.spec) + } + + public func outputURL(for page: PageModel, context: BuildContext) -> URL { + OpenAPIRoutes.outputURL(for: OpenAPIRoutes.landingPath(context), context: context) + } + + // MARK: - Body sections + + /// The API title + version + description header. + private func headerHTML() -> String { + let info = self.spec.info + var header = "<header class=\"sk-openapi-landing-header\">" + header += "<h1 class=\"sk-openapi-title\">\(OpenAPIHTML.escape(info.title))</h1>" + header += "<p class=\"sk-openapi-version\">\(OpenAPIHTML.escape(info.version))</p>" + if let description = info.description, !description.isEmpty { + header += "<p class=\"sk-openapi-description\">\(OpenAPIHTML.escape(description))</p>" + } + header += "</header>" + return header + } + + /// Optional getting-started prose from `Content/api-intro.md`. Returns an empty + /// string when the file is absent, so the landing is a no-op without it. + private func introHTML(context: BuildContext) -> String { + let url = context.projectDirectory + .appendingPathComponent(context.config.contentDirectory) + .appendingPathComponent("api-intro.md") + guard let markdown = try? String(contentsOf: url, encoding: .utf8) else { return "" } + // Reuse SiteKit's Markdown loader (no required frontmatter) to render the prose + // to HTML, so api-intro.md supports the same Markdown as the rest of a SiteKit site. + let source = MarkdownSource(filePath: url, content: markdown) + guard let page = try? MarkdownLoader(requiredFields: []).load(source: source) else { return "" } + return "<section class=\"sk-openapi-intro\">\(page.htmlContent)</section>" + } + + /// A card per tag section, linking to the tag page. The endpoint count reflects + /// every operation listed under the tag (an operation is counted under each tag it + /// carries), matching what the tag page shows. + private func tagCardsHTML(context: BuildContext) -> String { + let sections = OpenAPIRoutes.tagSections(self.spec) + guard !sections.isEmpty else { return "" } + + let cards = sections.map { section -> String in + let href = OpenAPIHTML.escape(OpenAPIRoutes.tagPath(context, tagSlug: section.slug)) + let count = section.operations.count + let countLabel = count == 1 ? "1 endpoint" : "\(count) endpoints" + var card = "<a class=\"sk-openapi-tag-card\" href=\"\(href)\">" + card += "<h2 class=\"sk-openapi-tag-card-title\">\(OpenAPIHTML.escape(section.tag.name))</h2>" + if let description = section.tag.description, !description.isEmpty { + card += "<p class=\"sk-openapi-tag-card-desc\">\(OpenAPIHTML.escape(description))</p>" + } + card += "<p class=\"sk-openapi-tag-card-count\">\(countLabel)</p>" + card += "</a>" + return card + }.joined() + + return "<section class=\"sk-openapi-tag-cards\">\(cards)</section>" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPILlmsTxtRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPILlmsTxtRenderer.swift new file mode 100644 index 0000000..1d312ee --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPILlmsTxtRenderer.swift @@ -0,0 +1,82 @@ +import Foundation +import SiteKit + +/// Generates `/llms.txt` for an OpenAPI docs site, listing **every** operation and schema page +/// individually (not just a section count, as the generic `LlmsTxtRenderer` does), so an AI +/// agent can reach any endpoint or model from one curated file. +/// +/// Replaces the stock `LlmsTxtRenderer` in the `.openAPI` blueprint: an API surface is bounded +/// and its value to a machine reader is the full endpoint + schema directory, not RSS feeds +/// (which an API docs site has none of). It walks the pages ``OpenAPIContentProvider`` injected +/// into `context.sections`, using each page's stamped `openAPIPath` for the URL – the same +/// ``OpenAPIRoutes`` truth the pages ship at – and groups them into Endpoints and Schemas. +/// `.global` scope: one llms.txt at the site root. +public struct OpenAPILlmsTxtRenderer: Renderer { + public var scope: RenderScope { .global } + + public init() {} + + public func render(context: BuildContext) throws -> [OutputFile] { + let config = context.config + let baseURL = config.baseURL + + var lines: [String] = [] + lines.append("# \(config.name)") + lines.append("") + if !config.description.isEmpty { + lines.append("> \(config.description)") + lines.append("") + } + + lines.append("## Machine-Readable Indexes") + lines.append("") + lines.append("- [Sitemap](\(baseURL)/sitemap.xml): Every page URL with last-modified dates") + lines.append("- [Navigation Index](\(baseURL)/assets/nav-index.json): Structured metadata for every page") + lines.append("- [Search Index](\(baseURL)/assets/search-index.json): Full-text records per operation and schema") + lines.append("") + + let openAPIPages = context.sections.flatMap(\.pages) + .filter { ($0.extensionValue("openAPIPath") as String?) != nil } + + let operations = + openAPIPages + .filter { ($0.extensionValue("openAPIOperation") as OpenAPISpec.Operation?) != nil } + if !operations.isEmpty { + lines.append("## Endpoints") + lines.append("") + for page in operations { + let operation: OpenAPISpec.Operation? = page.extensionValue("openAPIOperation") + let url = page.extensionValue("openAPIPath") ?? "" + let label = operation.map { "\($0.method.uppercased()) \($0.path)" } ?? page.title + lines.append("- [\(label)](\(baseURL)\(url)): \(self.describe(page))") + } + lines.append("") + } + + // Schemas live under the reserved /schemas/ namespace; classify by that path segment so no + // OpenAPIKit type is needed here. + let schemas = openAPIPages.filter { page in + (page.extensionValue("openAPIPath") as String? ?? "").contains("/schemas/") + } + if !schemas.isEmpty { + lines.append("## Schemas") + lines.append("") + for page in schemas { + let url = page.extensionValue("openAPIPath") ?? "" + lines.append("- [\(page.title)](\(baseURL)\(url)): \(self.describe(page))") + } + lines.append("") + } + + let content = lines.joined(separator: "\n") + let path = context.outputDirectory.appendingPathComponent("llms.txt") + return [OutputFile(outputPath: path, content: content)] + } + + /// A one-line description for a page: its summary, else its description, else its title. + private func describe(_ page: PageModel) -> String { + if let summary = page.summary, !summary.isEmpty { return summary } + if let description = page.description, !description.isEmpty { return description } + return page.title + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIMissingPage.swift b/Sources/SiteKitOpenAPI/OpenAPIMissingPage.swift new file mode 100644 index 0000000..b092cf3 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIMissingPage.swift @@ -0,0 +1,62 @@ +import Foundation +import SiteKit + +/// Renders the site's 404 page through the full ``OpenAPIShell`` – appbar (brand → landing, +/// search, theme toggle), the nav rail, and the footer – with a "page not found" message and a +/// link back to the API landing in the content area. +/// +/// Mirrors `DocCMissingPage`: a blueprint that ships an error page in its own chrome rather than +/// the base `ErrorPageRenderer`'s footer-only page, so a reader who lands on a missing URL can +/// navigate straight back into the docs instead of hitting a dead end. Still emitted at +/// `404.html` (the Cloudflare Pages convention), so the redirect renderers are unaffected. +/// Built from ``OpenAPISpec`` only – no `import OpenAPIKit`. +public struct OpenAPIMissingPage: Page { + private let spec: OpenAPISpec + + /// Creates the 404 renderer for `spec` (the spec backs the shared nav rail). + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func pages(in context: BuildContext) -> [PageModel] { + [ + PageModel( + title: "Page not found", + slug: "404", + htmlContent: "", + sourcePath: context.projectDirectory + .appendingPathComponent(context.config.contentDirectory) + .appendingPathComponent("openapi.yaml"), + pageType: .staticPage, + // A path that matches no nav item, so the shell marks nothing active on the 404. + extensions: ["openAPIPath": "/404.html"] + ) + ] + } + + public func renderHTML(_ page: PageModel, context: BuildContext) -> String { + let landing = OpenAPIRoutes.landingPath(context) + let renderer = OutputFileRenderer(context: context) + let head = renderer.buildHead( + title: "\(page.title) – \(context.config.name)", + description: "The page you are looking for could not be found.", + canonicalURL: "\(context.config.baseURL)/404.html", + ogType: "website" + ) + + let body = + "<article class=\"sk-openapi-notfound\">" + + "<h1 class=\"sk-openapi-title\">\(OpenAPIHTML.escape(page.title))</h1>" + + "<p class=\"sk-openapi-description\">The page you are looking for does not exist or may have moved.</p>" + + "<p><a class=\"sk-openapi-notfound-home\" href=\"\(OpenAPIHTML.escape(landing))\">" + + "Back to \(OpenAPIHTML.escape(context.config.name))</a></p>" + + "</article>" + + return OpenAPIShell.wrap(content: body, page: page, context: context, head: head, spec: self.spec) + } + + /// Writes the page to `<outputDir>/404.html` (the host serves it for unmatched paths). + public func outputURL(for page: PageModel, context: BuildContext) -> URL { + context.outputDirectory.appendingPathComponent("404.html") + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPINavScriptRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPINavScriptRenderer.swift new file mode 100644 index 0000000..f280301 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPINavScriptRenderer.swift @@ -0,0 +1,34 @@ +import Foundation +import SiteKit + +/// Emits SiteKitOpenAPI's bundled nav-rail enhancement script to +/// `/assets/js/openapi-nav.js`. `OpenAPIShell` links it from every page (deferred). +/// +/// The script is progressive enhancement only: the rail is a fully navigable list +/// without JS. The script adds collapse/expand twists per group, a live filter box, +/// scrolls the active item into view, and wires the mobile drawer toggle. Mirrors +/// `DocCSidebarScriptRenderer` / `DocCFilterScriptRenderer`, adapted to the +/// `sk-openapi-*` markup. A `Renderer` with `scope: .global`, so it runs once per +/// build. +/// +/// (Classic in-page-TOC scrollspy does not apply here: the rail is a cross-page tree, +/// not an in-page heading list, so "active section" is the current page's item, which +/// the server marks `aria-current` and the script scrolls into view. An in-page TOC +/// rail with heading scrollspy would be a separate addition.) +public struct OpenAPINavScriptRenderer: Renderer { + public var scope: RenderScope { .global } + + public init() {} + + /// The public URL `OpenAPIShell` links from the page (deferred). + public static let scriptURL = "/assets/js/openapi-nav.js" + + public func render(context: BuildContext) throws -> [OutputFile] { + let js = try OpenAPIStylesheetRenderer.loadResource(named: "openapi-nav", withExtension: "js") + let path = context.outputDirectory + .appendingPathComponent("assets") + .appendingPathComponent("js") + .appendingPathComponent("openapi-nav.js") + return [OutputFile(outputPath: path, content: js)] + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPINavigationTree.swift b/Sources/SiteKitOpenAPI/OpenAPINavigationTree.swift new file mode 100644 index 0000000..ea68f01 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPINavigationTree.swift @@ -0,0 +1,103 @@ +import Foundation +import SiteKit + +/// The navigation-tree data model for the OpenAPI sidebar: an ordered list of +/// groups, each a tag (or the synthetic Schemas group) holding its navigable items. +/// +/// Built purely from ``OpenAPISpec`` (no `import OpenAPIKit`), so the rail mirrors +/// the tag pages and schema pages exactly – including the cross-listing rule, where a +/// multi-tag operation appears under every tag it carries, each entry linking to its +/// one canonical operation page. Mirrors `DocCNavigationTree` (a phase-independent +/// builder the sidebar renderer consumes), kept deliberately flat: the API graph is +/// two levels (tag → operation, plus a flat Schemas group), so no deeper nesting. +enum OpenAPINavigationTree { + /// One navigable leaf: an operation (carrying its HTTP `method`) or a schema + /// (`method` is nil). `url` is the page it links to; `isDeprecated` drives the + /// dimming hook the stylesheet targets. + struct Item: Equatable { + /// The compact nav label (operation summary / id, or schema name). + let title: String + + /// The page this item links to (an operation's canonical page, or a schema page). + let url: String + + /// The uppercased HTTP method for an operation item; nil for a schema item. + let method: String? + + /// Whether the underlying operation or schema is marked `deprecated`. + let isDeprecated: Bool + + /// Whether this entry is the operation's canonical occurrence (under its first + /// tag). A cross-listed entry on a secondary tag is non-canonical; only the + /// canonical occurrence is marked `aria-current` on the operation's own page, so + /// a page advertises exactly one current item. Always true for schema items. + let isCanonical: Bool + } + + /// One group: a tag (its header links to the tag page) or the Schemas group (which + /// has no index page, so `url` is nil and the header is a plain label). + struct Group: Equatable { + /// The group title (the tag name, or `Schemas`). + let title: String + + /// The page the group header links to, or nil for a non-navigable label. + let url: String? + + /// The group's navigable items, in document order. + let items: [Item] + } + + /// Builds the ordered group list: one group per tag (in ``OpenAPIRoutes/tagSections(_:)`` + /// order, so the rail matches the tag pages and landing cards, cross-listing + /// included), then a Schemas group listing every component schema. Returns an empty + /// list when the spec declares no operations and no schemas. + static func build(_ spec: OpenAPISpec, context: BuildContext) -> [Group] { + var groups: [Group] = OpenAPIRoutes.tagSections(spec).map { section in + let items = section.operations.map { ref in + Item( + title: Self.operationTitle(ref.operation), + url: OpenAPIRoutes.operationPath(context, tagSlug: ref.canonicalTagSlug, operationSlug: ref.slug), + method: ref.operation.method, + isDeprecated: ref.operation.deprecated, + isCanonical: ref.isCanonical + ) + } + return Group( + title: section.tag.name, + url: OpenAPIRoutes.tagPath(context, tagSlug: section.slug), + items: items + ) + } + + if !spec.schemas.isEmpty { + let items = spec.schemas.map { schema in + Item( + title: schema.name, + url: OpenAPIRoutes.schemaPath(context, schemaSlug: OpenAPIRoutes.schemaSlug(for: schema.name, in: spec)), + method: nil, + isDeprecated: schema.schema.deprecated, + isCanonical: true + ) + } + // There is no `/schemas/` index page in this design, so the group header is a + // plain label (url nil); each item still links to its own schema page. + groups.append(Group(title: "Schemas", url: nil, items: items)) + } + + return groups + } + + /// The compact nav label for an operation: its summary when present (matching the + /// operation page's H1), otherwise its `operationId`, falling back to + /// `"<method> <path>"`. A long summary is clipped to one line with an ellipsis by + /// the stylesheet, with the full text on the link's `title` tooltip. + private static func operationTitle(_ operation: OpenAPISpec.Operation) -> String { + if let summary = operation.summary, !summary.isEmpty { + return summary + } + if let operationId = operation.operationId, !operationId.isEmpty { + return operationId + } + return "\(operation.method) \(operation.path)" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIOperationPage.swift b/Sources/SiteKitOpenAPI/OpenAPIOperationPage.swift new file mode 100644 index 0000000..206f443 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIOperationPage.swift @@ -0,0 +1,186 @@ +import Foundation +import SiteKit + +/// The leaf page: one page per operation, the richest of the OpenAPI pages. It +/// renders the method badge (`data-method`), the full path, the description, a +/// parameters table, the request-body shape, the responses per status code with +/// their schemas and examples, and the security requirements. +/// +/// Mirrors `DocCArticlePage` + `DocCAPIBadges`. **Static-first** (the v1.1.0 +/// decision): request/response shapes and examples are rendered as static HTML and +/// there is no live "try-it" request widget; a clearly-marked seam shows where one +/// would mount in a future release. +public struct OpenAPIOperationPage: Page { + private let spec: OpenAPISpec + + /// Creates the operation-page renderer for `spec`. + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func pages(in context: BuildContext) -> [PageModel] { + OpenAPIRoutes.tagSections(self.spec).flatMap { section -> [PageModel] in + // One page per operation, at its canonical location only. A cross-listed + // operation appears in several tag sections but renders a single page here, + // so skip the non-canonical entries. + section.operations.filter(\.isCanonical).map { ref in + let operation = ref.operation + let path = OpenAPIRoutes.operationPath(context, tagSlug: ref.canonicalTagSlug, operationSlug: ref.slug) + return PageModel( + title: operation.summary ?? "\(operation.method) \(operation.path)", + slug: ref.slug, + htmlContent: "", + sourcePath: context.projectDirectory + .appendingPathComponent(context.config.contentDirectory) + .appendingPathComponent("openapi.yaml"), + summary: operation.summary ?? operation.description, + description: operation.description ?? operation.summary, + pageType: .staticPage, + extensions: ["openAPIOperation": operation, "openAPIPath": path] + ) + } + } + } + + public func renderHTML(_ page: PageModel, context: BuildContext) -> String { + guard let operation: OpenAPISpec.Operation = page.extensionValue("openAPIOperation") else { + return OpenAPIShell.wrap(content: "", page: page, context: context, head: self.head(page: page, context: context), spec: self.spec) + } + + let body = + "<article class=\"sk-openapi-operation\">" + + self.headerHTML(operation) + + self.parametersHTML(operation, context: context) + + self.requestBodyHTML(operation, context: context) + + self.responsesHTML(operation, context: context) + + self.securityHTML(operation) + // v1.2.0: a future renderer injects the interactive try-it widget at this seam. + + "<!-- v1.2.0: try-it widget mounts here -->" + + "</article>" + + return OpenAPIShell.wrap(content: body, page: page, context: context, head: self.head(page: page, context: context), spec: self.spec) + } + + public func outputURL(for page: PageModel, context: BuildContext) -> URL { + let path: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.landingPath(context) + return OpenAPIRoutes.outputURL(for: path, context: context) + } + + // MARK: - Body sections + + private func headerHTML(_ operation: OpenAPISpec.Operation) -> String { + var header = "<header class=\"sk-openapi-op-header\">" + header += "<div class=\"sk-openapi-op-line\">" + header += OpenAPIBadges.methodBadge(operation.method) + header += "<code class=\"sk-openapi-op-path\">\(OpenAPIHTML.escape(operation.path))</code>" + header += OpenAPIBadges.deprecatedBadge(operation.deprecated) + header += "</div>" + if let summary = operation.summary, !summary.isEmpty { + header += "<h1 class=\"sk-openapi-title\">\(OpenAPIHTML.escape(summary))</h1>" + } + if let description = operation.description, !description.isEmpty, description != operation.summary { + header += "<p class=\"sk-openapi-description\">\(OpenAPIHTML.escape(description))</p>" + } + header += "</header>" + return header + } + + private func parametersHTML(_ operation: OpenAPISpec.Operation, context: BuildContext) -> String { + guard !operation.parameters.isEmpty else { return "" } + let rows = operation.parameters.map { parameter -> String in + let required = + parameter.required + ? "<span class=\"sk-openapi-required\" data-required=\"true\">required</span>" + : "<span class=\"sk-openapi-optional\">optional</span>" + let type = + parameter.schema.map { OpenAPISchemaHTML.typeLabel($0, context: context, spec: self.spec) } + ?? "<span class=\"sk-openapi-type\">any</span>" + let description = parameter.description.map { OpenAPIHTML.escape($0) } ?? "" + return "<tr class=\"sk-openapi-param\">" + + "<td class=\"sk-openapi-param-name\"><code>\(OpenAPIHTML.escape(parameter.name))</code></td>" + + "<td class=\"sk-openapi-param-in\" data-in=\"\(OpenAPIHTML.escape(parameter.location.rawValue))\">\(OpenAPIHTML.escape(parameter.location.rawValue))</td>" + + "<td class=\"sk-openapi-param-required\">\(required)</td>" + + "<td class=\"sk-openapi-param-type\">\(type)</td>" + + "<td class=\"sk-openapi-param-desc\">\(description)</td>" + + "</tr>" + }.joined() + return "<section class=\"sk-openapi-parameters\">" + + "<h2>Parameters</h2>" + + "<table class=\"sk-openapi-param-table\">" + + "<thead><tr><th>Name</th><th>In</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>" + + "<tbody>\(rows)</tbody>" + + "</table>" + + "</section>" + } + + private func requestBodyHTML(_ operation: OpenAPISpec.Operation, context: BuildContext) -> String { + guard let body = operation.requestBody else { return "" } + let requiredMarker = body.required ? " <span class=\"sk-openapi-required\" data-required=\"true\">required</span>" : "" + let description = body.description.map { "<p class=\"sk-openapi-description\">\(OpenAPIHTML.escape($0))</p>" } ?? "" + return "<section class=\"sk-openapi-request-body\">" + + "<h2>Request body\(requiredMarker)</h2>" + + description + + self.contentHTML(body.content, context: context) + + "</section>" + } + + private func responsesHTML(_ operation: OpenAPISpec.Operation, context: BuildContext) -> String { + guard !operation.responses.isEmpty else { return "" } + let blocks = operation.responses.map { response -> String in + let description = response.description.map { "<p class=\"sk-openapi-response-desc\">\(OpenAPIHTML.escape($0))</p>" } ?? "" + return "<div class=\"sk-openapi-response\" data-status=\"\(OpenAPIHTML.escape(response.statusCode))\">" + + "<h3 class=\"sk-openapi-status\"><code>\(OpenAPIHTML.escape(response.statusCode))</code></h3>" + + description + + self.contentHTML(response.content, context: context) + + "</div>" + }.joined() + return "<section class=\"sk-openapi-responses\"><h2>Responses</h2>\(blocks)</section>" + } + + /// Renders a content list (request or response): per media type, the content type, + /// the schema shape (a property table when the schema is an inline object), and the + /// example when one is declared. + private func contentHTML(_ content: [OpenAPISpec.MediaType], context: BuildContext) -> String { + guard !content.isEmpty else { return "" } + return content.map { media -> String in + var html = "<div class=\"sk-openapi-media\" data-content-type=\"\(OpenAPIHTML.escape(media.contentType))\">" + html += "<p class=\"sk-openapi-content-type\"><code>\(OpenAPIHTML.escape(media.contentType))</code></p>" + if let schema = media.schema { + html += "<p class=\"sk-openapi-media-type\">\(OpenAPISchemaHTML.typeLabel(schema, context: context, spec: self.spec))</p>" + html += OpenAPISchemaHTML.propertyTable(schema, context: context, spec: self.spec) + } + if let example = media.example { + html += "<details class=\"sk-openapi-example\"><summary>Example</summary>" + html += "<pre class=\"sk-openapi-example-body\"><code>\(OpenAPIHTML.escape(example))</code></pre>" + html += "</details>" + } + html += "</div>" + return html + }.joined() + } + + private func securityHTML(_ operation: OpenAPISpec.Operation) -> String { + guard !operation.security.isEmpty else { return "" } + let requirements = operation.security.map { requirement -> String in + let schemes = requirement.schemes.map { scheme -> String in + let scopes = scheme.scopes.isEmpty ? "" : " (\(scheme.scopes.map { OpenAPIHTML.escape($0) }.joined(separator: ", ")))" + return "<li><code>\(OpenAPIHTML.escape(scheme.name))</code>\(scopes)</li>" + }.joined() + return "<ul class=\"sk-openapi-security-set\">\(schemes)</ul>" + }.joined() + return "<section class=\"sk-openapi-security\"><h2>Security</h2>\(requirements)</section>" + } + + private func head(page: PageModel, context: BuildContext) -> String { + let path: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.landingPath(context) + return OutputFileRenderer(context: context).buildHead( + title: "\(page.title) – \(context.config.name)", + // Per-page, never blank: the operation's summary/description, else its title, which is + // itself the summary or a unique "<METHOD> <path>" – so the meta description is always + // present and page-specific even for a bare operation. + description: page.summary ?? page.description ?? page.title, + canonicalURL: "\(context.config.baseURL)\(path)", + ogType: "website" + ) + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIRoutes.swift b/Sources/SiteKitOpenAPI/OpenAPIRoutes.swift new file mode 100644 index 0000000..635c494 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIRoutes.swift @@ -0,0 +1,268 @@ +import Foundation +import SiteKit + +/// The deep-linkable URL scheme for the OpenAPI docs site, plus the slug helpers +/// the page renderers share so paths stay consistent across landing, tag, +/// operation, and schema pages. +/// +/// Every page lives under the configured section `urlPrefix` (default `api`): +/// - Landing: `/<prefix>/` +/// - Tag page: `/<prefix>/<tag-slug>/` +/// - Operation page: `/<prefix>/<tag-slug>/<operation-slug>/` +/// - Schema page: `/<prefix>/schemas/<schema-slug>/` +/// +/// Paths are stable (they become external deep links and SEO canonicals in a +/// later slice), so the slug rules here are the single source of truth. +enum OpenAPIRoutes { + /// The cleaned section URL prefix (no leading/trailing slashes), defaulting to + /// `api` when the site declares no section. + static func prefix(_ context: BuildContext) -> String { + let raw = context.config.effectiveSections.first?.urlPrefix ?? "api" + return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } + + /// The tag an untagged operation is grouped under, so no operation is dropped. + static let defaultTag = "general" + + /// Path segments the schema pages own (`/<prefix>/schemas/<schema>/`). A tag must + /// never slug to one of these, or that tag's operation pages would collide with + /// the schema namespace; the slug allocator reserves them up front. + static let reservedTagSlugs: Set<String> = ["schemas"] + + /// Folds an arbitrary string into an ASCII URL slug `[a-z0-9-]`: accented and + /// diacritic characters map to their ASCII base via ICU (`Café` → `cafe`, `Größe` + /// → `grosse`), the result is lowercased, only `[a-z0-9]` is kept, and every other + /// run collapses to a single hyphen (so `"/pets/{petId}"` becomes `"pets-petid"`). + /// + /// URL slugs are machine identifiers for deep links and SEO canonicals, where + /// ASCII is the safe, percent-encoding-free form; display text keeps its real + /// characters (umlauts included) and only the slug folds. The fold is a clean ICU + /// character mapping, never a `ue`→`ü`-style find-replace. + static func slugify(_ string: String) -> String { + let folded = string.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX")).lowercased() + var slug = "" + var lastWasHyphen = false + for character in folded { + if character.isASCII, character.isLetter || character.isNumber { + slug.append(character) + lastWasHyphen = false + } else if !lastWasHyphen { + slug.append("-") + lastWasHyphen = true + } + } + return slug.trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } + + /// Assigns each raw name a unique slug. The base comes from ``slugify(_:)``; + /// when two names fold to the same slug, the fold is empty (an all-non-ASCII + /// name), or the base is already reserved, the slug is disambiguated + /// deterministically with a numeric suffix (`-2`, `-3`, …) and a build warning, + /// so two pages never silently overwrite each other at the same output path. + /// + /// - Parameters: + /// - rawNames: the names to slug, in order (the order fixes which name keeps + /// the bare slug and which gets suffixed). + /// - reserved: slugs that are already taken before allocation begins. + /// - kind: a noun for the warning message (`tag`, `operation`, `schema`). + static func uniqueSlugs(_ rawNames: [String], reserving reserved: Set<String> = [], kind: String) -> [String] { + var used = reserved + var result: [String] = [] + for raw in rawNames { + var base = self.slugify(raw) + if base.isEmpty { base = "section" } + var candidate = base + if used.contains(candidate) { + var suffix = 2 + while used.contains("\(base)-\(suffix)") { suffix += 1 } + candidate = "\(base)-\(suffix)" + print( + "[SiteKit] Warning: OpenAPI \(kind) slug collision – '\(raw)' folds to '\(base)', already in use; using '\(candidate)' to keep deep links unique." + ) + } + used.insert(candidate) + result.append(candidate) + } + return result + } + + /// The slug for a tag name. + static func tagSlug(_ tag: String) -> String { + self.slugify(tag) + } + + /// The tag an operation is canonically grouped under: its first declared tag, + /// or ``defaultTag`` when it has none. + static func canonicalTag(for operation: OpenAPISpec.Operation) -> String { + operation.tags.first ?? self.defaultTag + } + + /// The slug for an operation: its `operationId` when present, otherwise a + /// `<method>-<path>` slug (so every operation has a stable, unique-enough slug). + static func operationSlug(for operation: OpenAPISpec.Operation) -> String { + if let operationId = operation.operationId, !operationId.isEmpty { + return self.slugify(operationId) + } + return self.slugify("\(operation.method)-\(operation.path)") + } + + /// The collision-safe slug assignment for the spec's component schemas, keyed by + /// schema name. Component schema names are unique keys, but two distinct names can + /// still fold to the same slug (`Pet` and `pet`), so the slugs are uniqued in + /// document order. Both the schema pages and the `$ref` links resolve through this + /// one map, so a link always lands on the page it names. + static func schemaSlugMap(_ spec: OpenAPISpec) -> [String: String] { + let names = spec.schemas.map(\.name) + let slugs = self.uniqueSlugs(names, kind: "schema") + return Dictionary(uniqueKeysWithValues: zip(names, slugs)) + } + + /// The unique slug for one schema `name` within `spec`, falling back to the bare + /// fold for a name not declared in `components/schemas` (a dangling `$ref`). + static func schemaSlug(for name: String, in spec: OpenAPISpec) -> String { + self.schemaSlugMap(spec)[name] ?? self.slugify(name) + } + + /// `/<prefix>/` – the landing page. + static func landingPath(_ context: BuildContext) -> String { + "/\(self.prefix(context))/" + } + + /// `/<prefix>/<tag-slug>/` – a tag page. + static func tagPath(_ context: BuildContext, tagSlug: String) -> String { + "/\(self.prefix(context))/\(tagSlug)/" + } + + /// `/<prefix>/<tag-slug>/<operation-slug>/` – an operation page. + static func operationPath(_ context: BuildContext, tagSlug: String, operationSlug: String) -> String { + "/\(self.prefix(context))/\(tagSlug)/\(operationSlug)/" + } + + /// `/<prefix>/schemas/<schema-slug>/` – a schema page. + static func schemaPath(_ context: BuildContext, schemaSlug: String) -> String { + "/\(self.prefix(context))/schemas/\(schemaSlug)/" + } + + /// One operation as listed under a tag section: the operation itself, its unique + /// slug, and the slug of its canonical tag (the tag whose page hosts the + /// operation's one canonical URL). `isCanonical` is true when the enclosing + /// section IS that canonical tag. A cross-listed entry on a secondary tag carries + /// the same `slug`/`canonicalTagSlug`, so its link points at the one canonical + /// page rather than a duplicate. + struct OperationRef { + let operation: OpenAPISpec.Operation + let slug: String + let canonicalTagSlug: String + let isCanonical: Bool + } + + /// One tag's section: the tag (name + description), its collision-safe slug, and + /// the operations listed under it. + struct TagSection { + let tag: OpenAPISpec.Tag + let slug: String + let operations: [OperationRef] + } + + /// Groups the spec's operations into tag sections, with collision-safe tag and + /// operation slugs so no two pages resolve to the same output path. + /// + /// Each operation has one canonical tag (its first declared tag, or ``defaultTag`` + /// when untagged) under which its single page lives, but it is cross-listed in the + /// section of every tag it carries: an operation tagged `[pets, admin]` appears in + /// both the `pets` and `admin` lists, each link pointing at the one canonical page. + /// The ``OperationRef/isCanonical`` flag marks which section owns the page, so the + /// operation-page renderer emits exactly one page per operation. Tag sections + /// appear in document order: declared tags first, then any tag an operation + /// introduces, with the synthetic `general` section always last. A tag that lists + /// no operation is omitted. Tag slugs are uniqued (reserving the schema namespace) + /// and operation slugs are uniqued within their canonical tag, so the landing, tag + /// pages, and operation URLs all agree on one stable, non-colliding path per page. + static func tagSections(_ spec: OpenAPISpec) -> [TagSection] { + var descriptions: [String: String?] = [:] + for tag in spec.tags { + descriptions[tag.name] = tag.description + } + + // Tag order: declared tags first, then any tag an operation carries (canonical + // or secondary, so a tag used only as a secondary tag still gets a section), + // with the synthetic general section last. + var order: [String] = spec.tags.map(\.name) + for operation in spec.operations { + for name in self.effectiveTags(for: operation) where !order.contains(name) { + order.append(name) + } + } + if let generalIndex = order.firstIndex(of: self.defaultTag) { + order.remove(at: generalIndex) + order.append(self.defaultTag) + } + + // Collision-safe tag slugs (reserving the schema namespace) and per-canonical-tag + // operation slugs, computed once so every renderer agrees on the same paths. + let tagSlugs = self.uniqueSlugs(order, reserving: self.reservedTagSlugs, kind: "tag") + let tagSlugByName = Dictionary(uniqueKeysWithValues: zip(order, tagSlugs)) + + var operationSlugByIndex: [Int: String] = [:] + for name in order { + let indices = spec.operations.indices.filter { self.canonicalTag(for: spec.operations[$0]) == name } + let rawNames = indices.map { self.operationRawName(spec.operations[$0]) } + let slugs = self.uniqueSlugs(rawNames, kind: "operation") + for (index, slug) in zip(indices, slugs) { + operationSlugByIndex[index] = slug + } + } + + // Build each section. An operation is listed under every tag it carries + // (cross-listed), but each entry links to the operation's one canonical page + // and only the canonical entry is marked, so the operation-page renderer emits + // a single page per operation. + return order.compactMap { name in + let sectionSlug = tagSlugByName[name] ?? self.slugify(name) + let refs: [OperationRef] = spec.operations.indices.compactMap { index in + let operation = spec.operations[index] + guard self.effectiveTags(for: operation).contains(name) else { return nil } + let canonicalName = self.canonicalTag(for: operation) + let canonicalTagSlug = tagSlugByName[canonicalName] ?? self.slugify(canonicalName) + let operationSlug = operationSlugByIndex[index] ?? self.operationSlug(for: operation) + return OperationRef( + operation: operation, + slug: operationSlug, + canonicalTagSlug: canonicalTagSlug, + isCanonical: canonicalName == name + ) + } + guard !refs.isEmpty else { return nil } + let tag = OpenAPISpec.Tag(name: name, description: descriptions[name] ?? nil) + return TagSection(tag: tag, slug: sectionSlug, operations: refs) + } + } + + /// The tags an operation is listed under: the tags it declares, or ``defaultTag`` + /// when it declares none (so untagged operations land in the `general` section). + private static func effectiveTags(for operation: OpenAPISpec.Operation) -> [String] { + operation.tags.isEmpty ? [self.defaultTag] : operation.tags + } + + /// The human-meaningful raw identifier an operation slug is folded from: its + /// `operationId` when present, otherwise `"<method> <path>"`. + private static func operationRawName(_ operation: OpenAPISpec.Operation) -> String { + if let operationId = operation.operationId, !operationId.isEmpty { + return operationId + } + return "\(operation.method) \(operation.path)" + } + + /// Maps a site-relative path (`/api/pets/`) to its `index.html` file URL under + /// the build output directory. + static func outputURL(for path: String, context: BuildContext) -> URL { + var relative = path.hasPrefix("/") ? String(path.dropFirst()) : path + if relative.hasSuffix("/") { relative = String(relative.dropLast()) } + if relative.isEmpty { + return context.outputDirectory.appendingPathComponent("index.html") + } + return context.outputDirectory + .appendingPathComponent(relative) + .appendingPathComponent("index.html") + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISchema.swift b/Sources/SiteKitOpenAPI/OpenAPISchema.swift new file mode 100644 index 0000000..e400af8 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISchema.swift @@ -0,0 +1,169 @@ +import Foundation + +extension OpenAPISpec { + /// One named schema from the spec's `components/schemas` section. + /// + /// The `name` is the component key (for example `Pet`), which doubles as the + /// slug for the schema's documentation page; `schema` is the flattened, + /// OpenAPIKit-free description the renderers walk. + public struct SchemaObject: Sendable, Equatable { + /// The component key, for example `Pet` for `#/components/schemas/Pet`. + public let name: String + + /// The flattened schema description. + public let schema: SchemaNode + + /// Memberwise initializer. + public init(name: String, schema: SchemaNode) { + self.name = name + self.schema = schema + } + } + + /// A flattened, render-ready description of a JSON Schema node. + /// + /// This is deliberately decoupled from OpenAPIKit's `JSONSchema` so the page + /// renderers never need to `import OpenAPIKit`. It captures the facets a docs + /// renderer cares about (type, format, the object's properties, an array's + /// element schema, `$ref` targets, enum values, composition) and flattens the + /// rest. Recursion runs through arrays (`properties`, `items`, `composition`) + /// so the value type stays a plain `struct` without boxing. + public struct SchemaNode: Sendable, Equatable { + /// The JSON Schema `type` keyword (`object`, `array`, `string`, `integer`, + /// `number`, `boolean`, `null`), or `nil` for a reference, a composition, or + /// an untyped fragment. + public let type: String? + + /// The `format` keyword refining `type` (for example `int64`, `date-time`). + public let format: String? + + /// The schema's `title`, if any. + public let title: String? + + /// The schema's `description`, if any. + public let description: String? + + /// The names of the required properties (object schemas only). + public let required: [String] + + /// The object's properties in declaration order (object schemas only). + public let properties: [SchemaProperty] + + /// The array element schema, in a zero-or-one-element array (array schemas + /// only). An array is used instead of an optional so the value type need not + /// be `indirect`. + public let items: [SchemaNode] + + /// The allowed values of an `enum` schema, rendered to their string form. + public let enumValues: [String] + + /// The local `$ref` target name (for example `Pet` for + /// `#/components/schemas/Pet`) when this node is a reference, else `nil`. + public let referenceName: String? + + /// The `allOf` / `oneOf` / `anyOf` composition this node represents, if any. + public let composition: Composition? + + /// Whether the schema is marked `deprecated`. + public let deprecated: Bool + + /// Whether the schema is nullable (the 3.0 `nullable: true` flag, normalized + /// from a `["T", "null"]` type array by OpenAPIKit on 3.1 documents). + public let nullable: Bool + + /// Memberwise initializer. Every field defaults to its empty value so a + /// renderer can construct a partial node without restating the whole shape. + public init( + type: String? = nil, + format: String? = nil, + title: String? = nil, + description: String? = nil, + required: [String] = [], + properties: [SchemaProperty] = [], + items: [SchemaNode] = [], + enumValues: [String] = [], + referenceName: String? = nil, + composition: Composition? = nil, + deprecated: Bool = false, + nullable: Bool = false + ) { + self.type = type + self.format = format + self.title = title + self.description = description + self.required = required + self.properties = properties + self.items = items + self.enumValues = enumValues + self.referenceName = referenceName + self.composition = composition + self.deprecated = deprecated + self.nullable = nullable + } + } + + /// One property of an object schema: its name, whether it is required, and the + /// flattened schema describing its value. + public struct SchemaProperty: Sendable, Equatable { + /// The property name as it appears in the object. + public let name: String + + /// Whether the parent object lists this property in its `required` array. + public let required: Bool + + /// The flattened schema of the property's value. + public let schema: SchemaNode + + /// Memberwise initializer. + public init(name: String, required: Bool, schema: SchemaNode) { + self.name = name + self.required = required + self.schema = schema + } + } + + /// An `allOf` / `oneOf` / `anyOf` schema composition and its member schemas. + public struct Composition: Sendable, Equatable { + /// Which JSON Schema composition keyword produced this node. + public enum Kind: String, Sendable, Equatable { + /// `allOf` – the value must satisfy every member schema. + case allOf + /// `oneOf` – the value must satisfy exactly one member schema. + case oneOf + /// `anyOf` – the value must satisfy at least one member schema. + case anyOf + } + + /// The `discriminator` of a polymorphic `oneOf` / `anyOf`: the property whose + /// value selects the concrete variant, plus any explicit value→schema mapping. + public struct Discriminator: Sendable, Equatable { + /// The name of the property that selects the variant (for example `petType`). + public let propertyName: String + + /// Explicit mappings from a discriminator value to a schema name, if declared. + public let mapping: [String: String] + + /// Memberwise initializer. + public init(propertyName: String, mapping: [String: String] = [:]) { + self.propertyName = propertyName + self.mapping = mapping + } + } + + /// The composition keyword. + public let kind: Kind + + /// The member schemas being composed, in declaration order. + public let subschemas: [SchemaNode] + + /// The discriminator selecting the variant, for a polymorphic `oneOf` / `anyOf`. + public let discriminator: Discriminator? + + /// Memberwise initializer. + public init(kind: Kind, subschemas: [SchemaNode], discriminator: Discriminator? = nil) { + self.kind = kind + self.subschemas = subschemas + self.discriminator = discriminator + } + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISchemaHTML.swift b/Sources/SiteKitOpenAPI/OpenAPISchemaHTML.swift new file mode 100644 index 0000000..f507ce7 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISchemaHTML.swift @@ -0,0 +1,115 @@ +import Foundation +import SiteKit + +/// Renders ``OpenAPISpec/SchemaNode`` values to semantic HTML, shared by the +/// operation page (request/response shapes) and the schema page (full detail). +/// +/// A `$ref` renders as a link to that schema's page rather than being expanded +/// inline, so the docs stay deep-linkable and a schema is documented in one place. +/// This slice emits structure + classes only; the stylesheet is a later slice. +enum OpenAPISchemaHTML { + /// A compact inline type label: a link for a `$ref`, `array of <item>` for an + /// array, the composition keyword for a composed schema, or `type (format)` with + /// a nullable marker for a scalar/object. + /// + /// `spec` is threaded so a `$ref` resolves to the same collision-safe schema slug + /// the schema page uses, keeping every link in step with its target page. + static func typeLabel(_ node: OpenAPISpec.SchemaNode, context: BuildContext, spec: OpenAPISpec) -> String { + if let referenceName = node.referenceName { + let slug = OpenAPIRoutes.schemaSlug(for: referenceName, in: spec) + let href = OpenAPIHTML.escape(OpenAPIRoutes.schemaPath(context, schemaSlug: slug)) + return "<a class=\"sk-openapi-type-ref\" href=\"\(href)\">\(OpenAPIHTML.escape(referenceName))</a>" + } + if let composition = node.composition { + let members = composition.subschemas.map { self.typeLabel($0, context: context, spec: spec) }.joined(separator: ", ") + return "<span class=\"sk-openapi-type\" data-composition=\"\(composition.kind.rawValue)\">\(composition.kind.rawValue) (\(members))</span>" + } + if node.type == "array" { + let item = node.items.first.map { self.typeLabel($0, context: context, spec: spec) } ?? "<span class=\"sk-openapi-type\">any</span>" + return "<span class=\"sk-openapi-type\">array of \(item)</span>" + } + var label = node.type ?? "any" + if let format = node.format, !format.isEmpty { + label += " (\(format))" + } + if node.nullable { + label += " · nullable" + } + return "<span class=\"sk-openapi-type\">\(OpenAPIHTML.escape(label))</span>" + } + + /// An object's property table (name / type / required / description). Returns an + /// empty string when the node has no properties. + static func propertyTable(_ node: OpenAPISpec.SchemaNode, context: BuildContext, spec: OpenAPISpec) -> String { + guard !node.properties.isEmpty else { return "" } + let rows = node.properties.map { property -> String in + let required = + property.required + ? "<span class=\"sk-openapi-required\" data-required=\"true\">required</span>" + : "<span class=\"sk-openapi-optional\">optional</span>" + let description = property.schema.description.map { OpenAPIHTML.escape($0) } ?? "" + return "<tr class=\"sk-openapi-prop\">" + + "<td class=\"sk-openapi-prop-name\"><code>\(OpenAPIHTML.escape(property.name))</code></td>" + + "<td class=\"sk-openapi-prop-type\">\(self.typeLabel(property.schema, context: context, spec: spec))</td>" + + "<td class=\"sk-openapi-prop-required\">\(required)</td>" + + "<td class=\"sk-openapi-prop-desc\">\(description)</td>" + + "</tr>" + }.joined() + return "<table class=\"sk-openapi-props\">" + + "<thead><tr><th>Property</th><th>Type</th><th>Required</th><th>Description</th></tr></thead>" + + "<tbody>\(rows)</tbody>" + + "</table>" + } + + /// The full schema-page body for one node: type, description, nullable/deprecated + /// facets, enum values, composition (with discriminator), property table, and the + /// element type of an array. + static func detail(_ node: OpenAPISpec.SchemaNode, context: BuildContext, spec: OpenAPISpec) -> String { + var html = "<p class=\"sk-openapi-schema-type\">Type: \(self.typeLabel(node, context: context, spec: spec))</p>" + if let description = node.description, !description.isEmpty { + html += "<p class=\"sk-openapi-description\">\(OpenAPIHTML.escape(description))</p>" + } + html += self.facetsHTML(node) + + if !node.enumValues.isEmpty { + let items = node.enumValues.map { "<li><code>\(OpenAPIHTML.escape($0))</code></li>" }.joined() + html += "<section class=\"sk-openapi-enum\"><h2>Allowed values</h2><ul>\(items)</ul></section>" + } + + if let composition = node.composition { + html += self.compositionHTML(composition, context: context, spec: spec) + } + + if !node.properties.isEmpty { + html += "<section class=\"sk-openapi-schema-props\"><h2>Properties</h2>\(self.propertyTable(node, context: context, spec: spec))</section>" + } + + if node.type == "array", let item = node.items.first { + html += "<section class=\"sk-openapi-array-items\"><h2>Items</h2><p>\(self.typeLabel(item, context: context, spec: spec))</p></section>" + } + + return html + } + + /// The nullable / deprecated facet chips, or an empty string when neither applies. + static func facetsHTML(_ node: OpenAPISpec.SchemaNode) -> String { + var badges = "" + if node.nullable { + badges += "<span class=\"sk-openapi-facet\" data-facet=\"nullable\">nullable</span>" + } + badges += OpenAPIBadges.deprecatedBadge(node.deprecated) + return badges.isEmpty ? "" : "<div class=\"sk-openapi-facets\">\(badges)</div>" + } + + /// The `allOf` / `oneOf` / `anyOf` member list plus the discriminator, if any. + static func compositionHTML(_ composition: OpenAPISpec.Composition, context: BuildContext, spec: OpenAPISpec) -> String { + let members = composition.subschemas.map { "<li>\(self.typeLabel($0, context: context, spec: spec))</li>" }.joined() + var html = "<section class=\"sk-openapi-composition\" data-composition=\"\(composition.kind.rawValue)\">" + html += "<h2>\(composition.kind.rawValue)</h2><ul>\(members)</ul>" + if let discriminator = composition.discriminator { + html += "<p class=\"sk-openapi-discriminator\">Discriminator: <code>\(OpenAPIHTML.escape(discriminator.propertyName))</code></p>" + } + html += "</section>" + return html + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISchemaPage.swift b/Sources/SiteKitOpenAPI/OpenAPISchemaPage.swift new file mode 100644 index 0000000..68e396f --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISchemaPage.swift @@ -0,0 +1,71 @@ +import Foundation +import SiteKit + +/// One page per component schema: the schema name, its type, the property table +/// (name / type / required / description), any composition (allOf / oneOf / anyOf +/// with its discriminator), enum values, and the nullable / deprecated facets. +/// +/// Mirrors `DocCArticlePage`. Operation pages link here for every `$ref`, so each +/// schema is documented once and deep-linkable. Rendering is delegated to +/// ``OpenAPISchemaHTML`` so the operation and schema pages speak one schema language. +public struct OpenAPISchemaPage: Page { + private let spec: OpenAPISpec + + /// Creates the schema-page renderer for `spec`. + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func pages(in context: BuildContext) -> [PageModel] { + self.spec.schemas.map { schema in + let slug = OpenAPIRoutes.schemaSlug(for: schema.name, in: self.spec) + let path = OpenAPIRoutes.schemaPath(context, schemaSlug: slug) + return PageModel( + title: schema.name, + slug: slug, + htmlContent: "", + sourcePath: context.projectDirectory + .appendingPathComponent(context.config.contentDirectory) + .appendingPathComponent("openapi.yaml"), + summary: schema.schema.description, + description: schema.schema.description, + pageType: .staticPage, + extensions: ["openAPISchema": schema, "openAPIPath": path] + ) + } + } + + public func renderHTML(_ page: PageModel, context: BuildContext) -> String { + guard let schema: OpenAPISpec.SchemaObject = page.extensionValue("openAPISchema") else { + return OpenAPIShell.wrap(content: "", page: page, context: context, head: self.head(page: page, context: context), spec: self.spec) + } + + let body = + "<article class=\"sk-openapi-schema\" data-schema=\"\(OpenAPIHTML.escape(schema.name))\">" + + "<header class=\"sk-openapi-schema-header\"><h1 class=\"sk-openapi-title\"><code>\(OpenAPIHTML.escape(schema.name))</code></h1></header>" + + OpenAPISchemaHTML.detail(schema.schema, context: context, spec: self.spec) + + "</article>" + + return OpenAPIShell.wrap(content: body, page: page, context: context, head: self.head(page: page, context: context), spec: self.spec) + } + + public func outputURL(for page: PageModel, context: BuildContext) -> URL { + let path: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.schemaPath(context, schemaSlug: page.slug) + return OpenAPIRoutes.outputURL(for: path, context: context) + } + + private func head(page: PageModel, context: BuildContext) -> String { + let path: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.schemaPath(context, schemaSlug: page.slug) + return OutputFileRenderer(context: context).buildHead( + title: "\(page.title) – \(context.config.name)", + // Per-page, never blank: the schema's own description, else a meaningful + // "<Name> schema" so the meta description is unique even when the spec omits one. + // (The operation page falls back through its title as a last resort; a schema's + // title is just its name, so the explicit "<Name> schema" reads better here than a + // bare title would.) + description: page.summary ?? "The \(page.title) schema.", + canonicalURL: "\(context.config.baseURL)\(path)", + ogType: "website" + ) + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISearchIndexRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPISearchIndexRenderer.swift new file mode 100644 index 0000000..bc6df7f --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISearchIndexRenderer.swift @@ -0,0 +1,88 @@ +import Foundation +import SiteKit + +/// Emits the OpenAPI full-text search index to `/assets/search-index.json`: one record per +/// generated page (landing, tags, operations, schemas), each with its title, URL, a short +/// summary, and facets (kind, plus HTTP method / tag for operations). +/// +/// The OpenAPI pages reach `context.sections` via ``OpenAPIContentProvider``; this renderer +/// walks them and keeps only those carrying an `openAPIPath` (the OpenAPI pages), so it stays +/// scoped to the API surface even if a host site mixes in other sections. URLs come from the +/// same ``OpenAPIRoutes`` stamp the page renderers use (via the shared ``OpenAPIPagePathResolver``), +/// never a recomputed path. The bundled `openapi-search.js` (``OpenAPISearchScriptRenderer``) +/// fetches this file to power the appbar search box. A `.global` renderer – one index per build. +/// +/// Unlike `DocCSearchIndexRenderer` (sharded, session-note-scoped), this is a single small +/// JSON array: an API surface is bounded (operations + schemas), so one file fetched once is +/// simpler than shard manifests, and it is OpenAPIKit-free. +public struct OpenAPISearchIndexRenderer: Renderer { + public var scope: RenderScope { .global } + + /// The public URL the search script fetches. + public static let indexURL = "/assets/search-index.json" + + /// Path authorities consulted per page (mirroring sitemap + nav index): a page the + /// resolvers mark `.unpublished` is omitted so a result never links to a 404. + let pathResolvers: [any PagePathResolving] + + public init(pathResolvers: [any PagePathResolving] = []) { + self.pathResolvers = pathResolvers + } + + /// One search record. `summary`, `method`, and `tag` are omitted from the JSON when absent. + struct Record: Codable, Equatable { + let title: String + let url: String + let kind: String + var summary: String? + var method: String? + var tag: String? + } + + public func render(context: BuildContext) throws -> [OutputFile] { + var records: [Record] = [] + for page in context.sections.flatMap(\.pages) { + guard let url: String = page.extensionValue("openAPIPath") else { continue } + if case .unpublished = self.pathResolvers.pathResolution(for: page, context: context) { + continue + } + + var record = Record(title: page.title, url: url, kind: Self.kind(of: page, url: url, context: context)) + if let summary = (page.summary ?? page.description).flatMap({ $0.isEmpty ? nil : $0 }) { + record.summary = summary + } + if let operation: OpenAPISpec.Operation = page.extensionValue("openAPIOperation") { + record.method = operation.method.uppercased() + record.tag = operation.tags.first + } + records.append(record) + } + + guard !records.isEmpty else { return [] } + + let encoder = JSONEncoder() + // Keep the URLs readable (`/api/pets/…`, not `\/api\/pets\/`) – any JSON parser accepts + // both, but the unescaped form is friendlier for an AI agent eyeballing the file. + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(records) + let path = context.outputDirectory + .appendingPathComponent("assets") + .appendingPathComponent("search-index.json") + return [OutputFile(outputPath: path, content: String(decoding: data, as: UTF8.self))] + } + + /// Classifies a page from its markers and URL (operation by its stamped operation, schema / + /// landing by URL shape, tag otherwise) – no OpenAPIKit and no spec re-walk needed. + private static func kind(of page: PageModel, url: String, context: BuildContext) -> String { + if page.extensionValue("openAPIOperation") as OpenAPISpec.Operation? != nil { + return "operation" + } + if url == OpenAPIRoutes.landingPath(context) { + return "landing" + } + if url.contains("/schemas/") { + return "schema" + } + return "tag" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISearchScriptRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPISearchScriptRenderer.swift new file mode 100644 index 0000000..dcb50fc --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISearchScriptRenderer.swift @@ -0,0 +1,30 @@ +import Foundation +import SiteKit + +/// Emits SiteKitOpenAPI's bundled appbar-search script to `/assets/js/openapi-search.js`. +/// `OpenAPIShell` links it (deferred) from every page. +/// +/// Progressive enhancement, mirroring `DocCSearchScriptRenderer`: the search box only does +/// anything with JavaScript, so the stylesheet keeps it hidden until `openapi-nav.js` adds the +/// `js` class. On first focus the script lazily fetches `/assets/search-index.json` +/// (``OpenAPISearchIndexRenderer``) and filters it client-side by title / summary / path, +/// rendering a results list that links to the matching pages. This is full-text site search, +/// distinct from the nav *filter* (which only hides non-matching rows already in the rail). +/// A `.global` renderer – emitted once per build. +public struct OpenAPISearchScriptRenderer: Renderer { + public var scope: RenderScope { .global } + + public init() {} + + /// The public URL `OpenAPIShell` links from the page (deferred). + public static let scriptURL = "/assets/js/openapi-search.js" + + public func render(context: BuildContext) throws -> [OutputFile] { + let js = try OpenAPIStylesheetRenderer.loadResource(named: "openapi-search", withExtension: "js") + let path = context.outputDirectory + .appendingPathComponent("assets") + .appendingPathComponent("js") + .appendingPathComponent("openapi-search.js") + return [OutputFile(outputPath: path, content: js)] + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIShell.swift b/Sources/SiteKitOpenAPI/OpenAPIShell.swift new file mode 100644 index 0000000..3b1ef10 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIShell.swift @@ -0,0 +1,161 @@ +import Foundation +import SiteKit + +/// The shared OpenAPI app-shell: the chrome every OpenAPI page renderer wraps its +/// body in, so the whole API-docs site presents one consistent docs layout. +/// +/// Mirrors `DocCShell`: it brings its own appbar and a persistent sidebar nav rail, +/// so it wraps through `PageShell.wrap(... chrome: .appShell)`, which suppresses the +/// generic site `<header>`/`<footer>` that would otherwise double up. Color, fonts, +/// and accents come from the theme tokens, so the shell inherits the active scheme +/// without hard-coded brand values. The stylesheet that targets these `sk-openapi-*` +/// classes (and the `data-method` verb colors and the sidebar collapse/expand +/// script) is a later slice; this slice emits the semantic structure those rules and +/// that script will target. +/// +/// Structure: +/// ``` +/// div.sk-openapi-layout +/// header.sk-openapi-appbar ← brand (links to the landing page) +/// div.sk-openapi-body ← flex row: [sidebar nav rail] | content +/// nav.sk-openapi-nav ← landing + tag/operation/schema tree (active item marked) +/// main.sk-openapi-scroll +/// div.sk-openapi-page +/// {content} ← the caller's page body +/// ``` +enum OpenAPIShell { + /// Assembles the app-shell (appbar + sidebar nav rail + content) around `content` + /// and returns the complete HTML page. + /// + /// - Parameters: + /// - content: The page-specific body HTML. + /// - page: The synthetic `PageModel` for this page, threaded to `PageShell`. The + /// page identifies the active nav item: every OpenAPI page stashes its + /// canonical path in the `openAPIPath` extension, and the landing page (the only + /// one without it) falls back to the landing path. + /// - context: The build context (config, theme, output paths). + /// - head: The fully-built `<head>` content (each renderer builds its own via + /// `OutputFileRenderer.buildHead(...)` so canonical/OG carry the page's real URL). + /// - spec: The loaded spec, from which the shared nav rail is built so every page + /// shows the same tree with this page marked active. + static func wrap(content: String, page: PageModel, context: BuildContext, head: String, spec: OpenAPISpec) -> String { + let currentPath: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.landingPath(context) + let nav = OpenAPISidebarRenderer.render(spec: spec, context: context, currentPath: currentPath) + + // Link the component stylesheet after the caller's critical head (so it never blocks + // first paint) and defer the enhancement scripts (progressive enhancement: the rail, + // search, and theme toggle all work as plain HTML without them). The inline theme-init + // runs before first paint so an OS-dark reader never sees a light flash. All are emitted + // once per build by their `.global` renderers. + let headWithAssets = + head + + self.themeInitScript(context: context) + + "<link rel=\"stylesheet\" href=\"\(OpenAPIStylesheetRenderer.cssURL)\"/>" + + "<script defer src=\"\(OpenAPINavScriptRenderer.scriptURL)\"></script>" + + "<script defer src=\"\(OpenAPISearchScriptRenderer.scriptURL)\"></script>" + + "<script defer src=\"\(OpenAPIThemeScriptRenderer.scriptURL)\"></script>" + + let shell = + "<div class=\"sk-openapi-layout\">" + + self.appbar(context: context) + + "<div class=\"sk-openapi-body\">" + + nav + // Off-canvas backdrop (mobile drawer): shown behind the rail when open, click/tap or + // Escape closes it. Gated behind html.js by the stylesheet (cut-the-mustard). + + "<div class=\"sk-openapi-scrim\" data-openapi-nav-scrim hidden></div>" + + "<main class=\"sk-openapi-scroll\">" + + "<div class=\"sk-openapi-page\">" + + content + + "</div>" + + self.footerHTML(context: context) + + "</main>" + + "</div>" + + "</div>" + + return PageShell.wrap( + content: shell, + page: page, + context: context, + head: headWithAssets, + bodyClass: "sk-openapi-shell-body", + chrome: .appShell + ) + } + + /// The appbar: the API name as a brand wordmark linking back to the landing page, plus a + /// full-text search box. The search box is only useful with JavaScript (it queries + /// `/assets/search-index.json`), so the stylesheet reveals it behind `html.js` – a JS-off + /// reader never sees a dead control. + static func appbar(context: BuildContext) -> String { + let homeURL = OpenAPIHTML.escape(OpenAPIRoutes.landingPath(context)) + let name = OpenAPIHTML.escape(context.config.name) + return "<header class=\"sk-openapi-appbar\">" + + "<a class=\"sk-openapi-brand\" href=\"\(homeURL)\">\(name)</a>" + + "<div class=\"sk-openapi-search\">" + + "<input type=\"search\" class=\"sk-openapi-search-input\" data-openapi-search" + + " placeholder=\"Search the API…\" aria-label=\"Search the API\" autocomplete=\"off\"" + + " role=\"combobox\" aria-expanded=\"false\" aria-controls=\"sk-openapi-search-results\"/>" + + "<div class=\"sk-openapi-search-results\" id=\"sk-openapi-search-results\" role=\"listbox\" hidden></div>" + + "</div>" + + Self.themeToggleHTML + + "</header>" + } + + /// The appearance toggle button, consistent with the base SiteKit (DocC) toggle: it flips + /// the effective theme (light ↔ dark) and persists the choice under the shared + /// `localStorage "theme"` key, the same contract `openapi-theme.js` and the head-init read – + /// so a reader's choice carries across every SiteKit surface on the site. The static moon + /// glyph is the default; `openapi-theme.js` swaps it to a sun while dark. Always rendered, + /// inert without JS (matching the base DocC toggle). + private static let themeToggleHTML = + "<button type=\"button\" class=\"sk-openapi-theme-toggle\" data-openapi-theme-toggle aria-label=\"Toggle light or dark appearance\">" + + "<svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"" + + " stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">" + + "<path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>" + + "</button>" + + /// The flash-free theme-init, emitted inline in `<head>` so the right `data-theme` is set + /// before first paint. Reads the shared `localStorage "theme"` key, falling back to the OS + /// `prefers-color-scheme` – the same default-follow-OS behaviour the base SiteKit sites get + /// from their theme `headInlineScript`. Skipped when the site's `theme.yaml` already provides + /// a `headInlineScript` (PageShell injects that), so an author override is never doubled. + private static func themeInitScript(context: BuildContext) -> String { + guard context.themeConfig?.headInlineScript == nil else { return "" } + return "<script>(function(){try{var t=localStorage.getItem('theme');" + + "if(t!=='light'&&t!=='dark'){t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';}" + + "document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>" + } + + /// The shared footer, rendered from `SiteConfig.footer` (the standard footer config every + /// SiteKit site uses): a row of footer links followed by a copyright line, token-styled and + /// scrolling with the content like the DocC footer. Returns an empty string when nothing is + /// configured, so no empty `<footer>` pollutes the DOM. + static func footerHTML(context: BuildContext) -> String { + guard let footer = context.config.footer else { return "" } + let links = footer.links ?? [] + let copyright = Self.copyrightLine(footer: footer, siteName: context.config.name) + guard !links.isEmpty || copyright != nil else { return "" } + + var inner = "" + if !links.isEmpty { + let items = links.map { link in + "<a class=\"sk-openapi-footer-link\" href=\"\(OpenAPIHTML.escape(link.url))\">\(OpenAPIHTML.escape(link.title))</a>" + }.joined() + inner += "<nav class=\"sk-openapi-footer-links\" aria-label=\"Footer\">\(items)</nav>" + } + if let copyright { + inner += "<p class=\"sk-openapi-footer-copyright\">\(OpenAPIHTML.escape(copyright))</p>" + } + return "<footer class=\"sk-openapi-footer\">\(inner)</footer>" + } + + /// The copyright line: the explicit `copyright` string when set, else `© <name>` from + /// `copyrightName` (falling back to the site name). Returns nil when neither yields text. + private static func copyrightLine(footer: FooterConfig, siteName: String) -> String? { + if let copyright = footer.copyright, !copyright.isEmpty { + return copyright + } + let name = footer.copyrightName ?? siteName + return name.isEmpty ? nil : "© \(name)" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISidebarRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPISidebarRenderer.swift new file mode 100644 index 0000000..f0e59c7 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISidebarRenderer.swift @@ -0,0 +1,107 @@ +import Foundation +import SiteKit + +/// Emits the persistent left-rail navigation tree shared by every OpenAPI page: a +/// landing link at the top, one group per tag (header → tag page) listing that tag's +/// operations (method badge + name → operation page), and a Schemas group listing +/// every schema. Mirrors `DocCSidebarRenderer`: it takes the current page's path and +/// marks the matching item `aria-current="page"` with an `is-active` class. +/// +/// Semantic HTML only this slice – the stylesheet and the collapse/expand script are +/// a later slice (the DocC parallel is `DocCSidebarScriptRenderer`; no `<script>` is +/// emitted here). The `sk-openapi-nav-*` classes plus the `data-method` / +/// `data-deprecated` hooks are exactly what that stylesheet and script will target. +/// +/// Built from ``OpenAPISpec`` only (no `import OpenAPIKit`), so the rail stays in step +/// with the tag and schema pages, cross-listing included (a multi-tag operation +/// appears under every tag it carries, each linking to its one canonical page). +enum OpenAPISidebarRenderer { + /// Renders the `<nav>` rail for `spec`, marking the item whose URL equals + /// `currentPath` as the active page. + static func render(spec: OpenAPISpec, context: BuildContext, currentPath: String) -> String { + let groups = OpenAPINavigationTree.build(spec, context: context) + + var html = "<nav class=\"sk-openapi-nav\" aria-label=\"API navigation\">" + html += Self.homeLinkHTML(spec: spec, context: context, currentPath: currentPath) + if !groups.isEmpty { + html += "<ul class=\"sk-openapi-nav-groups\">" + html += groups.map { Self.groupHTML($0, currentPath: currentPath) }.joined() + html += "</ul>" + } + html += "</nav>" + return html + } + + /// The top-of-rail link back to the landing page, marked active on the landing page. + private static func homeLinkHTML(spec: OpenAPISpec, context: BuildContext, currentPath: String) -> String { + let url = OpenAPIRoutes.landingPath(context) + let active = url == currentPath + return "<a class=\"sk-openapi-nav-home\(active ? " is-active" : "")\" href=\"\(OpenAPIHTML.escape(url))\"\(Self.currentAttribute(active))>" + + OpenAPIHTML.escape(spec.info.title) + + "</a>" + } + + /// One group: its header (a tag-page link, or a plain label for Schemas) plus the + /// nested list of its items. + private static func groupHTML(_ group: OpenAPINavigationTree.Group, currentPath: String) -> String { + var html = "<li class=\"sk-openapi-nav-group\">" + html += Self.groupHeaderHTML(group, currentPath: currentPath) + if !group.items.isEmpty { + html += "<ul class=\"sk-openapi-nav-items\">" + html += group.items.map { Self.itemHTML($0, currentPath: currentPath) }.joined() + html += "</ul>" + } + html += "</li>" + return html + } + + private static func groupHeaderHTML(_ group: OpenAPINavigationTree.Group, currentPath: String) -> String { + let title = OpenAPIHTML.escape(group.title) + let titleElement: String + if let url = group.url { + let active = url == currentPath + titleElement = + "<a class=\"sk-openapi-nav-group-title\(active ? " is-active" : "")\" href=\"\(OpenAPIHTML.escape(url))\"\(Self.currentAttribute(active))>\(title)</a>" + } else { + // A group with no index page (Schemas) renders a plain, non-link label. + titleElement = "<span class=\"sk-openapi-nav-group-title\">\(title)</span>" + } + // The title sits in a header row so the JS-injected collapse twist can be a sibling + // of the link, not a <button> nested inside the <a> (that would be an interactive + // inside an interactive – invalid HTML). + return "<div class=\"sk-openapi-nav-group-header\">\(titleElement)</div>" + } + + /// One leaf item: the method badge (operations only) plus the label, linking to the + /// item's page. The `<li>` carries `data-method` and the deprecated hook so the + /// stylesheet can color the verb and dim a deprecated row. + private static func itemHTML(_ item: OpenAPINavigationTree.Item, currentPath: String) -> String { + let active = item.url == currentPath + // Highlight every occurrence at the current path, but emit aria-current only on + // the canonical occurrence, so a cross-listed operation marks exactly one current + // item per page (a11y) while both occurrences still read as active. + let current = active && item.isCanonical + var attributes = "" + if let method = item.method { + attributes += " data-method=\"\(OpenAPIHTML.escape(method.lowercased()))\"" + } + if item.isDeprecated { + attributes += " data-deprecated=\"true\"" + } + let badge = item.method.map { OpenAPIBadges.methodBadge($0) } ?? "" + // The full label is on the link's title attribute so a summary clipped to one line + // (text-overflow: ellipsis) is still readable on hover. + let label = OpenAPIHTML.escape(item.title) + return "<li class=\"sk-openapi-nav-item\"\(attributes)>" + + "<a class=\"sk-openapi-nav-link\(active ? " is-active" : "")\" href=\"\(OpenAPIHTML.escape(item.url))\" title=\"\(label)\"\(Self.currentAttribute(current))>" + + badge + + "<span class=\"sk-openapi-nav-label\">\(label)</span>" + + "</a>" + + "</li>" + } + + /// The `aria-current="page"` attribute (with a leading space) when active, else empty. + private static func currentAttribute(_ active: Bool) -> String { + active ? " aria-current=\"page\"" : "" + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISpec.swift b/Sources/SiteKitOpenAPI/OpenAPISpec.swift new file mode 100644 index 0000000..1f6a22f --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISpec.swift @@ -0,0 +1,329 @@ +import Foundation + +/// A flattened, render-ready view of an OpenAPI document. +/// +/// `OpenAPISpecLoader` decodes an OpenAPI 3.0 or 3.1 file (YAML or JSON), +/// normalizes 3.0 documents to the 3.1 shape, and projects the result into this +/// value model. The model is intentionally decoupled from OpenAPIKit so the page +/// renderers (landing, tag, operation, schema) read only SiteKit-owned types and +/// never `import OpenAPIKit`. This is the contract every OpenAPI renderer builds on. +/// +/// Every type in the model is nested under `OpenAPISpec` (for example +/// ``OpenAPISpec/Operation`` and ``OpenAPISpec/SchemaNode``). The namespace keeps +/// the surface predictable and, in particular, keeps ``OpenAPISpec/Operation`` +/// from shadowing `Foundation.Operation` for a consumer that imports both. +public struct OpenAPISpec: Sendable, Equatable { + /// The document's `info` block: title, version, description. + public let info: Info + + /// The declared servers, in document order. + public let servers: [Server] + + /// The declared tags, in document order. Operations reference these by name. + public let tags: [Tag] + + /// Every operation across all paths, flattened to one list (method + path + + /// the operation's metadata), in document order. + public let operations: [Operation] + + /// The reusable schemas from `components/schemas`, in document order. + public let schemas: [SchemaObject] + + /// Memberwise initializer. + public init( + info: Info, + servers: [Server], + tags: [Tag], + operations: [Operation], + schemas: [SchemaObject] + ) { + self.info = info + self.servers = servers + self.tags = tags + self.operations = operations + self.schemas = schemas + } + + /// The document's `info` block. + public struct Info: Sendable, Equatable { + /// The API title, shown as the site/landing heading. + public let title: String + + /// The API version string (for example `1.0.0`). + public let version: String + + /// The API's short summary, if provided (OpenAPI 3.1 only). + public let summary: String? + + /// The API's longer description (Markdown), if provided. + public let description: String? + + /// Memberwise initializer. + public init(title: String, version: String, summary: String? = nil, description: String? = nil) { + self.title = title + self.version = version + self.summary = summary + self.description = description + } + } + + /// One server entry from the document's `servers` list. + public struct Server: Sendable, Equatable { + /// The server URL, with any `{variable}` templates left intact. + public let url: String + + /// The server's description, if provided. + public let description: String? + + /// Memberwise initializer. + public init(url: String, description: String? = nil) { + self.url = url + self.description = description + } + } + + /// One tag entry from the document's `tags` list. Operations group under a + /// tag's `name`. + public struct Tag: Sendable, Equatable { + /// The tag name, used to group operations and as the tag page slug. + public let name: String + + /// The tag's description (Markdown), if provided. + public let description: String? + + /// Memberwise initializer. + public init(name: String, description: String? = nil) { + self.name = name + self.description = description + } + } +} + +extension OpenAPISpec { + /// One operation: an HTTP method on a path plus its documented metadata. + public struct Operation: Sendable, Equatable { + /// The uppercased HTTP method (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, …). + public let method: String + + /// The templated path the operation lives under (for example `/pets/{id}`). + public let path: String + + /// The operation's stable `operationId`, if provided. + public let operationId: String? + + /// The operation's short summary, if provided. + public let summary: String? + + /// The operation's longer description (Markdown), if provided. + public let description: String? + + /// The tags this operation belongs to, in document order. + public let tags: [String] + + /// The operation's parameters (path, query, header, cookie), in document order. + public let parameters: [Parameter] + + /// The operation's request body, if it declares one. + public let requestBody: RequestBody? + + /// The operation's responses keyed by status, in document order. + public let responses: [Response] + + /// The operation's security requirements (an OR of requirement sets, each an + /// AND of named schemes), in document order. + public let security: [SecurityRequirement] + + /// Whether the operation is marked `deprecated`. + public let deprecated: Bool + + /// Memberwise initializer. + public init( + method: String, + path: String, + operationId: String? = nil, + summary: String? = nil, + description: String? = nil, + tags: [String] = [], + parameters: [Parameter] = [], + requestBody: RequestBody? = nil, + responses: [Response] = [], + security: [SecurityRequirement] = [], + deprecated: Bool = false + ) { + self.method = method + self.path = path + self.operationId = operationId + self.summary = summary + self.description = description + self.tags = tags + self.parameters = parameters + self.requestBody = requestBody + self.responses = responses + self.security = security + self.deprecated = deprecated + } + } + + /// One operation parameter (path, query, header, or cookie). + public struct Parameter: Sendable, Equatable { + /// Where a parameter is carried in the request. + public enum Location: Sendable, Equatable { + /// A query-string parameter (`?name=…`). + case query + /// A path parameter (a `{name}` segment). + case path + /// A header parameter. + case header + /// A cookie parameter. + case cookie + /// Any other (forward-compatible) location, carrying its raw spec value. + case other(String) + + /// The lowercase spec string for this location (`query`, `path`, …). + public var rawValue: String { + switch self { + case .query: "query" + case .path: "path" + case .header: "header" + case .cookie: "cookie" + case .other(let value): value + } + } + + /// Maps a raw OpenAPI location string to a `Location`, preserving unknown + /// values via `.other` rather than dropping them. + public init(rawValue: String) { + switch rawValue { + case "query": self = .query + case "path": self = .path + case "header": self = .header + case "cookie": self = .cookie + default: self = .other(rawValue) + } + } + } + + /// The parameter name. + public let name: String + + /// Where the parameter is carried. + public let location: Location + + /// The parameter's description (Markdown), if provided. + public let description: String? + + /// Whether the parameter is required. Path parameters are always required. + public let required: Bool + + /// Whether the parameter is marked `deprecated`. + public let deprecated: Bool + + /// The flattened schema describing the parameter's value, if it declares one. + public let schema: SchemaNode? + + /// Memberwise initializer. + public init( + name: String, + location: Location, + description: String? = nil, + required: Bool = false, + deprecated: Bool = false, + schema: SchemaNode? = nil + ) { + self.name = name + self.location = location + self.description = description + self.required = required + self.deprecated = deprecated + self.schema = schema + } + } + + /// An operation's request body. + public struct RequestBody: Sendable, Equatable { + /// The request body's description (Markdown), if provided. + public let description: String? + + /// Whether the request body is required. + public let required: Bool + + /// The body's representations keyed by media type, in document order. + public let content: [MediaType] + + /// Memberwise initializer. + public init(description: String? = nil, required: Bool = false, content: [MediaType] = []) { + self.description = description + self.required = required + self.content = content + } + } + + /// One media-type representation of a request or response body (for example + /// `application/json`) and its flattened schema. + public struct MediaType: Sendable, Equatable { + /// The media type string (for example `application/json`). + public let contentType: String + + /// The flattened schema describing this representation, if it declares one. + public let schema: SchemaNode? + + /// A representative example for this media type, pretty-printed as JSON, if + /// the spec declares an `example` or `examples` for it. Static-first: the + /// operation page renders this verbatim; there is no live request widget. + public let example: String? + + /// Memberwise initializer. + public init(contentType: String, schema: SchemaNode? = nil, example: String? = nil) { + self.contentType = contentType + self.schema = schema + self.example = example + } + } + + /// One response of an operation, keyed by status. + public struct Response: Sendable, Equatable { + /// The status key as written in the spec (for example `200`, `404`, + /// `default`, or a `2XX` range). + public let statusCode: String + + /// The response's description, if provided. + public let description: String? + + /// The response body's representations keyed by media type, in document order. + public let content: [MediaType] + + /// Memberwise initializer. + public init(statusCode: String, description: String? = nil, content: [MediaType] = []) { + self.statusCode = statusCode + self.description = description + self.content = content + } + } + + /// One security requirement: a set of named schemes that must ALL be satisfied + /// together. An operation's `security` list is the OR of these requirements. + public struct SecurityRequirement: Sendable, Equatable { + /// One named scheme reference inside a requirement, with its required scopes. + public struct SchemeRequirement: Sendable, Equatable { + /// The referenced `securityScheme` name from `components/securitySchemes`. + public let name: String + + /// The OAuth2 / OpenID-Connect scopes required, if any. + public let scopes: [String] + + /// Memberwise initializer. + public init(name: String, scopes: [String]) { + self.name = name + self.scopes = scopes + } + } + + /// The schemes that must all be satisfied for this requirement. + public let schemes: [SchemeRequirement] + + /// Memberwise initializer. + public init(schemes: [SchemeRequirement]) { + self.schemes = schemes + } + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift b/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift new file mode 100644 index 0000000..6c3d348 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPISpecLoader.swift @@ -0,0 +1,550 @@ +import Foundation +// The target declares only the `OpenAPIKitCompat` product, but `OpenAPIKitCompat` re-exports +// `OpenAPIKit` (3.1), `OpenAPIKit30` (3.0), and `OpenAPIKitCore`, so all three resolve transitively +// and are imported explicitly here to name their types directly (the 3.0 document, the 3.1 document, +// and the shared `Either` / `AnyCodable` / `JSONSchema` types) and to make the dependency intent legible. +import OpenAPIKit +import OpenAPIKit30 +import OpenAPIKitCompat +import OpenAPIKitCore +import SiteKit +import Yams + +// Both OpenAPIKit (3.1) and OpenAPIKit30 export `JSONSchema`, and both re-export `Either` / +// `AnyCodable` from OpenAPIKitCore, so the bare names are ambiguous while both modules are imported +// (OpenAPIKit30 is needed only to decode legacy 3.0 documents before converting them). The loader +// normalizes every document to the 3.1 model up front, so the mapping below speaks only the 3.1 +// types: these file-private aliases pin the bare names to the right module. +private typealias JSONSchema = OpenAPIKit.JSONSchema +private typealias Either<A, B> = OpenAPIKitCore.Either<A, B> +private typealias AnyCodable = OpenAPIKitCore.AnyCodable + +/// Loads an OpenAPI document from a file URL into the flattened ``OpenAPISpec``. +/// +/// The loader handles the full 2×2 input matrix on its own: +/// - **Format** is auto-detected by file extension: `.json` decodes with +/// `JSONDecoder`, `.yaml`/`.yml` (and anything else) decode with Yams. +/// - **Version** is auto-detected from the document's `openapi:` field: 3.1 +/// documents decode straight to OpenAPIKit's 3.1 model, while 3.0 documents +/// decode with `OpenAPIKit30` and are normalized to 3.1 through +/// `OpenAPIKitCompat`'s `convert(to:)`. Everything downstream therefore sees +/// one 3.1 shape and the renderers never branch on spec version. +/// +/// Conforms to SiteKit's `Loader` so it slots into the pipeline's loading phase; +/// its `Source` is a file `URL` and its `Output` is the typed ``OpenAPISpec``. +public struct OpenAPISpecLoader: Loader { + public typealias Source = URL + public typealias Output = OpenAPISpec + + public init() {} + + /// Errors thrown while loading and decoding an OpenAPI document. + public enum LoadError: Swift.Error, CustomStringConvertible, Equatable { + /// The `openapi:` version field was missing or not a recognized 3.0/3.1 value. + case unsupportedVersion(String) + + public var description: String { + switch self { + case .unsupportedVersion(let found): + "Unsupported or missing OpenAPI version '\(found)'. SiteKitOpenAPI supports OpenAPI 3.0.x and 3.1.x." + } + } + } + + /// Decodes the document at `source` and projects it into an ``OpenAPISpec``. + /// + /// Throws: + /// - ``LoadError/unsupportedVersion(_:)`` when the `openapi:` field is absent + /// (for example a Swagger 2.0 document) or names a major version other than + /// 3.0 / 3.1; + /// - a file-read error – which does name the file – when `url` cannot be read; + /// - a `DecodingError` when the document is present but malformed (the decoder + /// error pinpoints the offending key/path, though not the file name). + public func load(source url: URL) throws -> OpenAPISpec { + let data = try Data(contentsOf: url) + let isJSON = url.pathExtension.lowercased() == "json" + + let document = try Self.decodeDocument(data: data, isJSON: isJSON) + return Self.makeSpec(from: document) + } + + // MARK: - Decoding + + /// Detects the document's major version and decodes to a unified 3.1 model. + private static func decodeDocument(data: Data, isJSON: Bool) throws -> OpenAPIKit.OpenAPI.Document { + let version = try detectMajorVersion(data: data, isJSON: isJSON) + + switch version { + case .v3_1: + return try decode(OpenAPIKit.OpenAPI.Document.self, from: data, isJSON: isJSON) + case .v3_0: + let legacy = try decode(OpenAPIKit30.OpenAPI.Document.self, from: data, isJSON: isJSON) + return legacy.convert(to: .v3_1_1) + } + } + + /// The two major OpenAPI versions this loader accepts. + private enum MajorVersion { + case v3_0 + case v3_1 + } + + /// A minimal probe that reads only the `openapi:` field so the right typed + /// decoder can be chosen before the full (version-specific) decode. `openapi` + /// is optional so a document that omits it (for example Swagger 2.0, which uses + /// `swagger:` instead) decodes cleanly here and is rejected with a precise + /// ``LoadError/unsupportedVersion(_:)`` rather than a raw `DecodingError`. + private struct VersionProbe: Decodable { + let openapi: String? + } + + /// Reads the `openapi:` field and maps it to a ``MajorVersion``. + /// + /// Note: this re-parses the whole document as `VersionProbe` before the real + /// decode (a second full parse). Fine for build-time specs; revisit only if it + /// shows up in profiles. + private static func detectMajorVersion(data: Data, isJSON: Bool) throws -> MajorVersion { + let probe = try decode(VersionProbe.self, from: data, isJSON: isJSON) + let version = probe.openapi ?? "<missing>" + if version.hasPrefix("3.1") { + return .v3_1 + } else if version.hasPrefix("3.0") { + return .v3_0 + } else { + throw LoadError.unsupportedVersion(version) + } + } + + /// Decodes `T` from `data` using the JSON or YAML decoder per `isJSON`. + private static func decode<T: Decodable>(_ type: T.Type, from data: Data, isJSON: Bool) throws -> T { + if isJSON { + return try JSONDecoder().decode(T.self, from: data) + } else { + return try YAMLDecoder().decode(T.self, from: data) + } + } + + // MARK: - Mapping + + /// Projects a decoded 3.1 document into the flattened ``OpenAPISpec``. + /// + /// Path items and the schemas, parameters, request bodies, and responses + /// nested inside operations may each be either an inline value or a `$ref`. + /// Inline values are flattened in full; an in-file component `$ref` at the + /// path-item / parameter / request-body / response level is resolved against + /// `document.components` and flattened exactly as if it had been written inline, + /// so a spec that factors shared parameters or responses into `components/` + /// renders identical docs to one that inlines them. A `$ref` at the schema level + /// is preserved by name (``OpenAPISpec/SchemaNode/referenceName``) so the + /// renderers link to the schema page rather than inlining it. A reference whose + /// target is missing never drops silently: it becomes a visible placeholder plus + /// a build warning (see ``warnUnresolvedReference(kind:_:)`` and the helpers). + private static func makeSpec(from document: OpenAPIKit.OpenAPI.Document) -> OpenAPISpec { + let info = OpenAPISpec.Info( + title: document.info.title, + version: document.info.version, + summary: document.info.summary, + description: document.info.description + ) + + let servers = document.servers.map { server in + OpenAPISpec.Server(url: server.urlTemplate.absoluteString, description: server.description) + } + + let tags = (document.tags ?? []).map { tag in + OpenAPISpec.Tag(name: tag.name, description: tag.description) + } + + let components = document.components + var operations: [OpenAPISpec.Operation] = [] + for (path, pathItemEither) in document.paths { + // A path item may be inline or a $ref into components/pathItems. Resolve the + // reference so its operations are documented; an unresolvable reference warns + // and skips that path rather than silently dropping it with no signal. + let pathItem: OpenAPIKit.OpenAPI.PathItem + switch pathItemEither { + case .b(let inline): + pathItem = inline + case .a(let reference): + guard let resolved = components[reference] else { + Self.warnUnresolvedReference(kind: "path item", reference.name ?? reference.absoluteString) + continue + } + pathItem = resolved + } + for endpoint in pathItem.endpoints { + operations.append( + Self.makeOperation(endpoint.operation, method: endpoint.method.rawValue, path: path.rawValue, components: components) + ) + } + } + + let schemas = document.components.schemas.map { entry in + OpenAPISpec.SchemaObject(name: entry.key.rawValue, schema: Self.makeSchema(entry.value)) + } + + return OpenAPISpec(info: info, servers: servers, tags: tags, operations: operations, schemas: schemas) + } + + private static func makeOperation( + _ operation: OpenAPIKit.OpenAPI.Operation, + method: String, + path: String, + components: OpenAPIKit.OpenAPI.Components + ) -> OpenAPISpec.Operation { + OpenAPISpec.Operation( + method: method, + path: path, + operationId: operation.operationId, + summary: operation.summary, + description: operation.description, + tags: operation.tags ?? [], + parameters: operation.parameters.map { Self.makeParameter($0, components: components) }, + requestBody: operation.requestBody.map { Self.makeRequestBody($0, components: components) }, + responses: Self.makeResponses(operation.responses, components: components), + security: Self.makeSecurity(operation.security), + deprecated: operation.deprecated + ) + } + + /// Maps an operation parameter, resolving a component `$ref` against + /// `components.parameters` so a referenced parameter renders identically to an + /// inline one. An unresolvable `$ref` (missing target) becomes a visible + /// placeholder carrying the reference name plus a build warning – never a silent + /// drop. This is the unified drop-vs-emit rule shared across parameters, request + /// bodies, and responses. + private static func makeParameter( + _ parameterEither: Either<OpenAPIKit.OpenAPI.Reference<OpenAPIKit.OpenAPI.Parameter>, OpenAPIKit.OpenAPI.Parameter>, + components: OpenAPIKit.OpenAPI.Components + ) -> OpenAPISpec.Parameter { + switch parameterEither { + case .b(let parameter): + return Self.makeParameter(parameter, components: components) + case .a(let reference): + if let resolved = components[reference] { + return Self.makeParameter(resolved, components: components) + } + let name = reference.name ?? reference.absoluteString + Self.warnUnresolvedReference(kind: "parameter", name) + return OpenAPISpec.Parameter( + name: name, + location: .other("unresolved-reference"), + description: "Unresolved $ref – this parameter references a component that is not defined in the document.", + required: false, + schema: nil + ) + } + } + + /// Flattens an inline parameter into ``OpenAPISpec/Parameter``. + private static func makeParameter( + _ parameter: OpenAPIKit.OpenAPI.Parameter, + components: OpenAPIKit.OpenAPI.Components + ) -> OpenAPISpec.Parameter { + let schema: OpenAPISpec.SchemaNode? + switch parameter.schemaOrContent { + case .a(let schemaContext): + schema = Self.makeSchema(from: schemaContext.schema) + case .b(let contentMap): + schema = Self.makeContent(contentMap, components: components).first?.schema + } + + return OpenAPISpec.Parameter( + name: parameter.name, + location: OpenAPISpec.Parameter.Location(rawValue: parameter.location.rawValue), + description: parameter.description, + required: parameter.required, + deprecated: parameter.deprecated, + schema: schema + ) + } + + /// Maps an operation request body, resolving a component `$ref` against + /// `components.requestBodies` so a referenced body renders identically to an + /// inline one. An unresolvable `$ref` becomes a visible placeholder description + /// plus a build warning (the unified emit rule). + private static func makeRequestBody( + _ requestEither: Either<OpenAPIKit.OpenAPI.Reference<OpenAPIKit.OpenAPI.Request>, OpenAPIKit.OpenAPI.Request>, + components: OpenAPIKit.OpenAPI.Components + ) -> OpenAPISpec.RequestBody { + switch requestEither { + case .b(let request): + return Self.makeRequestBody(request, components: components) + case .a(let reference): + if let resolved = components[reference] { + return Self.makeRequestBody(resolved, components: components) + } + let name = reference.name ?? reference.absoluteString + Self.warnUnresolvedReference(kind: "request body", name) + return OpenAPISpec.RequestBody( + description: "Unresolved $ref – this request body references a component (\(name)) that is not defined in the document.", + required: false, + content: [] + ) + } + } + + /// Flattens an inline request body into ``OpenAPISpec/RequestBody``. + private static func makeRequestBody( + _ request: OpenAPIKit.OpenAPI.Request, + components: OpenAPIKit.OpenAPI.Components + ) -> OpenAPISpec.RequestBody { + OpenAPISpec.RequestBody( + description: request.description, + required: request.required, + content: Self.makeContent(request.content, components: components) + ) + } + + /// Maps the operation responses, resolving a component `$ref` against + /// `components.responses` so a referenced response (a shared `401`/`404`, say) + /// renders identically to an inline one. An unresolvable `$ref` becomes a visible + /// placeholder carrying the status code and reference name plus a build warning + /// (the unified emit rule), never a silent drop. + private static func makeResponses( + _ responses: OpenAPIKit.OpenAPI.Response.Map, + components: OpenAPIKit.OpenAPI.Components + ) -> [OpenAPISpec.Response] { + responses.map { entry in + let statusCode = entry.key.rawValue + switch entry.value { + case .b(let response): + return Self.makeResponse(statusCode: statusCode, response: response, components: components) + case .a(let reference): + if let resolved = components[reference] { + return Self.makeResponse(statusCode: statusCode, response: resolved, components: components) + } + let name = reference.name ?? reference.absoluteString + Self.warnUnresolvedReference(kind: "response", name) + return OpenAPISpec.Response( + statusCode: statusCode, + description: "Unresolved $ref – this response references a component (\(name)) that is not defined in the document.", + content: [] + ) + } + } + } + + /// Flattens an inline response into ``OpenAPISpec/Response``. + private static func makeResponse( + statusCode: String, + response: OpenAPIKit.OpenAPI.Response, + components: OpenAPIKit.OpenAPI.Components + ) -> OpenAPISpec.Response { + OpenAPISpec.Response( + statusCode: statusCode, + description: response.description, + content: Self.makeContent(response.content, components: components) + ) + } + + /// Maps the media-type representations of a request or response body. A schema + /// `$ref` inside a media type is preserved by name (a link, not inlined). A media + /// type that is itself a `$ref` cannot be resolved (OpenAPI has no + /// `components/content` dictionary), so it becomes a visible degenerate entry + /// carrying its content type plus a build warning rather than a silent drop – + /// keeping the emit-vs-drop behavior consistent with the other reference levels. + private static func makeContent( + _ content: OpenAPIKit.OpenAPI.Content.Map, + components: OpenAPIKit.OpenAPI.Components + ) -> [OpenAPISpec.MediaType] { + content.map { entry in + let contentType = entry.key.rawValue + switch entry.value { + case .b(let inline): + return OpenAPISpec.MediaType( + contentType: contentType, + schema: inline.schema.map { Self.makeSchema($0) }, + example: Self.makeExample(inline) + ) + case .a(let reference): + Self.warnUnresolvedReference(kind: "media type", reference.name ?? reference.absoluteString) + return OpenAPISpec.MediaType(contentType: contentType, schema: nil, example: nil) + } + } + } + + /// Extracts the media type's single `example`, pretty-printed as JSON. The + /// `examples` map (whose entries can also be external `$ref`/URL values) is left + /// to a later slice; the inline `example` covers the common case. + private static func makeExample(_ content: OpenAPIKit.OpenAPI.Content) -> String? { + guard let example = content.example else { return nil } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(example), let json = String(data: data, encoding: .utf8) else { return nil } + return json + } + + /// Logs a build-time warning for a `$ref` that could not be resolved against the + /// document components, matching the factory's warn-and-continue posture. The + /// caller still emits a visible placeholder so the gap also shows in the docs. + private static func warnUnresolvedReference(kind: String, _ name: String) { + print( + "[SiteKit] Warning: unresolved OpenAPI \(kind) $ref '\(name)' – no matching component definition; rendering a placeholder." + ) + } + + /// Maps each per-operation security requirement (a named scheme reference plus + /// its scopes). A future change could flatten the `components/securitySchemes` *definitions* + /// (type / location / OAuth flows) when the operation pages need to render them. + private static func makeSecurity(_ security: [OpenAPIKit.OpenAPI.SecurityRequirement]?) -> [OpenAPISpec.SecurityRequirement] { + (security ?? []).map { requirement in + let schemes = + requirement + .compactMap { reference, scopes -> OpenAPISpec.SecurityRequirement.SchemeRequirement? in + guard let name = reference.name else { return nil } + return OpenAPISpec.SecurityRequirement.SchemeRequirement(name: name, scopes: scopes) + } + // A requirement's schemes come from a dictionary (no inherent order); + // sort by name so the rendered output is deterministic build to build. + .sorted { $0.name < $1.name } + return OpenAPISpec.SecurityRequirement(schemes: schemes) + } + } + + // MARK: - Schema mapping + + /// Flattens an inline-or-referenced schema, preserving a top-level `$ref` by name. + private static func makeSchema(from schemaEither: Either<OpenAPIKit.OpenAPI.Reference<JSONSchema>, JSONSchema>) -> OpenAPISpec.SchemaNode { + switch schemaEither { + case .a(let reference): + return OpenAPISpec.SchemaNode(referenceName: reference.name) + case .b(let schema): + return Self.makeSchema(schema) + } + } + + /// Flattens an OpenAPIKit `JSONSchema` into the OpenAPIKit-free ``OpenAPISpec/SchemaNode``. + private static func makeSchema(_ schema: JSONSchema) -> OpenAPISpec.SchemaNode { + let title = schema.title + let description = schema.description + let deprecated = schema.deprecated + let nullable = schema.nullable + + switch schema.value { + case .reference(let reference, _): + return OpenAPISpec.SchemaNode( + title: title, + description: description, + referenceName: reference.name, + deprecated: deprecated, + nullable: nullable + ) + + case .object(_, let context): + let properties = context.properties.map { entry in + OpenAPISpec.SchemaProperty( + name: entry.key, + required: context.requiredProperties.contains(entry.key), + schema: Self.makeSchema(entry.value) + ) + } + return OpenAPISpec.SchemaNode( + type: "object", + title: title, + description: description, + required: context.requiredProperties, + properties: properties, + deprecated: deprecated, + nullable: nullable + ) + + case .array(_, let context): + let items = context.items.map { [Self.makeSchema($0)] } ?? [] + return OpenAPISpec.SchemaNode( + type: "array", + format: schema.formatString, + title: title, + description: description, + items: items, + deprecated: deprecated, + nullable: nullable + ) + + case .all(of: let subschemas, _): + return Self.makeComposition( + .allOf, + subschemas, + discriminator: schema.discriminator, + title: title, + description: description, + deprecated: deprecated, + nullable: nullable + ) + case .one(of: let subschemas, _): + return Self.makeComposition( + .oneOf, + subschemas, + discriminator: schema.discriminator, + title: title, + description: description, + deprecated: deprecated, + nullable: nullable + ) + case .any(of: let subschemas, _): + return Self.makeComposition( + .anyOf, + subschemas, + discriminator: schema.discriminator, + title: title, + description: description, + deprecated: deprecated, + nullable: nullable + ) + + default: + // Scalars (string / integer / number / boolean / null) plus `.not` and + // untyped `.fragment`: carry the type, format, and enum values where present. + return OpenAPISpec.SchemaNode( + type: schema.jsonType?.rawValue, + format: schema.formatString, + title: title, + description: description, + enumValues: Self.makeEnumValues(schema.allowedValues), + deprecated: deprecated, + nullable: nullable + ) + } + } + + private static func makeComposition( + _ kind: OpenAPISpec.Composition.Kind, + _ subschemas: [JSONSchema], + discriminator: OpenAPIKit.OpenAPI.Discriminator?, + title: String?, + description: String?, + deprecated: Bool, + nullable: Bool + ) -> OpenAPISpec.SchemaNode { + OpenAPISpec.SchemaNode( + title: title, + description: description, + composition: OpenAPISpec.Composition( + kind: kind, + subschemas: subschemas.map { Self.makeSchema($0) }, + discriminator: Self.makeDiscriminator(discriminator) + ), + deprecated: deprecated, + nullable: nullable + ) + } + + private static func makeDiscriminator(_ discriminator: OpenAPIKit.OpenAPI.Discriminator?) -> OpenAPISpec.Composition.Discriminator? { + guard let discriminator else { return nil } + var mapping: [String: String] = [:] + for (value, schemaName) in discriminator.mapping ?? [:] { + mapping[value] = schemaName + } + return OpenAPISpec.Composition.Discriminator(propertyName: discriminator.propertyName, mapping: mapping) + } + + /// Renders enum values to their string form. A structured (non-scalar) enum + /// value would yield a Swift debug string here; revisit if such enums appear. + private static func makeEnumValues(_ values: [AnyCodable]?) -> [String] { + (values ?? []).map { value in + if let string = value.value as? String { return string } + return String(describing: value.value) + } + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIStylesheetRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPIStylesheetRenderer.swift new file mode 100644 index 0000000..4ca3bdd --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIStylesheetRenderer.swift @@ -0,0 +1,105 @@ +import Foundation +import SiteKit + +/// Emits SiteKitOpenAPI's bundled component stylesheet to `/assets/css/openapi.css`, +/// and appends a generated block of semantic HTTP-verb color rules. +/// +/// `OpenAPIShell` links this stylesheet from every page's `<head>` (after the +/// critical theme CSS, so it never blocks first paint). The stylesheet reads the +/// theme token variables, so an OpenAPI site inherits its color scheme, fonts, and +/// spacing across all schemes and layouts in light and dark, with no layout change. +/// +/// The appended verb block is the `[data-method]` parallel to DocC's generated +/// `[data-framework]` tiles: one `.sk-openapi-method[data-method="<verb>"]` rule per +/// HTTP verb paints that verb's badge its semantic color (an industry-standard +/// Swagger/Stripe-like palette), shared by the operation-header badges and the +/// in-rail badges. The rules are generated (not hand-written per verb) so the palette +/// stays in one place. A `Renderer` with `scope: .global`, so it runs once per build. +public struct OpenAPIStylesheetRenderer: Renderer { + public var scope: RenderScope { .global } + + public init() {} + + /// The public URL `OpenAPIShell` links from `<head>`. + public static let cssURL = "/assets/css/openapi.css" + + /// The semantic HTTP-verb palette: an industry-standard hue per verb (the + /// Swagger-UI family). These are the one place fixed hues are allowed (the verb + /// semantics are universal); everything else derives from theme tokens. + /// + /// Each entry also fixes the badge `label` color, chosen so the label clears WCAG + /// AA (≥ 4.5:1) on that background: near-black on the light verbs (GET/POST/PUT/ + /// PATCH/DELETE), white on the dark verbs (HEAD/OPTIONS). The blanket white the + /// chips used before failed AA on the light hues, so the label color travels with + /// the background – both generated, no hand-maintained drift. Computed ratios: + /// GET 9.1, POST 10.3, PUT 10.3, PATCH 13.1, DELETE 5.8, HEAD 5.7, OPTIONS 6.9. + static let verbColors: [(verb: String, background: String, label: String)] = [ + ("get", "#61affe", "#000"), + ("post", "#49cc90", "#000"), + ("put", "#fca130", "#000"), + ("patch", "#50e3c2", "#000"), + ("delete", "#f93e3e", "#000"), + ("head", "#9012fe", "#fff"), + ("options", "#0d5aa7", "#fff"), + ] + + public func render(context: BuildContext) throws -> [OutputFile] { + var css = try Self.loadStylesheet() + css += Self.methodColorCSS() + + let path = context.outputDirectory + .appendingPathComponent("assets") + .appendingPathComponent("css") + .appendingPathComponent("openapi.css") + return [OutputFile(outputPath: path, content: css)] + } + + /// Generates the per-verb color block: one rule per ``verbColors`` entry painting + /// the method badge's background *and* its AA-checked label color. Shared by the + /// operation header and the nav rail (both use `.sk-openapi-method[data-method=…]`). + static func methodColorCSS() -> String { + var lines = [ + "", + "/* HTTP-verb colors – generated from the semantic verb palette. One rule per verb", + " paints the method badge background and its label color, the latter chosen so the", + " label clears WCAG AA (>= 4.5:1) on that hue (near-black on the light verbs, white", + " on the dark ones) in light and dark. */", + ] + for entry in self.verbColors { + lines.append( + ".sk-openapi-method[data-method=\"\(entry.verb)\"] { background: \(entry.background); color: \(entry.label); }" + ) + } + return lines.joined(separator: "\n") + "\n" + } + + /// Loads the bundled `openapi.css` from the module resources. Throws when the + /// resource is missing, so a build cannot silently produce an unstyled site. + static func loadStylesheet() throws -> String { + try Self.loadResource(named: "openapi", withExtension: "css") + } + + /// Loads a bundled text resource from this module's bundle, or throws a clear error + /// naming the missing file. (SiteKit's `BundledResource` is internal to that module, + /// so this target carries its own small loader.) + static func loadResource(named name: String, withExtension ext: String) throws -> String { + guard let url = Bundle.module.url(forResource: name, withExtension: ext) else { + throw OpenAPIResourceError.missing("\(name).\(ext)") + } + return try String(contentsOf: url, encoding: .utf8) + } +} + +/// An error raised when a bundled SiteKitOpenAPI asset (stylesheet, script) is absent +/// from the module bundle. +public enum OpenAPIResourceError: Error, Equatable, CustomStringConvertible { + /// The named resource was not found in the module bundle. + case missing(String) + + public var description: String { + switch self { + case .missing(let name): + "Bundled SiteKitOpenAPI resource '\(name)' is missing from the module bundle." + } + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPITagPage.swift b/Sources/SiteKitOpenAPI/OpenAPITagPage.swift new file mode 100644 index 0000000..a8c4863 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPITagPage.swift @@ -0,0 +1,116 @@ +import Foundation +import SiteKit + +/// One page per tag: the tag's name and description, followed by the list of its +/// operations (method badge + path + summary), each linking to its operation page. +/// +/// Mirrors `DocCArticlePage`'s shape (a titled article wrapped in the shell). The +/// tag-to-operation grouping comes from ``OpenAPIRoutes/tagSections(_:)``, so the tag +/// pages list exactly the operations the landing cards count and the operation URLs +/// nest under. +public struct OpenAPITagPage: Page { + private let spec: OpenAPISpec + + /// Creates the tag-page renderer for `spec`. + public init(spec: OpenAPISpec) { + self.spec = spec + } + + public func pages(in context: BuildContext) -> [PageModel] { + OpenAPIRoutes.tagSections(self.spec).map { section in + let path = OpenAPIRoutes.tagPath(context, tagSlug: section.slug) + return PageModel( + title: section.tag.name, + slug: section.slug, + htmlContent: "", + sourcePath: self.syntheticSource(context: context, slug: section.slug), + summary: section.tag.description, + description: section.tag.description, + pageType: .staticPage, + extensions: ["openAPITagName": section.tag.name, "openAPIPath": path] + ) + } + } + + public func renderHTML(_ page: PageModel, context: BuildContext) -> String { + let tagName: String = page.extensionValue("openAPITagName") ?? page.title + let path: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.tagPath(context, tagSlug: page.slug) + guard let section = OpenAPIRoutes.tagSections(self.spec).first(where: { $0.tag.name == tagName }) else { + // The tag vanished from the spec between pages(in:) and render – render an empty shell. + return OpenAPIShell.wrap( + content: "", + page: page, + context: context, + head: self.head(page: page, path: path, context: context), + spec: self.spec + ) + } + + let body = + "<article class=\"sk-openapi-tag\">" + + self.headerHTML(section: section) + + self.operationListHTML(section: section, context: context) + + "</article>" + + return OpenAPIShell.wrap( + content: body, + page: page, + context: context, + head: self.head(page: page, path: path, context: context), + spec: self.spec + ) + } + + public func outputURL(for page: PageModel, context: BuildContext) -> URL { + let path: String = page.extensionValue("openAPIPath") ?? OpenAPIRoutes.tagPath(context, tagSlug: page.slug) + return OpenAPIRoutes.outputURL(for: path, context: context) + } + + // MARK: - Body + + private func headerHTML(section: OpenAPIRoutes.TagSection) -> String { + var header = "<header class=\"sk-openapi-tag-header\">" + header += "<h1 class=\"sk-openapi-title\">\(OpenAPIHTML.escape(section.tag.name))</h1>" + if let description = section.tag.description, !description.isEmpty { + header += "<p class=\"sk-openapi-description\">\(OpenAPIHTML.escape(description))</p>" + } + header += "</header>" + return header + } + + private func operationListHTML(section: OpenAPIRoutes.TagSection, context: BuildContext) -> String { + let rows = section.operations.map { ref -> String in + let operation = ref.operation + // Every entry links to the operation's one canonical page (under its first + // tag), so a cross-listed operation never spawns a duplicate URL. + let href = OpenAPIHTML.escape( + OpenAPIRoutes.operationPath(context, tagSlug: ref.canonicalTagSlug, operationSlug: ref.slug) + ) + let summary = operation.summary.map { "<span class=\"sk-openapi-op-summary\">\(OpenAPIHTML.escape($0))</span>" } ?? "" + return "<li class=\"sk-openapi-op-item\">" + + "<a class=\"sk-openapi-op-link\" href=\"\(href)\">" + + OpenAPIBadges.methodBadge(operation.method) + + "<code class=\"sk-openapi-op-path\">\(OpenAPIHTML.escape(operation.path))</code>" + + summary + + OpenAPIBadges.deprecatedBadge(operation.deprecated) + + "</a>" + + "</li>" + }.joined() + return "<ul class=\"sk-openapi-op-list\">\(rows)</ul>" + } + + private func head(page: PageModel, path: String, context: BuildContext) -> String { + OutputFileRenderer(context: context).buildHead( + title: "\(page.title) – \(context.config.name)", + description: page.summary, + canonicalURL: "\(context.config.baseURL)\(path)", + ogType: "website" + ) + } + + private func syntheticSource(context: BuildContext, slug: String) -> URL { + context.projectDirectory + .appendingPathComponent(context.config.contentDirectory) + .appendingPathComponent("openapi.yaml") + } +} diff --git a/Sources/SiteKitOpenAPI/OpenAPIThemeScriptRenderer.swift b/Sources/SiteKitOpenAPI/OpenAPIThemeScriptRenderer.swift new file mode 100644 index 0000000..121b7c8 --- /dev/null +++ b/Sources/SiteKitOpenAPI/OpenAPIThemeScriptRenderer.swift @@ -0,0 +1,30 @@ +import Foundation +import SiteKit + +/// Emits the bundled appbar theme-toggle script to `/assets/js/openapi-theme.js`. The +/// `OpenAPIShell` links it (deferred) from every page. +/// +/// Consistent with the base SiteKit (DocC) theme toggle: same `localStorage "theme"` key and +/// `data-theme` contract, so a reader's light/dark choice persists across every SiteKit surface +/// on the site. With no stored key the toggle follows the OS appearance live (the inline +/// head-init applied the initial `data-theme`); a click flips the effective theme, persists the +/// opposite value, and stops following the OS. Progressive enhancement: without JS the button +/// renders as inert HTML and clicking does not switch (matching the base DocC toggle). A +/// `.global` renderer. +public struct OpenAPIThemeScriptRenderer: Renderer { + public var scope: RenderScope { .global } + + public init() {} + + /// The public URL `OpenAPIShell` links from the page (deferred). + public static let scriptURL = "/assets/js/openapi-theme.js" + + public func render(context: BuildContext) throws -> [OutputFile] { + let js = try OpenAPIStylesheetRenderer.loadResource(named: "openapi-theme", withExtension: "js") + let path = context.outputDirectory + .appendingPathComponent("assets") + .appendingPathComponent("js") + .appendingPathComponent("openapi-theme.js") + return [OutputFile(outputPath: path, content: js)] + } +} diff --git a/Sources/SiteKitOpenAPI/Resources/openapi-nav.js b/Sources/SiteKitOpenAPI/Resources/openapi-nav.js new file mode 100644 index 0000000..124ae99 --- /dev/null +++ b/Sources/SiteKitOpenAPI/Resources/openapi-nav.js @@ -0,0 +1,191 @@ +// SiteKit OpenAPI sidebar enhancement. Progressive enhancement only: the rail is a plain, +// fully navigable list without JS; this script adds collapse/expand twists, a live filter +// box, scrolls the active item into view, and wires the mobile drawer toggle. Mirrors the +// DocC sidebar/filter scripts, adapted to the sk-openapi-* markup. +(function () { + "use strict"; + + // Cut-the-mustard: announce that JS is on as early as the script runs, before the + // rail is touched. The stylesheet gates the mobile off-canvas drawer behind `html.js`, + // so a JS-off narrow viewport keeps the rail in normal flow (reachable, not a drawer). + document.documentElement.classList.add("js"); + + function ready(fn) { + if (document.readyState !== "loading") { + fn(); + } else { + document.addEventListener("DOMContentLoaded", fn); + } + } + + ready(function () { + var nav = document.querySelector(".sk-openapi-nav"); + if (!nav) { + return; + } + + addCollapseTwists(nav); + addFilter(nav); + scrollActiveIntoView(nav); + wireMobileDrawer(nav); + }); + + // Inject a twist button into each group's header row so a group can be collapsed/ + // expanded. The twist is inserted as a SIBLING of the title link (both children of + // .sk-openapi-nav-group-header), never inside the <a> – a button nested in an anchor + // is invalid (nested interactives). The title link still navigates to the tag page. + function addCollapseTwists(nav) { + var groups = nav.querySelectorAll(".sk-openapi-nav-group"); + groups.forEach(function (group, index) { + var header = group.querySelector(".sk-openapi-nav-group-header"); + var title = group.querySelector(".sk-openapi-nav-group-title"); + var items = group.querySelector(".sk-openapi-nav-items"); + if (!header || !items) { + return; + } + // Give the items list a stable id so the twist's aria-controls can point at the + // region it shows/hides. + if (!items.id) { + items.id = "sk-openapi-nav-items-" + index; + } + var sectionName = title ? title.textContent.trim() : "section"; + var twist = document.createElement("button"); + twist.type = "button"; + twist.className = "sk-openapi-nav-twist"; + twist.setAttribute("aria-expanded", "true"); + twist.setAttribute("aria-controls", items.id); + // Name the section so screen-reader users hear which group the twist toggles, + // rather than the same generic label repeated for every group. + twist.setAttribute("aria-label", "Toggle the " + sectionName + " section"); + twist.textContent = "▾"; // ▾ + twist.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + var collapsed = group.classList.toggle("is-collapsed"); + twist.setAttribute("aria-expanded", collapsed ? "false" : "true"); + }); + header.insertBefore(twist, header.firstChild); + }); + } + + // Inject a filter input that live-filters nav items by substring and hides empty groups. + function addFilter(nav) { + var input = document.createElement("input"); + input.type = "search"; + input.className = "sk-openapi-nav-filter"; + input.placeholder = "Filter…"; + input.setAttribute("aria-label", "Filter navigation"); + + var home = nav.querySelector(".sk-openapi-nav-home"); + if (home && home.nextSibling) { + nav.insertBefore(input, home.nextSibling); + } else { + nav.insertBefore(input, nav.firstChild); + } + + input.addEventListener("input", function () { + var query = input.value.trim().toLowerCase(); + nav.querySelectorAll(".sk-openapi-nav-group").forEach(function (group) { + var anyVisible = false; + group.querySelectorAll(".sk-openapi-nav-item").forEach(function (item) { + var label = item.textContent.toLowerCase(); + var match = query === "" || label.indexOf(query) !== -1; + item.hidden = !match; + if (match) { + anyVisible = true; + } + }); + // Hide a whole group when nothing inside it matches (and a query is active). + group.hidden = query !== "" && !anyVisible; + }); + }); + } + + // Keep the current page's item visible: scroll it into the rail's viewport on load. + function scrollActiveIntoView(nav) { + var active = nav.querySelector(".sk-openapi-nav-link.is-active"); + if (active && typeof active.scrollIntoView === "function") { + active.scrollIntoView({ block: "nearest" }); + } + } + + // Inject a hamburger toggle into the appbar that opens/closes the off-canvas rail on + // narrow viewports. The CSS shows the toggle only under the responsive breakpoint. + function wireMobileDrawer(nav) { + var layout = document.querySelector(".sk-openapi-layout"); + var appbar = document.querySelector(".sk-openapi-appbar"); + if (!layout || !appbar) { + return; + } + // Give the rail a stable id so the toggle's aria-controls can point at the region + // it opens and closes. + if (!nav.id) { + nav.id = "sk-openapi-nav"; + } + var toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "sk-openapi-nav-toggle"; + toggle.setAttribute("aria-label", "Toggle navigation"); + toggle.setAttribute("aria-controls", nav.id); + toggle.setAttribute("aria-expanded", "false"); + toggle.textContent = "☰"; // ☰ + + var scrim = document.querySelector("[data-openapi-nav-scrim]"); + // The scrolling content region (landing cards, page body, footer) – everything that + // sits behind the scrim when the drawer is open. + var mainEl = document.querySelector(".sk-openapi-scroll"); + + function setOpen(open) { + layout.classList.toggle("is-nav-open", open); + toggle.setAttribute("aria-expanded", open ? "true" : "false"); + if (scrim) { + scrim.hidden = !open; + } + // Contain focus the way base SiteKit's docc-sidebar.js does: make the background + // content inert (a native focus trap + aria-hidden rollup, so Tab cannot reach the + // page content hidden behind the scrim), lock the body scroll, and mark the rail a + // modal dialog. The appbar is intentionally NOT inert – it holds the toggle, the + // drawer's own close control – so closing by hamburger keeps working; Escape and a + // scrim tap close it too. + if (mainEl) { + mainEl.inert = open; + } + document.documentElement.style.overflow = open ? "hidden" : ""; + if (open) { + nav.setAttribute("role", "dialog"); + nav.setAttribute("aria-modal", "true"); + // Move focus into the rail so keyboard users land in the just-opened drawer. + nav.setAttribute("tabindex", "-1"); + nav.focus(); + } else { + nav.removeAttribute("aria-modal"); + nav.removeAttribute("role"); + toggle.focus(); + } + } + + toggle.addEventListener("click", function () { + setOpen(!layout.classList.contains("is-nav-open")); + }); + appbar.insertBefore(toggle, appbar.firstChild); + + // Tapping a link closes the drawer so the destination page is visible. + nav.addEventListener("click", function (event) { + if (event.target.closest("a")) { + setOpen(false); + } + }); + + // Backdrop tap and the Escape key close the drawer (only meaningful while it is open). + if (scrim) { + scrim.addEventListener("click", function () { + setOpen(false); + }); + } + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && layout.classList.contains("is-nav-open")) { + setOpen(false); + } + }); + } +})(); diff --git a/Sources/SiteKitOpenAPI/Resources/openapi-search.js b/Sources/SiteKitOpenAPI/Resources/openapi-search.js new file mode 100644 index 0000000..5ea117e --- /dev/null +++ b/Sources/SiteKitOpenAPI/Resources/openapi-search.js @@ -0,0 +1,116 @@ +// SiteKit OpenAPI appbar search. Progressive enhancement: the search box does nothing without +// JS (the stylesheet hides it until openapi-nav.js adds html.js), so here it is wired to fetch +// the static search index once and filter it client-side. Full-text site search, separate from +// the nav filter in openapi-nav.js (which only hides rows already in the rail). +(function () { + "use strict"; + + var INDEX_URL = "/assets/search-index.json"; + + function ready(fn) { + if (document.readyState !== "loading") { + fn(); + } else { + document.addEventListener("DOMContentLoaded", fn); + } + } + + ready(function () { + var input = document.querySelector("[data-openapi-search]"); + if (!input) { + return; + } + var results = document.getElementById("sk-openapi-search-results"); + if (!results) { + return; + } + + var records = null; + var loading = false; + + // Fetch the index lazily on first focus, so a reader who never searches pays nothing. + function loadIndex() { + if (records !== null || loading) { + return; + } + loading = true; + fetch(INDEX_URL) + .then(function (response) { + return response.ok ? response.json() : []; + }) + .then(function (data) { + records = Array.isArray(data) ? data : []; + render(input.value); + }) + .catch(function () { + records = []; + }); + } + + function matches(record, query) { + var hay = (record.title + " " + (record.summary || "") + " " + record.url + " " + (record.method || "")).toLowerCase(); + return hay.indexOf(query) !== -1; + } + + function render(rawQuery) { + var query = (rawQuery || "").trim().toLowerCase(); + results.textContent = ""; + if (query === "" || records === null) { + close(); + return; + } + var hits = []; + for (var i = 0; i < records.length && hits.length < 12; i++) { + if (matches(records[i], query)) { + hits.push(records[i]); + } + } + if (hits.length === 0) { + close(); + return; + } + hits.forEach(function (record) { + var link = document.createElement("a"); + link.className = "sk-openapi-search-hit"; + link.href = record.url; + link.setAttribute("role", "option"); + if (record.method) { + var badge = document.createElement("span"); + badge.className = "sk-openapi-method"; + badge.setAttribute("data-method", record.method.toLowerCase()); + badge.textContent = record.method; + link.appendChild(badge); + } + var label = document.createElement("span"); + label.className = "sk-openapi-search-hit-label"; + label.textContent = record.title; + link.appendChild(label); + results.appendChild(link); + }); + results.hidden = false; + input.setAttribute("aria-expanded", "true"); + } + + function close() { + results.hidden = true; + input.setAttribute("aria-expanded", "false"); + } + + input.addEventListener("focus", loadIndex); + input.addEventListener("input", function () { + render(input.value); + }); + // Esc clears and closes; clicking outside closes. + input.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + input.value = ""; + close(); + } + }); + document.addEventListener("click", function (event) { + if (!event.target.closest(".sk-openapi-search")) { + close(); + } + }); + }); +})(); diff --git a/Sources/SiteKitOpenAPI/Resources/openapi-theme.js b/Sources/SiteKitOpenAPI/Resources/openapi-theme.js new file mode 100644 index 0000000..560db34 --- /dev/null +++ b/Sources/SiteKitOpenAPI/Resources/openapi-theme.js @@ -0,0 +1,77 @@ +// SiteKit OpenAPI appbar theme toggle. Consistent with the base SiteKit (DocC) toggle: +// - Default (no stored 'theme' key): follow the OS appearance. The inline head-init already +// applied the initial data-theme; this script keeps following live OS changes via a +// matchMedia listener until the user clicks. +// - Click: flip the applied theme (light <-> dark), persist the opposite under localStorage +// 'theme', and stop following the OS. +// The key and values ('theme', 'light', 'dark') are identical to the head-init's, so the choice +// persists across page navigations and reloads and matches every other SiteKit surface. +// Progressive enhancement: without JS the button renders as inert HTML and clicking does not switch. +(function () { + "use strict"; + + var STORAGE_KEY = "theme"; + + var toggle = document.querySelector(".sk-openapi-theme-toggle"); + if (!toggle) { + return; + } + + var mql = window.matchMedia("(prefers-color-scheme:dark)"); + var mqlListener = null; + + var MOON_ICON = + "<svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"" + + " stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">" + + "<path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>"; + + var SUN_ICON = + "<svg width=\"17\" height=\"17\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"" + + " stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">" + + "<circle cx=\"12\" cy=\"12\" r=\"5\"/>" + + "<line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/>" + + "<line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/>" + + "<line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/>" + + "<line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/></svg>"; + + function currentTheme() { + return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light"; + } + + function applyTheme(value) { + document.documentElement.setAttribute("data-theme", value); + } + + function syncIcon() { + toggle.innerHTML = currentTheme() === "dark" ? SUN_ICON : MOON_ICON; + } + + function stopFollowingOS() { + if (mqlListener) { + mql.removeEventListener("change", mqlListener); + mqlListener = null; + } + } + + toggle.addEventListener("click", function () { + stopFollowingOS(); + var next = currentTheme() === "dark" ? "light" : "dark"; + localStorage.setItem(STORAGE_KEY, next); + applyTheme(next); + syncIcon(); + }); + + syncIcon(); + + if (!localStorage.getItem(STORAGE_KEY)) { + mqlListener = function (event) { + if (!localStorage.getItem(STORAGE_KEY)) { + applyTheme(event.matches ? "dark" : "light"); + syncIcon(); + } else { + stopFollowingOS(); + } + }; + mql.addEventListener("change", mqlListener); + } +})(); diff --git a/Sources/SiteKitOpenAPI/Resources/openapi.css b/Sources/SiteKitOpenAPI/Resources/openapi.css new file mode 100644 index 0000000..9a99d5b --- /dev/null +++ b/Sources/SiteKitOpenAPI/Resources/openapi.css @@ -0,0 +1,845 @@ +/* SiteKit OpenAPI component styles – layout + chrome for .openAPI documentation sites. + Everything reads from the theme token variables (the same ones base.css / tokens.css + expose), so an OpenAPI site inherits its color scheme, fonts, and spacing and works in + light and dark across all schemes with no per-color overrides. The one exception is the + semantic HTTP-verb palette, which is appended (generated) after this file by + OpenAPIStylesheetRenderer and harmonized with the token surface. */ + +/* Component chrome reads from a small set of locally-scoped variables so a theme can skin the + whole docs surface without touching component rules. Each falls back to the site's theme + tokens, so the generic default already inherits the active scheme (light or dark). */ +.sk-openapi-layout { + --sk-openapi-nav-width: 280px; + --sk-openapi-appbar-height: 52px; + --sk-openapi-content-max: 880px; + --sk-openapi-gap: 1rem; + --sk-openapi-nav-bg: var(--color-bg-card, var(--color-bg-alt, var(--color-bg))); + --sk-openapi-nav-border: var(--color-border); + --sk-openapi-row-radius: var(--radius, 8px); + --sk-openapi-row-hover: var(--color-bg-alt); + --sk-openapi-active-bg: var(--color-accent); + --sk-openapi-active-text: var(--color-accent-contrast, #fff); + --sk-openapi-muted: var(--color-text-muted, var(--color-text-secondary)); + --sk-openapi-code-bg: var(--color-bg-code, var(--color-bg-alt, var(--color-bg))); + --sk-openapi-code-inline-bg: var(--color-bg-code-inline, var(--color-bg-alt)); +} + +/* OpenAPI pages opt out of the generic site chrome (PageShell chrome: .appShell) and own the + whole viewport, so the shell can fix the appbar and scroll its regions independently. */ +body.sk-openapi-shell-body { + margin: 0; + padding: 0; + max-width: none; + display: block; +} + +.sk-openapi-layout { + display: flex; + flex-direction: column; + height: 100dvh; + min-height: 0; + overflow: hidden; + background: var(--color-bg); + color: var(--color-text); +} + +/* ── Appbar ─────────────────────────────────────────────────────────────────── */ +.sk-openapi-appbar { + display: flex; + align-items: center; + gap: 0.75rem; + height: var(--sk-openapi-appbar-height); + flex: 0 0 auto; + padding: 0 1rem; + border-bottom: 1px solid var(--sk-openapi-nav-border); + background: var(--color-header-bg, var(--color-bg)); +} + +.sk-openapi-brand { + font-family: var(--font-heading, var(--font-sans)); + font-weight: 700; + font-size: 1.05rem; + color: var(--color-text); + text-decoration: none; +} + +.sk-openapi-brand:hover { + color: var(--color-accent); +} + +/* Appbar full-text search (distinct from the nav filter). Hidden until openapi-nav.js adds + html.js, so a JS-off reader never sees a control that cannot work. */ +.sk-openapi-search { + position: relative; + margin-left: auto; + display: none; +} + +html.js .sk-openapi-search { + display: block; +} + +.sk-openapi-search-input { + width: 14rem; + max-width: 40vw; + padding: 0.35rem 0.6rem; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--sk-openapi-row-radius); + background: var(--color-bg); + color: var(--color-text); + font: inherit; + font-size: 0.85rem; +} + +.sk-openapi-search-results { + position: absolute; + top: calc(100% + 0.35rem); + right: 0; + width: min(22rem, 80vw); + max-height: 60vh; + overflow-y: auto; + z-index: 50; + padding: 0.3rem; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--sk-openapi-row-radius); + background: var(--sk-openapi-nav-bg); + box-shadow: var(--shadow, 0 4px 20px rgba(0, 0, 0, 0.15)); +} + +.sk-openapi-search-results[hidden] { + display: none; +} + +.sk-openapi-search-hit { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.4rem 0.5rem; + border-radius: var(--sk-openapi-row-radius); + color: var(--color-text); + text-decoration: none; +} + +.sk-openapi-search-hit:hover { + background: var(--sk-openapi-row-hover); +} + +.sk-openapi-search-hit-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The mobile nav toggle is injected by openapi-nav.js; hidden on wide viewports. */ +.sk-openapi-nav-toggle { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--sk-openapi-row-radius); + background: transparent; + color: var(--color-text); + cursor: pointer; + font-size: 1.1rem; + line-height: 1; +} + +/* ── Body row: nav rail | content ───────────────────────────────────────────── */ +.sk-openapi-body { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +/* ── Nav rail ───────────────────────────────────────────────────────────────── */ +.sk-openapi-nav { + flex: 0 0 var(--sk-openapi-nav-width); + width: var(--sk-openapi-nav-width); + overflow-y: auto; + overscroll-behavior: contain; + padding: 1rem 0.75rem 2rem; + border-right: 1px solid var(--sk-openapi-nav-border); + background: var(--sk-openapi-nav-bg); + font-size: 0.9rem; +} + +.sk-openapi-nav-home { + display: block; + padding: 0.4rem 0.6rem; + margin-bottom: 0.5rem; + border-radius: var(--sk-openapi-row-radius); + font-family: var(--font-heading, var(--font-sans)); + font-weight: 700; + color: var(--color-text); + text-decoration: none; +} + +.sk-openapi-nav-home:hover { + background: var(--sk-openapi-row-hover); +} + +.sk-openapi-nav-groups { + list-style: none; + margin: 0; + padding: 0; +} + +.sk-openapi-nav-group { + margin-bottom: 0.35rem; +} + +/* The header row holds the title link plus the JS-injected twist as siblings (the twist + must not nest inside the title's <a> – that would be an interactive inside an + interactive). The flex lives here so the twist and title align on one row. */ +.sk-openapi-nav-group-header { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.sk-openapi-nav-group-title { + flex: 1 1 auto; + min-width: 0; + padding: 0.35rem 0.6rem; + border-radius: var(--sk-openapi-row-radius); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + font-size: 0.74rem; + color: var(--sk-openapi-muted); + text-decoration: none; +} + +a.sk-openapi-nav-group-title:hover { + background: var(--sk-openapi-row-hover); + color: var(--color-text); +} + +/* Twist toggle injected by openapi-nav.js before a group title for collapse/expand. */ +.sk-openapi-nav-twist { + flex: 0 0 auto; + width: 1rem; + height: 1rem; + padding: 0; + border: 0; + background: transparent; + color: var(--sk-openapi-muted); + cursor: pointer; + line-height: 1; + transition: transform var(--transition, 0.15s ease); +} + +.sk-openapi-nav-group.is-collapsed .sk-openapi-nav-twist { + transform: rotate(-90deg); +} + +.sk-openapi-nav-group.is-collapsed .sk-openapi-nav-items { + display: none; +} + +.sk-openapi-nav-items { + list-style: none; + margin: 0 0 0 0.35rem; + padding: 0; + border-left: 1px solid var(--color-border-light, var(--sk-openapi-nav-border)); +} + +.sk-openapi-nav-item { + margin: 1px 0; +} + +.sk-openapi-nav-link { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.55rem; + border-radius: var(--sk-openapi-row-radius); + color: var(--color-text); + text-decoration: none; + transition: background var(--transition, 0.15s ease); +} + +.sk-openapi-nav-link:hover { + background: var(--sk-openapi-row-hover); +} + +.sk-openapi-nav-link.is-active { + background: var(--sk-openapi-active-bg); + color: var(--sk-openapi-active-text); +} + +/* The verb pill inverts to the active token pair when its row is active, so it stays + legible on the accent background for any theme – including a light accent, where a + hard-coded translucent-white pill would wash out. Background = the accent's contrast + color, label = the accent itself: an always-legible pair by the theme's definition. */ +.sk-openapi-nav-link.is-active .sk-openapi-method { + background: var(--sk-openapi-active-text); + color: var(--sk-openapi-active-bg); +} + +/* A long operation summary must never break the rail: clip to one line with an ellipsis. + The full text is on the link's title attribute (tooltip). */ +.sk-openapi-nav-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Deprecated rows dim and strike through, in the rail and on the pages. */ +[data-deprecated="true"] > .sk-openapi-nav-link .sk-openapi-nav-label, +.sk-openapi-nav-item[data-deprecated="true"] .sk-openapi-nav-label { + opacity: 0.55; + text-decoration: line-through; +} + +/* Filter box injected by openapi-nav.js at the top of the rail. */ +.sk-openapi-nav-filter { + width: 100%; + box-sizing: border-box; + margin-bottom: 0.75rem; + padding: 0.4rem 0.6rem; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--sk-openapi-row-radius); + background: var(--color-bg); + color: var(--color-text); + font: inherit; +} + +.sk-openapi-nav-item[hidden], +.sk-openapi-nav-group[hidden] { + display: none; +} + +/* ── Content area ──────────────────────────────────────────────────────────────── + The scroll region is a flex column so the page can grow into any leftover height and + pin the footer to the bottom of the viewport on short pages (the flexbox sticky-footer + pattern). On a tall page the page keeps its content height, the column overflows, and + overflow-y scrolls – the footer flows after the content as normal. */ +.sk-openapi-scroll { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + overflow-y: auto; + padding: 1.5rem; +} + +/* `flex: 1 0 auto` lets the content area grow (pushing the footer down) but never shrink + below its own content, so long pages stay full height and scroll. `width: 100%` keeps + it filling the column up to the max-width cap – a non-stretched flex child would + otherwise shrink-wrap its content – and `margin: 0 auto` then centers it horizontally. */ +.sk-openapi-page { + flex: 1 0 auto; + width: 100%; + max-width: var(--sk-openapi-content-max); + margin: 0 auto; +} + +.sk-openapi-title { + font-family: var(--font-heading, var(--font-sans)); + line-height: 1.2; +} + +.sk-openapi-description { + color: var(--color-text-secondary, var(--sk-openapi-muted)); +} + +.sk-openapi-page code { + font-family: var(--font-mono); + font-size: 0.875em; +} + +/* ── Method badge (shared header + rail) ──────────────────────────────────────── + Base chip style; the per-verb background colors are appended (generated) after this + file by OpenAPIStylesheetRenderer as [data-method="…"] rules. A neutral default keeps + an unknown verb legible. */ +.sk-openapi-method { + flex: 0 0 auto; + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: var(--sk-openapi-row-radius); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.04em; + color: #fff; + background: var(--sk-openapi-muted); + text-transform: uppercase; +} + +.sk-openapi-deprecated { + display: inline-block; + margin-left: 0.5rem; + padding: 0.05rem 0.4rem; + border-radius: var(--sk-openapi-row-radius); + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text); + background: var(--color-bg-alt); + text-transform: uppercase; +} + +/* ── Landing ────────────────────────────────────────────────────────────────── */ +.sk-openapi-landing-header { + margin-bottom: 1.5rem; +} + +.sk-openapi-version { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: var(--sk-openapi-row-radius); + background: var(--color-bg-alt); + color: var(--sk-openapi-muted); + font-family: var(--font-mono); + font-size: 0.8rem; +} + +.sk-openapi-tag-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: var(--sk-openapi-gap); + margin-top: 1.5rem; +} + +.sk-openapi-tag-card { + display: block; + padding: 1rem 1.1rem; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--radius-lg, var(--radius, 10px)); + background: var(--sk-openapi-nav-bg); + color: var(--color-text); + text-decoration: none; + transition: border-color var(--transition, 0.15s ease), box-shadow var(--transition, 0.15s ease); +} + +.sk-openapi-tag-card:hover { + border-color: var(--color-accent); + box-shadow: var(--shadow-hover, var(--shadow, 0 4px 14px rgba(0, 0, 0, 0.08))); +} + +.sk-openapi-tag-card-title { + margin: 0 0 0.35rem; + font-size: 1.1rem; +} + +.sk-openapi-tag-card-desc { + margin: 0 0 0.5rem; + color: var(--sk-openapi-muted); + font-size: 0.9rem; +} + +.sk-openapi-tag-card-count { + margin: 0; + color: var(--color-accent); + font-size: 0.82rem; + font-weight: 600; +} + +/* ── Tag page: operation list ───────────────────────────────────────────────── */ +.sk-openapi-op-list { + list-style: none; + margin: 1rem 0 0; + padding: 0; +} + +.sk-openapi-op-item { + margin: 0; +} + +.sk-openapi-op-link { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border-radius: var(--sk-openapi-row-radius); + border: 1px solid transparent; + color: var(--color-text); + text-decoration: none; +} + +.sk-openapi-op-link:hover { + background: var(--sk-openapi-row-hover); + border-color: var(--sk-openapi-nav-border); +} + +.sk-openapi-op-path { + font-family: var(--font-mono); + font-size: 0.9rem; +} + +.sk-openapi-op-summary { + color: var(--sk-openapi-muted); + font-size: 0.88rem; +} + +/* ── Operation page ─────────────────────────────────────────────────────────── */ +.sk-openapi-op-header { + margin-bottom: 1.5rem; +} + +.sk-openapi-op-line { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--radius, 8px); + background: var(--sk-openapi-nav-bg); +} + +.sk-openapi-op-line .sk-openapi-op-path { + font-size: 1rem; + font-weight: 600; +} + +.sk-openapi-parameters, +.sk-openapi-request-body, +.sk-openapi-responses, +.sk-openapi-security { + margin: 2rem 0; +} + +.sk-openapi-param-table, +.sk-openapi-props { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.sk-openapi-param-table th, +.sk-openapi-props th { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 2px solid var(--sk-openapi-nav-border); + color: var(--sk-openapi-muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.sk-openapi-param-table td, +.sk-openapi-props td { + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--color-border-light, var(--sk-openapi-nav-border)); + vertical-align: top; +} + +.sk-openapi-required { + color: var(--color-accent); + font-size: 0.78rem; + font-weight: 600; +} + +.sk-openapi-optional { + color: var(--sk-openapi-muted); + font-size: 0.78rem; +} + +.sk-openapi-param-in { + font-family: var(--font-mono); + font-size: 0.82rem; + color: var(--sk-openapi-muted); +} + +/* Schema-link chip (a $ref rendered as a link, on operation and schema pages). */ +.sk-openapi-type-ref { + display: inline-block; + padding: 0.05rem 0.4rem; + border-radius: var(--sk-openapi-row-radius); + background: var(--color-bg-alt); + color: var(--color-accent); + font-family: var(--font-mono); + font-size: 0.85em; + text-decoration: none; +} + +.sk-openapi-type-ref:hover { + background: var(--color-accent); + color: var(--sk-openapi-active-text); +} + +.sk-openapi-type { + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--color-text-secondary, var(--sk-openapi-muted)); +} + +/* Response blocks, one per status, with a status code chip. */ +.sk-openapi-response { + margin: 1rem 0; + padding: 0.75rem 1rem; + border: 1px solid var(--sk-openapi-nav-border); + border-left: 4px solid var(--color-accent); + border-radius: var(--radius, 8px); + background: var(--sk-openapi-nav-bg); +} + +.sk-openapi-status code { + font-size: 1rem; + font-weight: 700; +} + +.sk-openapi-response-desc { + color: var(--color-text-secondary, var(--sk-openapi-muted)); +} + +.sk-openapi-media { + margin-top: 0.6rem; +} + +.sk-openapi-content-type code { + color: var(--sk-openapi-muted); +} + +/* Example disclosure: a <details> with a <pre><code> JSON body. */ +.sk-openapi-example { + margin-top: 0.5rem; +} + +.sk-openapi-example summary { + cursor: pointer; + color: var(--color-accent); + font-size: 0.85rem; +} + +.sk-openapi-example-body { + margin: 0.5rem 0 0; + padding: 0.75rem 1rem; + overflow-x: auto; + border-radius: var(--radius, 8px); + background: var(--sk-openapi-code-bg); + font-family: var(--font-mono); + font-size: 0.82rem; + line-height: 1.5; +} + +.sk-openapi-security ul { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; +} + +.sk-openapi-security li { + padding: 0.2rem 0; +} + +/* ── Schema page ────────────────────────────────────────────────────────────── */ +.sk-openapi-schema-header { + margin-bottom: 1rem; +} + +.sk-openapi-facets { + display: flex; + gap: 0.4rem; + margin: 0.5rem 0; +} + +.sk-openapi-facet { + padding: 0.05rem 0.45rem; + border-radius: var(--sk-openapi-row-radius); + background: var(--color-bg-alt); + color: var(--sk-openapi-muted); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.sk-openapi-enum ul { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + list-style: none; + margin: 0.5rem 0 0; + padding: 0; +} + +.sk-openapi-enum li { + padding: 0.1rem 0.5rem; + border-radius: var(--sk-openapi-row-radius); + background: var(--color-bg-alt); + font-family: var(--font-mono); + font-size: 0.82rem; +} + +.sk-openapi-composition ul { + list-style: none; + margin: 0.5rem 0 0; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.sk-openapi-discriminator { + color: var(--sk-openapi-muted); + font-size: 0.88rem; +} + +/* ── Skip link ──────────────────────────────────────────────────────────────── + PageShell emits a "Skip to content" link with no base styling, so it would sit + visible at the top of every page. Clip it off-screen until it receives keyboard + focus, the standard accessible pattern (scoped to the OpenAPI surface: this rule + only ships in openapi.css). */ +.sk-openapi-shell-body .sk-skip-link { + position: absolute; + left: -9999px; + top: 0; + width: 1px; + height: 1px; + overflow: hidden; +} + +.sk-openapi-shell-body .sk-skip-link:focus { + left: 0; + width: auto; + height: auto; + z-index: 100; + margin: 0.5rem; + padding: 0.5rem 0.9rem; + border-radius: var(--sk-openapi-row-radius); + background: var(--color-accent); + color: var(--sk-openapi-active-text); + text-decoration: none; +} + +/* ── Theme toggle (appbar) ──────────────────────────────────────────────────── + Consistent with the base SiteKit DocC toggle: always rendered, inert without JS + (openapi-theme.js wires the click and swaps the icon). */ +.sk-openapi-theme-toggle { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-left: 0.5rem; + border: 1px solid var(--sk-openapi-nav-border); + border-radius: var(--sk-openapi-row-radius); + background: transparent; + color: var(--color-text); + cursor: pointer; +} + +.sk-openapi-theme-toggle:hover { + background: var(--sk-openapi-row-hover); + color: var(--color-accent); +} + +/* ── Footer ─────────────────────────────────────────────────────────────────── + Config-driven (SiteConfig.footer), token-styled, scrolls with the content like + the DocC footer. Omitted entirely when nothing is configured. */ +.sk-openapi-footer { + margin-top: 3rem; + padding: 1.5rem; + border-top: 1px solid var(--sk-openapi-nav-border); + color: var(--sk-openapi-muted); + font-size: 0.85rem; +} + +.sk-openapi-footer-links { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.sk-openapi-footer-link { + color: var(--color-text-secondary, var(--sk-openapi-muted)); + text-decoration: none; +} + +.sk-openapi-footer-link:hover { + color: var(--color-accent); +} + +.sk-openapi-footer-copyright { + margin: 0; +} + +/* ── 404 ────────────────────────────────────────────────────────────────────── + The not-found page renders through the full shell; the back-to-landing link is + an accent call to action. */ +.sk-openapi-notfound-home { + color: var(--color-accent); + font-weight: 600; + text-decoration: none; +} + +.sk-openapi-notfound-home:hover { + text-decoration: underline; +} + +/* The scrim is hidden until the drawer opens (the responsive rule below reveals it). */ +.sk-openapi-scrim { + display: none; +} + +/* ── Responsive: nav becomes an off-canvas drawer ───────────────────────────── + Cut-the-mustard: the off-canvas drawer is gated behind `html.js` (the script adds + that class early). With JS the rail slides in from the hamburger (below); without JS + the gate never matches, so the `html:not(.js)` rules render the rail in normal flow – + stacked above the content and fully reachable, just not a drawer. */ +@media (max-width: 860px) { + html.js .sk-openapi-nav-toggle { + display: inline-flex; + } + + html.js .sk-openapi-nav { + position: fixed; + top: var(--sk-openapi-appbar-height); + bottom: 0; + left: 0; + z-index: 40; + transform: translateX(-100%); + transition: transform var(--transition, 0.2s ease); + box-shadow: var(--shadow, 0 4px 20px rgba(0, 0, 0, 0.15)); + } + + html.js .sk-openapi-layout.is-nav-open .sk-openapi-nav { + transform: translateX(0); + } + + /* Backdrop behind the open drawer: dims the content and is the tap-outside-to-close + target (openapi-nav.js un-hides it and wires the click). Only with JS, since the + drawer itself only exists with JS. */ + html.js .sk-openapi-layout.is-nav-open .sk-openapi-scrim { + display: block; + position: fixed; + top: var(--sk-openapi-appbar-height); + right: 0; + bottom: 0; + left: 0; + z-index: 35; + background: rgba(0, 0, 0, 0.45); + } + + /* JS off: drop the fixed-viewport app-shell so the page scrolls normally, and stack + the rail above the content (full width, no inner scroll trap) so it is reachable. */ + html:not(.js) .sk-openapi-layout { + height: auto; + min-height: 100dvh; + overflow: visible; + } + + html:not(.js) .sk-openapi-body { + flex-direction: column; + } + + html:not(.js) .sk-openapi-nav { + flex: 0 0 auto; + width: auto; + overflow: visible; + border-right: 0; + border-bottom: 1px solid var(--sk-openapi-nav-border); + } +} + +@media (prefers-reduced-motion: reduce) { + .sk-openapi-nav, + .sk-openapi-nav-link, + .sk-openapi-nav-twist { + transition: none; + } +} diff --git a/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift b/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift new file mode 100644 index 0000000..2c73208 --- /dev/null +++ b/Sources/SiteKitOpenAPI/SiteBuilder+OpenAPI.swift @@ -0,0 +1,143 @@ +import Foundation +import SiteKit + +extension SiteBuilder { + /// OpenAPI documentation site: renders an OpenAPI 3.0/3.1 spec (YAML or JSON) + /// into a multi-page, style-conforming API-documentation site. + /// + /// The spec is discovered by convention at `Content/openapi.yaml` (falling + /// back to `openapi.yml` / `openapi.json`), or pointed at explicitly with + /// `specPath`. It is loaded up front and any discovery/decoding problem is + /// logged as a warning – the build then continues (warn-and-continue), so a + /// missing or malformed spec yields a site without the API pages rather than + /// aborting the build. + /// A future revision could make this fail-fast once the page renderers consume the + /// loaded spec (a build-phase error surface fits better than a throwing factory, since + /// SiteKit factories are non-throwing by convention like `.docc(...)`). + /// + /// Like `.docc(...)`, the blueprint brings its own shell and reads the token + /// CSS variables, so all color schemes work and no layout is touched. When the + /// spec loads, the landing, tag, operation, and schema page renderers consume it + /// and produce the multi-page docs site; alongside them this factory wires the + /// content-independent system renderers (sitemap, robots, CSS, favicons, llms.txt). + /// + /// - Parameters: + /// - config: The site configuration. + /// - projectDirectory: The site's root directory (holds `Content/`). + /// - cleanBeforeBuild: Whether to wipe the output directory first. + /// - specPath: An explicit spec location relative to `projectDirectory`, + /// overriding the conventional `Content/openapi.yaml` discovery. + public static func openAPI( + config: SiteConfig, + projectDirectory: URL, + cleanBeforeBuild: Bool = true, + specPath: String? = nil + ) -> SiteBuilder { + var builder = SiteBuilder(config: config, projectDirectory: projectDirectory) + .cleanBeforeBuild(cleanBeforeBuild) + + // Discover and load the spec now; on any problem log a warning and continue + // (warn-and-continue), so a missing or malformed spec yields a site without the + // API pages rather than aborting the build. The loaded model is injected into + // each page renderer (the renderers are OpenAPIKit-free and read only OpenAPISpec). + // A real fail-fast surface (build-phase error) may eventually fit better than a silently + // empty site once consumers rely on the API pages – factories are non-throwing by + // convention like `.docc(...)`, so revisit then. + if let specURL = Self.resolveSpecURL(specPath: specPath, config: config, projectDirectory: projectDirectory) { + do { + let spec = try OpenAPISpecLoader().load(source: specURL) + builder = + builder + .openAPIPageRenderers(for: spec) + // The 404 renders through the full OpenAPIShell (appbar + nav + footer) so a reader + // on a missing URL can navigate back; it needs the spec for the shared nav rail. + .renderer(OpenAPIMissingPage(spec: spec)) + // Register the spec-derived pages into the build context so sitemap, nav-index, + // search index, and llms.txt all enumerate them (they walk context.sections). + .contentSectionProvider(OpenAPIContentProvider(spec: spec)) + } catch { + print("[SiteKit] Warning: OpenAPI spec at '\(specURL.path)' could not be loaded – \(error)") + } + } else { + print( + "[SiteKit] Warning: no OpenAPI spec found (looked for '\(config.contentDirectory)/openapi.yaml', '.yml', '.json'). The OpenAPI blueprint needs a spec file." + ) + } + + // The OpenAPI pages live at nested paths the URL router cannot derive, so the + // machine-index renderers resolve each page to its real OpenAPIRoutes URL via the + // page's stamped `openAPIPath`. One resolver, shared by sitemap, nav-index, search. + let pathResolvers: [any PagePathResolving] = [OpenAPIPagePathResolver()] + + return + builder + .renderer(SitemapRenderer(pathResolvers: pathResolvers)) + .renderer(RobotsTxtRenderer()) + .renderer(NavIndexRenderer(pathResolvers: pathResolvers)) + .renderer(OpenAPISearchIndexRenderer(pathResolvers: pathResolvers)) + .renderer(OpenAPISearchScriptRenderer()) + .renderer(TokenCSSOutputRenderer()) + .renderer(BaseCSSOutputRenderer()) + .renderer(FontsFaceCSSRenderer()) + .renderer(OpenAPIStylesheetRenderer()) + .renderer(OpenAPINavScriptRenderer()) + .renderer(OpenAPIThemeScriptRenderer()) + // System renderers at parity with `.docc`: the two redirect renderers (both no-ops + // unless `SiteConfig.redirectsFile` is set). The 404 is the OpenAPIShell-rendered + // `OpenAPIMissingPage`, registered above with the spec. + .renderer(CloudflareHeadersRenderer()) + .renderer(HTMLRedirectPageRenderer()) + .renderer(CloudflareRedirectsRenderer()) + .renderer(FaviconRenderer()) + .renderer(OpenAPILlmsTxtRenderer()) + } + + /// Convenience overload that loads the `SiteConfig` from `configPath` (relative to the + /// current directory) and uses the current directory as the project root, so a `Site` + /// executable is a one-liner: `try SiteBuilder.openAPI(configPath: "SiteConfig.yaml").run()` + /// – the same shape as `.docc(configPath:)`. + /// + /// - Parameters: + /// - configPath: Path to the `SiteConfig.yaml`, relative to the current directory. + /// - cleanBeforeBuild: Whether to wipe the output directory first. + /// - specPath: An explicit spec location relative to the project root, overriding the + /// conventional `Content/openapi.yaml` discovery. + public static func openAPI( + configPath: String, + cleanBeforeBuild: Bool = true, + specPath: String? = nil + ) throws -> SiteBuilder { + let projectDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) + let config = try SiteConfig.load(contentsOf: URL(fileURLWithPath: configPath, relativeTo: projectDirectory).absoluteURL) + return self.openAPI( + config: config, + projectDirectory: projectDirectory, + cleanBeforeBuild: cleanBeforeBuild, + specPath: specPath + ) + } + + /// Registers the OpenAPI page renderers (landing, tag, operation, schema) for a + /// loaded `spec`. Each renderer captures the spec and produces its pages from it. + func openAPIPageRenderers(for spec: OpenAPISpec) -> SiteBuilder { + self + .renderer(OpenAPILandingPage(spec: spec)) + .renderer(OpenAPITagPage(spec: spec)) + .renderer(OpenAPIOperationPage(spec: spec)) + .renderer(OpenAPISchemaPage(spec: spec)) + } + + /// Resolves the spec file URL: the explicit `specPath` (relative to the + /// project root) when given, otherwise the first existing conventional + /// candidate under the content directory. Returns `nil` when no spec exists. + private static func resolveSpecURL(specPath: String?, config: SiteConfig, projectDirectory: URL) -> URL? { + if let specPath { + return projectDirectory.appendingPathComponent(specPath) + } + + let contentDirectory = projectDirectory.appendingPathComponent(config.contentDirectory) + let candidates = ["openapi.yaml", "openapi.yml", "openapi.json"] + .map { contentDirectory.appendingPathComponent($0) } + return candidates.first { FileManager.default.fileExists(atPath: $0.path) } + } +} diff --git a/Tests/SiteKitCLITests/BlueprintCatalogTests.swift b/Tests/SiteKitCLITests/BlueprintCatalogTests.swift index 58c606b..e95f852 100644 --- a/Tests/SiteKitCLITests/BlueprintCatalogTests.swift +++ b/Tests/SiteKitCLITests/BlueprintCatalogTests.swift @@ -1,5 +1,6 @@ import Foundation import Testing + @testable import SiteKitCLI @Suite("BlueprintCatalog") @@ -27,10 +28,12 @@ struct BlueprintCatalogTests { defer { try? manager.removeItem(at: catalog) } let blueprints = try BlueprintCatalog.all(in: catalog) - #expect(blueprints == [ - Blueprint(name: "Blog", description: "A full-featured blog."), - Blueprint(name: "Podcast", description: "A podcast website."), - ]) + #expect( + blueprints == [ + Blueprint(name: "Blog", description: "A full-featured blog."), + Blueprint(name: "Podcast", description: "A podcast website."), + ] + ) } @Test("Strips the bold markers from the line-3 description") @@ -62,11 +65,11 @@ struct BlueprintCatalogTests { } } - @Test("The real shipped catalog has the 9 blueprints") + @Test("The real shipped catalog has the 10 blueprints") func realCatalogHasAllBlueprints() throws { let blueprints = try BlueprintCatalog.all(in: PackageRoot.blueprintsDirectory) let names = blueprints.map(\.name).sorted() - #expect(names == ["AppLanding", "Blog", "DocC", "IndieDev", "Newsletter", "Plain", "Podcast", "Portfolio", "Snippets"]) + #expect(names == ["AppLanding", "Blog", "DocC", "IndieDev", "Newsletter", "OpenAPI", "Plain", "Podcast", "Portfolio", "Snippets"]) #expect(blueprints.allSatisfy { !$0.description.isEmpty }) } } diff --git a/Tests/SiteKitOpenAPITests/Fixtures/components-refs-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/components-refs-3.1.yaml new file mode 100644 index 0000000..7594e57 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/components-refs-3.1.yaml @@ -0,0 +1,79 @@ +openapi: 3.1.0 +info: + title: Refs API + version: 1.0.0 + description: Exercises in-file component $ref resolution for parameters, responses, and request bodies. +tags: + - name: items + description: Item endpoints that factor shared parameters and responses into components. +paths: + /items: + get: + operationId: listItems + summary: List items + tags: [items] + parameters: + - $ref: '#/components/parameters/PageLimit' + responses: + '200': + description: A page of items. + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + '401': + $ref: '#/components/responses/Unauthorized' + post: + operationId: createItem + summary: Create an item + tags: [items] + requestBody: + $ref: '#/components/requestBodies/ItemBody' + responses: + '201': + description: Created. + content: + application/json: + schema: + $ref: '#/components/schemas/Item' +components: + parameters: + PageLimit: + name: limit + in: query + description: Maximum number of items to return. + required: false + schema: + type: integer + format: int32 + requestBodies: + ItemBody: + description: The item to create. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + responses: + Unauthorized: + description: Authentication is required. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + schemas: + Item: + type: object + required: [id] + properties: + id: + type: integer + format: int64 + name: + type: string + Error: + type: object + required: [message] + properties: + message: + type: string diff --git a/Tests/SiteKitOpenAPITests/Fixtures/dangling-ref-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/dangling-ref-3.1.yaml new file mode 100644 index 0000000..17ca9eb --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/dangling-ref-3.1.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Dangling Refs API + version: 1.0.0 + description: A spec whose operation references components that are not defined, exercising the visible-placeholder path. +tags: + - name: things + description: Endpoints with unresolvable references. +paths: + /things: + get: + operationId: listThings + summary: List things + tags: [things] + parameters: + - $ref: '#/components/parameters/DoesNotExist' + responses: + '200': + $ref: '#/components/responses/AlsoMissing' +components: + schemas: + Thing: + type: object + properties: + id: + type: string diff --git a/Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml b/Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml new file mode 100644 index 0000000..0dc8267 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/features-3.0.yaml @@ -0,0 +1,43 @@ +openapi: 3.0.0 +info: + title: Feature Zoo + version: 2.0.0 +paths: {} +components: + schemas: + Widget: + type: object + required: + - status + properties: + nickname: + type: string + nullable: true + status: + type: string + enum: + - available + - pending + - sold + legacyId: + type: string + deprecated: true + Cat: + type: object + properties: + huntingSkill: + type: string + Dog: + type: object + properties: + packSize: + type: integer + Animal: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' diff --git a/Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml new file mode 100644 index 0000000..a6c4fcf --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/features-3.1.yaml @@ -0,0 +1,42 @@ +openapi: 3.1.0 +info: + title: Feature Zoo + version: 2.0.0 +paths: {} +components: + schemas: + Widget: + type: object + required: + - status + properties: + nickname: + type: ["string", "null"] + status: + type: string + enum: + - available + - pending + - sold + legacyId: + type: string + deprecated: true + Cat: + type: object + properties: + huntingSkill: + type: string + Dog: + type: object + properties: + packSize: + type: integer + Animal: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + discriminator: + propertyName: petType + mapping: + cat: '#/components/schemas/Cat' + dog: '#/components/schemas/Dog' diff --git a/Tests/SiteKitOpenAPITests/Fixtures/multi-tag-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/multi-tag-3.1.yaml new file mode 100644 index 0000000..7003376 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/multi-tag-3.1.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Multi-tag API + version: 1.0.0 + description: An operation that carries two tags, to exercise cross-listing. +tags: + - name: pets + description: Pet endpoints. + - name: admin + description: Administrative endpoints. +paths: + /pets: + get: + operationId: listPets + summary: List pets + tags: [pets] + responses: + '200': + description: OK. + /pets/{id}/ban: + post: + operationId: banPet + summary: Ban a pet + tags: [pets, admin] + responses: + '200': + description: OK. diff --git a/Tests/SiteKitOpenAPITests/Fixtures/nav-deprecated-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/nav-deprecated-3.1.yaml new file mode 100644 index 0000000..6cdc871 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/nav-deprecated-3.1.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Deprecated Nav API + version: 1.0.0 + description: A spec with a deprecated operation, to exercise the nav deprecated hook. +tags: + - name: legacy + description: Legacy endpoints. +paths: + /old: + get: + operationId: oldEndpoint + summary: Old endpoint + tags: [legacy] + deprecated: true + responses: + '200': + description: OK. +components: + schemas: + Legacy: + type: object + properties: + id: + type: string diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json new file mode 100644 index 0000000..1726472 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.json @@ -0,0 +1,140 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Swagger Petstore", + "version": "1.0.0", + "description": "A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification." + }, + "servers": [ + { + "url": "https://petstore.swagger.io/v1", + "description": "Production server" + } + ], + "tags": [ + { + "name": "pets", + "description": "Everything about your Pets" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { "type": "integer", "format": "int32" } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pets" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": ["pets"], + "requestBody": { + "description": "The pet to create", + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "responses": { + "201": { "description": "Null response" }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "Pets": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml new file mode 100644 index 0000000..91435c9 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.0.yaml @@ -0,0 +1,116 @@ +openapi: 3.0.0 +info: + title: Swagger Petstore + version: 1.0.0 + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification. +servers: + - url: https://petstore.swagger.io/v1 + description: Production server +tags: + - name: pets + description: Everything about your Pets +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + content: + application/json: + schema: + $ref: '#/components/schemas/Pets' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + requestBody: + description: The pet to create + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json new file mode 100644 index 0000000..901d780 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.json @@ -0,0 +1,140 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Swagger Petstore", + "version": "1.0.0", + "description": "A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification." + }, + "servers": [ + { + "url": "https://petstore.swagger.io/v1", + "description": "Production server" + } + ], + "tags": [ + { + "name": "pets", + "description": "Everything about your Pets" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { "type": "integer", "format": "int32" } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pets" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": ["pets"], + "requestBody": { + "description": "The pet to create", + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "responses": { + "201": { "description": "Null response" }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": ["pets"], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "Pets": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" } + } + } + } + } +} diff --git a/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml new file mode 100644 index 0000000..a493037 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/petstore-3.1.yaml @@ -0,0 +1,116 @@ +openapi: 3.1.0 +info: + title: Swagger Petstore + version: 1.0.0 + description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI specification. +servers: + - url: https://petstore.swagger.io/v1 + description: Production server +tags: + - name: pets + description: Everything about your Pets +paths: + /pets: + get: + summary: List all pets + operationId: listPets + tags: + - pets + parameters: + - name: limit + in: query + description: How many items to return at one time (max 100) + required: false + schema: + type: integer + format: int32 + responses: + '200': + description: A paged array of pets + content: + application/json: + schema: + $ref: '#/components/schemas/Pets' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: Create a pet + operationId: createPets + tags: + - pets + requestBody: + description: The pet to create + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + responses: + '201': + description: Null response + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /pets/{petId}: + get: + summary: Info for a specific pet + operationId: showPetById + tags: + - pets + parameters: + - name: petId + in: path + required: true + description: The id of the pet to retrieve + schema: + type: string + responses: + '200': + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Pet: + type: object + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string + Pets: + type: array + items: + $ref: '#/components/schemas/Pet' + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + message: + type: string diff --git a/Tests/SiteKitOpenAPITests/Fixtures/slug-collisions-3.1.yaml b/Tests/SiteKitOpenAPITests/Fixtures/slug-collisions-3.1.yaml new file mode 100644 index 0000000..8745fc0 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/slug-collisions-3.1.yaml @@ -0,0 +1,53 @@ +openapi: 3.1.0 +info: + title: Collisions API + version: 1.0.0 + description: Two tags and two operations that pre-fold to the same slug, plus an accented tag name. +tags: + - name: Pets + description: Capitalised tag. + - name: pets + description: Lowercase tag, folds to the same slug as Pets. + - name: Café + description: Accented tag name, folds to an ASCII slug. +paths: + /a: + get: + operationId: listPets + summary: List via Pets + tags: [Pets] + responses: + '200': + description: OK. + /b: + get: + operationId: getPets + summary: Get via pets + tags: [pets] + responses: + '200': + description: OK. + /store/list: + get: + operationId: listItems + summary: List items + tags: [Pets] + responses: + '200': + description: OK. + /store/list-again: + get: + operationId: ListItems + summary: List items again + tags: [Pets] + responses: + '200': + description: OK. + /menu: + get: + operationId: menu + summary: Café menu + tags: [Café] + responses: + '200': + description: OK. diff --git a/Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml b/Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml new file mode 100644 index 0000000..1652c34 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/Fixtures/swagger-2.0.yaml @@ -0,0 +1,27 @@ +swagger: '2.0' +info: + title: Legacy Petstore + version: 1.0.0 +host: petstore.swagger.io +basePath: /v1 +schemes: + - https +paths: + /pets: + get: + summary: List all pets + operationId: listPets + responses: + '200': + description: A list of pets +definitions: + Pet: + type: object + required: + - id + properties: + id: + type: integer + format: int64 + name: + type: string diff --git a/Tests/SiteKitOpenAPITests/OpenAPIChromeTests.swift b/Tests/SiteKitOpenAPITests/OpenAPIChromeTests.swift new file mode 100644 index 0000000..ef040b6 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/OpenAPIChromeTests.swift @@ -0,0 +1,251 @@ +import Foundation +import SiteKit +import Testing + +@testable import SiteKitOpenAPI + +/// Chrome tests for the finishing slice: the config-driven footer, the 404 + redirect +/// renderers, and the three accessibility polish items (skip-link focus reveal, mobile drawer +/// backdrop + close handlers, and the theme toggle made consistent with base SiteKit). +@Suite("OpenAPI chrome") +struct OpenAPIChromeTests { + private func petstoreSpec() throws -> OpenAPISpec { + let url = try #require(Bundle.module.url(forResource: "petstore-3.1", withExtension: "yaml", subdirectory: "Fixtures")) + return try OpenAPISpecLoader().load(source: url) + } + + private func config(footer: FooterConfig? = nil, redirectsFile: String? = nil) -> SiteConfig { + SiteConfig( + name: "Petstore", + baseURL: "https://example.com", + description: "Petstore API docs.", + footer: footer, + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")], + redirectsFile: redirectsFile + ) + } + + private func context(footer: FooterConfig? = nil) -> BuildContext { + BuildContext( + config: self.config(footer: footer), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIChromeSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + private func landingHTML(footer: FooterConfig? = nil) throws -> String { + try #require(try OpenAPILandingPage(spec: try self.petstoreSpec()).render(context: self.context(footer: footer)).first?.content) + } + + private func stylesheet() throws -> String { + try #require(try OpenAPIStylesheetRenderer().render(context: self.context()).first?.content) + } + + /// The declaration block (between `{` and the next `}`) of an exact CSS rule in `css`, + /// or nil when the rule is absent. Matches `selector {` so a compound selector like + /// `.sk-openapi-page code` does not collide with the bare `.sk-openapi-page` rule. + private static func declarations(of selector: String, in css: String) -> String? { + guard let open = css.range(of: "\(selector) {") else { return nil } + guard let close = css.range(of: "}", range: open.upperBound..<css.endIndex) else { return nil } + return String(css[open.upperBound..<close.lowerBound]) + } + + // MARK: - Footer + + @Test("The footer renders from config and is omitted when nothing is configured") + func footerFromConfig() throws { + let footer = FooterConfig( + links: [NavigationItemConfig(title: "Privacy", url: "/privacy/")], + copyright: "© 2026 Example" + ) + let withFooter = try self.landingHTML(footer: footer) + #expect(withFooter.contains("<footer class=\"sk-openapi-footer\">")) + #expect(withFooter.contains(">Privacy</a>")) + #expect(withFooter.contains("© 2026 Example")) + + // No footer config -> no footer element at all. + let withoutFooter = try self.landingHTML(footer: nil) + #expect(!withoutFooter.contains("sk-openapi-footer")) + } + + @Test("The stylesheet carries the flexbox sticky-footer hooks (scroll is a column, page grows)") + func stickyFooterHooks() throws { + let css = try self.stylesheet() + // The scroll region is a flex column so its children stack and one can grow into the + // leftover height – the basis of the flexbox sticky-footer pattern. + let scroll = try #require(Self.declarations(of: ".sk-openapi-scroll", in: css)) + #expect(scroll.contains("display: flex")) + #expect(scroll.contains("flex-direction: column")) + // The content area grows to push the footer to the bottom on short pages, yet never + // shrinks below its content so a long page stays full height and scrolls. + let page = try #require(Self.declarations(of: ".sk-openapi-page", in: css)) + #expect(page.contains("flex: 1 0 auto")) + } + + // MARK: - 404 (rendered through the full shell) + + @Test("The 404 page renders through the full shell with a way back into the docs") + func notFoundRendersFullShell() throws { + let footer = FooterConfig(links: [NavigationItemConfig(title: "Privacy", url: "/privacy/")], copyright: "© 2026 Example") + let files = try OpenAPIMissingPage(spec: try self.petstoreSpec()).render(context: self.context(footer: footer)) + let notFound = try #require(files.first { $0.outputPath.lastPathComponent == "404.html" }) + let html = notFound.content + + // Appbar (brand link back to the landing), the nav rail, and the footer – the full shell. + #expect(html.contains("sk-openapi-brand")) + #expect(html.contains("<nav class=\"sk-openapi-nav\"")) + #expect(html.contains("sk-openapi-footer")) + // The not-found message and the explicit link back to the API landing. + #expect(html.contains("Page not found")) + #expect(html.contains("sk-openapi-notfound-home")) + #expect(html.contains("href=\"/api/\"")) + } + + // MARK: - F2 skip link + + @Test("The skip link is hidden until focus on the OpenAPI surface") + func skipLinkHiddenUntilFocus() throws { + let css = try self.stylesheet() + #expect(css.contains(".sk-openapi-shell-body .sk-skip-link {")) + #expect(css.contains(".sk-openapi-shell-body .sk-skip-link:focus {")) + } + + // MARK: - F3 drawer backdrop + close + + @Test("The mobile drawer has a backdrop element and close handlers") + func drawerBackdropAndClose() throws { + // The shell renders the scrim element. + #expect(try self.landingHTML().contains("data-openapi-nav-scrim")) + + // The stylesheet shows the scrim behind the open drawer, gated by html.js. + let css = try self.stylesheet() + #expect(css.contains("html.js .sk-openapi-layout.is-nav-open .sk-openapi-scrim")) + + // The nav script wires the scrim click and the Escape key to close the drawer. + let js = try #require(try OpenAPINavScriptRenderer().render(context: self.context()).first?.content) + #expect(js.contains("data-openapi-nav-scrim")) + #expect(js.contains("\"Escape\"")) + } + + @Test("The open drawer contains focus: background inert, scroll locked, rail aria-modal") + func drawerContainsFocus() throws { + let js = try #require(try OpenAPINavScriptRenderer().render(context: self.context()).first?.content) + // The mechanism mirrors docc-sidebar.js: inert the background content, lock body scroll, + // and mark the rail a modal dialog – all driven by the open flag, so they clear on close. + #expect(js.contains("mainEl.inert = open")) + #expect(js.contains("documentElement.style.overflow = open ?")) + #expect(js.contains("\"aria-modal\"")) + #expect(js.contains("removeAttribute(\"aria-modal\")")) + } + + // MARK: - F4 theme toggle (consistent with base SiteKit) + + @Test("The theme toggle, head-init, and theme script are wired consistently with base") + func themeToggleConsistentWithBase() throws { + let html = try self.landingHTML() + // The appbar toggle button. + #expect(html.contains("data-openapi-theme-toggle")) + // The flash-free inline init reads the shared localStorage "theme" key + OS preference. + #expect(html.contains("localStorage.getItem('theme')")) + #expect(html.contains("prefers-color-scheme:dark")) + // The deferred toggle script is linked. + #expect(html.contains("<script defer src=\"/assets/js/openapi-theme.js\"></script>")) + + // The script renders and uses the same storage key + data-theme contract. + let js = try #require(try OpenAPIThemeScriptRenderer().render(context: self.context()).first?.content) + #expect(js.contains("\"theme\"")) + #expect(js.contains("data-theme")) + } + + @Test("The head-init defers to the theme's own headInlineScript when one is configured") + func headInitDefersToThemeConfig() throws { + let themed = BuildContext( + config: self.config(), + themeConfig: ThemeConfig(name: "Custom", headInlineScript: "/* author init */"), + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIChromeThemed"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + let html = try #require(try OpenAPILandingPage(spec: try self.petstoreSpec()).render(context: themed).first?.content) + // The shell does not emit its default init (the author's headInlineScript owns init). + #expect(!html.contains("localStorage.getItem('theme')")) + } + + // MARK: - Provider does not silently drop pages (CR followup C) + + @Test("The content provider still registers the pages when no section is explicitly configured") + func providerRegistersWithSynthesizedSection() throws { + // A config with no explicit sections: effectiveSections synthesizes a default, so the + // provider attaches the pages there rather than silently dropping every API page. + let bareConfig = SiteConfig(name: "Petstore", baseURL: "https://example.com", sections: nil) + let bareContext = BuildContext( + config: bareConfig, + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIChromeBare"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + let section = try #require(OpenAPIContentProvider(spec: try self.petstoreSpec()).contentSection(in: bareContext)) + #expect(!section.pages.isEmpty) + } + + // MARK: - Full build: footer + 404 + redirects all ship + + @Test("A full .openAPI build emits the footer, the 404 page, and the redirect outputs") + func chromeFullBuild() throws { + let projectDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("sitekit-openapi-chrome-\(UUID().uuidString)") + let contentDirectory = projectDirectory.appendingPathComponent("Content") + try FileManager.default.createDirectory(at: contentDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projectDirectory) } + + let fixture = try #require(Bundle.module.url(forResource: "petstore-3.1", withExtension: "yaml", subdirectory: "Fixtures")) + try FileManager.default.copyItem(at: fixture, to: contentDirectory.appendingPathComponent("openapi.yaml")) + try """ + redirects: + - from: /old-endpoint/ + to: /api/pets/showpetbyid/ + """.write(to: projectDirectory.appendingPathComponent("redirects.yaml"), atomically: true, encoding: .utf8) + + let footer = FooterConfig( + links: [NavigationItemConfig(title: "Imprint", url: "/imprint/")], + copyright: "© 2026 Example" + ) + + try SiteBuilder + .openAPI(config: self.config(footer: footer, redirectsFile: "redirects.yaml"), projectDirectory: projectDirectory) + .buildPipeline() + .build() + + let output = projectDirectory.appendingPathComponent("_Site") + func exists(_ relativePath: String) -> Bool { + FileManager.default.fileExists(atPath: output.appendingPathComponent(relativePath).path) + } + func read(_ relativePath: String) throws -> String { + try String(contentsOf: output.appendingPathComponent(relativePath), encoding: .utf8) + } + + // 404 page shipped, through the full shell (appbar + nav rail + back-to-landing link). + #expect(exists("404.html")) + let notFound = try read("404.html") + #expect(notFound.contains("sk-openapi-brand")) + #expect(notFound.contains("<nav class=\"sk-openapi-nav\"")) + #expect(notFound.contains("sk-openapi-notfound-home")) + // Redirect outputs: the Cloudflare `_redirects` map and the HTML stub. + #expect(exists("_redirects")) + #expect(exists("old-endpoint/index.html")) + // Footer on a rendered API page. + #expect(try read("api/index.html").contains("sk-openapi-footer")) + } +} diff --git a/Tests/SiteKitOpenAPITests/OpenAPIRenderersTests.swift b/Tests/SiteKitOpenAPITests/OpenAPIRenderersTests.swift new file mode 100644 index 0000000..ce0e039 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/OpenAPIRenderersTests.swift @@ -0,0 +1,739 @@ +import Foundation +import SiteKit +import Testing + +@testable import SiteKitOpenAPI + +/// HTML-structure and sample-site tests for the OpenAPI page renderers. The fixtures +/// are the bundled Petstore specs; the assertions check the semantic output (classes, +/// `data-` attributes, paths, names) the stylesheet targets, plus the expected +/// `OutputFile` set for a full sample build. +@Suite("OpenAPI renderers") +struct OpenAPIRenderersTests { + private func petstoreSpec() throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: "petstore-3.1", withExtension: "yaml", subdirectory: "Fixtures") + ) + return try OpenAPISpecLoader().load(source: url) + } + + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "Petstore", + baseURL: "https://example.com", + description: "Sample API docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPISite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + // MARK: - Sample site: the OutputFile set + + @Test("The four renderers produce one landing + one per tag + one per operation + one per schema") + func sampleSiteOutputFiles() throws { + let spec = try self.petstoreSpec() + let context = self.makeContext() + + var paths: [String] = [] + for renderer: any Renderer in [ + OpenAPILandingPage(spec: spec), + OpenAPITagPage(spec: spec), + OpenAPIOperationPage(spec: spec), + OpenAPISchemaPage(spec: spec), + ] { + paths += try renderer.render(context: context).map(\.outputPath.path) + } + + let expected = [ + "/tmp/_OpenAPISite/api/index.html", + "/tmp/_OpenAPISite/api/pets/index.html", + "/tmp/_OpenAPISite/api/pets/listpets/index.html", + "/tmp/_OpenAPISite/api/pets/createpets/index.html", + "/tmp/_OpenAPISite/api/pets/showpetbyid/index.html", + "/tmp/_OpenAPISite/api/schemas/pet/index.html", + "/tmp/_OpenAPISite/api/schemas/pets/index.html", + "/tmp/_OpenAPISite/api/schemas/error/index.html", + ] + #expect(Set(paths) == Set(expected)) + #expect(paths.count == expected.count) + } + + // MARK: - Landing + + @Test("Landing page has a tag card linking to each tag page") + func landingHasTagCards() throws { + let spec = try self.petstoreSpec() + let context = self.makeContext() + let html = try #require(try OpenAPILandingPage(spec: spec).render(context: context).first?.content) + + #expect(html.contains("Swagger Petstore")) + #expect(html.contains("class=\"sk-openapi-tag-card\"")) + #expect(html.contains("href=\"/api/pets/\"")) + #expect(html.contains(">pets<")) + } + + // MARK: - Tag page + + @Test("Tag page lists its operations with method badges linking to operation pages") + func tagPageListsOperations() throws { + let spec = try self.petstoreSpec() + let context = self.makeContext() + let html = try #require(try OpenAPITagPage(spec: spec).render(context: context).first?.content) + + #expect(html.contains("data-method=\"get\"")) + #expect(html.contains("data-method=\"post\"")) + #expect(html.contains("href=\"/api/pets/showpetbyid/\"")) + #expect(html.contains("/pets/{petId}")) + } + + // MARK: - Operation page + + @Test("Operation page renders method, path, parameters, responses, schema link, and the try-it seam") + func operationPageStructure() throws { + let spec = try self.petstoreSpec() + let context = self.makeContext() + let files = try OpenAPIOperationPage(spec: spec).render(context: context) + let html = try #require(files.first { $0.outputPath.path.contains("showpetbyid") }?.content) + + #expect(html.contains("data-method=\"get\"")) + #expect(html.contains("/pets/{petId}")) + // Parameter table: the petId path parameter. + #expect(html.contains("petId")) + #expect(html.contains("data-in=\"path\"")) + // Responses keyed by status. + #expect(html.contains("data-status=\"200\"")) + #expect(html.contains("data-status=\"default\"")) + // The 200 response body is the Pet schema, linked (not expanded inline). + #expect(html.contains("href=\"/api/schemas/pet/\"")) + // Static-first: the try-it widget seam is present, no request-sending code. + #expect(html.contains("<!-- v1.2.0: try-it widget mounts here -->")) + } + + @Test("Operation page renders the request body for POST") + func operationPageRequestBody() throws { + let spec = try self.petstoreSpec() + let context = self.makeContext() + let files = try OpenAPIOperationPage(spec: spec).render(context: context) + let html = try #require(files.first { $0.outputPath.path.contains("createpets") }?.content) + + #expect(html.contains("data-method=\"post\"")) + #expect(html.contains("sk-openapi-request-body")) + #expect(html.contains("application/json")) + #expect(html.contains("href=\"/api/schemas/pet/\"")) + } + + // MARK: - Schema page + + @Test("Schema page lists properties with required markers and types") + func schemaPageProperties() throws { + let spec = try self.petstoreSpec() + let context = self.makeContext() + let files = try OpenAPISchemaPage(spec: spec).render(context: context) + let html = try #require(files.first { $0.outputPath.path.contains("schemas/pet") }?.content) + + #expect(html.contains("data-schema=\"Pet\"")) + // Property names. + #expect(html.contains(">id<")) + #expect(html.contains(">name<")) + #expect(html.contains(">tag<")) + // Required marker on the required properties. + #expect(html.contains("data-required=\"true\"")) + // The format of the id property carries through. + #expect(html.contains("int64")) + } +} + +/// Operation-page tests for in-file component `$ref` resolution: a `$ref`'d +/// parameter, response, and request body must render identically to inline ones, +/// and an unresolvable `$ref` must surface a visible placeholder rather than +/// vanishing. These pin the regression closed (today the loader drops every +/// referenced node, so all four assertions below fail before resolution lands). +@Suite("OpenAPI component $ref resolution") +struct OpenAPIComponentRefTests { + private func refsSpec() throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: "components-refs-3.1", withExtension: "yaml", subdirectory: "Fixtures") + ) + return try OpenAPISpecLoader().load(source: url) + } + + private func danglingSpec() throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: "dangling-ref-3.1", withExtension: "yaml", subdirectory: "Fixtures") + ) + return try OpenAPISpecLoader().load(source: url) + } + + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "Refs", + baseURL: "https://example.com", + description: "Component-ref docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIRefsSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + private func operationHTML(_ spec: OpenAPISpec, slugFragment: String) throws -> String { + let files = try OpenAPIOperationPage(spec: spec).render(context: self.makeContext()) + return try #require(files.first { $0.outputPath.path.contains(slugFragment) }?.content) + } + + @Test("A component-$ref'd parameter resolves and renders like an inline parameter") + func componentRefParameterResolves() throws { + let html = try self.operationHTML(self.refsSpec(), slugFragment: "listitems") + + // The PageLimit parameter is declared as $ref'd; resolution gives it a real + // name, location, and type on the operation page. + #expect(html.contains("limit")) + #expect(html.contains("data-in=\"query\"")) + #expect(html.contains("integer")) + } + + @Test("A component-$ref'd response resolves with its status and schema link") + func componentRefResponseResolves() throws { + let html = try self.operationHTML(self.refsSpec(), slugFragment: "listitems") + + // The 401 Unauthorized response is declared as a $ref; resolution surfaces + // its status and its content schema (Error), linked not inlined. + #expect(html.contains("data-status=\"401\"")) + #expect(html.contains("href=\"/api/schemas/error/\"")) + } + + @Test("A component-$ref'd request body resolves with its content schema link") + func componentRefRequestBodyResolves() throws { + let html = try self.operationHTML(self.refsSpec(), slugFragment: "createitem") + + #expect(html.contains("sk-openapi-request-body")) + #expect(html.contains("application/json")) + #expect(html.contains("href=\"/api/schemas/item/\"")) + } + + @Test("An unresolvable $ref surfaces a visible placeholder instead of vanishing") + func unresolvableRefIsVisible() throws { + // Loading must not throw on dangling refs, and the operation page must still + // exist and carry the missing reference name (not silently drop the section). + let html = try self.operationHTML(self.danglingSpec(), slugFragment: "listthings") + + #expect(html.contains("DoesNotExist")) + #expect(html.contains("data-status=\"200\"")) + } +} + +/// Slug collision-guard tests: distinct tag names and distinct operation ids that +/// pre-fold to the same slug must still resolve to distinct output paths (suffixed +/// `-2`), never silently overwriting one another, and accented names must fold to +/// ASCII slugs. +@Suite("OpenAPI slug collisions") +struct OpenAPISlugCollisionTests { + private func collisionsSpec() throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: "slug-collisions-3.1", withExtension: "yaml", subdirectory: "Fixtures") + ) + return try OpenAPISpecLoader().load(source: url) + } + + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "Collisions", + baseURL: "https://example.com", + description: "Slug collision docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPICollisionsSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + private func allOutputPaths() throws -> [String] { + let spec = try self.collisionsSpec() + let context = self.makeContext() + var paths: [String] = [] + for renderer: any Renderer in [ + OpenAPILandingPage(spec: spec), + OpenAPITagPage(spec: spec), + OpenAPIOperationPage(spec: spec), + OpenAPISchemaPage(spec: spec), + ] { + paths += try renderer.render(context: context).map(\.outputPath.path) + } + return paths + } + + @Test("No two pages resolve to the same output path") + func noSilentOverwrite() throws { + let paths = try self.allOutputPaths() + // The guard's core promise: every page has a unique output file, so nothing is + // silently overwritten. + #expect(Set(paths).count == paths.count) + } + + @Test("Two tags that fold to the same slug get distinct tag pages") + func collidingTagsAreDistinct() throws { + let paths = try self.allOutputPaths() + // "Pets" and "pets" both fold to "pets"; the second is suffixed to "pets-2". + #expect(paths.contains("/tmp/_OpenAPICollisionsSite/api/pets/index.html")) + #expect(paths.contains("/tmp/_OpenAPICollisionsSite/api/pets-2/index.html")) + } + + @Test("Two operations that fold to the same slug get distinct operation pages") + func collidingOperationsAreDistinct() throws { + let paths = try self.allOutputPaths() + // operationIds "listItems" and "ListItems" both fold to "listitems" under the + // same tag; the second is suffixed to "listitems-2". + #expect(paths.contains("/tmp/_OpenAPICollisionsSite/api/pets/listitems/index.html")) + #expect(paths.contains("/tmp/_OpenAPICollisionsSite/api/pets/listitems-2/index.html")) + } + + @Test("An accented tag name folds to an ASCII slug") + func accentedTagFoldsToASCII() throws { + let paths = try self.allOutputPaths() + // "Café" folds to the ASCII slug "cafe". + #expect(paths.contains("/tmp/_OpenAPICollisionsSite/api/cafe/index.html")) + } +} + +/// Cross-listing tests: an operation tagged `[pets, admin]` keeps one canonical +/// page under its first tag but is listed on both tag pages (each link pointing at +/// the canonical page), and both landing cards count it. +@Suite("OpenAPI multi-tag cross-listing") +struct OpenAPIMultiTagTests { + private func multiTagSpec() throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: "multi-tag-3.1", withExtension: "yaml", subdirectory: "Fixtures") + ) + return try OpenAPISpecLoader().load(source: url) + } + + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "MultiTag", + baseURL: "https://example.com", + description: "Cross-listing docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIMultiTagSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + private func tagPageHTML(slugFragment: String) throws -> String { + let spec = try self.multiTagSpec() + let files = try OpenAPITagPage(spec: spec).render(context: self.makeContext()) + return try #require(files.first { $0.outputPath.path.contains("/api/\(slugFragment)/index.html") }?.content) + } + + @Test("A multi-tagged operation is listed on every tag page, linking to its canonical page") + func crossListedOnBothTags() throws { + let petsPage = try self.tagPageHTML(slugFragment: "pets") + let adminPage = try self.tagPageHTML(slugFragment: "admin") + + // banPet is tagged [pets, admin]; its canonical page lives under the first tag. + // Both tag pages link to that one canonical URL, never a per-tag duplicate. + #expect(petsPage.contains("href=\"/api/pets/banpet/\"")) + #expect(adminPage.contains("href=\"/api/pets/banpet/\"")) + // The admin page must NOT mint an admin-scoped URL for it. + #expect(!adminPage.contains("href=\"/api/admin/banpet/\"")) + } + + @Test("The canonical operation page exists exactly once, under the first tag") + func oneCanonicalPage() throws { + let spec = try self.multiTagSpec() + let files = try OpenAPIOperationPage(spec: spec).render(context: self.makeContext()) + let banPages = files.filter { $0.outputPath.path.contains("banpet") } + + #expect(banPages.count == 1) + #expect(banPages.first?.outputPath.path == "/tmp/_OpenAPIMultiTagSite/api/pets/banpet/index.html") + } + + @Test("Both landing cards count the multi-tagged operation") + func landingCardsCountUnderEveryTag() throws { + let spec = try self.multiTagSpec() + let html = try #require(try OpenAPILandingPage(spec: spec).render(context: self.makeContext()).first?.content) + + // pets owns listPets + banPet (2); admin lists the cross-listed banPet (1). + #expect(html.contains(">2 endpoints<")) + #expect(html.contains(">1 endpoint<")) + } +} + +/// Sidebar navigation-tree tests: the persistent rail on every page has a group per +/// tag (each operation under it with the right method hook), a Schemas group listing +/// every schema, a deprecated hook on deprecated operations, the active item marked +/// on the page being rendered, and cross-listing consistency (a multi-tag operation +/// appears under every tag it carries, matching the page lists). +@Suite("OpenAPI sidebar nav") +struct OpenAPINavTests { + private func spec(_ name: String) throws -> OpenAPISpec { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "yaml", subdirectory: "Fixtures")) + return try OpenAPISpecLoader().load(source: url) + } + + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "Nav", + baseURL: "https://example.com", + description: "Nav docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPINavSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + /// The `<nav class="sk-openapi-nav">…</nav>` slice of a rendered page, so an + /// assertion targets the rail and not the page body (which shares many hooks). + private func navSlice(_ html: String) throws -> String { + let start = try #require(html.range(of: "<nav class=\"sk-openapi-nav\"")) + let end = try #require(html.range(of: "</nav>", range: start.lowerBound..<html.endIndex)) + return String(html[start.lowerBound..<end.upperBound]) + } + + private func landingNav(_ specName: String) throws -> String { + let spec = try self.spec(specName) + let html = try #require(try OpenAPILandingPage(spec: spec).render(context: self.makeContext()).first?.content) + return try self.navSlice(html) + } + + @Test("The rail has a group per tag, each listing its operations with method hooks") + func groupPerTagWithOperations() throws { + let nav = try self.landingNav("petstore-3.1") + + #expect(nav.contains("aria-label=\"API navigation\"")) + #expect(nav.contains("class=\"sk-openapi-nav-group\"")) + // The single petstore tag and one of its operations, with the method hook + link. + #expect(nav.contains(">pets<")) + #expect(nav.contains("data-method=\"get\"")) + // LOW-4: the nav label is the operation summary (matching the page H1), not the id. + #expect(nav.contains(">Info for a specific pet<")) + #expect(nav.contains("href=\"/api/pets/showpetbyid/\"")) + } + + @Test("The rail has a Schemas group listing every schema") + func schemasGroupListsEverySchema() throws { + let nav = try self.landingNav("petstore-3.1") + + #expect(nav.contains(">Schemas<")) + #expect(nav.contains(">Pet<")) + #expect(nav.contains(">Pets<")) + #expect(nav.contains(">Error<")) + #expect(nav.contains("href=\"/api/schemas/pet/\"")) + #expect(nav.contains("href=\"/api/schemas/error/\"")) + } + + @Test("A deprecated operation carries the deprecated hook in the rail") + func deprecatedOperationHasHook() throws { + let nav = try self.landingNav("nav-deprecated-3.1") + + // LOW-4: label is the summary; the deprecated hook rides on the item. + #expect(nav.contains(">Old endpoint<")) + #expect(nav.contains("data-deprecated=\"true\"")) + } + + @Test("The active nav item is marked on the page being rendered") + func activeItemMarkedOnItsPage() throws { + let spec = try self.spec("petstore-3.1") + + // On the showPetById operation page, that item is active; another op is not. + let opFiles = try OpenAPIOperationPage(spec: spec).render(context: self.makeContext()) + let opHTML = try #require(opFiles.first { $0.outputPath.path.contains("showpetbyid") }?.content) + let opNav = try self.navSlice(opHTML) + // The is-active class is on the showPetById link, and that link carries aria-current. + #expect(opNav.contains("sk-openapi-nav-link is-active\" href=\"/api/pets/showpetbyid/\"")) + #expect(opNav.contains("title=\"Info for a specific pet\" aria-current=\"page\"")) + #expect(!opNav.contains("href=\"/api/pets/listpets/\" aria-current=\"page\"")) + + // On the landing page, the home link is the active one instead. + let landingNav = try self.landingNav("petstore-3.1") + #expect(landingNav.contains("sk-openapi-nav-home is-active")) + #expect(!landingNav.contains("href=\"/api/pets/showpetbyid/\" aria-current=\"page\"")) + } + + @Test("A multi-tag operation appears under every one of its tags in the rail") + func crossListedUnderEveryTagInNav() throws { + let nav = try self.landingNav("multi-tag-3.1") + + // Both tag groups are present. + #expect(nav.contains(">pets<")) + #expect(nav.contains(">admin<")) + // banPet (tagged [pets, admin]) is listed twice – once per tag – both links + // pointing at its single canonical page, matching the page-list cross-listing. + let canonicalLinks = nav.components(separatedBy: "href=\"/api/pets/banpet/\"").count - 1 + #expect(canonicalLinks == 2) + #expect(!nav.contains("href=\"/api/admin/banpet/\"")) + } + + @Test("The rail is emitted on every page type") + func navOnEveryPageType() throws { + let spec = try self.spec("petstore-3.1") + let context = self.makeContext() + let renderers: [any Renderer] = [ + OpenAPILandingPage(spec: spec), + OpenAPITagPage(spec: spec), + OpenAPIOperationPage(spec: spec), + OpenAPISchemaPage(spec: spec), + ] + for renderer in renderers { + for file in try renderer.render(context: context) { + #expect(file.content.contains("<nav class=\"sk-openapi-nav\"")) + } + } + } +} + +/// Styling tests: the stylesheet and script render as output files, the CSS carries a +/// generated color rule per HTTP verb, the shell links the stylesheet and defers the +/// script, and the three nav follow-ups (single aria-current, summary labels, explicit +/// landing path) hold. +@Suite("OpenAPI styling") +struct OpenAPIStylingTests { + private func spec(_ name: String) throws -> OpenAPISpec { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "yaml", subdirectory: "Fixtures")) + return try OpenAPISpecLoader().load(source: url) + } + + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "Styling", + baseURL: "https://example.com", + description: "Styling docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIStylingSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + private func navSlice(_ html: String) throws -> String { + let start = try #require(html.range(of: "<nav class=\"sk-openapi-nav\"")) + let end = try #require(html.range(of: "</nav>", range: start.lowerBound..<html.endIndex)) + return String(html[start.lowerBound..<end.upperBound]) + } + + @Test("The stylesheet renders to /assets/css/openapi.css with a color rule per verb") + func stylesheetEmitsCSSWithVerbRules() throws { + let files = try OpenAPIStylesheetRenderer().render(context: self.makeContext()) + let css = try #require(files.first) + #expect(css.outputPath.path.hasSuffix("/assets/css/openapi.css")) + + // One generated rule per semantic verb (the [data-method] palette). + for verb in ["get", "post", "put", "patch", "delete", "head", "options"] { + #expect(css.content.contains(".sk-openapi-method[data-method=\"\(verb)\"]")) + } + } + + @Test("The nav script renders to /assets/js/openapi-nav.js") + func scriptEmitsJS() throws { + let files = try OpenAPINavScriptRenderer().render(context: self.makeContext()) + let js = try #require(files.first) + #expect(js.outputPath.path.hasSuffix("/assets/js/openapi-nav.js")) + #expect(!js.content.isEmpty) + } + + @Test("The shell head links the stylesheet and defers the script") + func shellLinksAssets() throws { + let spec = try self.spec("petstore-3.1") + let html = try #require(try OpenAPILandingPage(spec: spec).render(context: self.makeContext()).first?.content) + + #expect(html.contains("<link rel=\"stylesheet\" href=\"/assets/css/openapi.css\"/>")) + #expect(html.contains("<script defer src=\"/assets/js/openapi-nav.js\"></script>")) + } + + @Test("LOW-3: a cross-listed op's page marks exactly one aria-current, both occurrences active") + func singleAriaCurrentOnCrossListedOpPage() throws { + let spec = try self.spec("multi-tag-3.1") + let files = try OpenAPIOperationPage(spec: spec).render(context: self.makeContext()) + let html = try #require(files.first { $0.outputPath.path.contains("banpet") }?.content) + let nav = try self.navSlice(html) + + // banPet is listed under both pets and admin; on its own page both occurrences + // read as active, but only the canonical (pets) one advertises aria-current. + let ariaCurrentCount = nav.components(separatedBy: "aria-current=\"page\"").count - 1 + let activeCount = nav.components(separatedBy: "sk-openapi-nav-link is-active").count - 1 + #expect(ariaCurrentCount == 1) + #expect(activeCount == 2) + } + + @Test("LOW-4: the nav label uses the operation summary, not the operationId") + func navLabelPrefersSummary() throws { + let spec = try self.spec("multi-tag-3.1") + let html = try #require(try OpenAPILandingPage(spec: spec).render(context: self.makeContext()).first?.content) + let nav = try self.navSlice(html) + + // banPet's summary is "Ban a pet"; the id "banPet" must not be the label. + #expect(nav.contains(">Ban a pet<")) + #expect(!nav.contains(">banPet<")) + } + + @Test("LOW-5: the landing page stashes its openAPIPath explicitly") + func landingStashesOpenAPIPath() throws { + let spec = try self.spec("petstore-3.1") + let context = self.makeContext() + let page = try #require(OpenAPILandingPage(spec: spec).pages(in: context).first) + let stashed: String? = page.extensionValue("openAPIPath") + #expect(stashed == "/api/") + } +} + +/// Styling code-review follow-up tests: the two MEDIUM and four LOW findings from the +/// styling review. Each asserts on the served artifact (generated CSS, bundled JS, rendered +/// nav markup), so the fix is proven by the file a browser actually receives. +@Suite("OpenAPI styling fixes") +struct OpenAPIStylingFixTests { + private func makeContext() -> BuildContext { + BuildContext( + config: SiteConfig( + name: "Fix", + baseURL: "https://example.com", + description: "Fix docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPIFixSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + } + + private func stylesheet() throws -> String { + try #require(try OpenAPIStylesheetRenderer().render(context: self.makeContext()).first?.content) + } + + private func navScript() throws -> String { + try #require(try OpenAPINavScriptRenderer().render(context: self.makeContext()).first?.content) + } + + private func navSlice(_ html: String) throws -> String { + let start = try #require(html.range(of: "<nav class=\"sk-openapi-nav\"")) + let end = try #require(html.range(of: "</nav>", range: start.lowerBound..<html.endIndex)) + return String(html[start.lowerBound..<end.upperBound]) + } + + private func landingHTML() throws -> String { + let url = try #require(Bundle.module.url(forResource: "petstore-3.1", withExtension: "yaml", subdirectory: "Fixtures")) + let spec = try OpenAPISpecLoader().load(source: url) + return try #require(try OpenAPILandingPage(spec: spec).render(context: self.makeContext()).first?.content) + } + + @Test("M1: each verb badge emits an AA label color, not a blanket white") + func verbBadgesEmitPerVerbAALabelColor() throws { + let css = try self.stylesheet() + + // Light verbs flip to near-black text (white failed AA on these hues); the two dark + // verbs keep white. The label color now travels with the background in each rule. + #expect(css.contains(".sk-openapi-method[data-method=\"get\"] { background: #61affe; color: #000; }")) + #expect(css.contains(".sk-openapi-method[data-method=\"post\"] { background: #49cc90; color: #000; }")) + #expect(css.contains(".sk-openapi-method[data-method=\"put\"] { background: #fca130; color: #000; }")) + #expect(css.contains(".sk-openapi-method[data-method=\"patch\"] { background: #50e3c2; color: #000; }")) + #expect(css.contains(".sk-openapi-method[data-method=\"delete\"] { background: #f93e3e; color: #000; }")) + #expect(css.contains(".sk-openapi-method[data-method=\"head\"] { background: #9012fe; color: #fff; }")) + #expect(css.contains(".sk-openapi-method[data-method=\"options\"] { background: #0d5aa7; color: #fff; }")) + } + + @Test("M2: the mobile off-canvas drawer is gated behind html.js, with a JS-off fallback") + func mobileDrawerGatedBehindJSClass() throws { + let css = try self.stylesheet() + + // The off-canvas transform and the hamburger only apply when JS is on. + #expect(css.contains("html.js .sk-openapi-nav-toggle")) + #expect(css.contains("html.js .sk-openapi-layout.is-nav-open .sk-openapi-nav")) + // JS off: the rail renders in normal document flow instead of off-canvas. + #expect(css.contains("html:not(.js) .sk-openapi-nav")) + #expect(css.contains("html:not(.js) .sk-openapi-body")) + // The translateX(-100%) hide must live INSIDE the html.js-gated rule – that is what + // keeps a JS-off narrow viewport from trapping the rail off-canvas. + let gatedStart = try #require(css.range(of: "html.js .sk-openapi-nav {")) + let gatedEnd = try #require(css.range(of: "}", range: gatedStart.upperBound..<css.endIndex)) + let gatedRule = String(css[gatedStart.upperBound..<gatedEnd.lowerBound]) + #expect(gatedRule.contains("transform: translateX(-100%)")) + } + + @Test("M2: the nav script adds the html.js class as early as it runs") + func navScriptAddsJSClassEarly() throws { + let js = try self.navScript() + #expect(js.contains("document.documentElement.classList.add(\"js\")")) + } + + @Test("L1: the active-row pill derives from the active token, not a hard-coded white") + func activePillDerivesFromActiveToken() throws { + let css = try self.stylesheet() + #expect(css.contains(".sk-openapi-nav-link.is-active .sk-openapi-method {")) + #expect(css.contains("background: var(--sk-openapi-active-text);")) + #expect(css.contains("color: var(--sk-openapi-active-bg);")) + // The old assume-dark-accent translucent white is gone. + #expect(!css.contains("rgba(255, 255, 255, 0.25)")) + } + + @Test("L4: the nav row radius follows the theme --radius token") + func rowRadiusFollowsThemeToken() throws { + let css = try self.stylesheet() + #expect(css.contains("--sk-openapi-row-radius: var(--radius, 8px);")) + } + + @Test("L2: each group title sits in a header wrapper so the twist can be its sibling") + func groupHeaderWrapsTitleForSiblingTwist() throws { + let nav = try self.navSlice(try self.landingHTML()) + + // The wrapper is present and holds the title link directly. + #expect(nav.contains("<div class=\"sk-openapi-nav-group-header\"><a class=\"sk-openapi-nav-group-title")) + // The twist is JS-injected, never server-rendered inside the anchor. + #expect(!nav.contains("sk-openapi-nav-twist")) + } + + @Test("L2/L3: the script inserts the twist into the header and wires section-named ARIA") + func navScriptWiresAriaControlsAndSectionLabel() throws { + let js = try self.navScript() + + // L2: the twist targets the header row (sibling of the title), not the anchor. + #expect(js.contains(".sk-openapi-nav-group-header")) + // L3: aria-controls on both the twist and the mobile toggle, plus a section-named + // label rather than a generic "Toggle section". + #expect(js.contains("twist.setAttribute(\"aria-controls\"")) + #expect(js.contains("toggle.setAttribute(\"aria-controls\"")) + #expect(js.contains("\"Toggle the \" + sectionName + \" section\"")) + #expect(!js.contains("\"Toggle section\"")) + } +} diff --git a/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift b/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift new file mode 100644 index 0000000..d254d84 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/OpenAPISpecLoaderTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing + +@testable import SiteKitOpenAPI + +/// Loads a fixture from the test bundle's `Fixtures` directory and runs it through the loader. +private func loadFixture(_ name: String, _ fileExtension: String = "yaml") throws -> OpenAPISpec { + let url = try #require( + Bundle.module.url(forResource: name, withExtension: fileExtension, subdirectory: "Fixtures"), + "Missing fixture \(name).\(fileExtension)" + ) + return try OpenAPISpecLoader().load(source: url) +} + +@Suite("OpenAPISpecLoader") +struct OpenAPISpecLoaderTests { + /// One fixture in the 2×2 decode matrix: an OpenAPI major version crossed with a serialization format. + struct Fixture: Sendable, CustomStringConvertible { + let name: String + let fileExtension: String + var description: String { "\(self.name).\(self.fileExtension)" } + } + + /// The full matrix: OpenAPI 3.0 and 3.1, each as YAML and JSON. Every fixture is the same logical + /// Petstore, so all four must decode into an identical model – which is itself the proof that the + /// 3.0 (via OpenAPIKitCompat conversion) and 3.1 (direct) paths normalize to one 3.1 shape. + static let fixtures: [Fixture] = [ + Fixture(name: "petstore-3.0", fileExtension: "yaml"), + Fixture(name: "petstore-3.0", fileExtension: "json"), + Fixture(name: "petstore-3.1", fileExtension: "yaml"), + Fixture(name: "petstore-3.1", fileExtension: "json"), + ] + + private func loadSpec(_ fixture: Fixture) throws -> OpenAPISpec { + try loadFixture(fixture.name, fixture.fileExtension) + } + + @Test("Decodes the info block", arguments: fixtures) + func info(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + #expect(spec.info.title == "Swagger Petstore") + #expect(spec.info.version == "1.0.0") + #expect(spec.info.description?.contains("sample API") == true) + } + + @Test("Decodes the server list", arguments: fixtures) + func servers(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + #expect(spec.servers.count == 1) + let server = try #require(spec.servers.first) + #expect(server.url == "https://petstore.swagger.io/v1") + #expect(server.description == "Production server") + } + + @Test("Decodes the tag list", arguments: fixtures) + func tags(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + #expect(spec.tags.count == 1) + let tag = try #require(spec.tags.first) + #expect(tag.name == "pets") + #expect(tag.description == "Everything about your Pets") + } + + @Test("Flattens every path/method into one operation list", arguments: fixtures) + func operationCount(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + // GET /pets, POST /pets, GET /pets/{petId} + #expect(spec.operations.count == 3) + } + + @Test("Maps a known operation's method, path, tags, parameters, and responses", arguments: fixtures) + func knownOperation(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + let operation = try #require( + spec.operations.first { $0.method == "GET" && $0.path == "/pets/{petId}" }, + "Missing GET /pets/{petId}" + ) + #expect(operation.operationId == "showPetById") + #expect(operation.summary == "Info for a specific pet") + #expect(operation.tags == ["pets"]) + #expect(operation.deprecated == false) + + let parameter = try #require(operation.parameters.first { $0.name == "petId" }) + #expect(parameter.location == .path) + #expect(parameter.required == true) + #expect(parameter.schema?.type == "string") + + let statusCodes = operation.responses.map(\.statusCode) + #expect(statusCodes.contains("200")) + #expect(statusCodes.contains("default")) + let okResponse = try #require(operation.responses.first { $0.statusCode == "200" }) + #expect(okResponse.content.first?.contentType == "application/json") + #expect(okResponse.content.first?.schema?.referenceName == "Pet") + } + + @Test("Maps an operation's request body", arguments: fixtures) + func requestBody(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + let operation = try #require(spec.operations.first { $0.method == "POST" && $0.path == "/pets" }) + let body = try #require(operation.requestBody) + #expect(body.required == true) + #expect(body.content.first?.contentType == "application/json") + #expect(body.content.first?.schema?.referenceName == "Pet") + } + + @Test("Maps component schemas with properties and required fields", arguments: fixtures) + func schemas(_ fixture: Fixture) throws { + let spec = try self.loadSpec(fixture) + let names = spec.schemas.map(\.name) + #expect(names.contains("Pet")) + #expect(names.contains("Pets")) + #expect(names.contains("Error")) + + let pet = try #require(spec.schemas.first { $0.name == "Pet" }) + #expect(pet.schema.type == "object") + #expect(pet.schema.required.contains("id")) + #expect(pet.schema.required.contains("name")) + let nameProperty = try #require(pet.schema.properties.first { $0.name == "name" }) + #expect(nameProperty.schema.type == "string") + #expect(nameProperty.required == true) + let idProperty = try #require(pet.schema.properties.first { $0.name == "id" }) + #expect(idProperty.schema.format == "int64") + + let pets = try #require(spec.schemas.first { $0.name == "Pets" }) + #expect(pets.schema.type == "array") + #expect(pets.schema.items.first?.referenceName == "Pet") + } +} + +/// Coverage for the schema-mapping branches the Petstore happy path never touches: +/// `nullable` convergence across 3.0/3.1, `enum`, schema-level `deprecated`, and +/// `oneOf` + discriminator. The `features-3.0`/`features-3.1` fixtures carry the +/// same logical schemas in each dialect, so a shared assertion run over both also +/// proves the 3.0-via-Compat path preserves these facets. +@Suite("OpenAPISpecLoader feature mapping") +struct OpenAPISpecLoaderFeatureTests { + /// The same feature schemas expressed in 3.0 and in 3.1 (both YAML). + static let dialects = ["features-3.0", "features-3.1"] + + @Test("Normalizes nullable identically: 3.0 `nullable: true` and 3.1 `[\"T\",\"null\"]`") + func nullableConverges() throws { + let spec30 = try loadFixture("features-3.0") + let spec31 = try loadFixture("features-3.1") + let nickname30 = try #require(self.nicknameProperty(in: spec30)) + let nickname31 = try #require(self.nicknameProperty(in: spec31)) + + #expect(nickname30.schema.nullable == true) + #expect(nickname31.schema.nullable == true) + #expect(nickname30.schema.type == "string") + #expect(nickname31.schema.type == "string") + // The whole node converges, not just the two facets above – the core correctness claim. + #expect(nickname30.schema == nickname31.schema) + } + + @Test("Maps enum values", arguments: dialects) + func enumValues(_ fixture: String) throws { + let spec = try loadFixture(fixture) + let widget = try #require(spec.schemas.first { $0.name == "Widget" }) + let status = try #require(widget.schema.properties.first { $0.name == "status" }) + #expect(status.schema.enumValues == ["available", "pending", "sold"]) + } + + @Test("Maps schema-level deprecated", arguments: dialects) + func deprecatedField(_ fixture: String) throws { + let spec = try loadFixture(fixture) + let widget = try #require(spec.schemas.first { $0.name == "Widget" }) + let legacyId = try #require(widget.schema.properties.first { $0.name == "legacyId" }) + #expect(legacyId.schema.deprecated == true) + } + + @Test("Maps oneOf composition with its discriminator", arguments: dialects) + func oneOfDiscriminator(_ fixture: String) throws { + let spec = try loadFixture(fixture) + let animal = try #require(spec.schemas.first { $0.name == "Animal" }) + let composition = try #require(animal.schema.composition) + #expect(composition.kind == .oneOf) + #expect(Set(composition.subschemas.compactMap(\.referenceName)) == ["Cat", "Dog"]) + let discriminator = try #require(composition.discriminator) + #expect(discriminator.propertyName == "petType") + // The mapping value is captured faithfully (the raw spec value – here a `$ref` + // string); the renderer resolves it to a schema page when rendering. + #expect(discriminator.mapping["cat"] == "#/components/schemas/Cat") + } + + private func nicknameProperty(in spec: OpenAPISpec) -> OpenAPISpec.SchemaProperty? { + spec.schemas.first { $0.name == "Widget" }?.schema.properties.first { $0.name == "nickname" } + } +} + +/// Coverage for the loader's error paths: an unsupported version (a real Swagger +/// 2.0 document with no `openapi` field), an empty file, malformed YAML, and a +/// missing file. +@Suite("OpenAPISpecLoader errors") +struct OpenAPISpecLoaderErrorTests { + @Test("A Swagger 2.0 document (no openapi field) throws unsupportedVersion, not a DecodingError") + func swagger2IsRejected() throws { + let url = try #require(Bundle.module.url(forResource: "swagger-2.0", withExtension: "yaml", subdirectory: "Fixtures")) + #expect(throws: OpenAPISpecLoader.LoadError.unsupportedVersion("<missing>")) { + try OpenAPISpecLoader().load(source: url) + } + } + + @Test("An empty spec throws") + func emptySpecThrows() throws { + let url = try Self.writeTemporary("", fileExtension: "yaml") + defer { try? FileManager.default.removeItem(at: url) } + #expect(throws: (any Error).self) { + try OpenAPISpecLoader().load(source: url) + } + } + + @Test("A malformed YAML spec throws") + func malformedSpecThrows() throws { + let url = try Self.writeTemporary("openapi: '3.1.0'\npaths: { : : :", fileExtension: "yaml") + defer { try? FileManager.default.removeItem(at: url) } + #expect(throws: (any Error).self) { + try OpenAPISpecLoader().load(source: url) + } + } + + @Test("A missing file throws") + func missingFileThrows() throws { + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("does-not-exist-\(UUID().uuidString).yaml") + #expect(throws: (any Error).self) { + try OpenAPISpecLoader().load(source: url) + } + } + + private static func writeTemporary(_ contents: String, fileExtension: String) throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("openapi-loader-\(UUID().uuidString).\(fileExtension)") + try contents.write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/Tests/SiteKitOpenAPITests/OpenAPISystemOutputTests.swift b/Tests/SiteKitOpenAPITests/OpenAPISystemOutputTests.swift new file mode 100644 index 0000000..b3a2827 --- /dev/null +++ b/Tests/SiteKitOpenAPITests/OpenAPISystemOutputTests.swift @@ -0,0 +1,202 @@ +import Foundation +import SiteKit +import Testing + +@testable import SiteKitOpenAPI + +/// Machine-output proof: the spec-derived OpenAPI pages, once registered into `BuildContext.sections` +/// via ``OpenAPIContentProvider``, are enumerated by every machine-index renderer – sitemap, +/// nav-index, search-index, and llms.txt. The headline keystone test is a red-green showing the +/// sitemap goes from missing the pages to containing them; a full pipeline build proves the +/// whole registration cascade end-to-end. +@Suite("OpenAPI system outputs") +struct OpenAPISystemOutputTests { + /// Every operation and schema page the Petstore spec must surface in each machine index. + static let operationPaths = ["/api/pets/listpets/", "/api/pets/createpets/", "/api/pets/showpetbyid/"] + static let schemaPaths = ["/api/schemas/pet/", "/api/schemas/pets/", "/api/schemas/error/"] + static var requiredPaths: [String] { operationPaths + schemaPaths } + + private func petstoreSpec() throws -> OpenAPISpec { + let url = try #require(Bundle.module.url(forResource: "petstore-3.1", withExtension: "yaml", subdirectory: "Fixtures")) + return try OpenAPISpecLoader().load(source: url) + } + + private func config() -> SiteConfig { + SiteConfig( + name: "Petstore", + baseURL: "https://example.com", + description: "Petstore API docs.", + sections: [SectionConfig(name: "API", slug: "api", contentDirectory: "Content", urlPrefix: "api")] + ) + } + + /// A context whose only section is the one ``OpenAPIContentProvider`` builds – exactly what + /// the pipeline merges in. `sections` empty reproduces the pre-keystone state. + private func context(withOpenAPISection: Bool) throws -> BuildContext { + let base = BuildContext( + config: self.config(), + themeConfig: nil, + sections: [], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: URL(fileURLWithPath: "/tmp/_OpenAPISystemSite"), + projectDirectory: URL(fileURLWithPath: "/tmp") + ) + guard withOpenAPISection else { return base } + let section = try #require(OpenAPIContentProvider(spec: try self.petstoreSpec()).contentSection(in: base)) + return BuildContext( + config: self.config(), + themeConfig: nil, + sections: [section], + staticPages: [], + tags: [:], + homeContent: nil, + outputDirectory: base.outputDirectory, + projectDirectory: base.projectDirectory + ) + } + + private func render(_ renderer: any Renderer, _ context: BuildContext) throws -> String { + try #require(try renderer.render(context: context).first?.content) + } + + /// JSON escapes `/` as `\/` (the nav-index uses `JSONSerialization`, which always does); + /// both decode to the same path, so normalize before substring-checking URLs. + private func unescapingSlashes(_ string: String) -> String { + string.replacingOccurrences(of: "\\/", with: "/") + } + + private var resolvers: [any PagePathResolving] { [OpenAPIPagePathResolver()] } + + // MARK: - Keystone red-green + + @Test("Keystone: the sitemap gains every operation + schema page only once the pages are registered") + func sitemapKeystoneRedGreen() throws { + // RED: without the registered section the synthetic pages are absent from the sitemap. + let before = try self.render(SitemapRenderer(pathResolvers: self.resolvers), try self.context(withOpenAPISection: false)) + for path in Self.requiredPaths { + #expect(!before.contains(path), "pre-registration sitemap must not contain \(path)") + } + + // GREEN: registering the provider's section makes every page appear. + let after = try self.render(SitemapRenderer(pathResolvers: self.resolvers), try self.context(withOpenAPISection: true)) + for path in Self.requiredPaths { + #expect(after.contains(path), "post-registration sitemap must contain \(path)") + } + } + + // MARK: - Per-renderer inclusion (direct, no fingerprint noise) + + @Test("Nav-index includes every operation + schema page") + func navIndexInclusion() throws { + let json = self.unescapingSlashes( + try self.render(NavIndexRenderer(pathResolvers: self.resolvers), try self.context(withOpenAPISection: true)) + ) + for path in Self.requiredPaths { + #expect(json.contains(path), "nav-index missing \(path)") + } + } + + @Test("Search-index has a record per operation + schema page, with method facets") + func searchIndexInclusion() throws { + let json = self.unescapingSlashes( + try self.render(OpenAPISearchIndexRenderer(pathResolvers: self.resolvers), try self.context(withOpenAPISection: true)) + ) + for path in Self.requiredPaths { + #expect(json.contains(path), "search-index missing \(path)") + } + // Operations carry a method facet (the GET/POST verbs from the Petstore spec). + #expect(json.contains("\"method\"")) + #expect(json.contains("\"GET\"")) + #expect(json.contains("\"POST\"")) + } + + @Test("llms.txt lists every operation + schema page") + func llmsTxtInclusion() throws { + let txt = try self.render(OpenAPILlmsTxtRenderer(), try self.context(withOpenAPISection: true)) + for path in Self.requiredPaths { + #expect(txt.contains(path), "llms.txt missing \(path)") + } + #expect(txt.contains("## Endpoints")) + #expect(txt.contains("## Schemas")) + } + + // MARK: - Search script + shell wiring + + @Test("The search script renders and the shell links it plus the search input") + func searchScriptAndShellWiring() throws { + let context = try self.context(withOpenAPISection: false) + + let scriptFiles = try OpenAPISearchScriptRenderer().render(context: context) + let script = try #require(scriptFiles.first) + #expect(script.outputPath.path.hasSuffix("/assets/js/openapi-search.js")) + #expect(script.content.contains("/assets/search-index.json")) + + // The shell renders the appbar search input and defers the search script. + let html = try #require(try OpenAPILandingPage(spec: try self.petstoreSpec()).render(context: context).first?.content) + #expect(html.contains("data-openapi-search")) + #expect(html.contains("<script defer src=\"/assets/js/openapi-search.js\"></script>")) + } + + // MARK: - Per-page SEO + + @Test("Operation and schema pages carry per-page, non-blank title/description/canonical") + func perPageSEO() throws { + let context = try self.context(withOpenAPISection: false) + let spec = try self.petstoreSpec() + + let opFiles = try OpenAPIOperationPage(spec: spec).render(context: context) + let opHTML = try #require(opFiles.first { $0.outputPath.path.contains("showpetbyid") }?.content) + #expect(opHTML.contains("<title>Info for a specific pet – Petstore")) + #expect(opHTML.contains("")) + + let schemaFiles = try OpenAPISchemaPage(spec: spec).render(context: context) + let petHTML = try #require(schemaFiles.first { $0.outputPath.path.contains("/schemas/pet/") }?.content) + #expect(petHTML.contains("Pet – Petstore")) + // Pet has no description in the spec, so the meaningful fallback fills the meta tag. + #expect(petHTML.contains("")) + #expect(petHTML.contains("")) + + // Page-specific, not a shared/generic title. + #expect(!opHTML.contains("Pet – Petstore")) + } + + // MARK: - End-to-end cascade (one full pipeline build) + + @Test("A full .openAPI build registers the pages so all four machine indexes include them") + func fullBuildCascade() throws { + let projectDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("sitekit-openapi-system-\(UUID().uuidString)") + let contentDirectory = projectDirectory.appendingPathComponent("Content") + try FileManager.default.createDirectory(at: contentDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: projectDirectory) } + + // The Petstore fixture as the site's spec. + let fixture = try #require(Bundle.module.url(forResource: "petstore-3.1", withExtension: "yaml", subdirectory: "Fixtures")) + try FileManager.default.copyItem(at: fixture, to: contentDirectory.appendingPathComponent("openapi.yaml")) + + try SiteBuilder + .openAPI(config: self.config(), projectDirectory: projectDirectory) + .buildPipeline() + .build() + + let output = projectDirectory.appendingPathComponent("_Site") + func read(_ relativePath: String) throws -> String { + try String(contentsOf: output.appendingPathComponent(relativePath), encoding: .utf8) + } + + let sitemap = try read("sitemap.xml") + let navIndex = self.unescapingSlashes(try read("assets/nav-index.json")) + let searchIndex = self.unescapingSlashes(try read("assets/search-index.json")) + let llms = try read("llms.txt") + + for path in Self.requiredPaths { + #expect(sitemap.contains(path), "sitemap.xml missing \(path)") + #expect(navIndex.contains(path), "nav-index.json missing \(path)") + #expect(searchIndex.contains(path), "search-index.json missing \(path)") + #expect(llms.contains(path), "llms.txt missing \(path)") + } + } +} diff --git a/Tests/SiteKitTests/ContentSectionProviderTests.swift b/Tests/SiteKitTests/ContentSectionProviderTests.swift new file mode 100644 index 0000000..65dc0c7 --- /dev/null +++ b/Tests/SiteKitTests/ContentSectionProviderTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing + +@testable import SiteKit + +/// Tests for the core `ContentSectionProviding` seam, in particular the multilingual contract: +/// a provider's pages are merged into the build's *global* pass (where the locale-independent, +/// site-wide outputs are produced) and never into the per-locale passes – an unlocalized +/// synthetic page must not be minted at a locale-prefixed URL it has no content for. The single +/// merge means the pages are seen once, not duplicated once per locale. +@Suite("ContentSectionProvider") +struct ContentSectionProviderTests { + /// Records the page slugs each scope's renderers observed in `context.sections`. + private final class Recorder: @unchecked Sendable { + var globalSlugs: [String] = [] + var perLocaleSlugs: [String] = [] + } + + /// A minimal provider contributing one synthetic page in its own section. + private struct MarkerProvider: ContentSectionProviding { + func contentSection(in context: BuildContext) -> ContentSection? { + let page = PageModel( + title: "Synthetic Marker", + slug: "providermarker", + htmlContent: "

Generated, not loaded from a file.

", + sourcePath: URL(fileURLWithPath: "/tmp/synthetic/providermarker"), + pageType: .staticPage + ) + let section = SectionConfig(name: "Synthetic", slug: "synthetic", contentDirectory: "Synthetic", urlPrefix: "synthetic") + return ContentSection(config: section, pages: [page]) + } + } + + /// A spy renderer that records, per scope, the page slugs it sees in the context. + private struct SpyRenderer: Renderer { + let recorder: Recorder + let isGlobal: Bool + var scope: RenderScope { self.isGlobal ? .global : .perLocale } + func render(context: BuildContext) throws -> [OutputFile] { + let slugs = context.sections.flatMap(\.pages).map(\.slug) + if self.isGlobal { + self.recorder.globalSlugs.append(contentsOf: slugs) + } else { + self.recorder.perLocaleSlugs.append(contentsOf: slugs) + } + return [] + } + } + + /// Writes a tiny multilingual site (one article in en + de) so a full build exercises both + /// the per-locale passes and the single global pass. + private func makeMultilingualSite() throws -> (config: SiteConfig, projectDirectory: URL) { + let projectDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("sitekit-provider-ml-\(UUID().uuidString)") + let blogDirectory = projectDirectory.appendingPathComponent("Content/Blog") + try FileManager.default.createDirectory(at: blogDirectory, withIntermediateDirectories: true) + + try """ + --- + title: Hello + date: 2024-01-15 + --- + A first article. + """.write(to: blogDirectory.appendingPathComponent("2024-01-15-hello.md"), atomically: true, encoding: .utf8) + try """ + --- + title: Hallo + date: 2024-01-15 + --- + Ein erster Artikel. + """.write(to: blogDirectory.appendingPathComponent("2024-01-15-hello.de.md"), atomically: true, encoding: .utf8) + + let config = SiteConfig( + name: "Provider Fixture", + baseURL: "https://example.com", + description: "Provider seam fixture", + sections: [SectionConfig(name: "Blog", slug: "blog", contentDirectory: "Blog", urlPrefix: "blog")], + localization: LocalizationConfig(defaultLanguage: "en", languages: ["de"]) + ) + return (config, projectDirectory) + } + + @Test("A provider's pages reach the global pass once, and never the per-locale passes") + func providedPagesReachGlobalPassOnce() throws { + let (config, projectDirectory) = try self.makeMultilingualSite() + defer { try? FileManager.default.removeItem(at: projectDirectory) } + + let recorder = Recorder() + try SiteBuilder + .blog(config: config, projectDirectory: projectDirectory) + .contentSectionProvider(MarkerProvider()) + .renderer(SpyRenderer(recorder: recorder, isGlobal: true)) + .renderer(SpyRenderer(recorder: recorder, isGlobal: false)) + .buildPipeline() + .build() + + // The global pass sees the synthetic page exactly once (en + de share one global pass). + let globalHits = recorder.globalSlugs.filter { $0 == "providermarker" }.count + #expect(globalHits == 1, "expected the provided page once in the global pass, saw \(globalHits)") + + // The per-locale passes never see it (no locale-prefixed, content-less URL is minted). + #expect(!recorder.perLocaleSlugs.contains("providermarker")) + } +} diff --git a/USE-CASES.md b/USE-CASES.md index f3ffc03..da382f4 100644 --- a/USE-CASES.md +++ b/USE-CASES.md @@ -13,6 +13,7 @@ This is the index, not the explanation. For a guided walkthrough read [README.md | Install SiteKit + scaffold my first site | [README – Get started](README.md#-get-started) · deep: [bootstrap.md](Plugin/skills/sitekit/references/bootstrap.md) | | Set up a new site end-to-end (the judgment-heavy flow) | [onboarding.md](Plugin/skills/sitekit/references/onboarding.md) | | Build a documentation site from a DocC catalog | [DocC blueprint](Plugin/blueprints/DocC.md) · deep: [markdown-extensions.md](Plugin/skills/sitekit/references/markdown-extensions.md) | +| Build API reference docs from an OpenAPI / Swagger spec | [OpenAPI blueprint](Plugin/blueprints/OpenAPI.md) · deep: [openapi.md](Plugin/skills/sitekit/references/openapi.md) | ## Author your site