Extra tags and Social Card integration in the Infrastructure package (#28)
This PR contains the work done to extends the `Page` protocol with the head tags that control search snippets and link previews: a meta description, a canonical URL, and a social card rendered as _Open Graph_ and _Twitter_ meta tags. All three are optional with nil defaults, so existing conformers compile and render unchanged. Reviewed-on: rock-n-code/loud-amsterdam#28 Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
@@ -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 `<meta charset="UTF-8">` 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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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(#"<meta name="description" content="A stub page.">"#))
|
||||
#expect(html.contains(#"<link rel="canonical" href="https://stub.example/">"#))
|
||||
}
|
||||
|
||||
@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(#"<meta property="og:type" content="website">"#))
|
||||
#expect(html.contains(#"<meta property="og:title" content="Stub Page">"#))
|
||||
#expect(html.contains(#"<meta property="og:description" content="A stub page.">"#))
|
||||
#expect(html.contains(#"<meta property="og:url" content="https://stub.example/">"#))
|
||||
#expect(html.contains(#"<meta property="og:image" content="https://stub.example/img/card.png">"#))
|
||||
#expect(html.contains(#"<meta property="og:image:width" content="2400">"#))
|
||||
#expect(html.contains(#"<meta property="og:image:height" content="1260">"#))
|
||||
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
|
||||
}
|
||||
|
||||
@Test
|
||||
func `appends the version token to the asset URLs`() {
|
||||
let html = StubPage(assetVersion: "0123456789abcdef").render()
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user