From b680ae0689b337929087c49afd4fc057795ba161 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 1 Aug 2026 17:21:16 +0000 Subject: [PATCH] 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: https://repo.rock-n-code.com/rock-n-code/loud-amsterdam/pulls/32 Co-authored-by: Javier Cicchelli --- Packages/Infrastructure/README.md | 8 +- .../Extensions/String+Separators.swift | 5 + .../Sources/Public/Protocols/Page.swift | 28 ++- .../Sources/Public/Types/StructuredData.swift | 106 ++++++++++++ .../StructuredData/StructuredDataNode.swift | 92 ++++++++++ .../StructuredDataProperty.swift | 82 +++++++++ .../StructuredData/StructuredDataValue.swift | 67 ++++++++ .../Cases/Public/Protocols/PageTests.swift | 25 +++ .../Public/Types/StructuredDataTests.swift | 159 ++++++++++++++++++ .../Tests/Utils/Pages/StubPage.swift | 7 + 10 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 Packages/Infrastructure/Sources/Internal/Extensions/String+Separators.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataNode.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataProperty.swift create mode 100644 Packages/Infrastructure/Sources/Public/Types/StructuredData/StructuredDataValue.swift create mode 100644 Packages/Infrastructure/Tests/Cases/Public/Types/StructuredDataTests.swift 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 }