Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -601,12 +601,38 @@ let site = KilnSite(
| `DocCSite(packages:groupOrder:archivesDirectory:)` | The API-reference site. `groupOrder` orders the catalog/switcher sections (unlisted groups sort after; ungrouped modules fall into `Other`). `archivesDirectory` defaults to `archives`. |
| `APIPackage(_ repo, group:, versions:)` | One git repo. `repo` is `"owner/name"`. `group` is a default group for its modules. Modules are declared per `PackageVersion`, not on the package. |
| `PackageVersion(_ id, name:, ref:, isDefault:, isPrerelease:, deprecated:, prereleaseLabel:, modules:)` | A version line and the `Module`s it ships. `id` is the URL segment (URL-safe, unique per package); `name` is the switcher label; `ref` is the git branch/tag Stage A builds. Exactly one version per package is the default and it must not be a pre-release. A pre-release shows a badge (in the switchers and on catalog cards) taken from `prereleaseLabel`, else inferred from the id/name as `alpha`/`beta`/`rc` (default `beta`). `PackageVersion.single(ref:modules:)` is shorthand for a lone default version. |
| `Module(_ name, title:, group:, description:, image:)` | A DocC target. `name` keys the archive and the URL and must be unique across the whole site; `title` overrides the display name; `group` overrides the package group; `description` is the catalog-card blurb; `image` is an optional logo shown on the module's landing page (a site-relative asset path or an absolute URL). |
| `Module(_ name, title:, group:, description:, image:)` | A DocC target. `name` keys the archive and the URL and must be unique across the whole site; `title` overrides the display name; `group` overrides the package group; `description` is the catalog-card blurb; `image` overrides the logo on the module's landing page (a site-relative asset path or an absolute URL); otherwise Kiln uses the archive's `theme.icons.technology`. |

The configuration is validated at build time (unique repos, unique module names
sitewide, exactly one non-pre-release default version per package, URL-safe
version ids, …); a `DocCConfigurationError` describes any problem.

### DocC module branding

Kiln preserves and reads an archive's `theme-settings.json`. Its
`theme.icons.technology` image appears beside the title on the module landing
page; an explicit `Module(image:)` takes precedence. Archive-local `images/`
paths are rebased for the site's mount path and the module's version, including
DocC paths with a hosting prefix such as `/sqlkit/images/SQLKit/logo.svg`.
Missing images or settings leave the usual header in place. Malformed JSON
produces a build warning without preventing the documentation from rendering.

The default theme loads `_kiln/css/docc-theme.css`. Themes that replace the base
template (including VaporDesignTheme) can load the same layout with
`extraCSS: ["_kiln/css/docc-theme.css"]`. The image follows the site's selected
light/dark mode when its SVG supports `prefers-color-scheme`.

Kiln decodes the light/dark `theme.color.documentation-intro-accent` values,
including `var(--color-…)` aliases, into `DocCArchive.themeSettings`. It does not
apply them to the page: text colours, fonts, backgrounds and navigation remain
owned by the site theme. DocC feature flags and other styling are not imported.

Archives built by older Kiln versions had their theme settings stripped. Rebuild
those archives once to restore the settings (for example,
`swift run APIDocs --rebuild sql-kit` in Vapor's api-docs repo); subsequent builds
retain them in the archive cache.


### URL scheme

URLs are **module-first**, and DocC's own `/documentation/<module>` prefix is
Expand Down
5 changes: 3 additions & 2 deletions Sources/Kiln/Configuration/DocC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,10 @@ public struct Module: Sendable {
public var group: String?
/// A short blurb shown on the module's catalog card.
public var description: String?
/// An optional logo/image shown on the module's catalog card. A site-relative
/// An optional logo/image shown on the module landing page. A site-relative
/// asset path (e.g. `"assets/logos/routing-kit.png"`, resolved against the
/// site's mount path) or an absolute `http(s)` URL. `nil` renders no image.
/// site's mount path) or an absolute `http(s)` URL. Overrides the archive's
/// `theme.icons.technology`; `nil` uses that icon when available.
public var image: String?

public init(
Expand Down
6 changes: 5 additions & 1 deletion Sources/Kiln/DocC/DocCArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public struct DocCArchive: Sendable {
/// The archive's root directory (used to resolve `images/`, `videos/`,
/// `downloads/` assets referenced by pages).
public let archiveURL: URL
/// Optional package branding from `theme-settings.json`.
public let themeSettings: DocCThemeSettings?
/// Non-fatal problems encountered while loading (a page that failed to decode,
/// a missing index). Loading tolerates these and continues; the caller surfaces
/// them as build warnings.
Expand All @@ -39,14 +41,16 @@ public struct DocCArchive: Sendable {
pages: [DocCPage],
index: RenderIndex?,
archiveURL: URL,
loadIssues: [String]
loadIssues: [String],
themeSettings: DocCThemeSettings? = nil
) {
self.moduleName = moduleName
self.bundleID = bundleID
self.pages = pages
self.index = index
self.archiveURL = archiveURL
self.loadIssues = loadIssues
self.themeSettings = themeSettings

var byPath: [String: DocCPage] = [:]
var byIdentifier: [String: DocCPage] = [:]
Expand Down
10 changes: 5 additions & 5 deletions Sources/Kiln/DocC/DocCArchiveBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -701,11 +701,11 @@ public struct DocCArchiveBuilder: Sendable {
guard fileManager.fileExists(atPath: archive.appendingPathComponent("metadata.json").path) else {
throw BuildError.archiveNotProduced(module: module.name, at: archive.path)
}
try stripToKilnEssentials(archive)
try Self.stripToKilnEssentials(archive)
}

/// Trim a generated `.doccarchive` to what Kiln reads: `metadata.json`, the
/// render nodes (`data/`), the navigator index (`index/`), and the asset
/// Trim a generated `.doccarchive` to what Kiln reads: `metadata.json`,
/// `theme-settings.json`, render nodes (`data/`), the navigator index (`index/`), and asset
/// folders it copies (`images`/`videos`/`downloads`). Modern
/// `generate-documentation` also bundles the swift-docc-render SPA
/// (css/js/index.html/documentation/…) which Kiln never touches and which
Expand All @@ -715,9 +715,9 @@ public struct DocCArchiveBuilder: Sendable {
/// written only under ``crossModuleLinks``) is kept: a *cached* archive is
/// passed as a `--dependency` to its dependents on later builds, and without
/// this metadata those links would silently stop resolving.
private func stripToKilnEssentials(_ archive: URL) throws {
static func stripToKilnEssentials(_ archive: URL) throws {
let keep: Set<String> = [
"metadata.json", "data", "index", "images", "videos", "downloads",
"metadata.json", "theme-settings.json", "data", "index", "images", "videos", "downloads",
"linkable-entities.json", "link-hierarchy.json",
]
let fileManager = FileManager.default
Expand Down
8 changes: 6 additions & 2 deletions Sources/Kiln/DocC/DocCArchiveLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ public import Foundation
/// Loads a single DocC `.doccarchive` directory into an in-memory ``DocCArchive``.
///
/// The loader reads `metadata.json` for the module's identity, decodes every
/// `data/**/*.json` render node and the `index/index.json` navigation tree, and
/// `data/**/*.json` render node, `theme-settings.json` branding, and the
/// `index/index.json` navigation tree, and
/// records any unrecognised render-JSON constructs into the supplied
/// ``DocCDiagnostics`` (see that type for the leniency contract). It never
/// compiles Swift — it only consumes the pre-built archive.
Expand Down Expand Up @@ -83,13 +84,16 @@ public struct DocCArchiveLoader {
issues.append("no index/index.json found")
}

let themeSettings = DocCThemeSettings.load(from: archiveURL, issues: &issues)

return DocCArchive(
moduleName: metadata.bundleDisplayName,
bundleID: metadata.bundleID ?? metadata.bundleDisplayName,
pages: pages,
index: index,
archiveURL: archiveURL,
loadIssues: issues
loadIssues: issues,
themeSettings: themeSettings
)
}

Expand Down
10 changes: 7 additions & 3 deletions Sources/Kiln/DocC/DocCRenderPhase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ struct DocCRenderPhase {
// browser (docc-nav.js).
let sidebar = navigationBuilder.renderHTML(navigationBuilder.build(archive.index), moduleTitle: module.displayTitle)

let moduleImageURL = Self.resolveModuleImage(module.image, basePath: basePath)
?? archive.themeSettings?.iconURL(archiveURL: archive.archiveURL, urls: urls)
for page in archive.pages {
let rendered = contentRenderer.render(page.node)
let versionSwitcherHTML = versionSwitcher.renderHTML(currentVersion: version, currentPath: page.path)
Expand Down Expand Up @@ -198,7 +200,7 @@ struct DocCRenderPhase {
}
let html = try await renderPage(page: page, rendered: rendered, urls: urls,
moduleTitle: module.displayTitle,
moduleImageURL: Self.resolveModuleImage(module.image, basePath: basePath),
moduleImageURL: moduleImageURL,
sidebarHTML: sidebar,
moduleSwitcherHTML: moduleSwitcherHTML,
versionSwitcherHTML: versionSwitcherHTML,
Expand Down Expand Up @@ -402,8 +404,10 @@ struct DocCRenderPhase {

// page.leaf emits page.content verbatim, so the DocC body carries its own
// header (role eyebrow + <h1>) — the render node deliberately omits it.
var body = "<header class=\"docc-header\">\n"
// The module's logo, if configured, sits at the top of its landing page.
let headerClass = isModuleLanding && moduleImageURL != nil
? "docc-header docc-module-header" : "docc-header"
var body = "<header class=\"\(headerClass)\">\n"
// The configured image or DocC technology logo accompanies the landing title.
if isModuleLanding, let image = moduleImageURL {
body += "<img class=\"docc-module-image\" src=\"\(HTMLEscaping.attribute(image))\" alt=\"\" loading=\"lazy\">\n"
}
Expand Down
97 changes: 97 additions & 0 deletions Sources/Kiln/DocC/DocCThemeSettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Foundation

/// The portable branding subset of DocC's `theme-settings.json`.
/// Layout, fonts, backgrounds and feature flags remain the host theme's concern.
public struct DocCThemeSettings: Decodable, Sendable {
/// Resolved colours for the documentation intro, including `var(--color-…)` aliases.
public let accentLight: String?
public let accentDark: String?
/// The technology icon path as specified by DocC (resolved when rendering).
public let technologyIcon: String?

private struct Color: Decodable {
let light: String?
let dark: String?

init(from decoder: any Decoder) throws {
let value = try decoder.singleValueContainer()
if let string = try? value.decode(String.self) {
light = string
dark = string
} else {
let variants = (try? value.decode([String: String].self)) ?? [:]
light = variants["light"]
dark = variants["dark"]
}
}
}

private struct Theme: Decodable {
let color: [String: Color]?
let icons: [String: String]?
}
private enum CodingKeys: String, CodingKey { case theme }

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let theme = try container.decodeIfPresent(Theme.self, forKey: .theme)
let colors = theme?.color ?? [:]
func resolve(dark: Bool) -> String? {
var key = "documentation-intro-accent"
var visited: Set<String> = []
while visited.insert(key).inserted, let color = colors[key] {
guard let raw = dark ? (color.dark ?? color.light) : (color.light ?? color.dark) else { return nil }
let value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("var(--color-"), value.hasSuffix(")") {
key = String(value.dropFirst("var(--color-".count).dropLast())
} else {
return Self.isColor(value) ? value : nil
}
}
return nil
}
accentLight = resolve(dark: false)
accentDark = resolve(dark: true)
technologyIcon = theme?.icons?["technology"]
}

// Accept colour literals, not arbitrary CSS declarations, URLs or gradients.
// Colours are exposed as data; the host theme chooses whether to use them.
private static func isColor(_ value: String) -> Bool {
let pattern = #"^(#[0-9a-fA-F]{3,4}|#[0-9a-fA-F]{6}|#[0-9a-fA-F]{8}|(?:rgb|rgba|hsl|hsla)\([0-9.,%+ /-]+\)|black|white|transparent)$"#
return value.range(of: pattern, options: .regularExpression) != nil
}

static func load(from archive: URL, issues: inout [String]) -> DocCThemeSettings? {
let file = archive.appendingPathComponent("theme-settings.json")
guard FileManager.default.fileExists(atPath: file.path) else { return nil }
do {
return try JSONDecoder().decode(Self.self, from: Data(contentsOf: file))
} catch {
issues.append("failed to decode theme-settings.json: \(error)")
return nil
}
}

/// Rebase an archive-local icon onto Kiln's module/version URL. DocC themes
/// often bake their original hosting prefix into `/sqlkit/images/SQLKit/…`.
/// Only use files actually present in this archive; never manufacture a 404.
func iconURL(archiveURL: URL, urls: DocCURLs) -> String? {
guard let icon = technologyIcon,
let components = URLComponents(string: icon),
components.scheme == nil, components.host == nil,
components.query == nil, components.fragment == nil else { return nil }
let parts = components.path.split(separator: "/").map(String.init)
guard !parts.contains(".."), !parts.contains("."),
let start = parts.firstIndex(of: "images") else { return nil }
let relative = parts[start...].joined(separator: "/")
let root = archiveURL.resolvingSymlinksInPath().path + "/"
let file = archiveURL.appendingPathComponent(relative).resolvingSymlinksInPath()
guard file.path.hasPrefix(root),
(try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else { return nil }
let encoded = relative.split(separator: "/").map {
String($0).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/?#%"))) ?? String($0)
}.joined(separator: "/")
return urls.moduleRootURL + encoded
}
}
24 changes: 24 additions & 0 deletions Sources/Kiln/Resources/DefaultTheme/css/docc-theme.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/* A module's DocC technology logo accompanies its title. Typography, colours,
backgrounds and navigation belong to the host theme, including Vapor's.
Shared themes can load this via extraCSS: ["_kiln/css/docc-theme.css"]. */
.docc-header.docc-module-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 1.5rem;
align-items: center;
color-scheme: light;
}
/* Embedded SVGs use prefers-color-scheme: inherit the site's selected mode,
including a manual selection that differs from the operating system. */
html.dark .docc-module-header,
[data-palette="dark"] .docc-module-header { color-scheme: dark; }
.docc-module-header > :not(.docc-module-image) { grid-column: 1; }
.docc-module-header .docc-module-image {
grid-column: 2;
grid-row: 1 / span 3;
width: auto;
max-width: min(8rem, 25vw);
height: 4rem;
object-fit: contain;
margin: 0;
}
1 change: 1 addition & 0 deletions Sources/Kiln/Resources/DefaultTheme/templates/base.leaf
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
#endif#endfor

<link rel="stylesheet" href="#(basePath)/_kiln/css/theme.css">
<link rel="stylesheet" href="#(basePath)/_kiln/css/docc-theme.css">
#for(href in site.extraCSS):<link rel="stylesheet" href="#(basePath)/#(href)">
#endfor

Expand Down
Loading
Loading