Merged the template branch into main to pick up the 7 upstream changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:17:26 +02:00
co-authored by Claude Opus 5
14 changed files with 82 additions and 25 deletions
+4 -4
View File
@@ -7,16 +7,16 @@ The shared [Hummingbird](https://github.com/hummingbird-project/hummingbird) too
| Routing | `RouterController`, `RouteCollectionBuilder`, the `addController` extension on `RouterMethods` |
| Middlewares | `SecurityHeadersMiddleware`, `HTTPSRedirectMiddleware`, `TrailingSlashRedirectMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware` (negotiating from a `lang` query parameter, then a leading path segment, then `Accept-Language`), `NotFoundMiddleware` |
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Link previews | `SocialCard`, its `Image` and `Style`, and the `Tag` meta tags it derives |
| Link previews | `SocialCard`, its `locale` and the `alternateLocales` of its other language editions, its `Image` and `Style`, and the `Tag` meta tags it derives |
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, the open `Name` and `Kind` vocabularies, and the site-wide initializer building the `Organization`/`WebSite` pair |
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to a tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
| Analytics | `Analytics`, the `Event`s a page reports (with `tagging()` to apply one to any attribute-bearing HTML or SVG tag), the tracker script `Attribute`s it derives, the optional session `recorder` script paired with it, and the `origin` its preconnect hint targets |
| Responses | `CachedHTMLResponse`, and `LocalizedHTMLCollectionResponse` rendering one of them per catalog language |
| Contexts | `LocalizedRequestContext` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
## Design rules
The package holds only what every service can reuse. Anything a service owns is injected, never referenced.
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page.
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. What a type needs, it takes as a parameter: the `bundle:` whose String Catalog names the supported languages, the `document:` closure that builds a page for a locale, a `Page` conformer's `metadata` and its optional head concerns (`summary`, `canonicalURL`, `socialCard`, `structuredData`, `analytics`). URLs arrive absolute and fully formed — composing them stays with the page. The same holds for request semantics: only the caller knows whether it negotiates the language or pins it by route, so `variesOnAcceptLanguage:` declares whether the responses carry `Vary: Accept-Language`.
- **Types own their format; `Page` renders generically.** Each head concern derives its own render-ready form — `SocialCard.tags`, `StructuredData.payload`, `Analytics.attributes` — which `Page` applies without knowing the vocabulary. A page's `scripts` and the tracker render as `defer`red head tags, the tracker preceded by a `preconnect` to its cross-origin host.
- **Nodes are joined by `@id`, not repetition.** A node another page must point at gets its identifier from a helper rather than a hand-spelled fragment — `organizationID(forSiteURL:)` names the node the site-wide initializer builds, and that initializer's `founder` takes such an identifier back. A service adds the helper for any node it owns, so neither side can drift.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites retroactively — the Website's `*+Defaults` are the pattern. The open schema.org vocabularies work the same way: the package declares the shared `Property.Name` and `Node.Kind` constants, a service adds its own.
@@ -12,7 +12,7 @@ public extension Analytics.Event {
/// ```
///
/// - Returns: one HTML attribute per event attribute, applied verbatim.
func tagging<Tag: HTMLTrait.Attributes.Global>() -> [HTMLAttribute<Tag>] {
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
attributes.map {
.custom(
name: $0.name,
@@ -9,9 +9,11 @@ import Localization
/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's
/// ``LocalizedRequestContext/language``.
///
/// The query parameter is the deliberate override a language switcher links to; failing that, a leading path segment naming a supported language
/// pins it, so an unrouted path under a language's prefix its not-found page answers in that language. Values naming no supported language
/// are ignored, leaving the header. The request is passed through untouched the path and routing are unaffected.
/// The query parameter is a deliberate override; failing that, a leading path segment naming a supported language pins it, so an unrouted path under
/// a language's prefix its not-found page answers in that language. Values naming no supported language are ignored, leaving the header. The
/// request is passed through untouched the path and routing are unaffected.
///
/// A site whose page routes pin their language by URL consults this only for responses that belong to no URL in practice, its not-found page.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
@@ -7,6 +7,9 @@ import Hummingbird
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
/// ``LocalizationMiddleware``, falling back to the default language.
///
/// Its responses declare `Vary: Accept-Language`: where the page routes pin their language by URL, this is the one responder that negotiates.
/// An unrouted path names no edition, so no canonical URL contradicts the header.
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
@@ -27,6 +30,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
self.responses = .init(
bundle: bundle,
status: .notFound,
variesOnAcceptLanguage: true,
document: document
)
}
@@ -7,8 +7,12 @@ import Localization
/// A per-language collection of pre-rendered HTML responses.
///
/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and
/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone.
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header naming the
/// language it was rendered in.
///
/// `Vary: Accept-Language` is the caller's to declare, since only the caller knows how it picks the language: a responder that negotiates the
/// header sets it, so shared caches key on the language rather than serving one to everyone; a responder that pins the language by route does
/// not, since the header would announce a negotiation that never happens.
public struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
@@ -25,21 +29,27 @@ public struct LocalizedHTMLCollectionResponse: Sendable {
/// - Parameters:
/// - bundle: the bundle whose String Catalog names the languages the document is rendered for.
/// - status: the status applied to every response. Defaults to `.ok`.
/// - variesOnAcceptLanguage: whether the responses declare `Vary: Accept-Language`. Defaults to `false`; a responder that
/// negotiates the header passes `true`.
/// - document: builds the document to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
status: HTTPResponse.Status = .ok,
variesOnAcceptLanguage: Bool = false,
document: (Locale) -> Document
) {
self.list = .init(bundle: bundle)
self.responses = list.all
.reduce(into: [:]) { responses, language in
var headers: HTTPFields = [.contentLanguage: language]
if variesOnAcceptLanguage {
headers[.vary] = "Accept-Language"
}
responses[language] = CachedHTMLResponse(
status: status,
additionalHeaders: [
.contentLanguage: language,
.vary: "Accept-Language",
],
additionalHeaders: headers,
document: document(.init(
identifier: language
))
@@ -7,6 +7,9 @@ public struct SocialCard: Sendable {
// MARK: Properties
/// The locales of the card's other language editions, in Open Graph's `language_TERRITORY` form; empty to omit their tags.
public let alternateLocales: [String]
/// The card's share image, or `nil` to omit its tags.
public let image: Image?
@@ -43,6 +46,7 @@ public struct SocialCard: Sendable {
/// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag.
/// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default)
/// to omit its tag.
/// - alternateLocales: the locales the card's page is also published in, in the same form; empty (the default) to omit their tags.
/// - image: the card's share image, or `nil` (the default) to omit its tags.
/// - type: the Open Graph type of the object the card describes. Defaults to `website`.
/// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``.
@@ -52,10 +56,12 @@ public struct SocialCard: Sendable {
url: String? = nil,
siteName: String? = nil,
locale: String? = nil,
alternateLocales: [String] = [],
image: Image? = nil,
type: String = "website",
style: Style = .summaryLargeImage
) {
self.alternateLocales = alternateLocales
self.image = image
self.locale = locale
self.siteName = siteName
@@ -68,8 +74,8 @@ public struct SocialCard: Sendable {
// MARK: Computed
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and
/// the Twitter card style last. A tag whose fact the card does not carry is left out.
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, locale, and alternate locales, then the
/// image group, and the Twitter card style last. A tag whose fact the card does not carry is left out.
public var tags: [Tag] {
let tags: [Tag?] = [
Tag(type, name: .type),
@@ -78,7 +84,7 @@ public struct SocialCard: Sendable {
summary.map { Tag($0, name: .description) },
url.map { Tag($0, name: .url) },
locale.map { Tag($0, name: .locale) },
] + (image?.tags ?? []) + [
] + alternateLocales.map { Tag($0, name: .localeAlternate) } + (image?.tags ?? []) + [
Tag(style.rawValue, name: .twitter),
]
@@ -60,6 +60,8 @@ extension SocialCard.Tag {
case imageWidth = "og:image:width"
/// The `og:locale` tag, carrying the locale of the card's text.
case locale = "og:locale"
/// The `og:locale:alternate` tag, carrying the locale of one other language edition of the card's page; repeated once per edition.
case localeAlternate = "og:locale:alternate"
/// The `og:site_name` tag, carrying the name of the site the card belongs to.
case siteName = "og:site_name"
/// The `og:title` tag, carrying the card's title.
@@ -65,6 +65,7 @@ public extension StructuredData {
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
init(
@@ -75,6 +76,7 @@ public extension StructuredData {
areaServed: String? = nil,
email: String? = nil,
logo: String? = nil,
inLanguage: [String] = [],
profiles: [String] = [],
founder: String? = nil
) {
@@ -116,6 +118,19 @@ public extension StructuredData {
organization.append(.init(.founder, value: .reference(founder)))
}
var website: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
]
if !inLanguage.isEmpty {
website.append(.init(
.inLanguage,
value: .array(inLanguage.map(Value.string))
))
}
self.init(nodes: [
.init(
type: .organization,
@@ -124,11 +139,7 @@ public extension StructuredData {
),
.init(
type: .website,
properties: [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
]
properties: website
),
])
}
@@ -79,6 +79,8 @@ public extension StructuredData.Property.Name {
static let email: Self = "email"
/// The person who founded an organization.
static let founder: Self = "founder"
/// The language a creative work a site, a page is published in.
static let inLanguage: Self = "inLanguage"
/// The absolute URL of an organization's logo.
static let logo: Self = "logo"
/// The name of the thing a node describes.
@@ -46,4 +46,14 @@ struct AnalyticsEventTaggingTests {
#expect(attributes.count == event.attributes.count)
}
@Test
func `applies the event name to an SVG tag`() {
// `SVGTag.path` is no `HTMLTrait.Attributes.Global`, so this stops compiling if the method narrows back to HTML tags.
let rendered = SVG.path {}
.attributes(contentsOf: Analytics.Event(name: "logo").tagging())
.render()
#expect(rendered.contains(#"data-umami-event="logo""#))
}
}
@@ -18,6 +18,7 @@ struct SocialCardTests {
url: "https://site.example/",
siteName: "A Site",
locale: "en",
alternateLocales: ["nl_NL", "de_DE"],
image: .init(
url: "https://site.example/img/card.png",
width: 2400,
@@ -33,6 +34,8 @@ struct SocialCardTests {
.init("A summary.", name: .description),
.init("https://site.example/", name: .url),
.init("en", name: .locale),
.init("nl_NL", name: .localeAlternate),
.init("de_DE", name: .localeAlternate),
.init("https://site.example/img/card.png", name: .image),
.init("2400", name: .imageWidth),
.init("1260", name: .imageHeight),
@@ -17,6 +17,7 @@ struct StructuredDataTests {
name: "A Site",
url: "https://site.example/",
logo: "https://site.example/logo.png",
inLanguage: ["en", "nl"],
profiles: [
"https://social.example/a-site",
"https://videos.example/a-site",
@@ -26,7 +27,7 @@ struct StructuredDataTests {
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"},"inLanguage":["en","nl"]}]}"#
)
}
+4
View File
@@ -39,6 +39,10 @@ RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get install -y libjemalloc-dev \
&& rm -rf /var/lib/apt/lists/*
# Pin git to HTTP/1.1 for the dependency clones. GitHub answers the git-upload-pack POST with a 401 when it is sent
# over HTTP/2 by the git 2.43 that Noble ships, which SPM reports as a clone failure asking for a username.
RUN git config --global http.version HTTP/1.1
# Set up a build area
WORKDIR /build
+4 -2
View File
@@ -60,8 +60,8 @@ Four of those are page-authored and optional. `IndexPage` supplies `canonicalURL
| --- | --- | --- |
| `summary` | `<meta name="description">` | The page's one-line description. |
| `canonicalURL` | `<link rel="canonical">` | Absolute URL. `IndexPage` derives it from `site.origin` and the page's own language; an unset origin omits it (see [Language editions](#language-editions)). |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:profiles:)` builds the site-wide `Organization` + `WebSite` pair. The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
| `socialCard` | Open Graph + Twitter `<meta>` tags | A `SocialCard` — title, summary, URL, site name, locale, alternate locales, share image. Scrapers require absolute URLs, so the page composes them from its own origin; `Page+Defaults` supplies the locale as `ogLocale`. |
| `structuredData` | `<script type="application/ld+json">` | A `StructuredData` graph of schema.org nodes; `StructuredData(name:url:logo:inLanguage:profiles:)` builds the site-wide `Organization` + `WebSite` pair, `inLanguage` declaring the languages the site publishes in (see [Language editions](#language-editions)). The payload is an inert data block, so the `Content-Security-Policy` does not apply to it. |
The fifth, `analytics`, is *configuration*-authored rather than page-authored: the executable builds an `Analytics` from the `analytics.*` keys and hands it to `RootController` and `NotFoundMiddleware`, which pass it to both pages. It renders as a `<link rel="preconnect">` plus a deferred `<script>` carrying the [Umami](https://umami.is) `data-` attributes, and — unlike the structured data — it *is* executable, so the `Content-Security-Policy` must allow its origin. It is empty by default; see [Analytics](#analytics) for how to turn it on.
@@ -85,6 +85,8 @@ The catalog's source language is the **default** and owns the site's bare paths;
Given a `site.origin`, each page then emits the `hreflang` alternates tying its editions together — one per language plus an `x-default` pointing at the default language's edition, whose bare URL negotiates the language and so is the right landing for everyone unmatched. A single-language site emits none: a set naming one edition tells a search engine nothing it cannot already see. `Page+Defaults`' `languageAlternates(origin:path:languages:)` takes the language set, so a page translated into only some of them narrows it rather than advertising an edition that does not exist.
The same set belongs in the site-wide structured data: `StructuredData`'s `inLanguage` declares it on the `WebSite` node, so a crawler reads the site's languages from the graph as well as from the alternates. A page's social card says the same to a scraper: its `locale` is the edition the page *is*, and `SocialCard`'s `alternateLocales` names the rest, each mapped through `ogLocales` the way `ogLocale` maps the page's.
`sitemap.xml` is the one part that does *not* follow the catalog: it is a static file, so a new language needs its editions added by hand — `/nl`, `/nl/<page>`, one `<loc>` each, alongside the default language's. Give each the same spelling the page's own canonical carries (the root is the bare origin, with no trailing slash), or the two disagree about which URL is canonical.
Visitors switch language two ways, both handled by `LocalizationMiddleware` ahead of the routes: a `?lang=` query parameter (what a language switcher links to) and the leading path segment. Either beats `Accept-Language`; a value naming no supported language is ignored. The path segment matters beyond the routed pages — it is what makes an *unrouted* path under a language's prefix answer its not-found page in that language.