Skip to content
Open
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
2 changes: 1 addition & 1 deletion Examples/APIDocsSite/Sources/APIDocsSite/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ let site = KilnSite(
theme: .default(
palette: .autoLightDark(primary: .black, accent: .blue),
logo: "assets/logo.svg",
favicon: "assets/logo.svg"
favicons: [.init(type: .svg, path: "assets/logo.svg")]
),
docc: DocCSite(
packages: [
Expand Down
5 changes: 4 additions & 1 deletion Examples/ExampleSite/Content/latest/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,10 @@ translations, fallback, and UI strings.
theme: .default(
palette: .autoLightDark(primary: .black, accent: .blue),
logo: "assets/logo.svg",
favicon: "assets/logo.svg"
favicons: [
.init(type: .svg, path: "assets/logo.svg"),
.init(type: .png(.x16)),
]
)
```

Expand Down
19 changes: 11 additions & 8 deletions Examples/ExampleSite/Content/latest/guides/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@ tweak it with options, or replace any part with your own templates.
theme: .default(
palette: .autoLightDark(primary: .black, accent: .blue),
logo: "assets/logo.svg",
favicon: "assets/logo.svg",
favicons: [
.init(type: .svg, path: "assets/logo.svg"),
.init(type: .png(.x16)),
],
fonts: .init(text: "Inter", code: "JetBrains Mono"),
features: [.backToTop, .searchHighlight]
)
```

| Option | Purpose |
| ---------- | ------- |
| `palette` | `Palette` with `primary`/`accent` `Color`s and a default mode (`.auto`/`.light`/`.dark`). |
| `logo` | Header logo (content-relative path). |
| `favicon` | Site favicon. |
| `fonts` | `Fonts(text:code:)` for body and code text. |
| `features` | Opt-in extras: `.searchSuggest`, `.searchHighlight`, `.navigationTabs`, `.backToTop`. |
| Option | Purpose | Default values |
|------------|-------------------------------------------------------------------------------------------------------------|-----------------------------------------|
| `palette` | `Palette` with `primary`/`accent` `Color`s and a default mode. | `.auto`/`.light`/`.dark` |
| `logo` | Header logo (content-relative path). | none |
| `favicons` | `.svg` for modern browsers, `.ico\|.png(.x16\|.x32)` for most browsers, `.png(.x180)` for apple-touch-icon. | path : assets/favicon\[-\(size)\].(ext) |
| `fonts` | `Fonts(text:code:)` for body and code text. | |
| `features` | Opt-in extras: `.searchSuggest`, `.searchHighlight`, `.navigationTabs`, `.backToTop`. | `.searchSuggest`, `.searchHighlight` |

`Color` has presets (`.black`, `.blue`, `.indigo`, …) or accepts any CSS string
via `Color("#2f6feb")`.
Expand Down
2 changes: 1 addition & 1 deletion Examples/ExampleSite/Sources/ExampleSite/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ let site = KilnSite(
directory: "Theme",
palette: .autoLightDark(primary: .black, accent: .blue),
logo: "assets/logo.svg",
favicon: "assets/logo.svg"
favicons: [.init(type: .svg, path: "assets/logo.svg")]
),
social: [
.init(icon: .github, link: "https://github.com/brokenhandsio/kiln"),
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ A few more things to know:
preview at `http://127.0.0.1:8080/docs/` mirrors production.

**Theme** options: `palette` (`Palette` with `primary`/`accent` `Color`s and a
`.auto`/`.light`/`.dark` default mode), `logo`, `favicon`, `fonts`
`.auto`/`.light`/`.dark` default mode), `logo`, `favicons`, `fonts`
(`Fonts(text:code:)`), and `features` (`.searchSuggest`, `.searchHighlight`,
`.navigationTabs`, `.backToTop`). `Color` has presets (`.black`, `.blue`,
`.indigo`, …) or accepts any CSS string via `Color("#2f6feb")`.
Expand Down
26 changes: 26 additions & 0 deletions Sources/Kiln/Configuration/FavIcon.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#if canImport(FoundationEssentials)
internal import FoundationEssentials
#else
internal import Foundation
#endif

/// Paths (relative to the content directory's assets) to a logo image.
public struct FavIcon: Sendable
{
public var type: FavIconType
public var path: String

public init(
type: FavIconType = .svg,
path: String? = nil
) {
self.type = type
self.path = path ?? type.defaultPath()
}

@available(*, deprecated, message: "This initializer guesses the type from a string and is therefore not as reliable as init(type:path:).")

@deberlhe-jb deberlhe-jb Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0xTim I'm not sure wether I should add the @_disfavoredOverload annotation here since it would pick SVG as the default format instead of guessing a possibly more appropriate one. What do you think ?

public init(path: String) {
self.path = path
self.type = FavIconType.guessFrom(file: path)
}
}
68 changes: 68 additions & 0 deletions Sources/Kiln/Configuration/FavIconType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#if canImport(FoundationEssentials)
internal import FoundationEssentials
#else
internal import Foundation
#endif

public enum FavIconType: Sendable, Equatable {
case svg
case png(PNGSize)
case ico

public enum PNGSize: Int, Sendable {
case xUnknown = 0
case x16 = 16
case x32 = 32
case x180 = 180
}

public func ext() -> String {
switch self {
case .svg: return "svg"
case .png: return "png"
case .ico: return "ico"
}
}

public func defaultPath() -> String {
switch self {
case .svg: return "assets/favicon.svg"
case .png(let size): return "assets/favicon-\(size.rawValue).png"
case .ico: return "assets/favicon.ico"
}
}

public func rel() -> String {
switch self {
case .png(.x180): return "apple-touch-icon"
case .svg, .ico, .png: return "icon"
}
}

public func sizes() -> String? {
switch self {
case .svg, .ico: return "any"
case .png(.xUnknown): return nil
case .png(let size): return "\(size.rawValue)x\(size.rawValue)"
}
}

public func mimeType() -> String? {
switch self {
case .svg: return "image/svg+xml"
case .png(.x180), .ico: return nil
case .png: return "image/png"
}
}

public static func guessFrom(file name: String) -> Self {
let ext = name.components(separatedBy: ".").last?.lowercased()

switch ext {
case "svg": return .svg
case "ico": return .ico
default: return .png(.xUnknown)
}
}
}

58 changes: 57 additions & 1 deletion Sources/Kiln/Configuration/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,13 @@ public struct Theme: Sendable {
public var palette: Palette
/// Path (relative to the content directory's assets) to a logo image.
public var logo: String?
/// Path to a favicon image.
public var favicon: String?
public var favicons: [FavIcon]
public var fonts: Fonts?
public var features: Set<ThemeFeature>

@available(*, deprecated, message: "init(favicon:) (singular) is deprecated in favor of init(favicons:) (plural).")
@_disfavoredOverload
public init(
source: Source = .default,
sharedLayers: [URL] = [],
Expand All @@ -81,9 +83,36 @@ public struct Theme: Sendable {
self.favicon = favicon
self.fonts = fonts
self.features = features

if let favicon = favicon {
self.favicons = [.init(path: favicon)]
} else {
self.favicons = []
}
}

public init(
source: Source = .default,
sharedLayers: [URL] = [],
palette: Palette = Palette(),
logo: String? = nil,
favicons: [FavIcon] = [],
fonts: Fonts? = nil,
features: Set<ThemeFeature> = [.searchSuggest, .searchHighlight]
) {
self.source = source
self.sharedLayers = sharedLayers
self.palette = palette
self.logo = logo
self.favicon = nil
self.favicons = favicons
self.fonts = fonts
self.features = features
}

/// Kiln's bundled default theme.
@available(*, deprecated, message: "default(favicon:) (singular) is deprecated in favor of default(favicons:) (plural).")
@_disfavoredOverload
public static func `default`(
sharedLayers: [URL] = [],
palette: Palette = Palette(),
Expand All @@ -95,7 +124,21 @@ public struct Theme: Sendable {
Theme(source: .default, sharedLayers: sharedLayers, palette: palette, logo: logo, favicon: favicon, fonts: fonts, features: features)
}

/// Kiln's bundled default theme.
public static func `default`(
sharedLayers: [URL] = [],
palette: Palette = Palette(),
logo: String? = nil,
favicons: [FavIcon] = [],
fonts: Fonts? = nil,
features: Set<ThemeFeature> = [.searchSuggest, .searchHighlight]
) -> Theme {
Theme(source: .default, sharedLayers: sharedLayers, palette: palette, logo: logo, favicons: favicons, fonts: fonts, features: features)
}

/// A theme that overrides the bundled default with your own templates/assets.
@available(*, deprecated, message: "custom(favicon:) (singular) is deprecated in favor of custom(favicons:) (plural).")
@_disfavoredOverload
public static func custom(
directory: String,
sharedLayers: [URL] = [],
Expand All @@ -107,4 +150,17 @@ public struct Theme: Sendable {
) -> Theme {
Theme(source: .custom(directory: directory), sharedLayers: sharedLayers, palette: palette, logo: logo, favicon: favicon, fonts: fonts, features: features)
}

/// A theme that overrides the bundled default with your own templates/assets.
public static func custom(
directory: String,
sharedLayers: [URL] = [],
palette: Palette = Palette(),
logo: String? = nil,
favicons: [FavIcon] = [],
fonts: Fonts? = nil,
features: Set<ThemeFeature> = [.searchSuggest, .searchHighlight]
) -> Theme {
Theme(source: .custom(directory: directory), sharedLayers: sharedLayers, palette: palette, logo: logo, favicons: favicons, fonts: fonts, features: features)
}
}
13 changes: 11 additions & 2 deletions Sources/Kiln/Rendering/RenderContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ struct RenderContext {
var baseURL: String
/// The site's mount path (e.g. `/docs`), or `""` when served from the domain
/// root. Prefixed onto hard-coded root-relative links in templates (theme
/// assets, logo, favicon, home links).
/// assets, logo, favicons, home links).
var basePath: String = ""

var pageTitle: String
Expand Down Expand Up @@ -219,7 +219,7 @@ struct RenderContext {
"author": .string(site.author),
"copyright": .string(site.copyright),
"logo": .string(site.theme.logo),
"favicon": .string(site.theme.favicon),
"favicons": .array(site.theme.favicons.map(Self.favIconData)),
"social": .array(site.social.map(Self.socialData)),
"extraCSS": .array(site.extraCSS.map { .string($0) }),
"extraJS": .array(site.extraJavaScript.map { .string($0) }),
Expand Down Expand Up @@ -279,6 +279,15 @@ struct RenderContext {
])
}

private static func favIconData(_ favicon: FavIcon) -> LeafData {
.dictionary([
"path": .string(favicon.path),
"rel": .string(favicon.type.rel()),
"sizes": .string(favicon.type.sizes()),
"mimeType": .string(favicon.type.mimeType()),
])
}

// MARK: Language

// MARK: Localised strings
Expand Down
3 changes: 2 additions & 1 deletion Sources/Kiln/Resources/DefaultTheme/templates/base.leaf
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
#if(page.imageURL):<meta name="twitter:image" content="#(page.imageURL)">
<meta name="twitter:image:alt" content="#if(page.isHome):#(site.name)#else:#(page.title)#endif">#endif

#if(site.favicon):<link rel="icon" href="#(basePath)/#(site.favicon)">#endif
#for(favicon in site.favicons):<link rel="#(favicon.rel)"#if(favicon.sizes): sizes="#(favicon.sizes)"#endif#if(favicon.mimeType): type="#(favicon.mimeType)"#endif href="#(basePath)/#(favicon.path)">
#endfor

#for(alt in languages):<link rel="alternate" hreflang="#(alt.locale)" href="#(alt.absoluteURL)">
#if(alt.isDefault):<link rel="alternate" hreflang="x-default" href="#(alt.absoluteURL)">
Expand Down
35 changes: 35 additions & 0 deletions Tests/KilnTests/BuildTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ struct BuildTests {
description: "Fixture site description.",
image: "assets/card.png",
twitterSite: "@fixture",
theme: .default(favicons: [
.init(type: .ico, path: "assets/customFaviconName.ico"),
.init(type: .ico),
.init(type: .svg, path: "assets/customFaviconName.svg"),
.init(type: .svg),
.init(type: .png(.x16), path: "assets/customFaviconName16.png"),
.init(type: .png(.x16)),
.init(type: .png(.x32), path: "assets/customFaviconName32.png"),
.init(type: .png(.x32)),
.init(type: .png(.x180), path: "assets/customFaviconName180.png"),
.init(type: .png(.x180)),
]),
carbonAds: .init(serve: "TESTSERVE", placement: "fixture"),
languages: languages ?? [
.init(.english, isDefault: true),
Expand Down Expand Up @@ -341,4 +353,27 @@ struct BuildTests {
#expect(index.docs.count == 3)
#expect(index.docs.contains { $0.title == "Home" })
}

@Test("Pages include favicons")
func favicons() async throws {
let output = try await buildFixture()
defer { try? FileManager.default.removeItem(at: output) }

let home = try read(output.appendingPathComponent("index.html"))
// SVG.
#expect(home.contains("<link rel=\"icon\" sizes=\"any\" type=\"image/svg+xml\" href=\"/assets/favicon.svg\">"))
#expect(home.contains("<link rel=\"icon\" sizes=\"any\" type=\"image/svg+xml\" href=\"/assets/customFaviconName.svg\">"))
// ICO.
#expect(home.contains("<link rel=\"icon\" sizes=\"any\" href=\"/assets/favicon.ico\">"))
#expect(home.contains("<link rel=\"icon\" sizes=\"any\" href=\"/assets/customFaviconName.ico\">"))
// PNG 16
#expect(home.contains("<link rel=\"icon\" sizes=\"16x16\" type=\"image/png\" href=\"/assets/favicon-16.png\">"))
#expect(home.contains("<link rel=\"icon\" sizes=\"16x16\" type=\"image/png\" href=\"/assets/customFaviconName16.png\">"))
// PNG 32
#expect(home.contains("<link rel=\"icon\" sizes=\"32x32\" type=\"image/png\" href=\"/assets/favicon-32.png\">"))
#expect(home.contains("<link rel=\"icon\" sizes=\"32x32\" type=\"image/png\" href=\"/assets/customFaviconName32.png\">"))
// PNG 180
#expect(home.contains("<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/assets/favicon-180.png\">"))
#expect(home.contains("<link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/assets/customFaviconName180.png\">"))
}
}
Loading