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:
2026-08-01 10:57:29 +00:00
committed by javier
parent ea00b94841
commit efc933d5d0
8 changed files with 483 additions and 12 deletions
@@ -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"
}
}