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