Project updates from Template (#1)

This PR contains the latest updates from the generic Website template, which have been added while working on #loud-amsterdam.

Reviewed-on: #1
Co-authored-by: Javier Cicchelli <javier@rock-n-code.com>
This commit was merged in pull request #1.
This commit is contained in:
2026-09-04 13:40:35 +00:00
committed by javier
parent 08b4d80064
commit 65b62681eb
60 changed files with 2347 additions and 326 deletions
@@ -1,21 +1,29 @@
/// A file extension used by an ``Asset``.
///
/// Each case's raw value is the extension itself (e.g. `"css"`), which an asset appends to its file name when resolving paths.
public enum AssetExtension: String, Sendable {
public enum AssetExtension: String, CaseIterable, Sendable {
/// A Cascading Style Sheets file.
case css
/// A JavaScript file.
case js
/// A Portable Network Graphics image.
case png
/// A Windows icon image.
case ico
/// A JPEG image.
case jpg
/// A JavaScript file.
case js
/// An MPEG-4 video.
case mp4
/// A Portable Network Graphics image.
case png
/// A Scalable Vector Graphics image.
case svg
/// A plain text file.
case txt
/// A web application manifest file.
case webmanifest
/// A WebP image.
case webp
/// A Web Open Font Format 2 font.
case woff2
/// An Extensible Markup Language file.
case xml
}
@@ -30,12 +38,16 @@ public extension AssetExtension {
var contentType: String {
switch self {
case .css: "text/css"
case .js: "text/javascript"
case .png: "image/png"
case .ico: "image/vnd.microsoft.icon"
case .jpg: "image/jpeg"
case .js: "text/javascript"
case .mp4: "video/mp4"
case .png: "image/png"
case .svg: "image/svg+xml"
case .txt: "text/plain"
case .webmanifest: "application/manifest+json"
case .webp: "image/webp"
case .woff2: "font/woff2"
case .xml: "application/xml"
}
}
@@ -45,6 +57,10 @@ public extension AssetExtension {
switch self {
case .css: "css"
case .js: "js"
case .jpg,
.webp: "img"
case .mp4: "video"
case .woff2: "font"
default: nil
}
}
@@ -0,0 +1,24 @@
import Elementary
public extension Analytics.Event {
// MARK: Methods
/// The event's ``attributes`` as attributes of the tag reporting it.
///
/// ```swift
/// a(.href(url)) { "Listen" }
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
/// ```
///
/// - Returns: one HTML attribute per event attribute, applied verbatim.
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
attributes.map {
.custom(
name: $0.name,
value: $0.value
)
}
}
}
@@ -5,8 +5,12 @@ public extension HTTPField.Name {
static let permissionsPolicy = Self("Permissions-Policy")!
/// The `Referrer-Policy` field name (not provided as a standard `HTTPField.Name`).
static let referrerPolicy = Self("Referrer-Policy")!
/// The `X-Robots-Tag` field name (not provided as a standard `HTTPField.Name`).
static let robotsTag = Self("X-Robots-Tag")!
/// The `X-Frame-Options` field name (not provided as a standard `HTTPField.Name`).
static let frameOptions = Self("X-Frame-Options")!
/// The `X-Forwarded-For` field name (not provided as a standard `HTTPField.Name`).
static let xForwardedFor = Self("X-Forwarded-For")!
/// The `X-Forwarded-Proto` field name (not provided as a standard `HTTPField.Name`).
static let xForwardedProto = Self("X-Forwarded-Proto")!
}
@@ -0,0 +1,182 @@
import Foundation
import HTTPTypes
import Hummingbird
/// Answers a request forwarded over plain HTTP with `301 Moved Permanently` to the same path on the site's HTTPS origin.
///
/// Behind a TLS-terminating proxy the server only sees plain HTTP, so the visitor's scheme survives only in the `X-Forwarded-Proto` header the proxy
/// sets. Redirecting collapses the `http://` and `https://` copies of every page onto one address what a search engine consolidates a site's signals
/// against.
///
/// Three deliberate choices:
/// - **`301`, not `302`.** A temporary redirect tells a crawler the HTTP address is the canonical one, so the HTTP URLs stay indexed. Browsers cache a
/// `301` for a long time, so the target must be settled before enabling this.
/// - **The target comes from ``Configuration/origin``, not the request's `Host` header.** A client cannot steer a configured origin, so it can neither
/// aim the redirect elsewhere nor poison a shared cache with the result.
/// - **`/.well-known/` is exempt.** A certificate authority looks there for an ACME challenge over plain HTTP; redirecting it away breaks renewal, and
/// the breakage surfaces only when the certificate expires.
///
/// - Note: `Context` is the request context the middleware is resolved against.
public struct HTTPSRedirectMiddleware<Context: RequestContext>: Sendable {
// MARK: Properties
/// The origin the redirects point at, and whether the forwarded-protocol header is trusted.
private let configuration: Configuration
/// Whether the middleware redirects at all, resolved once at initialization.
///
/// An untrusted header disables it, and so does an origin that is not itself HTTPS: redirecting to a plain-HTTP origin would loop.
private let isEnabled: Bool
// MARK: Initializers
/// Creates an HTTPS-redirect middleware.
/// - Parameter configuration: the origin the redirects point at, and whether the forwarded-protocol header is trusted.
public init(
configuration: Configuration
) {
self.configuration = configuration
self.isEnabled = configuration.trustForwardedProto
&& configuration.origin.hasPrefix(.httpsPrefix)
}
}
// MARK: - RouterMiddleware
extension HTTPSRedirectMiddleware: RouterMiddleware {
// MARK: Functions
/// Redirects a request forwarded over plain HTTP, and passes every other request down the chain.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the redirect, or the downstream response.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
guard
isEnabled,
isForwardedOverHTTP(request),
!isWellKnown(request)
else {
return try await next(
request,
context
)
}
var response = Response(status: .movedPermanently)
response.headers[.location] = configuration.origin + target(of: request)
return response
}
}
// MARK: - Helpers
private extension HTTPSRedirectMiddleware {
// MARK: Methods
/// Whether the proxy reported the visitor's request as plain HTTP.
///
/// Only the leftmost entry is read: a chain of proxies appends to the header, so that entry is the scheme the visitor used. The comparison is
/// case-insensitive, the header carrying a scheme name rather than a fixed-case token.
/// - Parameter request: the incoming request.
/// - Returns: `true` when the header's first entry names plain HTTP.
func isForwardedOverHTTP(
_ request: Request
) -> Bool {
request.headers[.xForwardedProto]?
.split(separator: ",")
.first?
.trimmingCharacters(in: .whitespaces)
.lowercased() == .httpScheme
}
/// Whether the request addresses the reserved well-known space, which is left on plain HTTP so an ACME challenge stays reachable.
/// - Parameter request: the incoming request.
/// - Returns: `true` when the path falls inside the well-known space.
func isWellKnown(
_ request: Request
) -> Bool {
request.uri.path.hasPrefix(.wellKnownPrefix)
}
/// The path and query the redirect preserves, so a visitor lands on the address they asked for.
///
/// Rebuilt from the parsed components rather than the raw request target: a target in absolute form would append a second origin to the first.
/// - Parameter request: the incoming request.
/// - Returns: the path, followed by the query when the request carries one.
func target(
of request: Request
) -> String {
guard
let query = request.uri.query,
!query.isEmpty
else {
return request.uri.path
}
return request.uri.path + .querySeparator + query
}
}
// MARK: - Configuration
extension HTTPSRedirectMiddleware {
/// The origin an ``HTTPSRedirectMiddleware`` redirects to, and whether it trusts the header telling it to.
public struct Configuration: Sendable {
// MARK: Properties
/// The public origin the redirects point at (scheme and host, no trailing slash).
///
/// An origin that is not itself HTTPS disables the middleware rather than redirecting into a loop.
public let origin: String
/// Whether the visitor's scheme is read from the `X-Forwarded-Proto` header.
///
/// This is the middleware's only trigger. Enable it solely behind a reverse proxy that sets the header: on a directly reachable server the header
/// is client-supplied, so it stays off by default.
public let trustForwardedProto: Bool
// MARK: Initializers
/// Creates an HTTPS-redirect configuration.
/// - Parameters:
/// - origin: the public origin the redirects point at (scheme and host, no trailing slash).
/// - trustForwardedProto: whether the visitor's scheme is read from the `X-Forwarded-Proto` header. Defaults to `false`.
public init(
origin: String,
trustForwardedProto: Bool = false
) {
self.origin = origin
self.trustForwardedProto = trustForwardedProto
}
}
}
// MARK: - String+Constants
private extension String {
/// The forwarded-protocol value naming a request made over plain HTTP.
static let httpScheme = "http"
/// The scheme prefix a redirect target must carry for the redirect to terminate.
static let httpsPrefix = "https://"
/// The delimiter placed between a redirect target's path and its query.
static let querySeparator = "?"
/// The reserved path prefix left on plain HTTP, so an ACME challenge stays reachable.
static let wellKnownPrefix = "/.well-known/"
}
@@ -5,11 +5,15 @@ import Localization
/// Resolves the visitor's preferred language and records it on the request context.
///
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `Accept-Language` header, negotiates the best supported
/// match (falling back to the default language), and stores it on the context's ``LocalizedRequestContext/language``.
/// Placed ahead of the localized responders in the middleware chain, it reads the request's `lang` query parameter, its path, and its
/// `Accept-Language` header, negotiates the best supported match (falling back to the default language), and stores it on the context's
/// ``LocalizedRequestContext/language``.
///
/// The request is otherwise passed through untouched the URL and routing are not affected so each page is served at its existing path and varies its
/// content by header.
/// The query parameter is a deliberate override; failing that, a leading path segment naming a supported language pins it, so an unrouted path under
/// a language's prefix its not-found page answers in that language. Values naming no supported language are ignored, leaving the header. The
/// request is passed through untouched the path and routing are unaffected.
///
/// A site whose page routes pin their language by URL consults this only for responses that belong to no URL in practice, its not-found page.
public struct LocalizationMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
@@ -49,7 +53,13 @@ extension LocalizationMiddleware: RouterMiddleware {
) async throws -> Response {
var context = context
// The deliberate query override first; failing that, the leading path segment, so a language's whole URL
// prefix routed or not answers in its language.
let requested = request.uri.queryParameters[.Parameter.language].map(String.init)
?? request.uri.path.split(separator: "/").first.map(String.init)
context.language = negotiate(
requested: requested,
acceptLanguage: request.headers[.acceptLanguage]
)
@@ -57,3 +67,13 @@ extension LocalizationMiddleware: RouterMiddleware {
}
}
// MARK: - Constants
private extension Substring {
/// A namespace for the query parameters the middleware reads.
enum Parameter {
/// The query parameter carrying an explicit language choice; the site's language switcher appends it to the current path.
static let language: Substring = "lang"
}
}
@@ -7,6 +7,9 @@ import Hummingbird
/// Placed ahead of `FileMiddleware` in the middleware chain, it catches the `.notFound` error that bubbles up when no file exists for the requested
/// path and responds with the rendered error page and a `404 Not Found` status. The page is served in the language stored on the context by
/// ``LocalizationMiddleware``, falling back to the default language.
///
/// Its responses declare `Vary: Accept-Language`: where the page routes pin their language by URL, this is the one responder that negotiates.
/// An unrouted path names no edition, so no canonical URL contradicts the header.
public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
// MARK: Properties
@@ -27,6 +30,7 @@ public struct NotFoundMiddleware<Context: LocalizedRequestContext> {
self.responses = .init(
bundle: bundle,
status: .notFound,
variesOnAcceptLanguage: true,
document: document
)
}
@@ -0,0 +1,114 @@
import HTTPTypes
import Hummingbird
/// Answers a request whose path carries a trailing slash with `301 Moved Permanently` to the same path without one.
///
/// The router matches `/page` and `/page/` alike, and `FileMiddleware` serves `/robots.txt/` as readily as `/robots.txt`, so without this
/// every address on the site answers under at least two URLs. A search engine treats those as separate pages competing with each other, and any link
/// earned by one is not credited to the other. Redirecting collapses them onto the form the pages already name as canonical.
///
/// Two deliberate choices:
/// - **The `Location` is relative.** A relative target resolves against the request's own scheme and host, so the middleware needs no configured
/// origin and cannot be aimed elsewhere by a forged `Host` header.
/// - **Only `GET` and `HEAD` are redirected.** A client answering a `301` to a `POST` may repeat it as a `GET` and drop the body, so a form
/// submission is left to the route that already matches it.
///
/// - Note: `Context` is the request context the middleware is resolved against.
public struct TrailingSlashRedirectMiddleware<Context: RequestContext>: Sendable {
// MARK: Initializers
/// Creates a trailing-slash redirect middleware.
public init() {}
}
// MARK: - RouterMiddleware
extension TrailingSlashRedirectMiddleware: RouterMiddleware {
// MARK: Functions
/// Redirects a `GET` or `HEAD` whose path carries a trailing slash, and passes every other request down the chain.
/// - Parameters:
/// - request: the incoming request.
/// - context: the context the request is resolved against.
/// - next: the next responder in the middleware chain.
/// - Returns: the redirect, or the downstream response.
/// - Throws: any error thrown downstream.
public func handle(
_ request: Request,
context: Context,
next: (Request, Context) async throws -> Response
) async throws -> Response {
let path = request.uri.path
let canonical = canonicalPath(of: path)
guard
request.method == .get || request.method == .head,
canonical != path
else {
return try await next(
request,
context
)
}
var response = Response(status: .movedPermanently)
response.headers[.location] = canonical + query(of: request)
return response
}
}
// MARK: - Helpers
private extension TrailingSlashRedirectMiddleware {
// MARK: Methods
/// The path with its trailing slashes removed, which is the form the pages name as their canonical URL.
///
/// A path of nothing but slashes collapses to the root, so `//` redirects to `/` while `/` itself is left alone.
/// - Parameter path: the requested path.
/// - Returns: the canonical form of the path.
func canonicalPath(
of path: String
) -> String {
var canonical = path
while canonical.hasSuffix(.pathSeparator), canonical != .pathSeparator {
canonical.removeLast()
}
return canonical
}
/// The query the redirect preserves, so a campaign-tagged link survives the canonicalization.
/// - Parameter request: the incoming request.
/// - Returns: the query prefixed with its delimiter, or an empty string when the request carries none.
func query(
of request: Request
) -> String {
guard
let query = request.uri.query,
!query.isEmpty
else {
return ""
}
return .querySeparator + query
}
}
// MARK: - String+Constants
private extension String {
/// The separator a canonical path never ends on, and the root path it collapses to.
static let pathSeparator = "/"
/// The delimiter placed between a redirect target's path and its query.
static let querySeparator = "?"
}
@@ -7,8 +7,12 @@ import Localization
/// A per-language collection of pre-rendered HTML responses.
///
/// At initialization it renders the document once for each language the bundle's ``LanguageList`` reports and caches the bytes, mirroring
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header and
/// `Vary: Accept-Language`, so shared caches key on the negotiated language instead of serving one language to everyone.
/// ``CachedHTMLResponse``'s render-once model but keyed by language. Each cached response carries a `Content-Language` header naming the
/// language it was rendered in.
///
/// `Vary: Accept-Language` is the caller's to declare, since only the caller knows how it picks the language: a responder that negotiates the
/// header sets it, so shared caches key on the language rather than serving one to everyone; a responder that pins the language by route does
/// not, since the header would announce a negotiation that never happens.
public struct LocalizedHTMLCollectionResponse: Sendable {
// MARK: Properties
@@ -25,21 +29,27 @@ public struct LocalizedHTMLCollectionResponse: Sendable {
/// - Parameters:
/// - bundle: the bundle whose String Catalog names the languages the document is rendered for.
/// - status: the status applied to every response. Defaults to `.ok`.
/// - variesOnAcceptLanguage: whether the responses declare `Vary: Accept-Language`. Defaults to `false`; a responder that
/// negotiates the header passes `true`.
/// - document: builds the document to render for a given locale.
public init<Document: HTMLDocument>(
bundle: Bundle,
status: HTTPResponse.Status = .ok,
variesOnAcceptLanguage: Bool = false,
document: (Locale) -> Document
) {
self.list = .init(bundle: bundle)
self.responses = list.all
.reduce(into: [:]) { responses, language in
var headers: HTTPFields = [.contentLanguage: language]
if variesOnAcceptLanguage {
headers[.vary] = "Accept-Language"
}
responses[language] = CachedHTMLResponse(
status: status,
additionalHeaders: [
.contentLanguage: language,
.vary: "Accept-Language",
],
additionalHeaders: headers,
document: document(.init(
identifier: language
))
@@ -60,11 +60,10 @@ public struct Analytics: Sendable {
// MARK: Computed
/// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted.
/// The tracker script's attributes: the website id, the reporting domains when set, then each enabled behavior flag; disabled flags are omitted.
public var attributes: [Attribute] {
var attributes: [Attribute] = [
.init("data-website-id", value: websiteID),
.init("data-domains", value: domains)
.init("data-website-id", value: websiteID)
]
if !domains.isEmpty {
@@ -7,6 +7,9 @@ public struct SocialCard: Sendable {
// MARK: Properties
/// The locales of the card's other language editions, in Open Graph's `language_TERRITORY` form; empty to omit their tags.
public let alternateLocales: [String]
/// The card's share image, or `nil` to omit its tags.
public let image: Image?
@@ -43,6 +46,7 @@ public struct SocialCard: Sendable {
/// - siteName: the name of the site the card belongs to, or `nil` (the default) to omit its tag.
/// - locale: the locale of the card's text, ideally in Open Graph's `language_TERRITORY` form (e.g. `en_US`), or `nil` (the default)
/// to omit its tag.
/// - alternateLocales: the locales the card's page is also published in, in the same form; empty (the default) to omit their tags.
/// - image: the card's share image, or `nil` (the default) to omit its tags.
/// - type: the Open Graph type of the object the card describes. Defaults to `website`.
/// - style: the layout a Twitter card scraper gives the card. Defaults to ``Style/summaryLargeImage``.
@@ -52,10 +56,12 @@ public struct SocialCard: Sendable {
url: String? = nil,
siteName: String? = nil,
locale: String? = nil,
alternateLocales: [String] = [],
image: Image? = nil,
type: String = "website",
style: Style = .summaryLargeImage
) {
self.alternateLocales = alternateLocales
self.image = image
self.locale = locale
self.siteName = siteName
@@ -68,8 +74,8 @@ public struct SocialCard: Sendable {
// MARK: Computed
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, and locale, then the image group, and
/// the Twitter card style last. A tag whose fact the card does not carry is left out.
/// The card's meta tags, in a stable order: the Open Graph type, site name, title, description, URL, locale, and alternate locales, then the
/// image group, and the Twitter card style last. A tag whose fact the card does not carry is left out.
public var tags: [Tag] {
let tags: [Tag?] = [
Tag(type, name: .type),
@@ -78,7 +84,7 @@ public struct SocialCard: Sendable {
summary.map { Tag($0, name: .description) },
url.map { Tag($0, name: .url) },
locale.map { Tag($0, name: .locale) },
] + (image?.tags ?? []) + [
] + alternateLocales.map { Tag($0, name: .localeAlternate) } + (image?.tags ?? []) + [
Tag(style.rawValue, name: .twitter),
]
@@ -60,6 +60,8 @@ extension SocialCard.Tag {
case imageWidth = "og:image:width"
/// The `og:locale` tag, carrying the locale of the card's text.
case locale = "og:locale"
/// The `og:locale:alternate` tag, carrying the locale of one other language edition of the card's page; repeated once per edition.
case localeAlternate = "og:locale:alternate"
/// The `og:site_name` tag, carrying the name of the site the card belongs to.
case siteName = "og:site_name"
/// The `og:title` tag, carrying the card's title.
@@ -23,6 +23,20 @@ public struct StructuredData: Equatable, Sendable {
self.nodes = nodes
}
// MARK: Methods
/// The `@id` of the `Organization` node the site-wide initializer builds.
///
/// A page asserting a relationship to it a `Person`'s `worksFor`, say references this instead of spelling the fragment again, so the two
/// cannot drift.
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
/// - Returns: the organization node's `@id`.
public static func organizationID(
forSiteURL url: String
) -> String {
url + "#organization"
}
// MARK: Computed
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
@@ -46,21 +60,49 @@ public extension StructuredData {
/// - Parameters:
/// - name: the name of the organization and the site.
/// - url: the absolute URL the site is served at.
/// - alternateName: the name the organization is also known by, or `nil` (the default) to omit its property.
/// - description: what the organization does, or `nil` (the default) to omit its property.
/// - areaServed: the area the organization serves, or `nil` (the default) to omit its property.
/// - email: the address the organization is written to, or `nil` (the default) to omit its property.
/// - logo: the absolute URL of the organization's logo, or `nil` (the default) to omit its property.
/// - inLanguage: the language codes the site is published in; empty (the default) to omit the property.
/// - profiles: the absolute URLs of the organization's public profiles, or empty (the default) to omit their property.
/// - founder: the `@id` of the `Person` node founding the organization, or `nil` (the default) to omit its property.
init(
name: String,
url: String,
alternateName: String? = nil,
description: String? = nil,
areaServed: String? = nil,
email: String? = nil,
logo: String? = nil,
profiles: [String] = []
inLanguage: [String] = [],
profiles: [String] = [],
founder: String? = nil
) {
let id = url + "#organization"
let id = Self.organizationID(forSiteURL: url)
var organization: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
]
if let alternateName {
organization.append(.init(.alternateName, value: .string(alternateName)))
}
if let description {
organization.append(.init(.description, value: .string(description)))
}
if let areaServed {
organization.append(.init(.areaServed, value: .string(areaServed)))
}
if let email {
organization.append(.init(.email, value: .string(email)))
}
if let logo {
organization.append(.init(.logo, value:.string(logo)))
}
@@ -72,6 +114,23 @@ public extension StructuredData {
))
}
if let founder {
organization.append(.init(.founder, value: .reference(founder)))
}
var website: [Property] = [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
]
if !inLanguage.isEmpty {
website.append(.init(
.inLanguage,
value: .array(inLanguage.map(Value.string))
))
}
self.init(nodes: [
.init(
type: .organization,
@@ -80,11 +139,7 @@ public extension StructuredData {
),
.init(
type: .website,
properties: [
.init(.name, value: .string(name)),
.init(.url, value: .string(url)),
.init(.publisher, value: .reference(id)),
]
properties: website
),
])
}
@@ -69,6 +69,18 @@ extension StructuredData.Property {
// MARK: - Constants
public extension StructuredData.Property.Name {
/// The name the thing a node describes is also known by.
static let alternateName: Self = "alternateName"
/// The area an organization serves.
static let areaServed: Self = "areaServed"
/// What the thing a node describes is or does.
static let description: Self = "description"
/// The address the thing a node describes is written to.
static let email: Self = "email"
/// The person who founded an organization.
static let founder: Self = "founder"
/// The language a creative work a site, a page is published in.
static let inLanguage: Self = "inLanguage"
/// The absolute URL of an organization's logo.
static let logo: Self = "logo"
/// The name of the thing a node describes.
@@ -1,5 +1,5 @@
extension StructuredData {
/// A value of a ``Property``: a string, a list, a nested node, or a reference to another node.
/// A value of a ``Property``: a string, a number, 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.
@@ -8,6 +8,8 @@ extension StructuredData {
case array([Value])
/// A nested node, e.g. the place a schema.org event is located at.
case node(Node)
/// A whole number, e.g. a list item's position, rendered unquoted so it reads as a number rather than as text.
case number(Int)
/// A reference to the ``Node/id`` of another node in the graph, rendered as an `@id` object.
case reference(String)
/// A string value.
@@ -28,6 +30,8 @@ extension StructuredData.Value {
"[\(values.map(\.fragment).joined(separator: .Separator.comma))]"
case .node(let node):
node.fragment
case .number(let number):
String(number)
case .reference(let id):
#"{"@id":\#(Self.literal(id))}"#
case .string(let string):