From 0a0ebb32663d5eafc3ea58231862fa7c0b1e17c2 Mon Sep 17 00:00:00 2001 From: Simon Leeb <52261246+sliemeobn@users.noreply.github.com> Date: Sat, 12 Oct 2024 17:17:58 +0200 Subject: [PATCH] improved performance (#49) * StaticString tagNames and tighter attribute render loop * inlinable attribute constructors * inlinable on all generic attribute initializers * benchmark timeouts * stick to strings for tags (staticstring performs better, but only by a few percent in realistic cases) * a bit of cleanup * adjusted formatted rendering --- .../ElementaryBenchmarks/Benchmarks.swift | 31 +++- .../Elementary/Core/AttributeStorage.swift | 159 ++++++++++-------- Sources/Elementary/Core/CoreModel.swift | 6 +- Sources/Elementary/Core/Html+Attributes.swift | 26 ++- Sources/Elementary/Core/Html+Elements.swift | 43 ++--- .../Elementary/Core/HtmlElement+Async.swift | 2 + .../Rendering/HtmlTextRenderer.swift | 60 +++---- .../Elementary/Rendering/RenderingUtils.swift | 68 ++++---- .../FormattedRenderingTest.swift | 25 +++ 9 files changed, 245 insertions(+), 175 deletions(-) diff --git a/Benchmarks/Benchmarks/ElementaryBenchmarks/Benchmarks.swift b/Benchmarks/Benchmarks/ElementaryBenchmarks/Benchmarks.swift index 7a9f455..f0d2834 100644 --- a/Benchmarks/Benchmarks/ElementaryBenchmarks/Benchmarks.swift +++ b/Benchmarks/Benchmarks/ElementaryBenchmarks/Benchmarks.swift @@ -4,7 +4,8 @@ import Elementary let benchmarks = { @Sendable in Benchmark.defaultConfiguration = .init( metrics: [.wallClock, .mallocCountTotal, .instructions, .throughput], - scalingFactor: .kilo + scalingFactor: .kilo, + maxDuration: .seconds(5) ) Benchmark("initialize nested html tags") { benchmark in @@ -156,6 +157,34 @@ let benchmarks = { @Sendable in }.renderAsync()) } } + + Benchmark("render full document (sync)") { benchmark in + for _ in benchmark.scaledIterations { + blackHole( + html { + head { + title { "Hello, World!" } + meta(.name("viewport"), .content("width=device-width, initial-scale=1")) + link(.rel("stylesheet"), .href("styles.css")) + } + body { + h1(.class("hello")) { "Hello, World!" } + p { "This is a paragraph." } + a(.href("https://swift.org")) { + "Swift" + } + ul(.class("fancy-list")) { + ForEach(0 ..< 1000) { i in + MyCustomElement { + MyListItem(number: i) + } + HTMLComment("This is a comment") + } + } + } + }.render()) + } + } } func makeNestedTagsElement() -> some HTML { diff --git a/Sources/Elementary/Core/AttributeStorage.swift b/Sources/Elementary/Core/AttributeStorage.swift index 3b4938d..428e59c 100644 --- a/Sources/Elementary/Core/AttributeStorage.swift +++ b/Sources/Elementary/Core/AttributeStorage.swift @@ -1,36 +1,55 @@ -struct StoredAttribute: Equatable, Sendable { - enum MergeMode: Equatable { +public struct _StoredAttribute: Equatable, Sendable { + @usableFromInline + enum MergeMode: Equatable, Sendable { case appendValue(_ separator: String = " ") case replaceValue case ignoreIfSet } - var name: String - var value: String? + public var name: String + public var value: String? + @usableFromInline var mergeMode: MergeMode = .replaceValue - mutating func appending(value: String?, separatedBy separator: String) { - self.value = switch (self.value, value) { - case (_, .none): self.value - case let (.none, .some(value)): value - case let (.some(existingValue), .some(otherValue)): "\(existingValue)\(separator)\(otherValue)" + @usableFromInline + init(name: String, value: String? = nil, mergeMode: MergeMode = .replaceValue) { + self.name = name + self.value = value + self.mergeMode = mergeMode + } + + mutating func mergeWith(_ attribute: consuming _StoredAttribute) { + switch attribute.mergeMode { + case let .appendValue(separator): + value = switch (value, attribute.value) { + case (_, .none): value + case let (.none, .some(value)): value + case let (.some(existingValue), .some(otherValue)): "\(existingValue)\(separator)\(otherValue)" + } + case .replaceValue: + value = attribute.value + case .ignoreIfSet: + break } } } -enum AttributeStorage: Sendable { +public enum _AttributeStorage: Sendable { case none - case single(StoredAttribute) - case multiple([StoredAttribute]) + case single(_StoredAttribute) + case multiple([_StoredAttribute]) + @inlinable init() { self = .none } + @inlinable init(_ attribute: HTMLAttribute) { self = .single(attribute.htmlAttribute) } + @inlinable init(_ attributes: [HTMLAttribute]) { switch attributes.count { case 0: self = .none @@ -39,7 +58,7 @@ enum AttributeStorage: Sendable { } } - var isEmpty: Bool { + public var isEmpty: Bool { switch self { case .none: return true case .single: return false @@ -47,7 +66,7 @@ enum AttributeStorage: Sendable { } } - mutating func append(_ attributes: consuming AttributeStorage) { + public mutating func append(_ attributes: consuming _AttributeStorage) { // maybe this was a bad idea.... switch (self, attributes) { case (_, .none): @@ -68,67 +87,64 @@ enum AttributeStorage: Sendable { } } - consuming func flattened() -> FlattenedAttributeView { + public consuming func flattened() -> _MergedAttributes { .init(storage: self) } } -extension AttributeStorage { - struct FlattenedAttributeView: Sequence, Sendable { - typealias Element = StoredAttribute - var storage: AttributeStorage +public struct _MergedAttributes: Sequence, Sendable { + public typealias Element = _StoredAttribute + var storage: _AttributeStorage - consuming func makeIterator() -> Iterator { - Iterator(storage) - } + public consuming func makeIterator() -> Iterator { + Iterator(storage) + } - struct Iterator: IteratorProtocol { - enum State { - case empty - case single(StoredAttribute) - case flattening([StoredAttribute], Int) - case _temporaryNothing - } + public struct Iterator: IteratorProtocol { + enum State { + case empty + case single(_StoredAttribute) + case flattening([_StoredAttribute], Int) + case _temporaryNothing + } - var state: State + var state: State - init(_ storage: consuming AttributeStorage) { - switch storage { - case .none: state = .empty - case let .single(attribute): state = .single(attribute) - case let .multiple(attributes): state = .flattening(attributes, 0) - } + init(_ storage: consuming _AttributeStorage) { + switch storage { + case .none: state = .empty + case let .single(attribute): state = .single(attribute) + case let .multiple(attributes): state = .flattening(attributes, 0) } + } - mutating func next() -> StoredAttribute? { - switch state { - case .empty: return nil - case let .single(attribute): + public mutating func next() -> _StoredAttribute? { + switch state { + case .empty: return nil + case let .single(attribute): + state = .empty + return attribute + case .flattening(var list, let index): + state = ._temporaryNothing + let (attribute, newIndex) = nextflattenedAttribute(attributes: &list, from: index) + + if let newIndex { + state = .flattening(list, newIndex) + } else { state = .empty - return attribute - case .flattening(var list, let index): - state = ._temporaryNothing - let (attribute, newIndex) = nextflattenedAttribute(attributes: &list, from: index) - - if let newIndex { - state = .flattening(list, newIndex) - } else { - state = .empty - } - - return attribute - case ._temporaryNothing: - fatalError("unexpected _temporaryNothing state") } + + return attribute + case ._temporaryNothing: + fatalError("unexpected _temporaryNothing state") } } } } -private let blankedOut = StoredAttribute(name: "") -private func nextflattenedAttribute(attributes: inout [StoredAttribute], from index: Int) -> (StoredAttribute, Int?) { - var attribute = attributes[index] - attributes[index] = blankedOut +private func nextflattenedAttribute(attributes: inout [_StoredAttribute], from index: Int) -> (_StoredAttribute, Int?) { + var attribute: _StoredAttribute = .blankedOut + swap(&attribute, &attributes[index]) var nextIndex: Int? @@ -136,21 +152,28 @@ private func nextflattenedAttribute(attributes: inout [StoredAttribute], from in // fast-skip blanked out attributes guard !attributes[j].name.isEmpty else { continue } - guard attributes[j].name == attribute.name else { + guard attributes[j].name.utf8Equals(attribute.name) else { if nextIndex == nil { nextIndex = j } continue } - switch attributes[j].mergeMode { - case let .appendValue(separator): - attribute.appending(value: attributes[j].value, separatedBy: separator) - case .replaceValue: - attribute.value = attributes[j].value - case .ignoreIfSet: - break - } - attributes[j] = blankedOut + var mergedAttribute: _StoredAttribute = .blankedOut + swap(&mergedAttribute, &attributes[j]) + + attribute.mergeWith(mergedAttribute) } return (attribute, nextIndex) } + +private extension _StoredAttribute { + static let blankedOut = _StoredAttribute(name: "") +} + +private extension String { + @inline(__always) + func utf8Equals(_ other: borrowing String) -> Bool { + // for embedded support + utf8.elementsEqual(other.utf8) + } +} diff --git a/Sources/Elementary/Core/CoreModel.swift b/Sources/Elementary/Core/CoreModel.swift index 73f9718..70ca68d 100644 --- a/Sources/Elementary/Core/CoreModel.swift +++ b/Sources/Elementary/Core/CoreModel.swift @@ -48,7 +48,7 @@ extension Never: HTMLTagDefinition { } public struct _RenderingContext { - var attributes: AttributeStorage + var attributes: _AttributeStorage public static var emptyContext: Self { Self(attributes: .none) } } @@ -60,9 +60,7 @@ public enum _HTMLRenderToken { case inline } - case startTagOpen(String, type: RenderingType) - case attribute(name: String, value: String?) - case startTagClose(isUnpaired: Bool = false) + case startTag(String, attributes: _MergedAttributes, isUnpaired: Bool, type: RenderingType) case endTag(String, type: RenderingType) case text(String) case raw(String) diff --git a/Sources/Elementary/Core/Html+Attributes.swift b/Sources/Elementary/Core/Html+Attributes.swift index 068ed5c..6b34e86 100644 --- a/Sources/Elementary/Core/Html+Attributes.swift +++ b/Sources/Elementary/Core/Html+Attributes.swift @@ -1,6 +1,7 @@ /// An HTML attribute that can be applied to an HTML element of the associated tag. public struct HTMLAttribute: Sendable { - var htmlAttribute: StoredAttribute + @usableFromInline + var htmlAttribute: _StoredAttribute /// The name of the attribute. public var name: String { htmlAttribute.name } @@ -11,7 +12,12 @@ public struct HTMLAttribute: Sendable { /// The action to take when merging an attribute with the same name. public struct HTMLAttributeMergeAction: Sendable { - var mergeMode: StoredAttribute.MergeMode + @usableFromInline + var mergeMode: _StoredAttribute.MergeMode + + init(mergeMode: _StoredAttribute.MergeMode) { + self.mergeMode = mergeMode + } /// Replaces the value of the existing attribute with the new value. public static var replacing: Self { .init(mergeMode: .replaceValue) } @@ -29,6 +35,7 @@ public extension HTMLAttribute { /// - name: The name of the attribute. /// - value: The value of the attribute. /// - action: The merge action to use with a previously attached attribute with the same name. + @inlinable init(name: String, value: String?, mergedBy action: HTMLAttributeMergeAction = .replacing) { htmlAttribute = .init(name: name, value: value, mergeMode: action.mergeMode) } @@ -36,6 +43,7 @@ public extension HTMLAttribute { /// Changes the default merge action of this attribute. /// - Parameter action: The new merge action to use. /// - Returns: A modified attribute with the specified merge action. + @inlinable consuming func mergedBy(_ action: HTMLAttributeMergeAction) -> HTMLAttribute { .init(name: name, value: value, mergedBy: action) } @@ -44,7 +52,14 @@ public extension HTMLAttribute { public struct _AttributedElement: HTML { public var content: Content - var attributes: AttributeStorage + @usableFromInline + var attributes: _AttributeStorage + + @usableFromInline + init(content: Content, attributes: _AttributeStorage) { + self.content = content + self.attributes = attributes + } @_spi(Rendering) public static func _render(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) { @@ -67,6 +82,7 @@ public extension HTML where Tag: HTMLTrait.Attributes.Global { /// - attribute: The attribute to add to the element. /// - condition: If set to false, the attribute will not be added. /// - Returns: A new element with the specified attribute added. + @inlinable func attributes(_ attribute: HTMLAttribute, when condition: Bool = true) -> _AttributedElement { if condition { return _AttributedElement(content: self, attributes: .init(attribute)) @@ -80,6 +96,7 @@ public extension HTML where Tag: HTMLTrait.Attributes.Global { /// - attributes: The attributes to add to the element. /// - condition: If set to false, the attributes will not be added. /// - Returns: A new element with the specified attributes added. + @inlinable func attributes(_ attributes: HTMLAttribute..., when condition: Bool = true) -> _AttributedElement { _AttributedElement(content: self, attributes: .init(condition ? attributes : [])) } @@ -89,13 +106,14 @@ public extension HTML where Tag: HTMLTrait.Attributes.Global { /// - attributes: The attributes to add to the element as an array. /// - condition: If set to false, the attributes will not be added. /// - Returns: A new element with the specified attributes added. + @inlinable func attributes(contentsOf attributes: [HTMLAttribute], when condition: Bool = true) -> _AttributedElement { _AttributedElement(content: self, attributes: .init(condition ? attributes : [])) } } private extension _RenderingContext { - mutating func prependAttributes(_ attributes: consuming AttributeStorage) { + mutating func prependAttributes(_ attributes: consuming _AttributeStorage) { attributes.append(self.attributes) self.attributes = attributes } diff --git a/Sources/Elementary/Core/Html+Elements.swift b/Sources/Elementary/Core/Html+Elements.swift index 4262519..ce630d1 100644 --- a/Sources/Elementary/Core/Html+Elements.swift +++ b/Sources/Elementary/Core/Html+Elements.swift @@ -2,7 +2,8 @@ public struct HTMLElement: HTML where Tag: HTMLTrait.Paired { /// The type of the HTML tag this element represents. public typealias Tag = Tag - var attributes: AttributeStorage + @usableFromInline + var attributes: _AttributeStorage // The content of the element. public var content: Content @@ -18,6 +19,7 @@ public struct HTMLElement: HTML where Tag /// - Parameters: /// - attribute: The attribute to apply to the element. /// - content: The content of the element. + @inlinable public init(_ attribute: HTMLAttribute, @HTMLBuilder content: () -> Content) { attributes = .init(attribute) self.content = content() @@ -27,6 +29,7 @@ public struct HTMLElement: HTML where Tag /// - Parameters: /// - attributes: The attributes to apply to the element. /// - content: The content of the element. + @inlinable public init(_ attributes: HTMLAttribute..., @HTMLBuilder content: () -> Content) { self.attributes = .init(attributes) self.content = content() @@ -36,6 +39,7 @@ public struct HTMLElement: HTML where Tag /// - Parameters: /// - attributes: The attributes to apply to the element as an array. /// - content: The content of the element. + @inlinable public init(attributes: [HTMLAttribute], @HTMLBuilder content: () -> Content) { self.attributes = .init(attributes) self.content = content() @@ -44,7 +48,8 @@ public struct HTMLElement: HTML where Tag @_spi(Rendering) public static func _render(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) { html.attributes.append(context.attributes) - renderer.appendStartTag(Tag.name, attributes: html.attributes, isUnpaired: false, renderType: Tag.renderingType) + + renderer.appendToken(.startTag(Tag.name, attributes: html.attributes.flattened(), isUnpaired: false, type: Tag.renderingType)) Content._render(html.content, into: &renderer, with: .emptyContext) renderer.appendToken(.endTag(Tag.name, type: Tag.renderingType)) } @@ -52,7 +57,8 @@ public struct HTMLElement: HTML where Tag @_spi(Rendering) public static func _render(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) async throws { html.attributes.append(context.attributes) - try await renderer.appendStartTag(Tag.name, attributes: html.attributes, isUnpaired: false, renderType: Tag.renderingType) + + try await renderer.appendToken(.startTag(Tag.name, attributes: html.attributes.flattened(), isUnpaired: false, type: Tag.renderingType)) try await Content._render(html.content, into: &renderer, with: .emptyContext) try await renderer.appendToken(.endTag(Tag.name, type: Tag.renderingType)) } @@ -62,27 +68,32 @@ public struct HTMLElement: HTML where Tag public struct HTMLVoidElement: HTML where Tag: HTMLTrait.Unpaired { /// The type of the HTML tag this element represents. public typealias Tag = Tag - var attributes: AttributeStorage + @usableFromInline + var attributes: _AttributeStorage /// Creates a new HTML void element. + @inlinable public init() { attributes = .init() } /// Creates a new HTML void element with the specified attribute. /// - Parameter attribute: The attribute to apply to the element. + @inlinable public init(_ attribute: HTMLAttribute) { attributes = .init(attribute) } /// Creates a new HTML void element with the specified attributes. /// - Parameter attributes: The attributes to apply to the element. + @inlinable public init(_ attributes: HTMLAttribute...) { self.attributes = .init(attributes) } /// Creates a new HTML void element with the specified attributes. /// - Parameter attributes: The attributes to apply to the element as an array. + @inlinable public init(attributes: [HTMLAttribute]) { self.attributes = .init(attributes) } @@ -90,13 +101,13 @@ public struct HTMLVoidElement: HTML where Tag: HTMLTrait @_spi(Rendering) public static func _render(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) { html.attributes.append(context.attributes) - renderer.appendStartTag(Tag.name, attributes: html.attributes, isUnpaired: true, renderType: Tag.renderingType) + renderer.appendToken(.startTag(Tag.name, attributes: html.attributes.flattened(), isUnpaired: true, type: Tag.renderingType)) } @_spi(Rendering) public static func _render(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) async throws { html.attributes.append(context.attributes) - try await renderer.appendStartTag(Tag.name, attributes: html.attributes, isUnpaired: true, renderType: Tag.renderingType) + try await renderer.appendToken(.startTag(Tag.name, attributes: html.attributes.flattened(), isUnpaired: true, type: Tag.renderingType)) } } @@ -155,26 +166,6 @@ extension HTMLVoidElement: Sendable {} extension HTMLComment: Sendable {} extension HTMLRaw: Sendable {} -private extension _HTMLRendering { - mutating func appendStartTag(_ tagName: String, attributes: consuming AttributeStorage, isUnpaired: Bool, renderType: _HTMLRenderToken.RenderingType) { - appendToken(.startTagOpen(tagName, type: renderType)) - for attribute in attributes.flattened() { - appendToken(.attribute(name: attribute.name, value: attribute.value)) - } - appendToken(.startTagClose(isUnpaired: isUnpaired)) - } -} - -private extension _AsyncHTMLRendering { - mutating func appendStartTag(_ tagName: String, attributes: consuming AttributeStorage, isUnpaired: Bool, renderType: _HTMLRenderToken.RenderingType) async throws { - try await appendToken(.startTagOpen(tagName, type: renderType)) - for attribute in attributes.flattened() { - try await appendToken(.attribute(name: attribute.name, value: attribute.value)) - } - try await appendToken(.startTagClose(isUnpaired: isUnpaired)) - } -} - private extension HTMLTagDefinition { static var renderingType: _HTMLRenderToken.RenderingType { _rendersInline ? .inline : .block diff --git a/Sources/Elementary/Core/HtmlElement+Async.swift b/Sources/Elementary/Core/HtmlElement+Async.swift index 95a40f4..135a35a 100644 --- a/Sources/Elementary/Core/HtmlElement+Async.swift +++ b/Sources/Elementary/Core/HtmlElement+Async.swift @@ -6,6 +6,7 @@ public extension HTMLElement { /// - Parameters: /// - attributes: The attributes to apply to the element. /// - content: The future content of the element. + @inlinable init(_ attributes: HTMLAttribute..., @HTMLBuilder content: @escaping @Sendable () async throws -> AwaitedContent) where Self.Content == AsyncContent { @@ -20,6 +21,7 @@ public extension HTMLElement { /// - Parameters: /// - attributes: The attributes to apply to the element. /// - content: The future content of the element. + @inlinable init(attributes: [HTMLAttribute], @HTMLBuilder content: @escaping @Sendable () async throws -> AwaitedContent) where Self.Content == AsyncContent { diff --git a/Sources/Elementary/Rendering/HtmlTextRenderer.swift b/Sources/Elementary/Rendering/HtmlTextRenderer.swift index ed16343..7c99e84 100644 --- a/Sources/Elementary/Rendering/HtmlTextRenderer.swift +++ b/Sources/Elementary/Rendering/HtmlTextRenderer.swift @@ -31,10 +31,10 @@ struct PrettyHTMLTextRenderer { indentation = String(repeating: " ", count: spaces) } - private var result: String = "" - private var currentIndentation: String = "" - private var currentTokenContext = _HTMLRenderToken.RenderingType.block + private var result = "" + private var currentIndentation = "" private var currentInlineText = "" + private var currentTokenContext = _HTMLRenderToken.RenderingType.block private var isInLineAfterBlockTagOpen = false consuming func collect() -> String { @@ -86,31 +86,28 @@ extension PrettyHTMLTextRenderer: _HTMLRendering { mutating func appendToken(_ token: consuming _HTMLRenderToken) { let renderedToken = token.renderedValue() - if token.shouldInline(currentlyInlined: isInLineAfterBlockTagOpen || !currentInlineText.isEmpty) { - if !isInLineAfterBlockTagOpen { - addLineBreak() - } - - currentInlineText += renderedToken - } else { - switch token { - case .startTagOpen: + switch token { + case let .startTag(_, attributes: _, isUnpaired: isUnpaired, type: type): + switch type { + case .inline: + currentInlineText += renderedToken + case .block: flushInlineText(forceLineBreak: isInLineAfterBlockTagOpen) addLineBreak() - result += renderedToken - increaseIndentation() - case let .startTagClose(isUnpaired): - assert(currentInlineText.isEmpty, "unexpected inline text \(currentInlineText)") - result += renderedToken if isUnpaired { - decreaseIndentation() isInLineAfterBlockTagOpen = false } else { + increaseIndentation() isInLineAfterBlockTagOpen = true } - case .endTag: + } + case let .endTag(_, type): + switch type { + case .inline: + currentInlineText += renderedToken + case .block: var shouldLineBreak = false if isInLineAfterBlockTagOpen { @@ -128,27 +125,14 @@ extension PrettyHTMLTextRenderer: _HTMLRendering { } result += renderedToken - case .attribute: - assert(currentInlineText.isEmpty, "unexpected inline text \(currentInlineText)") - result += renderedToken - default: - assertionFailure("unexpected rendering case for \(renderedToken)") - flushInlineText() + } + case .text, .raw, .comment: + if isInLineAfterBlockTagOpen { + currentInlineText += renderedToken + } else { + addLineBreak() result += renderedToken } } } } - -extension _HTMLRenderToken { - func shouldInline(currentlyInlined: Bool) -> Bool { - switch self { - case .startTagOpen(_, .inline), .endTag(_, .inline), .text, .raw, .comment: - return true - case .attribute, .startTagClose: - return currentlyInlined - default: - return false - } - } -} diff --git a/Sources/Elementary/Rendering/RenderingUtils.swift b/Sources/Elementary/Rendering/RenderingUtils.swift index fe7b300..084b3b8 100644 --- a/Sources/Elementary/Rendering/RenderingUtils.swift +++ b/Sources/Elementary/Rendering/RenderingUtils.swift @@ -22,11 +22,42 @@ extension _RenderingContext { } extension [UInt8] { - mutating func writeEscapedAttribute(_ value: consuming String) { + mutating func appendToken(_ token: consuming _HTMLRenderToken) { + // avoid strings and append each component directly + switch token { + case let .startTag(tagName, attributes: attributes, isUnpaired: _, type: _): + append(60) // < + append(contentsOf: tagName.utf8) + for attribute in attributes { + append(32) // space + append(contentsOf: attribute.name.utf8) + if let value = attribute.value { + append(contentsOf: [61, 34]) // =" + appendEscapedAttributeValue(value) + append(34) // " + } + } + append(62) // > + case let .endTag(tagName, _): + append(contentsOf: [60, 47]) // + case let .text(text): + appendEscapedText(text) + case let .raw(raw): + append(contentsOf: raw.utf8) + case let .comment(comment): + append(contentsOf: "".utf8) + } + } + + mutating func appendEscapedAttributeValue(_ value: consuming String) { for byte in value.utf8 { switch byte { case 38: // & - self.append(contentsOf: "&".utf8) + append(contentsOf: "&".utf8) case 34: // " append(contentsOf: """.utf8) default: @@ -35,7 +66,7 @@ extension [UInt8] { } } - mutating func writeEscapedContent(_ value: consuming String) { + mutating func appendEscapedText(_ value: consuming String) { for byte in value.utf8 { switch byte { case 38: // & @@ -49,35 +80,4 @@ extension [UInt8] { } } } - - mutating func appendToken(_ token: consuming _HTMLRenderToken) { - // avoid strings and append each component directly - switch token { - case let .startTagOpen(tagName, _): - append(60) // < - append(contentsOf: tagName.utf8) - case let .attribute(name, value): - append(32) // space - append(contentsOf: name.utf8) - if let value = value { - append(contentsOf: [61, 34]) // =" - writeEscapedAttribute(value) - append(34) // " - } - case .startTagClose: - append(62) // > - case let .endTag(tagName, _): - append(contentsOf: [60, 47]) // - case let .text(text): - writeEscapedContent(text) - case let .raw(raw): - append(contentsOf: raw.utf8) - case let .comment(comment): - append(contentsOf: "".utf8) - } - } } diff --git a/Tests/ElementaryTests/FormattedRenderingTest.swift b/Tests/ElementaryTests/FormattedRenderingTest.swift index cf58677..11f981d 100644 --- a/Tests/ElementaryTests/FormattedRenderingTest.swift +++ b/Tests/ElementaryTests/FormattedRenderingTest.swift @@ -103,6 +103,31 @@ final class FormatedRenderingTests: XCTestCase { ) } + func testManyUnpairedTags() { + HTMLFormattedAssertEqual( + div { + br() + img() + img() + p { + svg {} + img() + } + }, + """ +
+
+ + +

+ + +

+
+ """ + ) + } + func testFormatsMixed() { HTMLFormattedAssertEqual( div {