diff --git a/Packages/Infrastructure/README.md b/Packages/Infrastructure/README.md
index 5028528..b724c15 100644
--- a/Packages/Infrastructure/README.md
+++ b/Packages/Infrastructure/README.md
@@ -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
diff --git a/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift b/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift
new file mode 100644
index 0000000..758fc4d
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift
@@ -0,0 +1,5 @@
+extension String {
+ enum Separator {
+ static let comma = ","
+ }
+}
diff --git a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
index 7ab842e..30c25e1 100644
--- a/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
+++ b/Packages/Infrastructure/Sources/Public/Protocols/Page.swift
@@ -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 `` 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
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift
new file mode 100644
index 0000000..5e5addb
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData.swift
@@ -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)
+ }
+
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift
new file mode 100644
index 0000000..6ba19af
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift
@@ -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"
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift
new file mode 100644
index 0000000..fb52cb0
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift
@@ -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"
+}
diff --git a/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift
new file mode 100644
index 0000000..4ee5324
--- /dev/null
+++ b/Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift
@@ -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 + "\""
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
index b1a10af..e0fc405 100644
--- a/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
+++ b/Packages/Infrastructure/Tests/Cases/Public/Protocols/PageTests.swift
@@ -97,6 +97,31 @@ struct PageTests {
#expect(html.contains(#""#))
}
+ @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(
+ #""#
+ ))
+ }
+
@Test
func `appends the version token to the asset URLs`() {
let html = StubPage(assetVersion: "0123456789abcdef").render()
diff --git a/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift
new file mode 100644
index 0000000..f0bfcb3
--- /dev/null
+++ b/Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift
@@ -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/"
+ )
+
+ #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(""))
+ #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 "
+ let data = StructuredData(
+ name: name,
+ url: "https://site.example/",
+ 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/")
+ }
+
+}
diff --git a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
index 68c8ac0..ffc755e 100644
--- a/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
+++ b/Packages/Infrastructure/Tests/Utils/Pages/StubPage.swift
@@ -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
}