Project updates from Template
This commit contains the latest updates from the generic Website template, which move the toolchain to Swift 6.4, gate analytics and structured data behind traits, and rework page routing: - Updated the Swift toolchain for all the package dependencies and the Website service to version 6.4. (345b305) - Adapted all the existing middlewares in the Infrastructure package to Swift 6.4. (26008df) - Moved test resource teardown in the Persistence and the Website library into defer blocks. (27984a9) - Gated analytics behind a trait and reworked structured data in the Infrastructure package. (c7d4769) - Enabled the Analytics trait in the Website service. (f6a6bbd) - Reworked page routing in the Website service. (d1cb2c1) - Gated the structured data features in the Infrastructure package behind a trait. (f0a8791) - Enabled the StructuredData trait of the Infrastructure package in the Website service. (bbfdeb3) - Fixed the asset pin and a build warning in the Dockerfile of the Website service. (6ed0f9b) The root README change in 345b305 was left out, as this project no longer carries that file. From d1cb2c1, the RootController rework was left out, as the prototype serves a session-aware landing page and routes its other pages through their own controllers; the view-event reporter was appended to the prototype's shared script instead. The prototype code was adapted to the stricter Swift 6.4 settings: `String(format:)` was swapped for safe formatting, the NoIndex middleware takes a `@concurrent` next handler, and the tests flagged by warnings-as-errors were tightened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
326299fc35
commit
911424de24
57 files changed
+864
-273
No files matched your search
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Elementary
|
||||
|
||||
public extension Analytics.Event {
|
||||
@@ -11,9 +12,12 @@ public extension Analytics.Event {
|
||||
/// .attributes(contentsOf: Analytics.Event(name: "playlist").tagging())
|
||||
/// ```
|
||||
///
|
||||
/// - Parameter trigger: what reports the event; a click by default.
|
||||
/// - Returns: one HTML attribute per event attribute, applied verbatim.
|
||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>() -> [HTMLAttribute<Tag>] {
|
||||
attributes.map {
|
||||
func tagging<Tag: MarkupTagDefinition & MarkupTrait.AllowsAttributes>(
|
||||
on trigger: Trigger = .click
|
||||
) -> [HTMLAttribute<Tag>] {
|
||||
attributes(on: trigger).map {
|
||||
.custom(
|
||||
name: $0.name,
|
||||
value: $0.value
|
||||
@@ -22,3 +26,4 @@ public extension Analytics.Event {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -5,6 +5,8 @@ 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 `Sec-Fetch-Mode` field name (not provided as a standard `HTTPField.Name`).
|
||||
static let secFetchMode = Self("Sec-Fetch-Mode")!
|
||||
/// 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`).
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#if Analytics
|
||||
import Elementary
|
||||
|
||||
public extension MarkupContent where Self: _Attributed, Tag: MarkupTrait.AllowsAttributes {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Tags the element with the event it reports to the analytics tracker.
|
||||
///
|
||||
/// ```swift
|
||||
/// a(.href(url)) { "Listen" }
|
||||
/// .reporting(.init(name: "playlist"))
|
||||
/// ```
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - event: the event the element reports.
|
||||
/// - trigger: what reports the event; a click by default.
|
||||
/// - Returns: the element, carrying the event's ``Analytics/Event/attributes(on:)``.
|
||||
func reporting(
|
||||
_ event: Analytics.Event,
|
||||
on trigger: Analytics.Event.Trigger = .click
|
||||
) -> Self {
|
||||
attributes(contentsOf: event.tagging(on: trigger))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public extension MarkupContent where Tag: MarkupTrait.AllowsAttributes {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
/// Tags the content with the event it reports to the analytics tracker.
|
||||
///
|
||||
/// The fallback for content that is not an element itself, mirroring Elementary's `attributes(contentsOf:)`.
|
||||
/// - Parameters:
|
||||
/// - event: the event the content reports.
|
||||
/// - trigger: what reports the event; a click by default.
|
||||
/// - Returns: the content, carrying the event's ``Analytics/Event/attributes(on:)``.
|
||||
@_disfavoredOverload
|
||||
func reporting(
|
||||
_ event: Analytics.Event,
|
||||
on trigger: Analytics.Event.Trigger = .click
|
||||
) -> _AttributedContent<Self> {
|
||||
attributes(contentsOf: event.tagging(on: trigger))
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -47,7 +47,7 @@ extension CompressionMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let response = try await next(
|
||||
request,
|
||||
|
||||
@@ -59,7 +59,7 @@ extension HTTPSRedirectMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
guard
|
||||
isEnabled,
|
||||
|
||||
@@ -60,7 +60,7 @@ extension NotFoundMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
do {
|
||||
return try await next(request, context)
|
||||
|
||||
@@ -57,7 +57,7 @@ extension RateLimitMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let admission = buckets.admit(
|
||||
client(
|
||||
|
||||
@@ -49,7 +49,7 @@ extension SecurityHeadersMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response: Response
|
||||
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ extension TrailingSlashRedirectMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
let path = request.uri.path
|
||||
let canonical = canonicalPath(of: path)
|
||||
|
||||
@@ -46,7 +46,7 @@ extension VaryMiddleware: RouterMiddleware {
|
||||
public func handle(
|
||||
_ request: Request,
|
||||
context: Context,
|
||||
next: (Request, Context) async throws -> Response
|
||||
next: @concurrent (Request, Context) async throws -> Response
|
||||
) async throws -> Response {
|
||||
var response = try await next(
|
||||
request,
|
||||
|
||||
@@ -6,6 +6,8 @@ import Foundation
|
||||
/// 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, the structured data script, the analytics
|
||||
/// tracker script, and the metadata followed by the stylesheet links and the deferred script tags in the head, and the content as the body.
|
||||
///
|
||||
/// ``analytics`` requires the `Analytics` trait; ``structuredData``, the `StructuredData` trait.
|
||||
public protocol Page: HTMLDocument, Sendable {
|
||||
|
||||
// MARK: Associated types
|
||||
@@ -18,8 +20,10 @@ public protocol Page: HTMLDocument, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
#if Analytics
|
||||
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
|
||||
var analytics: Analytics? { get }
|
||||
#endif
|
||||
|
||||
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
||||
var assetVersion: String? { get }
|
||||
@@ -48,8 +52,10 @@ public protocol Page: HTMLDocument, Sendable {
|
||||
/// to omit them.
|
||||
var socialCard: SocialCard? { get }
|
||||
|
||||
#if StructuredData
|
||||
/// 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 }
|
||||
#endif
|
||||
|
||||
/// The stylesheets linked in the document head, in order.
|
||||
var stylesheets: [any Asset] { get }
|
||||
@@ -94,6 +100,7 @@ public extension Page {
|
||||
.content("width=device-width, initial-scale=1")
|
||||
)
|
||||
|
||||
#if Analytics
|
||||
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
|
||||
if let origin = analytics?.origin {
|
||||
link(
|
||||
@@ -101,6 +108,7 @@ public extension Page {
|
||||
.href(origin)
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
if let summary {
|
||||
meta(
|
||||
@@ -128,6 +136,7 @@ public extension Page {
|
||||
}
|
||||
}
|
||||
|
||||
#if StructuredData
|
||||
if let structuredData {
|
||||
script(.custom(
|
||||
name: "type",
|
||||
@@ -136,7 +145,9 @@ public extension Page {
|
||||
HTMLRaw(structuredData.payload)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if Analytics
|
||||
if let analytics {
|
||||
script(
|
||||
.defer,
|
||||
@@ -160,6 +171,7 @@ public extension Page {
|
||||
) {}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
metadata
|
||||
|
||||
@@ -184,20 +196,24 @@ public extension Page {
|
||||
}
|
||||
}
|
||||
|
||||
#if Analytics
|
||||
/// The analytics tracker is omitted unless the page provides one.
|
||||
var analytics: Analytics? {
|
||||
nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The social card is omitted unless the page provides one.
|
||||
var socialCard: SocialCard? {
|
||||
nil
|
||||
}
|
||||
|
||||
#if StructuredData
|
||||
/// The structured data is omitted unless the page provides one.
|
||||
var structuredData: StructuredData? {
|
||||
nil
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The summary is omitted unless the page provides one.
|
||||
var summary: String? {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
import Foundation
|
||||
|
||||
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
|
||||
@@ -114,3 +115,4 @@ public struct Analytics: Sendable {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if Analytics
|
||||
extension Analytics {
|
||||
/// A name-value pair rendered as an HTML attribute.
|
||||
///
|
||||
@@ -29,3 +30,4 @@ extension Analytics {
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,8 +1,11 @@
|
||||
#if Analytics
|
||||
extension Analytics {
|
||||
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
|
||||
///
|
||||
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
|
||||
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
|
||||
///
|
||||
/// A ``Trigger/view`` renders under `data-view-event`, which a page script reports instead of the tracker.
|
||||
public struct Event: Equatable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
@@ -34,22 +37,51 @@ extension Analytics {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
||||
/// The event's attributes when clicked; see ``attributes(on:)``.
|
||||
public var attributes: [Attribute] {
|
||||
[.init(Constant.Name.prefix, value: name)]
|
||||
attributes(on: .click)
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
|
||||
/// The event's attributes — the event name, then each property — as full `data-` attribute names an element applies verbatim.
|
||||
/// - Parameter trigger: what reports the event.
|
||||
/// - Returns: one attribute for the name, then one per property.
|
||||
public func attributes(on trigger: Trigger) -> [Attribute] {
|
||||
[.init(trigger.prefix, value: name)]
|
||||
+ properties.map {
|
||||
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
|
||||
.init("\(trigger.prefix)-\($0.name)", value: $0.value)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
// MARK: - Types
|
||||
|
||||
private enum Constant {
|
||||
enum Name {
|
||||
/// The attribute name the tracker watches for, and the prefix each property renders under.
|
||||
static let prefix = "data-umami-event"
|
||||
extension Analytics.Event {
|
||||
/// What reports an event.
|
||||
public enum Trigger: Sendable {
|
||||
/// A click, which the tracker records itself.
|
||||
case click
|
||||
/// The element first coming fully on screen, which a page script records.
|
||||
case view
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private extension Analytics.Event.Trigger {
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The attribute name marking the event, and the prefix each property renders under.
|
||||
var prefix: String {
|
||||
switch self {
|
||||
case .click: "data-umami-event"
|
||||
case .view: "data-view-event"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
@@ -45,6 +45,7 @@ extension SocialCard {
|
||||
Tag(String(width), name: .imageWidth),
|
||||
Tag(String(height), name: .imageHeight),
|
||||
alt.map { Tag($0, name: .imageAlt) },
|
||||
alt.map { Tag($0, name: .twitterImageAlt) },
|
||||
]
|
||||
|
||||
return tags.compactMap { $0 }
|
||||
|
||||
@@ -68,6 +68,8 @@ extension SocialCard.Tag {
|
||||
case title = "og:title"
|
||||
/// The `twitter:card` tag, carrying the layout a Twitter card scraper gives the card.
|
||||
case twitter = "twitter:card"
|
||||
/// The `twitter:image:alt` tag, carrying the share image's text for assistive technologies.
|
||||
case twitterImageAlt = "twitter:image:alt"
|
||||
/// The `og:type` tag, carrying the Open Graph type of the object the card describes.
|
||||
case type = "og:type"
|
||||
/// The `og:url` tag, carrying the absolute URL the card's page is served at.
|
||||
@@ -84,7 +86,7 @@ public extension SocialCard.Tag.Name {
|
||||
|
||||
/// The attribute keying a tag with this name: `property` for the Open Graph names, `name` for the Twitter ones.
|
||||
var attribute: SocialCard.Tag.Attribute {
|
||||
self == .twitter ? .name : .property
|
||||
rawValue.hasPrefix("twitter:") ? .name : .property
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
#if StructuredData
|
||||
/// 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.
|
||||
/// shape directly, or uses ``init(organization:inLanguage:)`` 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.
|
||||
@@ -37,6 +38,15 @@ public struct StructuredData: Equatable, Sendable {
|
||||
url + "#organization"
|
||||
}
|
||||
|
||||
/// The `@id` of the `WebSite` node the site-wide initializer builds, for a page node's `isPartOf` to reference.
|
||||
/// - Parameter url: the absolute URL the site is served at, as passed to that initializer.
|
||||
/// - Returns: the website node's `@id`.
|
||||
public static func websiteID(
|
||||
forSiteURL url: String
|
||||
) -> String {
|
||||
url + "#website"
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The minified JSON-LD payload: the schema.org `@context`, and the ``nodes`` in a `@graph`.
|
||||
@@ -53,75 +63,18 @@ public struct StructuredData: Equatable, Sendable {
|
||||
|
||||
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.
|
||||
/// Creates the site-wide structured data: the organization's node and the `WebSite` node it publishes, both with an `@id`.
|
||||
/// - 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.
|
||||
/// - organization: the organization publishing the site.
|
||||
/// - 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,
|
||||
inLanguage: [String] = [],
|
||||
profiles: [String] = [],
|
||||
founder: String? = nil
|
||||
organization: Organization,
|
||||
inLanguage: [String] = []
|
||||
) {
|
||||
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)))
|
||||
}
|
||||
|
||||
if !profiles.isEmpty {
|
||||
organization.append(.init(
|
||||
.sameAs,
|
||||
value: .array(profiles.map(Value.string))
|
||||
))
|
||||
}
|
||||
|
||||
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)),
|
||||
.init(.name, value: .string(organization.name)),
|
||||
.init(.url, value: .string(organization.url)),
|
||||
.init(.publisher, value: .reference(Self.organizationID(forSiteURL: organization.url))),
|
||||
]
|
||||
|
||||
if !inLanguage.isEmpty {
|
||||
@@ -132,13 +85,10 @@ public extension StructuredData {
|
||||
}
|
||||
|
||||
self.init(nodes: [
|
||||
.init(
|
||||
type: .organization,
|
||||
id: id,
|
||||
properties: organization
|
||||
),
|
||||
organization.node,
|
||||
.init(
|
||||
type: .website,
|
||||
id: Self.websiteID(forSiteURL: organization.url),
|
||||
properties: website
|
||||
),
|
||||
])
|
||||
@@ -159,3 +109,4 @@ private extension StructuredData {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A schema.org `PostalAddress`.
|
||||
public struct Address: Equatable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The ISO 3166-1 alpha-2 country code (e.g. `NL`).
|
||||
public let country: String
|
||||
|
||||
/// The city.
|
||||
public let locality: String
|
||||
|
||||
/// The postal code.
|
||||
public let postalCode: String
|
||||
|
||||
/// The street and house number.
|
||||
public let street: String
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates a postal address.
|
||||
/// - Parameters:
|
||||
/// - street: the street and house number.
|
||||
/// - postalCode: the postal code.
|
||||
/// - locality: the city.
|
||||
/// - country: the ISO 3166-1 alpha-2 country code (e.g. `NL`).
|
||||
public init(
|
||||
street: String,
|
||||
postalCode: String,
|
||||
locality: String,
|
||||
country: String
|
||||
) {
|
||||
self.country = country
|
||||
self.locality = locality
|
||||
self.postalCode = postalCode
|
||||
self.street = street
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The address's node.
|
||||
var node: Node {
|
||||
.init(
|
||||
type: .postalAddress,
|
||||
properties: [
|
||||
.init(.streetAddress, value: .string(street)),
|
||||
.init(.postalCode, value: .string(postalCode)),
|
||||
.init(.addressLocality, value: .string(locality)),
|
||||
.init(.addressCountry, value: .string(country)),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A schema.org node of a ``StructuredData`` graph: its type, its optional identifier, and its properties.
|
||||
public struct Node: Equatable, Sendable {
|
||||
@@ -87,6 +88,9 @@ extension StructuredData.Node {
|
||||
public extension StructuredData.Node.Kind {
|
||||
/// An organization, e.g. the one publishing a website.
|
||||
static let organization: Self = "Organization"
|
||||
/// A postal address.
|
||||
static let postalAddress: Self = "PostalAddress"
|
||||
/// A website.
|
||||
static let website: Self = "WebSite"
|
||||
}
|
||||
#endif
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A schema.org `Organization` publishing a site; a fact it does not carry is left out.
|
||||
public struct Organization: Equatable, Sendable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The postal address.
|
||||
public let address: Address?
|
||||
|
||||
/// The name it is also known by.
|
||||
public let alternateName: String?
|
||||
|
||||
/// The area it serves.
|
||||
public let areaServed: String?
|
||||
|
||||
/// What it does.
|
||||
public let description: String?
|
||||
|
||||
/// The email address.
|
||||
public let email: String?
|
||||
|
||||
/// The `@id` of the `Person` node founding it.
|
||||
public let founder: String?
|
||||
|
||||
/// The ISO 8601 date it was founded.
|
||||
public let foundingDate: String?
|
||||
|
||||
/// The ISO 6523 identifier.
|
||||
public let iso6523Code: String?
|
||||
|
||||
/// The registered name.
|
||||
public let legalName: String?
|
||||
|
||||
/// The absolute URL of the logo.
|
||||
public let logo: String?
|
||||
|
||||
/// The name, shared by the site.
|
||||
public let name: String
|
||||
|
||||
/// The absolute URLs of its public profiles.
|
||||
public let profiles: [String]
|
||||
|
||||
/// The phone number.
|
||||
public let telephone: String?
|
||||
|
||||
/// The absolute URL of the site.
|
||||
public let url: String
|
||||
|
||||
/// The VAT number.
|
||||
public let vatID: String?
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
/// Creates an organization.
|
||||
/// - Parameters:
|
||||
/// - name: the name, shared by the site.
|
||||
/// - url: the absolute URL of the site.
|
||||
/// - alternateName: the name it is also known by.
|
||||
/// - legalName: the registered name.
|
||||
/// - description: what it does.
|
||||
/// - areaServed: the area it serves.
|
||||
/// - email: the email address.
|
||||
/// - telephone: the phone number.
|
||||
/// - address: the postal address.
|
||||
/// - vatID: the VAT number.
|
||||
/// - iso6523Code: the ISO 6523 identifier.
|
||||
/// - logo: the absolute URL of the logo.
|
||||
/// - profiles: the absolute URLs of its public profiles.
|
||||
/// - foundingDate: the ISO 8601 date it was founded.
|
||||
/// - founder: the `@id` of the `Person` node founding it.
|
||||
public init(
|
||||
name: String,
|
||||
url: String,
|
||||
alternateName: String? = nil,
|
||||
legalName: String? = nil,
|
||||
description: String? = nil,
|
||||
areaServed: String? = nil,
|
||||
email: String? = nil,
|
||||
telephone: String? = nil,
|
||||
address: Address? = nil,
|
||||
vatID: String? = nil,
|
||||
iso6523Code: String? = nil,
|
||||
logo: String? = nil,
|
||||
profiles: [String] = [],
|
||||
foundingDate: String? = nil,
|
||||
founder: String? = nil
|
||||
) {
|
||||
self.address = address
|
||||
self.alternateName = alternateName
|
||||
self.areaServed = areaServed
|
||||
self.description = description
|
||||
self.email = email
|
||||
self.founder = founder
|
||||
self.foundingDate = foundingDate
|
||||
self.iso6523Code = iso6523Code
|
||||
self.legalName = legalName
|
||||
self.logo = logo
|
||||
self.name = name
|
||||
self.profiles = profiles
|
||||
self.telephone = telephone
|
||||
self.url = url
|
||||
self.vatID = vatID
|
||||
}
|
||||
|
||||
// MARK: Computed
|
||||
|
||||
/// The organization's node.
|
||||
var node: Node {
|
||||
let properties: [Property?] = [
|
||||
.init(.name, value: .string(name)),
|
||||
.init(.url, value: .string(url)),
|
||||
alternateName.map { .init(.alternateName, value: .string($0)) },
|
||||
legalName.map { .init(.legalName, value: .string($0)) },
|
||||
description.map { .init(.description, value: .string($0)) },
|
||||
areaServed.map { .init(.areaServed, value: .string($0)) },
|
||||
email.map { .init(.email, value: .string($0)) },
|
||||
telephone.map { .init(.telephone, value: .string($0)) },
|
||||
address.map { .init(.address, value: .node($0.node)) },
|
||||
vatID.map { .init(.vatID, value: .string($0)) },
|
||||
iso6523Code.map { .init(.iso6523Code, value: .string($0)) },
|
||||
logo.map { .init(.logo, value: .string($0)) },
|
||||
profiles.isEmpty ? nil : .init(.sameAs, value: .array(profiles.map(Value.string))),
|
||||
foundingDate.map { .init(.foundingDate, value: .string($0)) },
|
||||
founder.map { .init(.founder, value: .reference($0)) },
|
||||
]
|
||||
|
||||
return .init(
|
||||
type: .organization,
|
||||
id: StructuredData.organizationID(forSiteURL: url),
|
||||
properties: properties.compactMap { $0 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+22
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A named property of a ``Node``, in the position it renders.
|
||||
public struct Property: Equatable, Sendable {
|
||||
@@ -69,6 +70,12 @@ extension StructuredData.Property {
|
||||
// MARK: - Constants
|
||||
|
||||
public extension StructuredData.Property.Name {
|
||||
/// The postal address of the thing a node describes.
|
||||
static let address: Self = "address"
|
||||
/// The country of a postal address.
|
||||
static let addressCountry: Self = "addressCountry"
|
||||
/// The city of a postal address.
|
||||
static let addressLocality: Self = "addressLocality"
|
||||
/// The name the thing a node describes is also known by.
|
||||
static let alternateName: Self = "alternateName"
|
||||
/// The area an organization serves.
|
||||
@@ -79,16 +86,31 @@ public extension StructuredData.Property.Name {
|
||||
static let email: Self = "email"
|
||||
/// The person who founded an organization.
|
||||
static let founder: Self = "founder"
|
||||
/// The ISO 8601 date an organization was founded.
|
||||
static let foundingDate: Self = "foundingDate"
|
||||
/// The language a creative work — a site, a page — is published in.
|
||||
static let inLanguage: Self = "inLanguage"
|
||||
/// An organization's ISO 6523 identifier.
|
||||
static let iso6523Code: Self = "iso6523Code"
|
||||
/// An organization's registered name.
|
||||
static let legalName: Self = "legalName"
|
||||
/// 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 postal code of a postal address.
|
||||
static let postalCode: Self = "postalCode"
|
||||
/// 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 street and house number of a postal address.
|
||||
static let streetAddress: Self = "streetAddress"
|
||||
/// The phone number of the thing a node describes.
|
||||
static let telephone: Self = "telephone"
|
||||
/// The absolute URL of the thing a node describes.
|
||||
static let url: Self = "url"
|
||||
/// An organization's VAT number.
|
||||
static let vatID: Self = "vatID"
|
||||
}
|
||||
#endif
|
||||
@@ -1,3 +1,4 @@
|
||||
#if StructuredData
|
||||
extension StructuredData {
|
||||
/// A value of a ``Property``: a string, a number, a list, a nested node, or a reference to another node.
|
||||
///
|
||||
@@ -69,3 +70,4 @@ extension StructuredData.Value {
|
||||
}
|
||||
|
||||
}
|
||||
#endif
|
||||
Reference in new issue
Block a user