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

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

Reviewed-on: rock-n-code/loud-amsterdam#32
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit is contained in:
2026-08-01 17:21:16 +00:00
committed by javier
parent 1d1f0a9dc9
commit b680ae0689
10 changed files with 572 additions and 7 deletions
@@ -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>")
}
}