Structured Data support for the Page protocol in the Infrastructure package (#32)

This PR contains the work done to introduce a `StructuredData` type that pages use to describe themselves to search engines as schema.org JSON-LD, and wires it into the Page protocol so the payload renders automatically in the document head.

Reviewed-on: rock-n-code/loud-amsterdam#32
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-08-01 17:21:16 +00:00
committed by javier
parent 1d1f0a9dc9
commit b680ae0689
10 changed files with 572 additions and 7 deletions
+5 -3
View File
@@ -9,14 +9,15 @@ The package provides, grouped by role:
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
| Structured data | `StructuredData`, the `Node`, `Property`, and `Value` types of its schema.org graph, and the open `Name` and `Kind` vocabularies |
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
| Contexts | `LocalizedRequestContext` |
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
## Design rules
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, and `socialCard` values its other head tags render, each omitted unless the page provides it. A `SocialCard` takes its URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing the card.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow.
- **No site-specific content.** No page markup, no asset catalog, no `Bundle.module` lookups. A type that needs a service's content takes it as a parameter: the `bundle:` whose String Catalog names the supported languages (`LocalizationMiddleware`, `LocalizedHTMLCollectionResponse`, `NotFoundMiddleware`), the `document:` closure that builds a page for a locale, and the `metadata` requirement through which a `Page` conformer supplies its icon links and theme colors — as are the `summary`, `canonicalURL`, `socialCard`, and `structuredData` values its other head tags render, each omitted unless the page provides it. A `SocialCard` and a `StructuredData` node take their URLs fully formed and absolute; composing them from an origin and a versioned asset path stays with the page providing them.
- **Services fill the gaps once, via extensions.** A service restores its convenient call sites with retroactive extensions — the Website's `Page+Defaults`, `LocalizationMiddleware+Defaults`, and `NotFoundMiddleware+Defaults` are the pattern to follow. The open schema.org vocabularies extend the same way: the package declares only the `Property.Name` and `Node.Kind` constants every service shares, and a service adds the ones its own node shapes need.
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
## Layout
@@ -31,8 +32,9 @@ Sources/
│ ├── Middlewares/ the five HTTP middlewares
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
│ └── Types/ SocialCard, with its image and tag types in SocialCard/
│ └── Types/ SocialCard and StructuredData, with their nested types in SocialCard/ and StructuredData/
└── Internal/
├── Extensions/ implementation details (the String separators)
└── Types/ implementation details (FNV1aHash)
Tests/
├── Cases/ the test suites, mirroring the Sources/ layout
@@ -0,0 +1,5 @@
extension String {
enum Separator {
static let comma = ","
}
}
@@ -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, 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.
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, 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
@@ -42,6 +42,9 @@ public protocol Page: HTMLDocument, Sendable {
/// to omit them.
var socialCard: SocialCard? { get }
/// The page's structured data, rendered as a JSON-LD script in the document head, or `nil` (the default) to omit it.
var structuredData: StructuredData? { get }
/// The stylesheets linked in the document head, in order.
var stylesheets: [any Asset] { get }
@@ -74,11 +77,14 @@ public extension Page {
}
}
/// 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 viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
/// (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.
///
/// The structured data is an inert data block browsers never execute it, so a site's `Content-Security-Policy` does not apply to it
/// that search engines read for the organization's name, logo, and profiles.
@HTMLBuilder
var head: some HTML {
meta(
@@ -112,6 +118,15 @@ public extension Page {
}
}
if let structuredData {
script(.custom(
name: "type",
value: "application/ld+json"
)) {
HTMLRaw(structuredData.payload)
}
}
metadata
for file in stylesheets {
@@ -130,6 +145,11 @@ public extension Page {
nil
}
/// The structured data is omitted unless the page provides one.
var structuredData: StructuredData? {
nil
}
/// The summary is omitted unless the page provides one.
var summary: String? {
nil
@@ -0,0 +1,106 @@
/// The structured data of a page, rendered as a JSON-LD script in the document head.
///
/// The data is a graph of schema.org ``Node`` values each a ``Node/type``, an optional ``Node/id``, and ``Property`` values in render
/// order and derives the ``payload`` embedding them for the search engines that read it. A page either composes the nodes of its own
/// shape directly, or uses ``init(name:url:logo:profiles:)`` for the site-wide pair every page shares.
///
/// Search engines require absolute URLs, so a node takes its URLs fully formed; composing them from an origin and a versioned asset path stays with the
/// page providing the data.
public struct StructuredData: Equatable, Sendable {
// MARK: Properties
/// The schema.org nodes of the data's graph, in the order they render.
public let nodes: [Node]
// MARK: Initializers
/// Creates structured data from the nodes of its graph.
/// - Parameter nodes: the schema.org nodes of the data's graph, in the order they render.
public init(
nodes: [Node]
) {
self.nodes = nodes
}
// MARK: Computed
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
///
/// The values are rendered as JSON string literals with `<` escaped as well, so a value can never close the `script` tag embedding
/// the payload.
public var payload: String {
#"{"@context":"https://schema.org","@graph":[\#(fragments)]}"#
}
}
// MARK: - Initializers
public extension StructuredData {
/// Creates the site-wide structured data: an `Organization` node carrying the name, URL, logo, and profiles, and a `WebSite` node
/// carrying the name and URL and referencing the organization as its `publisher`. The nodes are linked through an `@id` derived
/// from the URL, so search engines read the site as published by the organization rather than as two unrelated assertions.
/// A property whose fact the data does not carry is left out.
/// - Parameters:
/// - name: the name of the organization and the site.
/// - url: the absolute URL the site is served at.
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
init(
name: String,
url: String,
logo: String? = nil,
profiles: [String] = []
) {
let id = url + "#organization"
var organization: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
]
if let logo {
organization.append(.init(.logo, value:.string(logo)))
}
if !profiles.isEmpty {
organization.append(.init(
.sameAs,
value: .array(profiles.map(Value.string))
))
}
self.init(nodes: [
.init(
type: .organization,
id: id,
properties: organization
),
.init(
type: .website,
properties: [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
]
),
])
}
}
// MARK: - Helpers
private extension StructuredData {
// MARK: Computed
var fragments: String {
nodes
.map(\.fragment)
.joined(separator: .Separator.comma)
}
}
@@ -0,0 +1,92 @@
extension StructuredData {
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
public struct Node: Equatable, Sendable {
// MARK: Properties
/// The node's identifier, rendered as its `@id` property, or `nil` to omit it.
///
/// Another node references this node through ``Value/reference(_:)`` with the same identifier.
public let id: String?
/// The node's properties, in the order they render after the type and identifier.
public let properties: [Property]
/// The node's schema.org type (e.g. ``Kind/organization``), rendered as its `@type` property.
public let type: Kind
// MARK: Initializers
/// Creates a node.
/// - Parameters:
/// - type: the node's schema.org type (e.g. ``Kind/organization``).
/// - id: the node's identifier, or `nil` (the default) to omit it.
/// - properties: the node's properties, in the order they render.
public init(
type: Kind,
id: String? = nil,
properties: [Property]
) {
self.id = id
self.properties = properties
self.type = type
}
// MARK: Computed
/// The node's minified JSON object: the `@type`, the `@id` (when the node carries one), and the ``properties`` in order.
var fragment: String {
var members = [#""@type":\#(Value.literal(type.rawValue))"#]
if let id {
members.append(#""@id":\#(Value.literal(id))"#)
}
members += properties.map(\.fragment)
return "{\(members.joined(separator: .Separator.comma))}"
}
}
}
// MARK: - Structures
extension StructuredData.Node {
/// The schema.org type of a ``StructuredData/Node``.
///
/// Schema.org's vocabulary is open, so the kind is a typed string rather than a closed enumeration: the kinds every service shares
/// come as constants, a service declares the kinds its own node shapes need in an extension, and a one-off kind can be spelled as a
/// string literal.
public struct Kind: Equatable, ExpressibleByStringLiteral, Sendable {
// MARK: Properties
/// The type as it renders in the payload.
public let rawValue: String
// MARK: Initializers
/// Creates a kind.
/// - Parameter rawValue: the type as it renders in the payload.
public init(_ rawValue: String) {
self.rawValue = rawValue
}
/// Creates a kind from a string literal.
/// - Parameter value: the type as it renders in the payload.
public init(stringLiteral value: String) {
self.init(value)
}
}
}
// MARK: - Constants
public extension StructuredData.Node.Kind {
/// An organization, e.g. the one publishing a website.
static let organization: Self = "Organization"
/// A website.
static let website: Self = "WebSite"
}
@@ -0,0 +1,82 @@
extension StructuredData {
/// A named property of a ``Node``, in the position it renders.
public struct Property: Equatable, Sendable {
// MARK: Properties
/// The property's schema.org name (e.g. `sameAs`).
public let name: Name
/// The property's value.
public let value: Value
// MARK: Initializers
/// Creates a property.
/// - Parameters:
/// - name: the property's schema.org name (e.g. `sameAs`).
/// - value: the property's value.
public init(
_ name: Name,
value: Value
) {
self.name = name
self.value = value
}
// MARK: Computed
/// The property's minified JSON member: its name and its rendered value.
var fragment: String {
#"\#(Value.literal(name.rawValue)):\#(value.fragment)"#
}
}
}
// MARK: - Structures
extension StructuredData.Property {
/// The schema.org name of a ``StructuredData/Property``.
///
/// Schema.org's vocabulary is open, so the name is a typed string rather than a closed enumeration: the names every service shares
/// come as constants, a service declares the names its own node shapes need in an extension, and a one-off name can be spelled as a
/// string literal.
public struct Name: Equatable, ExpressibleByStringLiteral, Sendable {
// MARK: Properties
/// The name as it renders in the payload.
public let rawValue: String
// MARK: Initializers
/// Creates a name.
/// - Parameter rawValue: the name as it renders in the payload.
public init(_ rawValue: String) {
self.rawValue = rawValue
}
/// Creates a name from a string literal.
/// - Parameter value: the name as it renders in the payload.
public init(stringLiteral value: String) {
self.init(value)
}
}
}
// MARK: - Constants
public extension StructuredData.Property.Name {
/// The absolute URL of an organization's logo.
static let logo: Self = "logo"
/// The name of the thing a node describes.
static let name: Self = "name"
/// The organization publishing a website.
static let publisher: Self = "publisher"
/// The absolute URLs of the profiles that also identify the thing a node describes.
static let sameAs: Self = "sameAs"
/// The absolute URL of the thing a node describes.
static let url: Self = "url"
}
@@ -0,0 +1,67 @@
extension StructuredData {
/// A value of a ``Property``: a string, a list, a nested node, or a reference to another node.
///
/// Every string a value renders is escaped as a JSON literal with `<` escaped as well, so a value can never close the `script`
/// tag embedding the payload it renders into.
public indirect enum Value: Equatable, Sendable {
/// A list of values.
case array([Value])
/// A nested node, e.g. the place a schema.org event is located at.
case node(Node)
/// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
case reference(String)
/// A string value.
case string(String)
}
}
// MARK: - Extensions
extension StructuredData.Value {
// MARK: Computed
/// The value's minified JSON fragment.
var fragment: String {
switch self {
case .array(let values):
"[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
case .node(let node):
node.fragment
case .reference(let id):
#"{"@id":\#(Self.literal(id))}"#
case .string(let string):
Self.literal(string)
}
}
// MARK: Methods
/// Renders a string as a JSON string literal, escaping `<` as well since the payload is embedded in a `script` tag the string
/// could otherwise close.
/// - Parameter value: the string to render.
/// - Returns: the quoted and escaped literal.
static func literal(_ value: String) -> String {
var literal = "\""
for scalar in value.unicodeScalars {
switch scalar {
case "\"":
literal += #"\""#
case "\\":
literal += #"\\"#
case "<":
literal += #"\u003c"#
case let scalar where scalar.value < 0x20:
let hex = String(scalar.value, radix: 16)
literal += #"\u"# + String(repeating: "0", count: 4 - hex.count) + hex
default:
literal.unicodeScalars.append(scalar)
}
}
return literal + "\""
}
}
@@ -97,6 +97,31 @@ struct PageTests {
#expect(html.contains(#"<meta name="twitter:card" content="summary_large_image">"#))
}
@Test
func `omits the structured data script by default`() {
let html = StubPage().render()
#expect(!html.contains("application/ld+json"))
}
@Test
func `renders the structured data script when provided`() {
let html = StubPage(structuredData: .init(
name: "Stub Site",
url: "https://stub.example/",
logo: "https://stub.example/logo.png",
profiles: ["https://social.example/stub"]
)).render()
#expect(html.contains(
#"<script type="application/ld+json">"# +
#"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://stub.example/#organization","name":"Stub Site","url":"https://stub.example/","logo":"https://stub.example/logo.png","sameAs":["https://social.example/stub"]},"# +
#"{"@type":"WebSite","name":"Stub Site","url":"https://stub.example/","publisher":{"@id":"https://stub.example/#organization"}}]}"# +
#"</script>"#
))
}
@Test
func `appends the version token to the asset URLs`() {
let html = StubPage(assetVersion: "0123456789abcdef").render()
@@ -0,0 +1,159 @@
import Foundation
import Testing
@testable import Infrastructure
@Suite(
"StructuredData type",
.tags(.type)
)
struct StructuredDataTests {
// MARK: Functional tests
@Test
func `derives the full payload from complete data`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/",
logo: "https://site.example/logo.png",
profiles: [
"https://social.example/a-site",
"https://videos.example/a-site",
]
)
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/","logo":"https://site.example/logo.png","# +
#""sameAs":["https://social.example/a-site","https://videos.example/a-site"]},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
)
}
@Test
func `omits the properties of the facts minimal data does not carry`() {
let data = StructuredData(
name: "A Site",
url: "https://site.example/"
)
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site","url":"https://site.example/"},"# +
#"{"@type":"WebSite","name":"A Site","url":"https://site.example/","publisher":{"@id":"https://site.example/#organization"}}]}"#
)
}
@Test
func `renders a composed node graph`() {
let data = StructuredData(nodes: [
.init(
type: "MusicEvent",
properties: [
.init(.name, value: .string("A Gig")),
.init("location", value: .node(.init(
type: "Place",
properties: [
.init(.name, value: .string("A Venue")),
]
))),
.init("organizer", value: .reference("https://site.example/#organization")),
]
),
])
#expect(data.payload == #"{"@context":"https://schema.org","@graph":["# +
#"{"@type":"MusicEvent","name":"A Gig","location":{"@type":"Place","name":"A Venue"},"# +
#""organizer":{"@id":"https://site.example/#organization"}}]}"#
)
}
@Test
func `escapes the values it embeds in the payload`() {
let data = StructuredData(
name: #"A "Quoted" \ Site"#,
url: "https://site.example/</script>"
)
#expect(data.payload.contains(#""name":"A \"Quoted\" \\ Site""#))
// The `<` is escaped so a value can never close the script tag embedding the payload.
#expect(!data.payload.contains("</script>"))
#expect(data.payload.contains("\\" + "u003c/script>"))
}
@Test
func `renders a node fragment with its identifier`() {
let node = StructuredData.Node(
type: "Organization",
id: "https://site.example/#organization",
properties: [
.init(.name, value: .string("A Site")),
]
)
#expect(node.fragment == #"{"@type":"Organization","@id":"https://site.example/#organization","name":"A Site"}"#)
}
@Test
func `renders a node fragment without an identifier or properties`() {
let node = StructuredData.Node(
type: "Organization",
properties: []
)
#expect(node.fragment == #"{"@type":"Organization"}"#)
}
@Test
func `renders the common names and kinds by their schema.org spelling`() {
#expect(StructuredData.Property.Name.logo.rawValue == "logo")
#expect(StructuredData.Property.Name.name.rawValue == "name")
#expect(StructuredData.Property.Name.publisher.rawValue == "publisher")
#expect(StructuredData.Property.Name.sameAs.rawValue == "sameAs")
#expect(StructuredData.Property.Name.url.rawValue == "url")
#expect(StructuredData.Node.Kind.organization.rawValue == "Organization")
#expect(StructuredData.Node.Kind.website.rawValue == "WebSite")
}
@Test
func `renders the fragment of every value case`() {
#expect(StructuredData.Value.string("A Value").fragment == #""A Value""#)
#expect(StructuredData.Value.array([.string("A"), .string("B")]).fragment == #"["A","B"]"#)
#expect(StructuredData.Value.reference("https://site.example/#organization").fragment == #"{"@id":"https://site.example/#organization"}"#)
#expect(StructuredData.Value.node(.init(type: "Place", properties: [])).fragment == #"{"@type":"Place"}"#)
}
@Test
func `renders a string as a quoted literal`() {
#expect(StructuredData.Value.literal("A Value") == #""A Value""#)
#expect(StructuredData.Value.literal("") == "\"\"")
}
@Test
func `pads the escape of a control character to four digits`() {
#expect(StructuredData.Value.literal("\u{0}") == "\"" + "\\" + "u0000" + "\"")
#expect(StructuredData.Value.literal("\u{1f}") == "\"" + "\\" + "u001f" + "\"")
#expect(StructuredData.Value.literal("\u{a}") == "\"" + "\\" + "u000a" + "\"")
// The first scalar past the control range passes through untouched.
#expect(StructuredData.Value.literal(" ") == #"" ""#)
}
@Test
func `derives a payload that parses back to the facts it carries`() throws {
let name = "A \"Site\"\nwith \\ every <hazard>"
let data = StructuredData(
name: name,
url: "https://site.example/</script>",
logo: "https://site.example/logo.png",
profiles: ["https://social.example/a-site"]
)
let object = try JSONSerialization.jsonObject(with: Data(data.payload.utf8))
let graph = try #require((object as? [String: Any])?["@graph"] as? [[String: Any]])
#expect(graph.count == 2)
#expect(graph[0]["name"] as? String == name)
#expect(graph[0]["sameAs"] as? [String] == ["https://social.example/a-site"])
#expect(graph[1]["url"] as? String == "https://site.example/</script>")
}
}
@@ -19,6 +19,9 @@ struct StubPage: Page {
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
let socialCard: SocialCard?
/// The structured data rendered as a JSON-LD script in the document head, or `nil` to omit it.
let structuredData: StructuredData?
/// The summary rendered in the document head, or `nil` to omit it.
let summary: String?
@@ -33,6 +36,8 @@ struct StubPage: Page {
/// to omit it.
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
/// (the default) to omit them.
/// - structuredData: the structured data rendered as a JSON-LD script in the document
/// head, or `nil` (the default) to omit it.
/// - summary: the summary rendered in the document head, or `nil` (the default)
/// to omit it.
init(
@@ -40,12 +45,14 @@ struct StubPage: Page {
assetVersion: String? = nil,
canonicalURL: String? = nil,
socialCard: SocialCard? = nil,
structuredData: StructuredData? = nil,
summary: String? = nil
) {
self.assetVersion = assetVersion
self.canonicalURL = canonicalURL
self.locale = locale
self.socialCard = socialCard
self.structuredData = structuredData
self.summary = summary
}