Added language support to the Website service target.

This commit is contained in:
2026-08-30 21:47:08 +02:00
parent 9b16ce3339
commit a3d9655766
12 changed files with 568 additions and 17 deletions
@@ -0,0 +1,128 @@
import Elementary
import Foundation
import Infrastructure
import Testing
@testable import WebsiteLibrary
@Suite(
"Page+Defaults extension",
.tags(.extensionTests)
)
struct PageDefaultsTests {
// MARK: Computed tests
@Test
func `pairs english with a territory for its social card locale`() {
#expect(StubPage().ogLocale == "en_US")
}
/// Open Graph prefers `language_TERRITORY`, but a scraper accepts the bare code better than pinning a territory the site never named.
@Test
func `leaves an unpaired language as a bare code`() {
#expect(StubPage(locale: .init(identifier: "nl")).ogLocale == "nl")
}
// MARK: Method tests
@Test
func `renders no language alternates without an origin`() {
let html = StubPage().languageAlternates(
origin: nil,
path: "/",
languages: Self.languages
).render()
#expect(html.isEmpty)
}
@Test
func `renders no language alternates for a single language`() {
let html = StubPage().languageAlternates(
origin: "https://example.com",
path: "/",
languages: [.default]
).render()
#expect(html.isEmpty)
}
/// The default language keeps the bare root; the prefixed edition collapses onto its prefix rather than carrying a trailing slash.
@Test
func `renders an alternate per language and an x-default at the root`() {
let html = StubPage().languageAlternates(
origin: "https://example.com",
path: "/",
languages: Self.languages
).render()
#expect(html.contains(#"<link rel="alternate" hreflang="en" href="https://example.com">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="nl" href="https://example.com/nl">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="x-default" href="https://example.com">"#))
}
@Test
func `prefixes the alternates of a nested page`() {
let html = StubPage().languageAlternates(
origin: "https://example.com",
path: "/privacy",
languages: Self.languages
).render()
#expect(html.contains(#"<link rel="alternate" hreflang="en" href="https://example.com/privacy">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="nl" href="https://example.com/nl/privacy">"#))
#expect(html.contains(#"<link rel="alternate" hreflang="x-default" href="https://example.com/privacy">"#))
}
}
// MARK: - Helpers
private extension PageDefaultsTests {
// MARK: Constants
/// A two-language set, standing in for the multi-language catalog the template itself does not ship.
static let languages: [Language] = [
.default,
.init(identifier: "nl")
]
// MARK: Types
/// The barest ``Page`` conformance, so the shared defaults can be exercised without a real page's content.
struct StubPage: Page {
// MARK: Properties
let assetVersion: String? = nil
let locale: Locale
// MARK: Initializers
init(locale: Locale = .init(identifier: "en")) {
self.locale = locale
}
// MARK: Properties
var content: some HTML {
HTMLRaw("")
}
var scripts: [any Asset] {
[]
}
var stylesheets: [any Asset] {
[]
}
var title: String {
"Stub"
}
}
}
@@ -39,6 +39,32 @@ struct IndexPageTests {
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="preload""#))
}
@Test
func `renders no canonical URL until an origin is given`() {
#expect(!IndexPage(locale: .init(identifier: "en")).render().contains(#"rel="canonical""#))
}
@Test
func `renders the canonical URL as the bare origin for the default language`() {
// The root is the one path with no trailing slash for `TrailingSlashRedirectMiddleware` to strip.
let html = IndexPage(
locale: .init(identifier: "en"),
siteOrigin: "https://example.com"
).render()
#expect(html.contains(#"<link rel="canonical" href="https://example.com">"#))
}
@Test
func `renders no language alternates for a single-language catalog`() {
// The template serves one language, so a set naming one edition would tell a crawler nothing.
#expect(Language.all.count == 1)
#expect(!IndexPage(
locale: .init(identifier: "en"),
siteOrigin: "https://example.com"
).render().contains("hreflang"))
}
@Test
func `renders versioned asset URLs when given a version`() {
let html = IndexPage(
@@ -0,0 +1,109 @@
import Foundation
import Testing
@testable import WebsiteLibrary
@Suite(
"Language type",
.tags(.type)
)
struct LanguageTests {
// MARK: Catalog tests
/// The template ships an English-only catalog, so a site adding a language sees this pin fail and reviews the URL rules below.
@Test
func `derives its languages from the String Catalog`() {
#expect(Language.all == [Language(identifier: "en")])
#expect(Language.default == Language(identifier: "en"))
}
// MARK: Initializer tests
@Test
func `reads the language a locale names`() {
#expect(Language(of: .init(identifier: "en")).identifier == "en")
}
@Test
func `reads a regional locale as its primary language`() {
#expect(Language(of: .init(identifier: "en_GB")).identifier == "en")
}
@Test
func `falls back to the default for a language the catalog does not serve`() {
#expect(Language(of: .init(identifier: "fr")) == .default)
}
// MARK: Computed tests
@Test
func `owns the bare paths as the default language`() {
let language = Language.default
#expect(language.isDefault)
#expect(language.pathPrefix.isEmpty)
}
@Test
func `prefixes its paths as a non-default language`() {
let language = Language(identifier: "nl")
#expect(!language.isDefault)
#expect(language.pathPrefix == "/nl")
}
// MARK: Method tests
@Test(arguments: zip(
["/", "/privacy"],
["/", "/privacy"]
))
func `path`(
forDefaultLanguageAt barePath: String,
expects path: String
) {
#expect(Language.default.path(barePath) == path)
}
/// The root collapses onto the prefix alone: a trailing slash is the very spelling `TrailingSlashRedirectMiddleware` redirects away from.
@Test(arguments: zip(
["/", "/privacy"],
["/nl", "/nl/privacy"]
))
func `path`(
forPrefixedLanguageAt barePath: String,
expects path: String
) {
#expect(Language(identifier: "nl").path(barePath) == path)
}
@Test(arguments: zip(
["/", "/privacy"],
["https://example.com", "https://example.com/privacy"]
))
func `url`(
forDefaultLanguageAt barePath: String,
expects url: String
) {
#expect(Language.default.url(
origin: "https://example.com",
path: barePath
) == url)
}
@Test(arguments: zip(
["/", "/privacy"],
["https://example.com/nl", "https://example.com/nl/privacy"]
))
func `url`(
forPrefixedLanguageAt barePath: String,
expects url: String
) {
#expect(Language(identifier: "nl").url(
origin: "https://example.com",
path: barePath
) == url)
}
}
@@ -131,6 +131,37 @@ struct RootControllerTests {
}
}
@Test
func `serves the landing page with a canonical URL when an origin is configured`() async throws {
try await app(
siteOrigin: "https://example.com"
).test(.router) { client in
try await client.execute(
uri: "/",
method: .get
) { response in
#expect(String(buffer: response.body).contains(#"<link rel="canonical" href="https://example.com">"#))
}
}
}
/// The template's catalog serves one language, so the default owns every path and no prefixed route is registered.
@Test
func `registers no prefixed route for a single-language catalog`() async throws {
let prefixed = Language.all.filter { !$0.isDefault }
#expect(prefixed.isEmpty)
try await app.test(.router) { client in
try await client.execute(
uri: "/en",
method: .get
) { response in
#expect(response.status == .notFound)
}
}
}
@Test
func `embeds no analytics tracker by default`() async throws {
try await app.test(.router) { client in
@@ -176,13 +207,15 @@ private extension RootControllerTests {
// MARK: Methods
/// Builds an application whose root controller appends the given version token to the landing
/// page's asset URLs and embeds the given analytics tracker.
/// page's asset URLs, derives its absolute links from the given origin, and embeds the given analytics tracker.
/// - Parameters:
/// - assetVersion: the version token appended to the page's asset URLs.
/// - siteOrigin: the public origin the page derives its canonical URL and language alternates from, or `nil` (the default) to omit them.
/// - analytics: the analytics tracker the landing page embeds, or `nil` (the default) to omit it.
/// - Returns: the configured application.
func app(
assetVersion: String? = nil,
siteOrigin: String? = nil,
analytics: Analytics? = nil
) -> some ApplicationProtocol {
let router = Router(context: WebsiteRequestContext.self)
@@ -193,6 +226,7 @@ private extension RootControllerTests {
router.addRoutes(RootController<WebsiteRequestContext>(
assetVersion: assetVersion,
siteOrigin: siteOrigin,
analytics: analytics
).routes)
@@ -5,6 +5,8 @@ extension Tag {
@Tag static var controller: Tag
/// Tests exercising an enumeration of the Website library.
@Tag static var enumeration: Tag
/// Tests exercising an extension of the Website library.
@Tag static var extensionTests: Tag
/// Tests exercising a page of the Website library.
@Tag static var page: Tag
/// Tests exercising a type of the Website library.