diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
index 7875f6b..7ab842e 100644
--- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
+++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
@@ -4,8 +4,8 @@ import Foundation
/// A page of a website: an HTML document with the shared scaffolding assembled around the page's content.
///
/// A conforming page supplies its locale, its title, the stylesheets and scripts it needs, its head metadata, and its content; the protocol assembles the
-/// rest of the document around them: the viewport declaration and stylesheet links followed by the metadata in the head, and the content followed by
-/// the script tags in the body.
+/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, and the metadata followed by the
+/// stylesheet links in the head, and the content followed by the script tags in the body.
public protocol Page: HTMLDocument, Sendable {
// MARK: Associated types
@@ -21,6 +21,9 @@ public protocol Page: HTMLDocument, Sendable {
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
var assetVersion: String? { get }
+ /// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag.
+ var canonicalURL: String? { get }
+
/// The page's markup, rendered before the ``scripts``.
@HTMLBuilder
var content: Content { get }
@@ -28,30 +31,41 @@ public protocol Page: HTMLDocument, Sendable {
/// The locale the page content is localized to.
var locale: Locale { get }
- /// The markup placed in the document head after the ``stylesheets``: icon and manifest
- /// links, extra meta tags, and the like.
+ /// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like.
@HTMLBuilder
var metadata: Metadata { get }
/// The scripts loaded at the end of the document body, in order.
var scripts: [any Asset] { get }
+ /// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
+ /// to omit them.
+ var socialCard: SocialCard? { get }
+
/// The stylesheets linked in the document head, in order.
var stylesheets: [any Asset] { get }
+ /// The page's summary, rendered as a `meta name="description"` tag in the document head, or `nil` (the default) to omit the tag.
+ var summary: String? { get }
+
}
// MARK: - Implementations
public extension Page {
-
+
// MARK: Computed
-
+
+ /// The canonical URL is omitted unless the page provides one.
+ var canonicalURL: String? {
+ nil
+ }
+
/// The page ``content`` followed by its ``scripts``.
@HTMLBuilder
var body: some HTML {
content
-
+
for file in scripts {
script(.src(file.urlPath(
for: .js,
@@ -59,8 +73,9 @@ public extension Page {
))) {}
}
}
-
- /// The viewport declaration and ``stylesheets`` links followed by the ``metadata``, placed in the document head.
+
+ /// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags (when provided), and the ``metadata``
+ /// followed by the ``stylesheets`` links, placed in the document head.
///
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `` before this markup,
/// and HTML5 allows only one.
@@ -70,9 +85,35 @@ public extension Page {
.name(.viewport),
.content("width=device-width, initial-scale=1")
)
+
+ if let summary {
+ meta(
+ .name(.description),
+ .content(summary)
+ )
+ }
+
+ if let canonicalURL {
+ link(
+ .rel("canonical"),
+ .href(canonicalURL)
+ )
+ }
+
+ if let socialCard {
+ for tag in socialCard.tags {
+ meta(
+ .custom(
+ name: tag.attribute.rawValue,
+ value: tag.name.rawValue
+ ),
+ .content(tag.content)
+ )
+ }
+ }
metadata
-
+
for file in stylesheets {
link(
.rel(.stylesheet),
@@ -83,5 +124,15 @@ public extension Page {
)
}
}
+
+ /// The social card is omitted unless the page provides one.
+ var socialCard: SocialCard? {
+ nil
+ }
+ /// The summary is omitted unless the page provides one.
+ var summary: String? {
+ nil
+ }
+
}
diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift
new file mode 100644
index 0000000..101ef61
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard.swift
@@ -0,0 +1,138 @@
+/// The content of a page's link-preview card, rendered as Open Graph and Twitter meta tags in the document head.
+///
+/// The card carries the facts a link scraper reads — the page's title, summary, URL, and share image — and derives the meta ``tags`` expressing
+/// them. Scrapers require absolute URLs, so the card takes ``url`` and ``Image/url`` fully formed; composing them from an origin and a versioned
+/// asset path stays with the page providing the card.
+public struct SocialCard: Sendable {
+
+ // MARK: Properties
+
+ /// The card's share image, or `nil` to omit its tags.
+ public let image: Image?
+
+ /// The locale of the card's text, or `nil` to omit its tag.
+ ///
+ /// Open Graph specifies the `language_TERRITORY` form (e.g. `en_US`); scrapers also accept a bare language code (e.g. `en`).
+ public let locale: String?
+
+ /// The name of the site the card belongs to, or `nil` to omit its tag.
+ public let siteName: String?
+
+ /// The layout a Twitter card scraper gives the card.
+ public let style: Style
+
+ /// The card's summary, or `nil` to omit its tag.
+ public let summary: String?
+
+ /// The card's title.
+ public let title: String
+
+ /// The Open Graph type of the object the card describes.
+ public let type: String
+
+ /// The absolute URL the card's page is served at, or `nil` to omit its tag.
+ public let url: String?
+
+ // MARK: Initializers
+
+ /// Creates a link-preview card.
+ /// - Parameters:
+ /// - title: the card's title.
+ /// - summary: the card's summary, or `nil` (the default) to omit its tag.
+ /// - url: the absolute URL the card's page is served at, or `nil` (the default) to omit its tag.
+ /// - 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.
+ /// - 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``.
+ public init(
+ title: String,
+ summary: String? = nil,
+ url: String? = nil,
+ siteName: String? = nil,
+ locale: String? = nil,
+ image: Image? = nil,
+ type: String = "website",
+ style: Style = .summaryLargeImage
+ ) {
+ self.image = image
+ self.locale = locale
+ self.siteName = siteName
+ self.style = style
+ self.summary = summary
+ self.title = title
+ self.type = type
+ self.url = url
+ }
+
+ // 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.
+ public var tags: [SocialCardTag] {
+ let tags: [SocialCardTag?] = [
+ SocialCardTag(
+ attribute: .property,
+ content: type,
+ name: .type
+ ),
+ siteName.map {
+ SocialCardTag(
+ attribute: .property,
+ content: $0,
+ name: .siteName
+ )
+ },
+ SocialCardTag(
+ attribute: .property,
+ content: title,
+ name: .title
+ ),
+ summary.map {
+ SocialCardTag(
+ attribute: .property,
+ content: $0,
+ name: .description
+ )
+ },
+ url.map {
+ SocialCardTag(
+ attribute: .property,
+ content: $0,
+ name: .url
+ )
+ },
+ locale.map {
+ SocialCardTag(
+ attribute: .property,
+ content: $0,
+ name: .locale
+ )
+ },
+ ] + (image?.tags ?? []) + [
+ SocialCardTag(
+ attribute: .name,
+ content: style.rawValue,
+ name: .twitter
+ ),
+ ]
+
+ return tags.compactMap { $0 }
+ }
+
+}
+
+// MARK: - Enumerations
+
+public extension SocialCard {
+
+ /// The layout a Twitter card scraper gives a ``SocialCard``.
+ enum Style: String, Sendable {
+ /// A compact card with a small thumbnail.
+ case summary
+ /// A card with a large image above the text.
+ case summaryLargeImage = "summary_large_image"
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift
new file mode 100644
index 0000000..d00398f
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardImage.swift
@@ -0,0 +1,72 @@
+extension SocialCard {
+ /// The share image of a ``SocialCard``.
+ public struct Image: Sendable {
+
+ // MARK: Properties
+
+ /// The image's text for assistive technologies, or `nil` to omit it.
+ public let alt: String?
+
+ /// The image's height in pixels, letting scrapers lay the card out before fetching the image.
+ public let height: Int
+
+ /// The absolute URL the image is served at.
+ public let url: String
+
+ /// The image's width in pixels, letting scrapers lay the card out before fetching the image.
+ public let width: Int
+
+ // MARK: Initializers
+
+ /// Creates a share image.
+ /// - Parameters:
+ /// - url: the absolute URL the image is served at.
+ /// - width: the image's width in pixels.
+ /// - height: the image's height in pixels.
+ /// - alt: the image's text for assistive technologies, or `nil` (the default) to omit it.
+ public init(
+ url: String,
+ width: Int,
+ height: Int,
+ alt: String? = nil
+ ) {
+ self.alt = alt
+ self.height = height
+ self.url = url
+ self.width = width
+ }
+
+ // MARK: Computed
+
+ /// The image's meta tags, in a stable order: its URL, width, and height, then its alt text when it carries one.
+ public var tags: [SocialCardTag] {
+ let tags: [SocialCardTag?] = [
+ SocialCardTag(
+ attribute: .property,
+ content: url,
+ name: .image
+ ),
+ SocialCardTag(
+ attribute: .property,
+ content: String(width),
+ name: .imageWidth
+ ),
+ SocialCardTag(
+ attribute: .property,
+ content: String(height),
+ name: .imageHeight
+ ),
+ alt.map {
+ SocialCardTag(
+ attribute: .property,
+ content: $0,
+ name: .imageAlt
+ )
+ },
+ ]
+
+ return tags.compactMap { $0 }
+ }
+
+ }
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift
new file mode 100644
index 0000000..414782b
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/SocialCard/SocialCardTag.swift
@@ -0,0 +1,55 @@
+/// A head meta tag of a ``SocialCard``: which attribute keys it, and its name and content.
+public struct SocialCardTag: Equatable, Sendable {
+
+ // MARK: Properties
+
+ /// The attribute the tag is keyed by.
+ public let attribute: Attribute
+
+ /// The tag's value.
+ public let content: String
+
+ /// The tag's name.
+ public let name: Name
+
+}
+
+// MARK: - Enumerations
+
+extension SocialCardTag {
+
+ /// The meta attribute a ``SocialCardTag`` is keyed by, named by its raw value.
+ public enum Attribute: String, Sendable {
+ /// The `name` attribute, keying the Twitter tags.
+ case name
+ /// The `property` attribute, keying the Open Graph tags.
+ case property
+ }
+
+ /// The name of a ``SocialCardTag``, carried in its raw value.
+ public enum Name: String, Sendable {
+ /// The `og:description` tag, carrying the card's summary.
+ case description = "og:description"
+ /// The `og:image` tag, carrying the share image's absolute URL.
+ case image = "og:image"
+ /// The `og:image:alt` tag, carrying the share image's text for assistive technologies.
+ case imageAlt = "og:image:alt"
+ /// The `og:image:width` tag, carrying the share image's width in pixels.
+ case imageWidth = "og:image:width"
+ /// The `og:image:height` tag, carrying the share image's height in pixels.
+ case imageHeight = "og:image:height"
+ /// The `og:locale` tag, carrying the locale of the card's text.
+ case locale = "og:locale"
+ /// 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.
+ case title = "og:title"
+ /// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
+ case twitter = "twitter:card"
+ /// The `og:type` tag, carrying the Open Graph type of the object the card describes.
+ case type = "og:type"
+ /// The `og:url` tag, carrying the absolute URL the card's page is served at.
+ case url = "og:url"
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
index 485d65e..b1a10af 100644
--- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
+++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
@@ -47,6 +47,56 @@ struct PageTests {
#expect(content.lowerBound < script.lowerBound)
}
+ @Test
+ func `omits the summary and canonical tags by default`() {
+ let html = StubPage().render()
+
+ #expect(!html.contains(#"name="description""#))
+ #expect(!html.contains(#"rel="canonical""#))
+ }
+
+ @Test
+ func `renders the summary and canonical tags when provided`() {
+ let html = StubPage(
+ canonicalURL: "https://stub.example/",
+ summary: "A stub page."
+ ).render()
+
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ }
+
+ @Test
+ func `omits the social card tags by default`() {
+ let html = StubPage().render()
+
+ #expect(!html.contains(#"property="og:"#))
+ #expect(!html.contains(#"name="twitter:card""#))
+ }
+
+ @Test
+ func `renders the social card tags when provided`() {
+ let html = StubPage(socialCard: .init(
+ title: "Stub Page",
+ summary: "A stub page.",
+ url: "https://stub.example/",
+ image: .init(
+ url: "https://stub.example/img/card.png",
+ width: 2400,
+ height: 1260
+ )
+ )).render()
+
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ #expect(html.contains(#""#))
+ }
+
@Test
func `appends the version token to the asset URLs`() {
let html = StubPage(assetVersion: "0123456789abcdef").render()
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift
new file mode 100644
index 0000000..1282bb9
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Types/SocialCardTests.swift
@@ -0,0 +1,84 @@
+import Testing
+
+@testable import Infrastructure
+
+@Suite(
+ "SocialCard type",
+ .tags(.type)
+)
+struct SocialCardTests {
+
+ // MARK: Functional tests
+
+ @Test
+ func `derives the full tag list from a complete card`() {
+ let card = SocialCard(
+ title: "A Title",
+ summary: "A summary.",
+ url: "https://site.example/",
+ siteName: "A Site",
+ locale: "en",
+ image: .init(
+ url: "https://site.example/img/card.png",
+ width: 2400,
+ height: 1260,
+ alt: "An image."
+ )
+ )
+
+ #expect(card.tags == [
+ .init(attribute: .property, content: "website", name: .type),
+ .init(attribute: .property, content: "A Site", name: .siteName),
+ .init(attribute: .property, content: "A Title", name: .title),
+ .init(attribute: .property, content: "A summary.", name: .description),
+ .init(attribute: .property, content: "https://site.example/", name: .url),
+ .init(attribute: .property, content: "en", name: .locale),
+ .init(attribute: .property, content: "https://site.example/img/card.png", name: .image),
+ .init(attribute: .property, content: "2400", name: .imageWidth),
+ .init(attribute: .property, content: "1260", name: .imageHeight),
+ .init(attribute: .property, content: "An image.", name: .imageAlt),
+ .init(attribute: .name, content: "summary_large_image", name: .twitter),
+ ])
+ }
+
+ @Test
+ func `omits the tags of the facts a minimal card does not carry`() {
+ let card = SocialCard(title: "A Title")
+
+ #expect(card.tags == [
+ .init(attribute: .property, content: "website", name: .type),
+ .init(attribute: .property, content: "A Title", name: .title),
+ .init(attribute: .name, content: "summary_large_image", name: .twitter),
+ ])
+ }
+
+ @Test
+ func `omits the image alt tag when the image carries none`() {
+ let card = SocialCard(
+ title: "A Title",
+ image: .init(
+ url: "https://site.example/img/card.png",
+ width: 2400,
+ height: 1260
+ )
+ )
+
+ let names = card.tags.map(\.name)
+
+ #expect(names.contains(.image))
+ #expect(!names.contains(.imageAlt))
+ }
+
+ @Test
+ func `carries the type and style it is given`() {
+ let card = SocialCard(
+ title: "A Title",
+ type: "article",
+ style: .summary
+ )
+
+ #expect(card.tags.contains(.init(attribute: .property, content: "article", name: .type)))
+ #expect(card.tags.contains(.init(attribute: .name, content: "summary", name: .twitter)))
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift
index 9fec14b..1117cd8 100644
--- a/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift
+++ b/Packages/Infrastructure/Tests/Utils/Extensions/Tag+Constants.swift
@@ -9,6 +9,6 @@ extension Tag {
@Tag static var middleware: Tag
/// Tests exercising a protocol scaffolding of the Infrastructure package.
@Tag static var `protocol`: Tag
- /// Tests exercising an internal type of the Infrastructure package.
+ /// Tests exercising a type of the Infrastructure package.
@Tag static var type: Tag
}
diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
index eae187d..68c8ac0 100644
--- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
+++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
@@ -10,9 +10,18 @@ struct StubPage: Page {
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
let assetVersion: String?
+ /// The canonical URL rendered in the document head, or `nil` to omit it.
+ let canonicalURL: String?
+
/// The locale the page content is localized to.
let locale: Locale
+ /// The card rendered as link-preview tags in the document head, or `nil` to omit them.
+ let socialCard: SocialCard?
+
+ /// The summary rendered in the document head, or `nil` to omit it.
+ let summary: String?
+
// MARK: Initializers
/// Creates a stub page.
@@ -20,12 +29,24 @@ struct StubPage: Page {
/// - locale: the locale the page content is localized to. Defaults to `en`.
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
/// default) to leave them unversioned.
+ /// - canonicalURL: the canonical URL rendered in the document head, or `nil` (the default)
+ /// to omit it.
+ /// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
+ /// (the default) to omit them.
+ /// - summary: the summary rendered in the document head, or `nil` (the default)
+ /// to omit it.
init(
locale: Locale = .init(identifier: "en"),
- assetVersion: String? = nil
+ assetVersion: String? = nil,
+ canonicalURL: String? = nil,
+ socialCard: SocialCard? = nil,
+ summary: String? = nil
) {
self.assetVersion = assetVersion
+ self.canonicalURL = canonicalURL
self.locale = locale
+ self.socialCard = socialCard
+ self.summary = summary
}
// MARK: Computed