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