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:
@@ -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