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"
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user