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` |
|
| Middlewares | `SecurityHeadersMiddleware`, `VaryMiddleware`, `RateLimitMiddleware`, `LocalizationMiddleware`, `NotFoundMiddleware` |
|
||||||
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
| Pages and assets | `Page`, `Asset`, `AssetExtension`, `FingerprintAssets` |
|
||||||
| Link previews | `SocialCard`, its `Image`, and the `Tag` meta tags it derives |
|
| 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` |
|
| Responses | `CachedHTMLResponse`, `LocalizedHTMLCollectionResponse` |
|
||||||
| Contexts | `LocalizedRequestContext` |
|
| Contexts | `LocalizedRequestContext` |
|
||||||
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
| Constants | The `HTTPField.Name` header names, `Int.RateLimit` limits, and `String.Security` header values the middlewares default to |
|
||||||
|
|
||||||
## Design rules
|
## Design rules
|
||||||
The package holds only what every service can reuse; anything a service owns is injected, never referenced:
|
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.
|
- **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.
|
- **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`.
|
- **Method structs.** Single-operation types such as `FingerprintAssets` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
@@ -31,8 +32,9 @@ Sources/
|
|||||||
│ ├── Middlewares/ the five HTTP middlewares
|
│ ├── Middlewares/ the five HTTP middlewares
|
||||||
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
│ ├── Protocols/ Asset, LocalizedRequestContext, Page, RouterController
|
||||||
│ ├── Responses/ CachedHTMLResponse, LocalizedHTMLCollectionResponse
|
│ ├── 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/
|
└── Internal/
|
||||||
|
├── Extensions/ implementation details (the String separators)
|
||||||
└── Types/ implementation details (FNV1aHash)
|
└── Types/ implementation details (FNV1aHash)
|
||||||
Tests/
|
Tests/
|
||||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
├── 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 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
|
/// 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
|
/// rest of the document around them: the viewport declaration, the summary, canonical, and social card tags, the structured data script, and the
|
||||||
/// stylesheet links in the head, and the content followed by the script tags in the body.
|
/// 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 {
|
public protocol Page: HTMLDocument, Sendable {
|
||||||
|
|
||||||
// MARK: Associated types
|
// MARK: Associated types
|
||||||
@@ -42,6 +42,9 @@ public protocol Page: HTMLDocument, Sendable {
|
|||||||
/// to omit them.
|
/// to omit them.
|
||||||
var socialCard: SocialCard? { get }
|
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.
|
/// The stylesheets linked in the document head, in order.
|
||||||
var stylesheets: [any Asset] { get }
|
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``
|
/// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags and the ``structuredData`` script
|
||||||
/// followed by the ``stylesheets`` links, placed in the document head.
|
/// (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,
|
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
|
||||||
/// and HTML5 allows only one.
|
/// 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
|
@HTMLBuilder
|
||||||
var head: some HTML {
|
var head: some HTML {
|
||||||
meta(
|
meta(
|
||||||
@@ -112,6 +118,15 @@ public extension Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let structuredData {
|
||||||
|
script(.custom(
|
||||||
|
name: "type",
|
||||||
|
value: "application/ld+json"
|
||||||
|
)) {
|
||||||
|
HTMLRaw(structuredData.payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
metadata
|
metadata
|
||||||
|
|
||||||
for file in stylesheets {
|
for file in stylesheets {
|
||||||
@@ -130,6 +145,11 @@ public extension Page {
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The structured data is omitted unless the page provides one.
|
||||||
|
var structuredData: StructuredData? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
/// The summary is omitted unless the page provides one.
|
/// The summary is omitted unless the page provides one.
|
||||||
var summary: String? {
|
var summary: String? {
|
||||||
nil
|
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">"#))
|
#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
|
@Test
|
||||||
func `appends the version token to the asset URLs`() {
|
func `appends the version token to the asset URLs`() {
|
||||||
let html = StubPage(assetVersion: "0123456789abcdef").render()
|
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.
|
/// The card rendered as link-preview tags in the document head, or `nil` to omit them.
|
||||||
let socialCard: SocialCard?
|
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.
|
/// The summary rendered in the document head, or `nil` to omit it.
|
||||||
let summary: String?
|
let summary: String?
|
||||||
|
|
||||||
@@ -33,6 +36,8 @@ struct StubPage: Page {
|
|||||||
/// to omit it.
|
/// to omit it.
|
||||||
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
|
/// - socialCard: the card rendered as link-preview tags in the document head, or `nil`
|
||||||
/// (the default) to omit them.
|
/// (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)
|
/// - summary: the summary rendered in the document head, or `nil` (the default)
|
||||||
/// to omit it.
|
/// to omit it.
|
||||||
init(
|
init(
|
||||||
@@ -40,12 +45,14 @@ struct StubPage: Page {
|
|||||||
assetVersion: String? = nil,
|
assetVersion: String? = nil,
|
||||||
canonicalURL: String? = nil,
|
canonicalURL: String? = nil,
|
||||||
socialCard: SocialCard? = nil,
|
socialCard: SocialCard? = nil,
|
||||||
|
structuredData: StructuredData? = nil,
|
||||||
summary: String? = nil
|
summary: String? = nil
|
||||||
) {
|
) {
|
||||||
self.assetVersion = assetVersion
|
self.assetVersion = assetVersion
|
||||||
self.canonicalURL = canonicalURL
|
self.canonicalURL = canonicalURL
|
||||||
self.locale = locale
|
self.locale = locale
|
||||||
self.socialCard = socialCard
|
self.socialCard = socialCard
|
||||||
|
self.structuredData = structuredData
|
||||||
self.summary = summary
|
self.summary = summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user