208 lines
7.0 KiB
Swift
208 lines
7.0 KiB
Swift
import Elementary
|
|
import Foundation
|
|
|
|
/// 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
|
|
/// 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.
|
|
public protocol Page: HTMLDocument, Sendable {
|
|
|
|
// MARK: Associated types
|
|
|
|
/// The type of the page's markup.
|
|
associatedtype Content: HTML
|
|
|
|
/// The type of the page's head metadata markup.
|
|
associatedtype Metadata: HTML
|
|
|
|
// MARK: Properties
|
|
|
|
/// The analytics tracker embedded as a deferred script in the document head, or `nil` (the default) to omit it.
|
|
var analytics: Analytics? { get }
|
|
|
|
/// The version token appended to the page's asset URLs, or `nil` to leave them unversioned.
|
|
var assetVersion: String? { get }
|
|
|
|
/// The canonical URL the page is served at, rendered as a `link rel="canonical"` tag in the document head, or `nil` (the default) to omit the tag.
|
|
var canonicalURL: String? { get }
|
|
|
|
/// The page's markup, rendered as the document body.
|
|
@HTMLBuilder
|
|
var content: Content { get }
|
|
|
|
/// The locale the page content is localized to.
|
|
var locale: Locale { get }
|
|
|
|
/// The markup placed in the document head before the ``stylesheets`` links: icon and manifest links, extra meta tags, and the like.
|
|
@HTMLBuilder
|
|
var metadata: Metadata { get }
|
|
|
|
/// The scripts loaded from the document head, in order.
|
|
///
|
|
/// Rendered as `defer`red tags: the downloads start while the head is parsed, and the scripts still execute in order only after the
|
|
/// document is fully parsed — the same semantics end-of-body tags would give, minus the late download start.
|
|
var scripts: [any Asset] { get }
|
|
|
|
/// The card controlling the page's link previews, rendered as Open Graph and Twitter meta tags in the document head, or `nil` (the default)
|
|
/// to omit them.
|
|
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.
|
|
var stylesheets: [any Asset] { get }
|
|
|
|
/// The page's summary, rendered as a `meta name="description"` tag in the document head, or `nil` (the default) to omit the tag.
|
|
var summary: String? { get }
|
|
|
|
}
|
|
|
|
// MARK: - Implementations
|
|
|
|
public extension Page {
|
|
|
|
// MARK: Computed
|
|
|
|
/// The canonical URL is omitted unless the page provides one.
|
|
var canonicalURL: String? {
|
|
nil
|
|
}
|
|
|
|
/// The page ``content``; the ``scripts`` load deferred from the ``head``.
|
|
@HTMLBuilder
|
|
var body: some HTML {
|
|
content
|
|
}
|
|
|
|
/// The viewport declaration, the ``analytics`` origin preconnect hint, the ``summary``, ``canonicalURL``, and ``socialCard``
|
|
/// tags, the ``structuredData`` script and the ``analytics`` tracker script (when provided), and the ``metadata`` followed by the
|
|
/// ``stylesheets`` links and the deferred ``scripts`` tags, placed in the document head.
|
|
///
|
|
/// The charset declaration is omitted: Elementary's `HTMLDocument` scaffolding already emits `<meta charset="UTF-8">` before this markup,
|
|
/// 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. The analytics tracker, by contrast, is an executable script the
|
|
/// policy must allow, and it is `defer`red so it never delays the page render; each behavior flag renders its `data-` attribute only when enabled.
|
|
/// When recorder mode is on, the session recorder script follows the tracker script, deferred as well and carrying only the website id.
|
|
@HTMLBuilder
|
|
var head: some HTML {
|
|
meta(
|
|
.name(.viewport),
|
|
.content("width=device-width, initial-scale=1")
|
|
)
|
|
|
|
// Rendered first so the cross-origin handshake starts before the parser reaches the tracker script tag.
|
|
if let origin = analytics?.origin {
|
|
link(
|
|
.rel("preconnect"),
|
|
.href(origin)
|
|
)
|
|
}
|
|
|
|
if let summary {
|
|
meta(
|
|
.name(.description),
|
|
.content(summary)
|
|
)
|
|
}
|
|
|
|
if let canonicalURL {
|
|
link(
|
|
.rel("canonical"),
|
|
.href(canonicalURL)
|
|
)
|
|
}
|
|
|
|
if let socialCard {
|
|
for tag in socialCard.tags {
|
|
meta(
|
|
.custom(
|
|
name: tag.attribute.rawValue,
|
|
value: tag.name.rawValue
|
|
),
|
|
.content(tag.content)
|
|
)
|
|
}
|
|
}
|
|
|
|
if let structuredData {
|
|
script(.custom(
|
|
name: "type",
|
|
value: "application/ld+json"
|
|
)) {
|
|
HTMLRaw(structuredData.payload)
|
|
}
|
|
}
|
|
|
|
if let analytics {
|
|
script(
|
|
.defer,
|
|
.src(analytics.scriptURL)
|
|
) {}
|
|
.attributes(contentsOf: analytics.attributes.map {
|
|
.custom(
|
|
name: $0.name,
|
|
value: $0.value
|
|
)
|
|
})
|
|
|
|
if let recorderScriptURL = analytics.recorderScriptURL {
|
|
script(
|
|
.defer,
|
|
.src(recorderScriptURL),
|
|
.custom(
|
|
name: "data-website-id",
|
|
value: analytics.websiteID
|
|
)
|
|
) {}
|
|
}
|
|
}
|
|
|
|
metadata
|
|
|
|
for file in stylesheets {
|
|
link(
|
|
.rel(.stylesheet),
|
|
.href(file.urlPath(
|
|
for: .css,
|
|
version: assetVersion
|
|
))
|
|
)
|
|
}
|
|
|
|
for file in scripts {
|
|
script(
|
|
.defer,
|
|
.src(file.urlPath(
|
|
for: .js,
|
|
version: assetVersion
|
|
))
|
|
) {}
|
|
}
|
|
}
|
|
|
|
/// The analytics tracker is omitted unless the page provides one.
|
|
var analytics: Analytics? {
|
|
nil
|
|
}
|
|
|
|
/// The social card is omitted unless the page provides one.
|
|
var socialCard: SocialCard? {
|
|
nil
|
|
}
|
|
|
|
/// The structured data is omitted unless the page provides one.
|
|
var structuredData: StructuredData? {
|
|
nil
|
|
}
|
|
|
|
/// The summary is omitted unless the page provides one.
|
|
var summary: String? {
|
|
nil
|
|
}
|
|
|
|
}
|