Implemented the scripts' deferral on the Page protocol in the Infrastructure package.

This commit is contained in:
2026-08-04 12:19:12 +02:00
parent 5c070c2b1a
commit c603421c28
2 changed files with 27 additions and 16 deletions
@@ -5,7 +5,7 @@ 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 in the head, and the content followed by the script tags in the body.
/// 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
@@ -27,7 +27,7 @@ public protocol Page: HTMLDocument, Sendable {
/// 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 before the ``scripts``.
/// The page's markup, rendered as the document body.
@HTMLBuilder
var content: Content { get }
@@ -38,7 +38,10 @@ public protocol Page: HTMLDocument, Sendable {
@HTMLBuilder
var metadata: Metadata { get }
/// The scripts loaded at the end of the document body, in order.
/// 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)
@@ -67,21 +70,15 @@ public extension Page {
nil
}
/// The page ``content`` followed by its ``scripts``.
/// The page ``content``; the ``scripts`` load deferred from the ``head``.
@HTMLBuilder
var body: some HTML {
content
for file in scripts {
script(.src(file.urlPath(
for: .js,
version: assetVersion
))) {}
}
}
/// The viewport declaration, the ``summary``, ``canonicalURL``, and ``socialCard`` tags, the ``structuredData`` script and the
/// ``analytics`` tracker script (when provided), and the ``metadata`` followed by the ``stylesheets`` links, placed in the document head.
/// ``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.
@@ -155,6 +152,16 @@ public extension Page {
))
)
}
for file in scripts {
script(
.defer,
.src(file.urlPath(
for: .js,
version: assetVersion
))
) {}
}
}
/// The analytics tracker is omitted unless the page provides one.
@@ -21,7 +21,7 @@ struct PageTests {
#expect(html.contains(#"name="viewport""#))
#expect(html.contains(#"<meta name="stub" content="marker">"#))
#expect(html.contains(#"<link rel="stylesheet" href="/css/stub.css">"#))
#expect(html.contains(#"<script src="/js/stub.js"></script>"#))
#expect(html.contains(#"<script defer src="/js/stub.js"></script>"#))
#expect(html.contains("Stub content"))
}
@@ -38,13 +38,17 @@ struct PageTests {
}
@Test
func `renders the scripts after the content`() throws {
func `renders the scripts deferred in the head, after the stylesheets`() throws {
let html = StubPage().render()
let content = try #require(html.range(of: "Stub content"))
let stylesheet = try #require(html.range(of: "/css/stub.css"))
let script = try #require(html.range(of: "/js/stub.js"))
let content = try #require(html.range(of: "Stub content"))
#expect(content.lowerBound < script.lowerBound)
// Deferred head scripts start downloading during head parsing but still execute, in order, only after the
// document is parsed the semantics end-of-body tags gave, minus the late download start.
#expect(stylesheet.lowerBound < script.lowerBound)
#expect(script.lowerBound < content.lowerBound)
}
@Test