Skip to content

Commit 5e6a7b5

Browse files
authored
auto-merging style and class attribute values (#72)
* extended attribute storage for mergable styles and class lists * clean up and tests * foo removal * Delete Sources/Elementary/HtmlAttributes+Events.swift * gosh darn case insensitive git troubles * keep value setter for API * dictionary sequence issue * tiny bit more consistency and stinginess
1 parent 53327ab commit 5e6a7b5

File tree

7 files changed

+454
-187
lines changed

7 files changed

+454
-187
lines changed

Sources/Elementary/Core/AttributeStorage.swift

Lines changed: 16 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,9 @@
1-
public struct _StoredAttribute: Equatable, Sendable {
2-
@usableFromInline
3-
enum MergeMode: Equatable, Sendable {
4-
case appendValue(_ separator: String = " ")
5-
case replaceValue
6-
case ignoreIfSet
7-
}
8-
9-
public var name: String
10-
public var value: String?
11-
@usableFromInline
12-
var mergeMode: MergeMode = .replaceValue
13-
14-
@usableFromInline
15-
init(name: String, value: String? = nil, mergeMode: MergeMode = .replaceValue) {
16-
self.name = name
17-
self.value = value
18-
self.mergeMode = mergeMode
19-
}
20-
21-
mutating func mergeWith(_ attribute: consuming _StoredAttribute) {
22-
switch attribute.mergeMode {
23-
case let .appendValue(separator):
24-
value =
25-
switch (value, attribute.value) {
26-
case (_, .none): value
27-
case let (.none, .some(value)): value
28-
case let (.some(existingValue), .some(otherValue)): "\(existingValue)\(separator)\(otherValue)"
29-
}
30-
case .replaceValue:
31-
value = attribute.value
32-
case .ignoreIfSet:
33-
break
34-
}
35-
}
36-
}
37-
1+
/// An internal type that stores HTML attributes for elements.
2+
///
3+
/// It is optimized to avoid allocations for single attribute elements, and implements a lazy "flattening" iterator for rendering.
4+
///
5+
/// The storage automatically optimizes for the number of attributes being stored,
6+
/// using the most efficient representation in each case.
387
public enum _AttributeStorage: Sendable, Equatable {
398
case none
409
case single(_StoredAttribute)
@@ -147,7 +116,12 @@ public struct _MergedAttributes: Sequence, Sendable {
147116
}
148117
}
149118

150-
private func nextflattenedAttribute(attributes: inout [_StoredAttribute], from index: Int) -> (_StoredAttribute, Int?) {
119+
private func nextflattenedAttribute(
120+
attributes: inout [_StoredAttribute],
121+
from index: Int
122+
) -> (
123+
_StoredAttribute, Int?
124+
) {
151125
var attribute: _StoredAttribute = .blankedOut
152126
swap(&attribute, &attributes[index])
153127

@@ -171,13 +145,13 @@ private func nextflattenedAttribute(attributes: inout [_StoredAttribute], from i
171145
return (attribute, nextIndex)
172146
}
173147

174-
private extension _StoredAttribute {
175-
static let blankedOut = _StoredAttribute(name: "")
148+
extension _StoredAttribute {
149+
fileprivate static let blankedOut = _StoredAttribute(name: "")
176150
}
177151

178-
private extension String {
152+
extension String {
179153
@inline(__always)
180-
func utf8Equals(_ other: borrowing String) -> Bool {
154+
fileprivate func utf8Equals(_ other: borrowing String) -> Bool {
181155
// for embedded support
182156
utf8.elementsEqual(other.utf8)
183157
}

Sources/Elementary/Core/Html+Attributes.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,24 +33,34 @@ public struct HTMLAttributeMergeAction: Sendable {
3333
public static func appending(separatedBy: String) -> Self { .init(mergeMode: .appendValue(separatedBy)) }
3434
}
3535

36-
public extension HTMLAttribute {
36+
extension HTMLAttribute {
3737
/// Creates a new HTML attribute with the specified name and value.
3838
/// - Parameters:
3939
/// - name: The name of the attribute.
4040
/// - value: The value of the attribute.
4141
/// - action: The merge action to use with a previously attached attribute with the same name.
4242
@inlinable
43-
init(name: String, value: String?, mergedBy action: HTMLAttributeMergeAction = .replacing) {
43+
public init(name: String, value: String?, mergedBy action: HTMLAttributeMergeAction = .replacing) {
4444
htmlAttribute = .init(name: name, value: value, mergeMode: action.mergeMode)
4545
}
4646

4747
/// Changes the default merge action of this attribute.
4848
/// - Parameter action: The new merge action to use.
4949
/// - Returns: A modified attribute with the specified merge action.
5050
@inlinable
51-
consuming func mergedBy(_ action: HTMLAttributeMergeAction) -> HTMLAttribute {
51+
public consuming func mergedBy(_ action: HTMLAttributeMergeAction) -> HTMLAttribute {
5252
.init(name: name, value: value, mergedBy: action)
5353
}
54+
55+
@inlinable
56+
init(classes: _StoredAttribute.Classes) {
57+
htmlAttribute = .init(classes)
58+
}
59+
60+
@inlinable
61+
init(styles: _StoredAttribute.Styles) {
62+
htmlAttribute = .init(styles)
63+
}
5464
}
5565

5666
public struct _AttributedElement<Content: HTML>: HTML {
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/// An internal type representing a type-erased HTML attribute.
2+
///
3+
/// This type is used to store HTML attributes and their values of an element. It supports different types of values
4+
/// like plain strings, styles, and classes, and provides different merge strategies when combining attributes.
5+
///
6+
/// The merge strategies control how attributes with the same name are combined:
7+
/// - `.appendValue`: Appends values with a separator (default is space)
8+
/// - `.replaceValue`: Replaces any existing value
9+
/// - `.ignoreIfSet`: Keeps the existing value if present
10+
public struct _StoredAttribute: Equatable, Sendable {
11+
@usableFromInline
12+
enum MergeMode: Equatable, Sendable {
13+
case appendValue(_ separator: String = " ")
14+
case replaceValue
15+
case ignoreIfSet
16+
}
17+
18+
enum Value: Equatable {
19+
case empty
20+
case plain(String)
21+
case styles(Styles)
22+
case classes(Classes)
23+
}
24+
25+
public var name: String
26+
var _value: Value
27+
28+
// NOTE: this is mainly here to not break API for now
29+
public var value: String? {
30+
get {
31+
switch _value {
32+
case .empty: return nil
33+
case let .plain(value): return value
34+
case let .styles(styles): return styles.flatten()
35+
case let .classes(classes): return classes.flatten()
36+
}
37+
}
38+
set {
39+
_value = newValue.map { .plain($0) } ?? .empty
40+
}
41+
}
42+
43+
@usableFromInline
44+
var mergeMode: MergeMode = .replaceValue
45+
46+
@usableFromInline
47+
init(name: String, value: String? = nil, mergeMode: MergeMode = .replaceValue) {
48+
self.name = name
49+
self._value = value.map { .plain($0) } ?? .empty
50+
self.mergeMode = mergeMode
51+
}
52+
53+
@usableFromInline
54+
init(_ styles: Styles) {
55+
self.name = "style"
56+
self._value = .styles(styles)
57+
self.mergeMode = .appendValue(";")
58+
}
59+
60+
@usableFromInline
61+
init(_ classes: Classes) {
62+
self.name = "class"
63+
self._value = .classes(classes)
64+
self.mergeMode = .appendValue(" ")
65+
}
66+
67+
mutating func mergeWith(_ attribute: consuming _StoredAttribute) {
68+
switch attribute.mergeMode {
69+
case let .appendValue(separator):
70+
switch (_value, attribute._value) {
71+
case (_, .empty):
72+
break
73+
case (.empty, let other):
74+
_value = other
75+
case (.plain(let existing), .plain(let other)):
76+
_value = .plain("\(existing)\(separator)\(other)")
77+
case (.styles(var existing), .styles(let other)):
78+
existing.append(contentsOf: other)
79+
_value = .styles(existing)
80+
case (.classes(var existing), .classes(let other)):
81+
existing.append(contentsOf: other)
82+
_value = .classes(existing)
83+
case (.plain(let existing), .styles(let styles)):
84+
var newStyles = Styles(plainValue: existing)
85+
newStyles.append(contentsOf: styles)
86+
_value = .styles(newStyles)
87+
case (.styles(var styles), .plain(let other)):
88+
styles.append(plainValue: other)
89+
_value = .styles(styles)
90+
case (.plain(let existing), .classes(let classes)):
91+
var newClasses = Classes([existing])
92+
newClasses.append(contentsOf: classes)
93+
_value = .classes(newClasses)
94+
case (.classes(var classes), .plain(let other)):
95+
classes.append(plainValue: other)
96+
_value = .classes(classes)
97+
case (.styles, .classes), (.classes, .styles):
98+
assertionFailure("Cannot merge styles and classes")
99+
// If trying to merge incompatible types, just replace
100+
_value = attribute._value
101+
}
102+
case .replaceValue:
103+
_value = attribute._value
104+
case .ignoreIfSet:
105+
break
106+
}
107+
}
108+
}
109+
110+
extension _StoredAttribute {
111+
@usableFromInline
112+
struct Styles: Equatable, Sendable {
113+
@usableFromInline
114+
struct Entry: Equatable, Sendable {
115+
let key: String
116+
let value: String
117+
118+
@usableFromInline
119+
init(key: String, value: String) {
120+
self.key = key
121+
self.value = value
122+
}
123+
}
124+
125+
@usableFromInline
126+
var styles: [Entry]
127+
128+
@inlinable
129+
init(_ elements: some Sequence<(key: String, value: String)>) {
130+
self.styles = elements.map { Entry(key: $0.0, value: $0.1) }
131+
}
132+
133+
@usableFromInline
134+
init(plainValue: String) {
135+
self.styles = [Entry(key: "", value: plainValue)]
136+
}
137+
138+
mutating func append(plainValue: String) {
139+
styles.append(Entry(key: "", value: plainValue))
140+
}
141+
142+
mutating func append(contentsOf other: consuming Styles) {
143+
let originalCount = styles.count
144+
for entry in other.styles {
145+
if entry.key.isEmpty {
146+
styles.append(entry)
147+
} else {
148+
for i in 0..<originalCount {
149+
if styles[i].key.utf8Equals(entry.key) {
150+
styles.remove(at: i)
151+
break
152+
}
153+
}
154+
styles.append(entry)
155+
}
156+
}
157+
}
158+
159+
consuming func flatten() -> String {
160+
var result = ""
161+
for (index, entry) in styles.enumerated() {
162+
if index > 0 {
163+
result += ";"
164+
}
165+
if entry.key.isEmpty {
166+
result += entry.value
167+
} else {
168+
result += "\(entry.key):\(entry.value)"
169+
}
170+
}
171+
return result
172+
}
173+
}
174+
175+
@usableFromInline
176+
struct Classes: Equatable, Sendable {
177+
@usableFromInline
178+
var classes: [String]
179+
180+
@inlinable
181+
init(_ elements: [String]) {
182+
self.classes = elements
183+
}
184+
185+
@inlinable
186+
init(_ elements: some Sequence<String>) {
187+
self.classes = Array(elements)
188+
}
189+
190+
mutating func append(plainValue newClass: String) {
191+
classes.append(newClass)
192+
}
193+
194+
mutating func append(contentsOf other: consuming Classes) {
195+
let originalCount = classes.count
196+
for newClass in other.classes {
197+
if !classes.prefix(originalCount).contains(where: { $0.utf8Equals(newClass) }) {
198+
classes.append(newClass)
199+
}
200+
}
201+
}
202+
203+
consuming func flatten() -> String {
204+
classes.joined(separator: " ")
205+
}
206+
}
207+
}
208+
209+
extension String {
210+
@inline(__always)
211+
fileprivate func utf8Equals(_ other: borrowing String) -> Bool {
212+
// for embedded support
213+
utf8.elementsEqual(other.utf8)
214+
}
215+
}

0 commit comments

Comments
 (0)