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 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
|
/// 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
|
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, and the metadata followed by the
|
||||||
/// the script tags in the body.
|
/// stylesheet links in the head, and the content followed by the script tags in the body.
|
||||||
public protocol Page: HTMLDocument, Sendable {
|
public protocol Page: HTMLDocument, Sendable {
|
||||||
|
|
||||||
// MARK: Associated types
|
// 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.
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
var assetVersion: String? { get }
|
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``.
|
/// The page's markup, rendered before the ``scripts``.
|
||||||
@HTMLBuilder
|
@HTMLBuilder
|
||||||
var content: Content { get }
|
var content: Content { get }
|
||||||
@@ -28,30 +31,41 @@ public protocol Page: HTMLDocument, Sendable {
|
|||||||
/// The locale the page content is localized to.
|
/// The locale the page content is localized to.
|
||||||
var locale: Locale { get }
|
var locale: Locale { get }
|
||||||
|
|
||||||
/// The markup placed in the document head after the ``stylesheets``: icon and manifest
|
/// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like.
|
||||||
/// links, extra meta tags, and the like.
|
|
||||||
@HTMLBuilder
|
@HTMLBuilder
|
||||||
var metadata: Metadata { get }
|
var metadata: Metadata { get }
|
||||||
|
|
||||||
/// The scripts loaded at the end of the document body, in order.
|
/// The scripts loaded at the end of the document body, in order.
|
||||||
var scripts: [any Asset] { get }
|
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.
|
/// The stylesheets linked in the document head, in order.
|
||||||
var stylesheets: [any Asset] { get }
|
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
|
// MARK: - Implementations
|
||||||
|
|
||||||
public extension Page {
|
public extension Page {
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The canonical URL is omitted unless the page provides one.
|
||||||
|
var canonicalURL: String? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
/// The page ``content`` followed by its ``scripts``.
|
/// The page ``content`` followed by its ``scripts``.
|
||||||
@HTMLBuilder
|
@HTMLBuilder
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
content
|
content
|
||||||
|
|
||||||
for file in scripts {
|
for file in scripts {
|
||||||
script(.src(file.urlPath(
|
script(.src(file.urlPath(
|
||||||
for: .js,
|
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,
|
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
|
||||||
/// and HTML5 allows only one.
|
/// and HTML5 allows only one.
|
||||||
@@ -70,9 +85,35 @@ public extension Page {
|
|||||||
.name(.viewport),
|
.name(.viewport),
|
||||||
.content("width=device-width, initial-scale=1")
|
.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
|
metadata
|
||||||
|
|
||||||
for file in stylesheets {
|
for file in stylesheets {
|
||||||
link(
|
link(
|
||||||
.rel(.stylesheet),
|
.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)
|
#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
|
@Test
|
||||||
func `appends the version token to the asset URLs`() {
|
func `appends the version token to the asset URLs`() {
|
||||||
let html = StubPage(assetVersion: "0123456789abcdef").render()
|
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
|
@Tag static var middleware: Tag
|
||||||
/// Tests exercising a protocol scaffolding of the Infrastructure package.
|
/// Tests exercising a protocol scaffolding of the Infrastructure package.
|
||||||
@Tag static var `protocol`: Tag
|
@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
|
@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.
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||||
let assetVersion: String?
|
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.
|
/// The locale the page content is localized to.
|
||||||
let locale: Locale
|
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
|
// MARK: Initializers
|
||||||
|
|
||||||
/// Creates a stub page.
|
/// Creates a stub page.
|
||||||
@@ -20,12 +29,24 @@ struct StubPage: Page {
|
|||||||
/// - locale: the locale the page content is localized to. Defaults to `en`.
|
/// - 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
|
/// - assetVersion: the version token appended to the page's asset URLs, or `nil` (the
|
||||||
/// default) to leave them unversioned.
|
/// 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(
|
init(
|
||||||
locale: Locale = .init(identifier: "en"),
|
locale: Locale = .init(identifier: "en"),
|
||||||
assetVersion: String? = nil
|
assetVersion: String? = nil,
|
||||||
|
canonicalURL: String? = nil,
|
||||||
|
socialCard: SocialCard? = nil,
|
||||||
|
summary: String? = nil
|
||||||
) {
|
) {
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
|
self.canonicalURL = canonicalURL
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
|
self.socialCard = socialCard
|
||||||
|
self.summary = summary
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Computed
|
// MARK: Computed
|
||||||
|
|||||||
Reference in New Issue
Block a user