Skip to content

Commit 0abe8b5

Browse files
authored
fix: various qol changes
fix: various qol changes
2 parents cfd0190 + 23b82b4 commit 0abe8b5

File tree

9 files changed

+96
-37
lines changed

9 files changed

+96
-37
lines changed

Sources/WebUI/Core/Document.swift

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
11
import Foundation
22
import Logging
33

4+
public enum ScriptAttribute: String {
5+
/// Defers downloading of the script to during page pare
6+
case `defer`
7+
/// Causes the script to run as soon as it's available
8+
case async
9+
}
10+
411
/// Represents an immutable HTML document with metadata and content.
512
public struct Document {
613
private let logger = Logger(label: "com.webui.document")
714
public let path: String?
815
public var metadata: Metadata
9-
public var scripts: [String]?
16+
public var scripts: [String: ScriptAttribute?]?
1017
public var stylesheets: [String]?
1118
public var theme: Theme?
1219
public let head: String?
@@ -30,23 +37,26 @@ public struct Document {
3037
public init(
3138
path: String? = nil,
3239
metadata: Metadata,
33-
scripts: [String]? = nil,
40+
scripts: [String: ScriptAttribute?]? = nil,
3441
stylesheets: [String]? = nil,
3542
theme: Theme? = nil,
3643
head: String? = nil,
3744
@HTMLBuilder content: @escaping () -> [any HTML]
3845
) {
39-
self.path = path
4046
self.metadata = metadata
47+
self.path = path ?? metadata.title?.pathFormatted()
4148
self.scripts = scripts
4249
self.stylesheets = stylesheets
4350
self.theme = theme
4451
self.head = head
4552
self.contentBuilder = content
4653

47-
logger.debug("Document initialized with path: \(path ?? "index"), title: \(metadata.pageTitle)")
54+
logger.debug(
55+
"Document initialized with path: \(path ?? "index"), title: \(metadata.pageTitle)"
56+
)
4857
logger.trace(
49-
"Document has \(scripts?.count ?? 0) scripts, \(stylesheets?.count ?? 0) stylesheets")
58+
"Document has \(scripts?.count ?? 0) scripts, \(stylesheets?.count ?? 0) stylesheets"
59+
)
5060
}
5161

5262
/// Renders the document as a complete HTML string.
@@ -63,21 +73,18 @@ public struct Document {
6373
if let image = metadata.image, !image.isEmpty {
6474
logger.trace("Adding og:image meta tag: \(image)")
6575
optionalMetaTags.append(
66-
"<meta property=\"og:image\" content=\"\(image)\">")
76+
"<meta property=\"og:image\" content=\"\(image)\">"
77+
)
6778
}
6879
if let author = metadata.author, !author.isEmpty {
6980
logger.trace("Adding author meta tag: \(author)")
7081
optionalMetaTags.append("<meta name=\"author\" content=\"\(author)\">")
7182
}
72-
if let type = metadata.type {
73-
logger.trace("Adding og:type meta tag: \(type.rawValue)")
74-
optionalMetaTags.append(
75-
"<meta property=\"og:type\" content=\"\(type.rawValue)\">")
76-
}
7783
if let twitter = metadata.twitter, !twitter.isEmpty {
7884
logger.trace("Adding twitter meta tag: \(twitter)")
7985
optionalMetaTags.append(
80-
"<meta name=\"twitter:creator\" content=\"@\(twitter)\">")
86+
"<meta name=\"twitter:creator\" content=\"@\(twitter)\">"
87+
)
8188
}
8289
if let keywords = metadata.keywords, !keywords.isEmpty {
8390
logger.trace("Adding keywords meta tag with \(keywords.count) keywords")
@@ -97,14 +104,17 @@ public struct Document {
97104
if let scripts = scripts {
98105
logger.trace("Adding \(scripts.count) script tags")
99106
for script in scripts {
100-
optionalMetaTags.append("<script src=\"\(script)\"></script>")
107+
optionalMetaTags.append(
108+
"<script \(script.value?.rawValue ?? "") src=\"\(script.key)\"></script>"
109+
)
101110
}
102111
}
103112
if let stylesheets = stylesheets {
104113
logger.trace("Adding \(stylesheets.count) stylesheet links")
105114
for stylesheet in stylesheets {
106115
optionalMetaTags.append(
107-
"<link rel=\"stylesheet\" href=\"\(stylesheet)\">")
116+
"<link rel=\"stylesheet\" href=\"\(stylesheet)\">"
117+
)
108118
}
109119
}
110120

@@ -117,6 +127,7 @@ public struct Document {
117127
<meta property="og:title" content="\(metadata.pageTitle)">
118128
<meta name="description" content="\(metadata.description)">
119129
<meta property="og:description" content="\(metadata.description)">
130+
"<meta property=\"og:type\" content=\"\(metadata.type.rawValue)\">"
120131
<meta name="twitter:card" content="summary_large_image">
121132
\(optionalMetaTags.joined(separator: "\n"))
122133
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>

Sources/WebUI/Core/Metadata.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public struct Metadata {
3939
public var keywords: [String]?
4040
public var twitter: String?
4141
public var locale: Locale
42-
public var type: ContentType?
42+
public var type: ContentType
4343
public var themeColor: ThemeColor?
4444

4545
/// Creates metadata for a document’s head section.
@@ -54,7 +54,7 @@ public struct Metadata {
5454
/// - keywords: SEO keywords, optional.
5555
/// - twitter: Twitter handle without "@", optional.
5656
/// - locale: Language setting, defaults to `.en`.
57-
/// - type: Open Graph content type, optional.
57+
/// - type: Open Graph content type, defaults to `.website`.
5858
/// - themeColor: Theme colors for light and dark modes, optional.
5959
public init(
6060
site: String? = nil,
@@ -67,7 +67,7 @@ public struct Metadata {
6767
keywords: [String]? = nil,
6868
twitter: String? = nil,
6969
locale: Locale = .en,
70-
type: ContentType? = nil,
70+
type: ContentType = .website,
7171
themeColor: ThemeColor? = nil
7272
) {
7373
self.site = site
@@ -105,7 +105,7 @@ extension Metadata {
105105
/// - type: Optional content type, default is the same as `base`.
106106
/// - themeColor: Optional theme color, default is the same as `base`.
107107
public init(
108-
base: Metadata,
108+
from base: Metadata,
109109
site: String? = nil,
110110
title: String? = nil,
111111
titleSeperator: String? = nil,

Sources/WebUI/Elements/Base/Media.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,30 @@ public final class Source: Element {
6464

6565
/// Generates an HTML img element.
6666
public final class Image: Element {
67+
let source: String
6768
let description: String
6869
let size: MediaSize?
6970

7071
/// Creates a new HTML img element.
7172
///
7273
/// - Parameters:
74+
/// - source: Where the image is located.
7375
/// - description: Alt text for accessibility.
7476
/// - size: Image size dimensions, optional.
7577
/// - data: Dictionary of `data-*` attributes for element relevant storing data.
7678
public init(
79+
source: String,
7780
description: String,
7881
size: MediaSize? = nil,
7982
data: [String: String]? = nil
8083
) {
84+
self.source = source
8185
self.description = description
8286
self.size = size
8387
var customAttributes: [String] = []
88+
if !source.isEmpty {
89+
customAttributes.append("src=\"\(source)\"")
90+
}
8491
if !description.isEmpty {
8592
customAttributes.append("alt=\"\(description)\"")
8693
}
@@ -140,7 +147,7 @@ public final class Picture: Element {
140147
for source in sources {
141148
Source(src: source.src, type: source.type?.rawValue)
142149
}
143-
Image(description: description, size: size)
150+
Image(source: sources[0].src, description: description, size: size)
144151
}
145152
)
146153
}

Sources/WebUI/HTML/Children.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/// Represents a collection of HTML children, built declaratively.
2+
public struct Children {
3+
private let content: [any HTML]
4+
5+
/// Initializes children using an HTMLBuilder closure.
6+
public init(@HTMLBuilder content: HTMLContentBuilder) {
7+
self.content = content()
8+
}
9+
10+
/// Renders all children as a single HTML string.
11+
public func render() -> String {
12+
content.map { $0.render() }.joined()
13+
}
14+
}

Sources/WebUI/HTML/HTMLBuilder.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,6 @@ public struct HTMLBuilder {
6969
components.flatMap { $0 }
7070
}
7171
}
72+
73+
/// Type alias for a closure that builds HTML content.
74+
public typealias HTMLContentBuilder = () -> [any HTML]

Sources/WebUI/Styles/Appearance/Border.swift

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ extension Element {
9292
public func border(
9393
width: Int? = nil,
9494
edges: Edge...,
95-
radius: (side: RadiusSide?, size: RadiusSize)? = nil,
9695
style: BorderStyle? = nil,
9796
color: Color? = nil,
9897
on modifiers: Modifier...
@@ -104,7 +103,8 @@ extension Element {
104103
if let width = width {
105104
if style == .divide {
106105
for edge in effectiveEdges {
107-
let edgePrefix = edge == .horizontal ? "x" : edge == .vertical ? "y" : ""
106+
let edgePrefix =
107+
edge == .horizontal ? "x" : edge == .vertical ? "y" : ""
108108
if !edgePrefix.isEmpty {
109109
baseClasses.append("divide-\(edgePrefix)-\(width)")
110110
}
@@ -114,7 +114,8 @@ extension Element {
114114
contentsOf: effectiveEdges.map { edge in
115115
let edgePrefix = edge.rawValue.isEmpty ? "" : "-\(edge.rawValue)"
116116
return "border\(edgePrefix)\(width != 0 ? "-\(width)" : "")"
117-
})
117+
}
118+
)
118119
}
119120
}
120121

@@ -124,14 +125,8 @@ extension Element {
124125
contentsOf: effectiveEdges.map { edge in
125126
let edgePrefix = edge.rawValue.isEmpty ? "" : "-\(edge.rawValue)"
126127
return "border\(edgePrefix)"
127-
})
128-
}
129-
130-
// Handle radius
131-
if let (side, size) = radius {
132-
let sidePrefix = side?.rawValue ?? ""
133-
let sideClass = sidePrefix.isEmpty ? "" : "-\(sidePrefix)"
134-
baseClasses.append("rounded\(sideClass)-\(size.rawValue)")
128+
}
129+
)
135130
}
136131

137132
// Handle style
@@ -140,7 +135,8 @@ extension Element {
140135
contentsOf: effectiveEdges.map { edge in
141136
let edgePrefix = edge.rawValue.isEmpty ? "" : "-\(edge.rawValue)"
142137
return "border\(edgePrefix)-\(styleValue.rawValue)"
143-
})
138+
}
139+
)
144140
}
145141

146142
// Handle color
@@ -162,6 +158,27 @@ extension Element {
162158
)
163159
}
164160

161+
public func rounded(
162+
_ size: RadiusSize,
163+
_ edge: RadiusSide = .all,
164+
on modifiers: Modifier...
165+
) -> Element {
166+
let sidePrefix = edge.rawValue.isEmpty ? "" : "-\(edge.rawValue)"
167+
let baseClasses = ["rounded\(sidePrefix)-\(size.rawValue)"]
168+
let newClasses = combineClasses(baseClasses, withModifiers: modifiers)
169+
170+
return Element(
171+
tag: self.tag,
172+
id: self.id,
173+
classes: (self.classes ?? []) + newClasses,
174+
role: self.role,
175+
label: self.label,
176+
isSelfClosing: self.isSelfClosing,
177+
customAttributes: self.customAttributes,
178+
content: self.contentBuilder
179+
)
180+
}
181+
165182
public func outline(
166183
width: Int? = nil,
167184
style: BorderStyle? = nil,

Sources/WebUI/Utilities/String.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ extension String: HTML {
1111

1212
/// Converts the string to a lowercase, hyphen-separated path representation.
1313
public func pathFormatted() -> String {
14-
lowercased()
14+
self.lowercased()
1515
.replacingOccurrences(of: "[^a-z0-9 ]", with: "", options: .regularExpression)
1616
.split(separator: " ")
1717
.filter { !$0.isEmpty }

Tests/WebUITests/Core/DocumentTests.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,17 @@ import Testing
7777
description: "Testing script inclusion"
7878
),
7979
scripts: [
80-
"https://cdn.example.com/script1.js",
81-
"/public/script2.js",
80+
"https://cdn.example.com/script1.js": .async,
81+
"/public/script2.js": .defer,
8282
]
8383
) {
8484
"Script Test"
8585
}.render()
8686

8787
#expect(
8888
rendered.contains(
89-
"<script src=\"https://cdn.example.com/script1.js\"></script>"))
90-
#expect(rendered.contains("<script src=\"/public/script2.js\"></script>"))
89+
"<script async src=\"https://cdn.example.com/script1.js\"></script>"))
90+
#expect(rendered.contains("<script defer src=\"/public/script2.js\"></script>"))
9191
}
9292

9393
/// Tests that custom stylesheets are correctly added to the document head.

Tests/WebUITests/Styles/AppearanceTests.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,17 @@ import Testing
6767

6868
@Test("Border with radius")
6969
func testBorderWithRadius() async throws {
70-
let element = Element(tag: "div").border(radius: (.all, .md))
70+
let element = Element(tag: "div").rounded(.md)
7171
let rendered = element.render()
7272
#expect(rendered.contains("class=\"rounded-md\""))
7373
}
74+
75+
@Test("Border with radius on just one side")
76+
func testBorderWithOneSidedRadius() async throws {
77+
let element = Element(tag: "div").rounded(.full, .topLeft)
78+
let rendered = element.render()
79+
#expect(rendered.contains("class=\"rounded-tl-full\""))
80+
}
7481

7582
@Test("Border with specific edge and color")
7683
func testBorderWithEdgeAndColor() async throws {

0 commit comments

Comments
 (0)