diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..b2b6541 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: RandomHashTags +thanks_dev: d/gh/randomhashtags +buy_me_a_coffee: randomhashtags \ No newline at end of file diff --git a/Benchmarks/Benchmarks/Benchmarks/Benchmarks.swift b/Benchmarks/Benchmarks/Benchmarks/Benchmarks.swift index 5b0914d..0819e44 100644 --- a/Benchmarks/Benchmarks/Benchmarks/Benchmarks.swift +++ b/Benchmarks/Benchmarks/Benchmarks/Benchmarks.swift @@ -10,6 +10,7 @@ import Utilities import TestElementary import TestPlot +import TestSwiftDOM import TestSwiftHTMLBB import TestSwiftHTMLKit import TestSwiftHTMLPF @@ -27,7 +28,8 @@ let benchmarks = { "Plot" : PlotTests(), "Pointfreeco" : SwiftHTMLPFTests(), "SwiftHTMLKit" : SwiftHTMLKitTests(), - "Swim (custom renderer)" : SwimTests(), + "SwiftDOM" : SwiftDOMTests(), + "Swim" : SwimTests(), "VaporHTMLKit" : VaporHTMLKitTests(), "Vaux (custom renderer)" : VauxTests() ] @@ -48,4 +50,26 @@ let benchmarks = { } } } + + /*let test:SwiftHTMLKitTests = SwiftHTMLKitTests() + Benchmark("SwiftHTMLKit static (StaticString)") { + for _ in $0.scaledIterations { + blackHole(test.staticHTML()) + } + } + Benchmark("SwiftHTMLKit static ([UInt8])") { + for _ in $0.scaledIterations { + blackHole(test.staticHTMLUTF8Bytes()) + } + } + Benchmark("SwiftHTMLKit static ([UInt16])") { + for _ in $0.scaledIterations { + blackHole(test.staticHTMLUTF16Bytes()) + } + } + Benchmark("SwiftHTMLKit static (ByteBuffer)") { + for _ in $0.scaledIterations { + blackHole(test.staticHTMLByteBuffer()) + } + }*/ } \ No newline at end of file diff --git a/Benchmarks/Benchmarks/Elementary/Elementary.swift b/Benchmarks/Benchmarks/Elementary/Elementary.swift index 0a0e080..cdbba2d 100644 --- a/Benchmarks/Benchmarks/Elementary/Elementary.swift +++ b/Benchmarks/Benchmarks/Elementary/Elementary.swift @@ -14,27 +14,30 @@ package struct ElementaryTests : HTMLGenerator { package func staticHTML() -> String { StaticView().render() } + package func dynamicHTML(_ context: HTMLContext) -> String { - DynamicView(context: context).rawHTML.render() + DynamicView(context: context).render() } } -struct StaticView : HTMLDocument { - var title:String = "StaticView" - - var head : some HTML { - "" - } - var body : some HTML { - h1 { "Swift HTML Benchmarks" } +struct StaticView : HTML { + var content : some HTML { + HTMLRaw("") + html { + head { + title { "StaticView" } + } + body { + h1 { "Swift HTML Benchmarks" } + } + } } } -struct DynamicView { +struct DynamicView : HTML { let context:HTMLContext - - // Elementary puts the title element first in the head, which is wrong; it needs to be charset | this is a workaround - @HTMLBuilder var rawHTML : some HTML { + + var content : some HTML { HTMLRaw("") html { head { @@ -45,12 +48,12 @@ struct DynamicView { } body { h1 { context.heading } - div(attributes: [.id(context.desc_id)]) { + div(.id(context.desc_id)) { p { context.string } } h2 { context.user.details_heading } h3 { context.user.qualities_heading } - ul(attributes: [.id(context.user.qualities_id)]) { + ul(.id(context.user.qualities_id)) { for quality in context.user.qualities { li { quality } } diff --git a/Benchmarks/Benchmarks/Networking/main.swift b/Benchmarks/Benchmarks/Networking/main.swift index 063a6d1..165719e 100644 --- a/Benchmarks/Benchmarks/Networking/main.swift +++ b/Benchmarks/Benchmarks/Networking/main.swift @@ -6,6 +6,7 @@ // import HTTPTypes +import ServiceLifecycle import Utilities @@ -37,7 +38,7 @@ import Hummingbird struct HeaderMiddleware : RouterMiddleware { func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { var response = try await next(request, context) - response.headers[HTTPField.Name("Content-Type")!] = "text/html" + response.headers[HTTPField.Name.contentType] = "text/html" return response } } diff --git a/Benchmarks/Benchmarks/SwiftDOM/SwiftDOM.swift b/Benchmarks/Benchmarks/SwiftDOM/SwiftDOM.swift new file mode 100644 index 0000000..7c15115 --- /dev/null +++ b/Benchmarks/Benchmarks/SwiftDOM/SwiftDOM.swift @@ -0,0 +1,71 @@ +// +// SwiftDOM.swift +// +// +// Created by Evan Anderson on 10/13/24. +// + + +import Utilities +import DOM + +package struct SwiftDOMTests : HTMLGenerator { + package init() {} + + package func staticHTML() -> String { + let document:HTML = .document { + $0[.html] { + $0[.head] { $0[.title] = "StaticView" } + $0[.body] { $0[.h1] = "Swift HTML Benchmarks" } + } + } + return "\(document)" + } + package func dynamicHTML(_ context: HTMLContext) -> String { + let qualities:(inout HTML.ContentEncoder) throws -> () = { + for quality in context.user.qualities { + $0[.li] = quality + } + } + let document:HTML = .document { + $0[.html] { + $0[.head] { + $0[.meta] { $0[name: .charset] = context.charset } + $0[.title] = context.title + $0[.meta] { + $0[name: .content] = context.meta_description + $0[name: .name] = "description" + } + $0[.meta] { + $0[name: .content] = context.keywords_string + $0[name: .name] = "keywords" + } + } + $0[.body] { + $0[.h1] = context.heading + $0[.div] { + $0.id = context.desc_id + } content: { + $0[.p] = context.string + } + $0[.h2] = context.user.details_heading + $0[.h3] = context.user.qualities_heading + $0[.ul] { + $0.id = context.user.qualities_id + } content: { + try! qualities(&$0) + } + } + } + } + return "\(document)" + } +} + +// required to compile +extension String:HTML.OutputStreamable +{ +} +extension String:SVG.OutputStreamable +{ +} \ No newline at end of file diff --git a/Benchmarks/Benchmarks/SwiftHTMLKit/SwiftHTMLKit.swift b/Benchmarks/Benchmarks/SwiftHTMLKit/SwiftHTMLKit.swift index dd5c259..9fdfa4b 100644 --- a/Benchmarks/Benchmarks/SwiftHTMLKit/SwiftHTMLKit.swift +++ b/Benchmarks/Benchmarks/SwiftHTMLKit/SwiftHTMLKit.swift @@ -7,6 +7,7 @@ import Utilities import SwiftHTMLKit +import NIOCore package struct SwiftHTMLKitTests : HTMLGenerator { package init() {} @@ -21,8 +22,42 @@ package struct SwiftHTMLKitTests : HTMLGenerator { ) ) } + + package func staticHTMLUTF8Bytes() -> [UInt8] { + #htmlUTF8Bytes( + #head( + #title("StaticView") + ), + #body( + #h1("Swift HTML Benchmarks") + ) + ) + } + + package func staticHTMLUTF16Bytes() -> [UInt16] { + #htmlUTF16Bytes( + #head( + #title("StaticView") + ), + #body( + #h1("Swift HTML Benchmarks") + ) + ) + } + + package func staticHTMLByteBuffer() -> ByteBuffer { + #htmlByteBuffer( + #head( + #title("StaticView") + ), + #body( + #h1("Swift HTML Benchmarks") + ) + ) + } + // performance notes - // - maping makes unneccessary copies and hurts throughput + // - maping makes unnecessary copies and hurts throughput // - interpolation hurts performance, a lot less than maping but still noticeable // - adding strings (concatenation) is faster than interpolation // - calculating the size of the result than assigning the contents in a String is significantly worse than interpolation and concatenation diff --git a/Benchmarks/Benchmarks/Swim/Swim.swift b/Benchmarks/Benchmarks/Swim/Swim.swift index 85de749..370bb7d 100644 --- a/Benchmarks/Benchmarks/Swim/Swim.swift +++ b/Benchmarks/Benchmarks/Swim/Swim.swift @@ -7,41 +7,13 @@ import Utilities import Swim - -extension Node { - var rendered : String { - switch self { - case .element(let name, let attributes, let child): - var attributes_string:String = "" - for (key, value) in attributes { - attributes_string += " " + key + "=\"" + value + "\"" - } - return (name == "html" ? "" : "") + "<" + name + attributes_string + ">" + (child?.rendered ?? "") + (isVoid(name) ? "" : "") - case .text(let string): return string - case .raw(let string): return string - case .comment(_): return "" - case .documentType(let string): return string - case .fragment(let children): - var string:String = "" - for child in children { - string += child.rendered - } - return string - case .trim: return "" - } - } - func isVoid(_ tag: String) -> Bool { - switch tag { - case "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr": return true - default: return false - } - } -} +import HTML package struct SwimTests : HTMLGenerator { package init() {} package func staticHTML() -> String { + var string:String = "" html { head { title { "StaticView" } @@ -51,14 +23,17 @@ package struct SwimTests : HTMLGenerator { "Swift HTML Benchmarks" } } - }.rendered + }.write(to: &string) + return string } + package func dynamicHTML(_ context: HTMLContext) -> String { + var string:String = "" var test:[Node] = [] for quality in context.user.qualities { test.append(li { quality } ) } - return html { + html { head { meta(charset: context.charset) title { context.title } @@ -74,6 +49,7 @@ package struct SwimTests : HTMLGenerator { h3 { context.user.qualities_heading } ul(id: context.user.qualities_id) { test } } - }.rendered + }.write(to: &string) + return string } } \ No newline at end of file diff --git a/Benchmarks/Benchmarks/Tokamak/Tokamak.swift b/Benchmarks/Benchmarks/Tokamak/Tokamak.swift new file mode 100644 index 0000000..8724fd0 --- /dev/null +++ b/Benchmarks/Benchmarks/Tokamak/Tokamak.swift @@ -0,0 +1,21 @@ +// +// Tokamak.swift +// +// +// Created by Evan Anderson on 10/13/24. +// + +import Utilities +//import TokamakDOM +//import TokamakStaticHTML + +package struct TokamakTests : HTMLGenerator { + package init() {} + + package func staticHTML() -> String { + ""//HTML() + } + package func dynamicHTML(_ context: HTMLContext) -> String { + "" + } +} \ No newline at end of file diff --git a/Benchmarks/Benchmarks/UnitTests/UnitTests.swift b/Benchmarks/Benchmarks/UnitTests/UnitTests.swift index 1963ccd..ad26004 100644 --- a/Benchmarks/Benchmarks/UnitTests/UnitTests.swift +++ b/Benchmarks/Benchmarks/UnitTests/UnitTests.swift @@ -11,6 +11,7 @@ import SwiftHTMLKit import TestElementary import TestPlot +import TestSwiftDOM import TestSwiftHTMLBB import TestSwiftHTMLKit import TestSwiftHTMLPF @@ -25,24 +26,25 @@ struct UnitTests { "Elementary" : ElementaryTests(), "Plot" : PlotTests(), "Pointfreeco" : SwiftHTMLPFTests(), + "SwiftDOM" : SwiftDOMTests(), "SwiftHTMLKit" : SwiftHTMLKitTests(), "Swim" : SwimTests(), "VaporHTMLKit" : VaporHTMLKitTests(), - "Vaux" : VauxTests() + "Vaux (custom renderer)" : VauxTests() ] + // Some tests fail due to: + // - Plot closes void tags + // - Swim doesn't minify (keeps new line characters and some whitespace); uses a dictionary for meta values; closes void tags + // - Vaux is doodoo + @Test func staticHTML() { - let expected_value:String = #html( - #head( - #title("StaticView") - ), - #body( - #h1("Swift HTML Benchmarks") - ) - ) + let expected_value:String = libraries["SwiftHTMLKit"]!.staticHTML() for (key, value) in libraries { var string:String = value.staticHTML() if key == "Swim" { - string = string.replacingOccurrences(of: "\n", with: "") + string.replace("\n", with: "") + } else if key == "SwiftDOM" { + string.replace("'", with: "\"") } #expect(string == expected_value, Comment(rawValue: key)) } @@ -50,9 +52,14 @@ struct UnitTests { @Test func dynamicHTML() { let context:HTMLContext = HTMLContext() let expected_value:String = libraries["SwiftHTMLKit"]!.dynamicHTML(context) - // Plot closes void tags | Swim uses a dictionary for meta values | Vaux is doodoo for (key, value) in libraries { - #expect(value.dynamicHTML(context) == expected_value, Comment(rawValue: key)) + var string:String = value.dynamicHTML(context) + if key == "Swim" { + string.replace("\n", with: "") + } else if key == "SwiftDOM" { + string.replace("'", with: "\"") + } + #expect(string == expected_value, Comment(rawValue: key)) } } } \ No newline at end of file diff --git a/Benchmarks/Benchmarks/Utilities/Utilities.swift b/Benchmarks/Benchmarks/Utilities/Utilities.swift index c89e03c..5689ba0 100644 --- a/Benchmarks/Benchmarks/Utilities/Utilities.swift +++ b/Benchmarks/Benchmarks/Utilities/Utilities.swift @@ -71,11 +71,11 @@ package struct HTMLContext { Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec sed diam eget nibh semper varius. Phasellus sed feugiat turpis, sit amet pharetra erat. Integer eleifend tortor ut mauris lobortis consequat. Aliquam fermentum mollis fringilla. Morbi et enim in ligula luctus facilisis quis sed leo. Nullam ut suscipit arcu, eu hendrerit eros. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla maximus tempus dui. In aliquam neque ut urna euismod, vitae ullamcorper nisl fermentum. Integer ac ultricies erat, id volutpat leo. Morbi faucibus tortor at lectus feugiat, quis ultricies lectus dictum. Pellentesque congue blandit ligula, nec convallis lectus volutpat congue. Nam lobortis sapien nec nulla accumsan, a pharetra quam convallis. Donec vulputate rutrum dolor ac cursus. Mauris condimentum convallis malesuada. Mauris eros quam, dictum id elementum et, pharetra in metus. Quisque fermentum congue risus, accumsan consectetur neque aliquam quis. Vestibulum ipsum massa, euismod faucibus est in, condimentum venenatis risus. Quisque congue vehicula tellus, et dignissim augue accumsan ac. Pellentesque tristique ornare ligula, vitae iaculis dui varius vel. Ut sed sem sed purus facilisis porta quis eu tortor. Donec in vehicula tortor. Sed eget aliquet enim. Mauris tincidunt placerat risus, ut gravida lacus vehicula eget. Curabitur ultrices sapien tortor, eu gravida velit efficitur sed. Suspendisse eu volutpat est, ut bibendum velit. Maecenas mollis sit amet sapien laoreet pulvinar. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Morbi lorem ante, volutpat et accumsan a, fermentum vel metus. - """ - var string:String = "" - for _ in 0..<10 { + """.replacingOccurrences(of: "\n", with: "") + var string:String = lorem_ipsum + /*for _ in 1..<10 { string += lorem_ipsum - } + }*/ self.string = string integer = 293785 diff --git a/Benchmarks/Package.swift b/Benchmarks/Package.swift index 0aebda0..d29b668 100644 --- a/Benchmarks/Package.swift +++ b/Benchmarks/Package.swift @@ -9,28 +9,34 @@ let package = Package( .macOS(.v14) ], dependencies: [ - // dsls .package(url: "https://github.com/ordo-one/package-benchmark", from: "1.27.0"), .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0"), + // dsls .package(name: "swift-htmlkit", path: "../"), - //.package(url: "https://github.com/RandomHashTags/swift-htmlkit", from: "0.5.0"), - .package(url: "https://github.com/sliemeobn/elementary", from: "0.3.4"), - .package(url: "https://github.com/vapor-community/HTMLKit", from: "2.8.1"), - .package(url: "https://github.com/pointfreeco/swift-html", from: "0.4.1"), + .package(url: "https://github.com/sliemeobn/elementary", exact: "0.4.1"), + .package(url: "https://github.com/vapor-community/HTMLKit", exact: "2.8.1"), + .package(url: "https://github.com/pointfreeco/swift-html", exact: "0.4.1"), .package(url: "https://github.com/RandomHashTags/fork-bb-swift-html", branch: "main"), - .package(url: "https://github.com/JohnSundell/Plot", from: "0.14.0"), + .package(url: "https://github.com/JohnSundell/Plot", exact: "0.14.0"), //.package(url: "https://github.com/toucansites/toucan", from: "1.0.0-alpha.1"), // unstable - .package(url: "https://github.com/RandomHashTags/fork-Swim", branch: "main"), + .package(url: "https://github.com/robb/Swim", exact: "0.4.0"), .package(url: "https://github.com/RandomHashTags/fork-Vaux", branch: "master"), - .package(url: "https://github.com/vapor/leaf", from: "4.4.0"), + .package(url: "https://github.com/RandomHashTags/fork-swift-dom", branch: "master"), + //.package(url: "https://github.com/TokamakUI/Tokamak", from: "0.11.1"), // swift-benchmark problem + + //.package(url: "https://github.com/vapor/leaf", exact: "4.4.0"), // tight integration with Vapor // networking + .package(url: "https://github.com/apple/swift-nio", from: "2.75.0"), .package(url: "https://github.com/vapor/vapor", from: "4.106.0"), .package(url: "https://github.com/hummingbird-project/hummingbird", from: "2.1.0") ], targets: [ .target( name: "Utilities", + dependencies: [ + .product(name: "NIOCore", package: "swift-nio") + ], path: "Benchmarks/Utilities" ), .target( @@ -45,7 +51,7 @@ let package = Package( name: "TestLeaf", dependencies: [ "Utilities", - .product(name: "Leaf", package: "Leaf") + //.product(name: "Leaf", package: "Leaf") ], path: "Benchmarks/Leaf" ), @@ -57,11 +63,19 @@ let package = Package( ], path: "Benchmarks/Plot" ), + .target( + name: "TestSwiftDOM", + dependencies: [ + "Utilities", + .product(name: "DOM", package: "fork-swift-dom", moduleAliases: ["DOM":"SwiftDOM"]) + ], + path: "Benchmarks/SwiftDOM" + ), .target( name: "TestSwiftHTMLBB", dependencies: [ "Utilities", - .product(name: "SwiftHtml", package: "fork-bb-swift-html") + .product(name: "SwiftHtml", package: "fork-bb-swift-html", moduleAliases: ["SwiftHtml":"SwiftHTMLBB"]) ], path: "Benchmarks/SwiftHTMLBB" ), @@ -69,8 +83,8 @@ let package = Package( name: "TestSwiftHTMLKit", dependencies: [ "Utilities", - .product(name: "HTMLKit", package: "swift-htmlkit", moduleAliases: ["HTMLKit" : "SwiftHTMLKit"]), - .product(name: "HTMLKit", package: "HTMLKit", moduleAliases: ["HTMLKit" : "VaporHTMLKit"]) + .product(name: "HTMLKit", package: "swift-htmlkit", moduleAliases: ["HTMLKit":"SwiftHTMLKit"]), + .product(name: "HTMLKit", package: "HTMLKit", moduleAliases: ["HTMLKit":"VaporHTMLKit"]) ], path: "Benchmarks/SwiftHTMLKit" ), @@ -78,7 +92,7 @@ let package = Package( name: "TestSwiftHTMLPF", dependencies: [ "Utilities", - .product(name: "Html", package: "swift-html") + .product(name: "Html", package: "swift-html", moduleAliases: ["Html":"SwiftHTMLPF"]) ], path: "Benchmarks/SwiftHTMLPF" ), @@ -86,10 +100,20 @@ let package = Package( name: "TestSwim", dependencies: [ "Utilities", - .product(name: "Swim", package: "fork-Swim") + .product(name: "Swim", package: "Swim"), + .product(name: "HTML", package: "Swim", moduleAliases: ["HTML":"SwimHTML"]) ], path: "Benchmarks/Swim" ), + .target( + name: "TestTokamak", + dependencies: [ + "Utilities", + //.product(name: "TokamakDOM", package: "Tokamak"), + //.product(name: "TokamakStaticHTML", package: "Tokamak") + ], + path: "Benchmarks/Tokamak" + ), .target( name: "TestToucan", dependencies: [ @@ -101,8 +125,8 @@ let package = Package( name: "TestVaporHTMLKit", dependencies: [ "Utilities", - .product(name: "HTMLKit", package: "swift-htmlkit", moduleAliases: ["HTMLKit" : "SwiftHTMLKit"]), - .product(name: "HTMLKit", package: "HTMLKit", moduleAliases: ["HTMLKit" : "VaporHTMLKit"]) + .product(name: "HTMLKit", package: "HTMLKit", moduleAliases: ["HTMLKit":"VaporHTMLKit"]), + .product(name: "HTMLKit", package: "swift-htmlkit", moduleAliases: ["HTMLKit":"SwiftHTMLKit"]) ], path: "Benchmarks/VaporHTMLKit" ), @@ -122,6 +146,7 @@ let package = Package( "TestElementary", "TestLeaf", "TestPlot", + "TestSwiftDOM", "TestSwiftHTMLBB", "TestSwiftHTMLKit", "TestSwiftHTMLPF", @@ -145,6 +170,7 @@ let package = Package( "TestElementary", "TestLeaf", "TestPlot", + "TestSwiftDOM", "TestSwiftHTMLBB", "TestSwiftHTMLKit", "TestSwiftHTMLPF", @@ -164,6 +190,7 @@ let package = Package( "TestElementary", "TestLeaf", "TestPlot", + "TestSwiftDOM", "TestSwiftHTMLBB", "TestSwiftHTMLKit", "TestSwiftHTMLPF", diff --git a/Benchmarks/img/throughput_dynamic.png b/Benchmarks/img/throughput_dynamic.png index 3c2f4fb..5419801 100644 Binary files a/Benchmarks/img/throughput_dynamic.png and b/Benchmarks/img/throughput_dynamic.png differ diff --git a/Benchmarks/img/throughput_static.png b/Benchmarks/img/throughput_static.png index 6b27886..56a7dfd 100644 Binary files a/Benchmarks/img/throughput_static.png and b/Benchmarks/img/throughput_static.png differ diff --git a/Package.swift b/Package.swift index b1fa6d5..9010d10 100644 --- a/Package.swift +++ b/Package.swift @@ -13,15 +13,18 @@ let package = Package( .library( name: "HTMLKit", targets: ["HTMLKit"] - ), + ) ], dependencies: [ - .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0"), + .package(url: "https://github.com/swiftlang/swift-syntax", from: "600.0.0"), + .package(url: "https://github.com/apple/swift-nio", from: "2.75.0") ], targets: [ .target( name: "HTMLKitUtilities", - dependencies: [] + dependencies: [ + .product(name: "NIOCore", package: "swift-nio") + ] ), .macro( name: "HTMLKitMacros", diff --git a/README.md b/README.md index 3a0d6cc..1df5b5c 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ Write HTML using Swift Macros. -Requires at least Swift 5.9 Apache 2.0 License +Requires at least Swift 5.9 Apache 2.0 License - [Why](#why) -- [Examples](#examples) +- [Usage](#usage) - [Basic](#basic) - [Advanced](#advanced) - [Benchmarks](#benchmarks) @@ -11,14 +11,13 @@ Write HTML using Swift Macros. - [Dynamic](#dynamic) - [Conclusion](#conclusion) - [Contributing](#contributing) -- [Funding](#funding) ## Why - Swift Macros are powerful, efficient and essentially removes any runtime overhead - Alternative libraries may not fit all situations and may restrict how the html is generated, manipulated, prone to human error, or cost a constant performance overhead (middleware, rendering, result builders, etc) - HTML macros enforce safety, can be used anywhere, and compile directly to strings - The output is minified at no performance cost -## Examples +## Usage ### Basic
How do I use this library? @@ -84,14 +83,17 @@ If you know the data **at compile time** (and not working with HTML macros): - `#escapeHTML()` macro If you're working with **runtime** data: + +> \>\>\> !! You need to `import HTMLKitUtilities` to use these functions !! <<< + - `.escapeHTML(escapeAttributes:)` - mutates `self` escaping HTML and, optionally, attribute characters - `.escapeHTMLAttributes()` - mutates `self` escaping only attribute characters - `.escapingHTML(escapeAttributes:)` - - creates a copy of `self` escaping HTML and, optionally, attribute characters + - returns a copy of `self` escaping HTML and, optionally, attribute characters - `.escapingHTMLAttributes()` - - creates a copy of `self` escaping only attribute characters + - returns a copy of `self` escaping only attribute characters
@@ -173,14 +175,28 @@ Use the `HTMLElementAttribute.event(, "")`. ``` +
+I need the output as a different type! + +Use a different html macro. Currently supported types (more to come with new language features): +- `#html()` -> `String`/`StaticString` +- `#htmlUTF8Bytes()` -> `[UInt8]` +- `#htmlUTF16Bytes()` -> `[UInt16]` +- `#htmlUTF8CString()` -> `ContiguousArray` +- `#htmlData()` -> `Foundation.Data` +- `#htmlByteBuffer()` -> `NIOCore.ByteBuffer` + +
+ ## Benchmarks - Libraries tested - [BinaryBuilds/swift-html](https://github.com/BinaryBirds/swift-html) v1.7.0 (patched version [here](https://github.com/RandomHashTags/fork-bb-swift-html)) - - [sliemeobn/elementary](https://github.com/sliemeobn/elementary) v0.3.4 + - [sliemeobn/elementary](https://github.com/sliemeobn/elementary) v0.4.1 - [JohnSundell/Plot](https://github.com/JohnSundell/Plot) v0.14.0 + - [tayloraswift/swift-dom](https://github.com/tayloraswift/swift-dom) v1.1.0 (patched version [here](https://github.com/RandomHashTags/fork-swift-dom)) - [RandomHashTags/swift-htmlkit](https://github.com/RandomHashTags/swift-htmlkit) v0.6.0 (this library) - [pointfreeco/swift-html](https://github.com/pointfreeco/swift-html) v0.4.1 - - [robb/Swim](https://github.com/robb/Swim) v0.4.0 (patched version [here](https://github.com/RandomHashTags/fork-Swim); custom renderer [here](https://github.com/RandomHashTags/swift-htmlkit/blob/main/Benchmarks/Benchmarks/Swim/Swim.swift)) + - [robb/Swim](https://github.com/robb/Swim) v0.4.0 - [vapor-community/HTMLKit](https://github.com/vapor-community/HTMLKit) v2.8.1 - [dokun1/Vaux](https://github.com/dokun1/Vaux) v0.2.0 (patched version [here](https://github.com/RandomHashTags/fork-Vaux); custom renderer [here](https://github.com/RandomHashTags/swift-htmlkit/blob/main/Benchmarks/Benchmarks/Vaux/Vaux.swift)) @@ -198,8 +214,4 @@ Executed command: `swift package -c release --allow-writing-to-package-directory This library is the clear leader in performance & efficiency. Static webpages offer the best performance, while dynamic pages still tops the charts (I am actively researching and testing improvements for dynamic pages). ## Contributing -Create a PR. - -## Funding -Love this library? Consider supporting this project by sponsoring the developers. -- [RandomHashTags](https://github.com/sponsors/RandomHashTags) \ No newline at end of file +Create a PR. \ No newline at end of file diff --git a/Sources/HTMLKit/HTMLKit.swift b/Sources/HTMLKit/HTMLKit.swift index 0008797..b927bb0 100644 --- a/Sources/HTMLKit/HTMLKit.swift +++ b/Sources/HTMLKit/HTMLKit.swift @@ -7,104 +7,49 @@ import HTMLKitUtilities +#if canImport(Foundation) +import struct Foundation.Data +#endif + +import struct NIOCore.ByteBuffer + // MARK: StaticString equality public extension StaticString { - static func == (left: Self, right: String) -> Bool { left.string == right } - - static func == (left: Self, right: Self) -> Bool { left.string == right.string } - static func != (left: Self, right: Self) -> Bool { left.string != right.string } - var string : String { - withUTF8Buffer { - String(decoding: $0, as: UTF8.self) - } - } - /*func string(with replacements: [String]) -> String { - return withUTF8Buffer { p in - let values = p.split(separator: 96) - let last:Int = values.count-1 - var amount:Int = p.count - last - for i in 0.. = .allocate(capacity: amount) - var new_index:Int = 0 - for index in 0...SubSequence = values[index] - for i in 0.. Bool { left.description == right.description } + static func != (left: Self, right: Self) -> Bool { left.description != right.description } } -public extension String { - static func == (left: Self, right: StaticString) -> Bool { left == right.string } +// MARK: StaticString and StringProtocol equality +public extension StringProtocol { + static func == (left: Self, right: StaticString) -> Bool { left == right.description } + static func == (left: StaticString, right: Self) -> Bool { left.description == right } } -/* -// MARK: DynamicString -public struct DynamicString { - public let string:StaticString - public let values:[String] - - public init(string: StaticString, values: [String]) { - self.string = string - self.values = values - } - - public var test : String { - return string.string - } -}*/ - -/* -package struct NonCopyableString : ~Copyable { - private let storage:UnsafeMutableBufferPointer - - package init(capacity: Int) { - storage = .allocate(capacity: capacity) - } - package init(_ string: String) { - storage = .allocate(capacity: string.count) - for i in 0.. UInt8 { - get { - storage[index] - } - set { - storage[index] = newValue - } - } - - package var count : Int { storage.count } - package var isEmpty : Bool { storage.isEmpty } - package var string : String { String(decoding: storage, as: UTF8.self) } - - deinit { - storage.deinitialize() - storage.deallocate() - } -}*/ - @freestanding(expression) public macro escapeHTML(_ innerHTML: T...) -> T = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") -// MARK: Elements +// MARK: HTML Representation @freestanding(expression) public macro html(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> T = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") +@freestanding(expression) +public macro htmlUTF8Bytes(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> [UInt8] = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") + +@freestanding(expression) +public macro htmlUTF16Bytes(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> [UInt16] = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") + +@freestanding(expression) +public macro htmlUTF8CString(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> ContiguousArray = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") + +#if canImport(Foundation) +@freestanding(expression) +public macro htmlData(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> Data = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") +#endif + +@freestanding(expression) +public macro htmlByteBuffer(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> ByteBuffer = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") + +// MARK: Elements + @freestanding(expression) public macro custom(tag: String, isVoid: Bool, attributes: [HTMLElementAttribute] = [], _ innerHTML: T...) -> T = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement") diff --git a/Sources/HTMLKitMacros/HTMLElement.swift b/Sources/HTMLKitMacros/HTMLElement.swift index 96124a1..d317dd1 100644 --- a/Sources/HTMLKitMacros/HTMLElement.swift +++ b/Sources/HTMLKitMacros/HTMLElement.swift @@ -10,23 +10,65 @@ import SwiftSyntaxMacros import SwiftDiagnostics import HTMLKitUtilities -struct HTMLElement : ExpressionMacro { +#if canImport(Foundation) +import struct Foundation.Data +#endif + +import struct NIOCore.ByteBuffer + +enum HTMLElement : ExpressionMacro { static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> ExprSyntax { - var dynamicVariables:[String] = [] - let (string, isDynamic):(String, Bool) = parse_macro(context: context, expression: node.as(MacroExpansionExprSyntax.self)!, isRoot: true, dynamicVariables: &dynamicVariables) - return isDynamic ? "\(raw: string)" : "\"\(raw: string)\"" + let string:String = expand_macro(context: context, macro: node.macroExpansion!) + var set:Set = [.htmlUTF8Bytes, .htmlUTF16Bytes, .htmlUTF8CString, .htmlByteBuffer] + + #if canImport(Foundation) + set.insert(.htmlData) + #endif + + if set.contains(HTMLElementType(rawValue: node.macroName.text)) { + let has_interpolation:Bool = !string.ranges(of: try! Regex("\\((.*)\\)")).isEmpty + guard !has_interpolation else { + context.diagnose(Diagnostic(node: node, message: DiagnosticMsg(id: "interpolationNotAllowedForDataType", message: "String Interpolation is not allowed for this data type. Runtime values get converted to raw text, which is not the expected result."))) + return "" + } + func bytes(_ bytes: [T]) -> String { + return "[" + bytes.map({ "\($0)" }).joined(separator: ",") + "]" + } + switch HTMLElementType(rawValue: node.macroName.text) { + case .htmlUTF8Bytes: + return "\(raw: bytes([UInt8](string.utf8)))" + case .htmlUTF16Bytes: + return "\(raw: bytes([UInt16](string.utf16)))" + case .htmlUTF8CString: + return "\(raw: string.utf8CString)" + + #if canImport(Foundation) + case .htmlData: + return "Data(\(raw: bytes([UInt8](string.utf8))))" + #endif + + case .htmlByteBuffer: + return "ByteBuffer(bytes: \(raw: bytes([UInt8](string.utf8))))" + + default: break + } + } + return "\"\(raw: string)\"" } } private extension HTMLElement { - static func parse_macro(context: some MacroExpansionContext, expression: MacroExpansionExprSyntax, isRoot: Bool, dynamicVariables: inout [String]) -> (String, Bool) { - guard let elementType:HTMLElementType = HTMLElementType(rawValue: expression.macroName.text) else { return ("\(expression)", true) } - let childs:SyntaxChildren = expression.arguments.children(viewMode: .all) + // MARK: Expand Macro + static func expand_macro(context: some MacroExpansionContext, macro: MacroExpansionExprSyntax) -> String { + guard let elementType:HTMLElementType = HTMLElementType(rawValue: macro.macroName.text) else { + return "\(macro)" + } + let childs:SyntaxChildren = macro.arguments.children(viewMode: .all) if elementType == .escapeHTML { - return (childs.compactMap({ + return childs.compactMap({ guard let child:LabeledExprSyntax = $0.labeled else { return nil } - return parse_inner_html(context: context, elementType: elementType, child: child, dynamicVariables: &dynamicVariables) - }).joined(), false) + return parse_inner_html(context: context, elementType: elementType, child: child) + }).joined() } let tag:String, isVoid:Bool var children:Slice @@ -36,88 +78,86 @@ private extension HTMLElement { children = childs.dropFirst() // tag children.removeFirst() // isVoid } else { - tag = elementType.rawValue + tag = elementType.rawValue.starts(with: "html") ? "html" : elementType.rawValue isVoid = elementType.isVoid children = childs.prefix(childs.count) } - let data:ElementData = parse_arguments(context: context, elementType: elementType, children: children, dynamicVariables: &dynamicVariables) - var string:String = (elementType == .html ? "" : "") + "<" + tag + data.attributes + ">" + data.innerHTML + let (attributes, innerHTML):(String, String) = parse_arguments(context: context, elementType: elementType, children: children) + var string:String = (tag == "html" ? "" : "") + "<" + tag + attributes + ">" + innerHTML if !isVoid { string += "" } - return (string, false) - - /*if isRoot { - return dynamicVariables.isEmpty ? (string, false) : ("DynamicString(string: \"" + string + "\").test", true) - //return dynamicVariables.isEmpty ? (string, false) : ("DynamicString(string: \"" + string + "\", values: [" + dynamicVariables.map({ $0.contains("\\(") ? "\"\($0)\"" : $0 }).joined(separator: ",") + "]).test", true) - } else { - return (string, false) - }*/ + return string } - static func parse_arguments(context: some MacroExpansionContext, elementType: HTMLElementType, children: Slice, dynamicVariables: inout [String]) -> ElementData { - var attributes:[String] = [], innerHTML:[String] = [] + // MARK: Parse Arguments + static func parse_arguments(context: some MacroExpansionContext, elementType: HTMLElementType, children: Slice) -> (attributes: String, innerHTML: String) { + var attributes:String = " ", innerHTML:String = "" for element in children { if let child:LabeledExprSyntax = element.labeled { if var key:String = child.label?.text { if key == "attributes" { - attributes.append(contentsOf: parse_global_attributes(context: context, elementType: elementType, array: child.expression.array!, dynamicVariables: &dynamicVariables)) + for attribute in parse_global_attributes(context: context, elementType: elementType, array: child.expression.array!.elements) { + attributes += attribute + " " + } } else { if key == "acceptCharset" { key = "accept-charset" } - if let string:String = parse_attribute(context: context, elementType: elementType, key: key, argument: child, dynamicVariables: &dynamicVariables) { - attributes.append(key + (string.isEmpty ? "" : "=\\\"" + string + "\\\"")) + if let string:String = parse_attribute(context: context, elementType: elementType, key: key, expression: child.expression) { + attributes += key + (string.isEmpty ? "" : "=\\\"" + string + "\\\"") + " " } } // inner html - } else if let inner_html:String = parse_inner_html(context: context, elementType: elementType, child: child, dynamicVariables: &dynamicVariables) { - innerHTML.append(inner_html) + } else if let inner_html:String = parse_inner_html(context: context, elementType: elementType, child: child) { + innerHTML += inner_html } } } - return ElementData(attributes: attributes, innerHTML: innerHTML) + attributes.removeLast() + return (attributes, innerHTML) } - static func parse_global_attributes(context: some MacroExpansionContext, elementType: HTMLElementType, array: ArrayExprSyntax, dynamicVariables: inout [String]) -> [String] { + // MARK: Parse Global Attributes + static func parse_global_attributes(context: some MacroExpansionContext, elementType: HTMLElementType, array: ArrayElementListSyntax) -> [String] { var keys:Set = [], attributes:[String] = [] - for element in array.elements { - let function:FunctionCallExprSyntax = element.expression.as(FunctionCallExprSyntax.self)!, key_argument:LabeledExprSyntax = function.arguments.first!, key_element:ExprSyntax = key_argument.expression - var key:String = function.calledExpression.memberAccess!.declName.baseName.text, value:String? = nil + for element in array { + let function:FunctionCallExprSyntax = element.expression.functionCall!, first_expression:ExprSyntax = function.arguments.first!.expression + var key:String = function.calledExpression.memberAccess!.declName.baseName.text, value:String! = nil switch key { case "custom", "data": - var (literalValue, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, argument: function.arguments.last!, dynamicVariables: &dynamicVariables)! + var returnType:LiteralReturnType = .string + (value, returnType) = parse_literal_value(context: context, elementType: elementType, key: key, expression: function.arguments.last!.expression)! if returnType == .string { - literalValue.escapeHTML(escapeAttributes: true) + value.escapeHTML(escapeAttributes: true) } - value = literalValue if key == "custom" { - key = key_element.stringLiteral!.string + key = first_expression.stringLiteral!.string } else { - key += "-\(key_element.stringLiteral!.string)" + key += "-\(first_expression.stringLiteral!.string)" } break case "event": - key = "on" + key_element.memberAccess!.declName.baseName.text - if var (literalValue, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, argument: function.arguments.last!, dynamicVariables: &dynamicVariables) { + key = "on" + first_expression.memberAccess!.declName.baseName.text + if var (string, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, expression: function.arguments.last!.expression) { if returnType == .string { - literalValue.escapeHTML(escapeAttributes: true) + string.escapeHTML(escapeAttributes: true) } - value = literalValue + value = string } else { unallowed_expression(context: context, node: function.arguments.last!) return [] } break default: - if let string:String = parse_attribute(context: context, elementType: elementType, key: key, argument: key_argument, dynamicVariables: &dynamicVariables) { + if let string:String = parse_attribute(context: context, elementType: elementType, key: key, expression: first_expression) { value = string } break } if key.contains(" ") { - context.diagnose(Diagnostic(node: key_element, message: DiagnosticMsg(id: "spacesNotAllowedInAttributeDeclaration", message: "Spaces are not allowed in attribute declaration."))) + context.diagnose(Diagnostic(node: first_expression, message: DiagnosticMsg(id: "spacesNotAllowedInAttributeDeclaration", message: "Spaces are not allowed in attribute declaration."))) } else if let value:String = value { if keys.contains(key) { - context.diagnose(Diagnostic(node: key_element, message: DiagnosticMsg(id: "globalAttributeAlreadyDefined", message: "Global attribute is already defined."))) + context.diagnose(Diagnostic(node: first_expression, message: DiagnosticMsg(id: "globalAttributeAlreadyDefined", message: "Global attribute \"" + key + "\" is already defined."))) } else { attributes.append(key + (value.isEmpty ? "" : "=\\\"" + value + "\\\"")) keys.insert(key) @@ -126,14 +166,15 @@ private extension HTMLElement { } return attributes } - static func parse_inner_html(context: some MacroExpansionContext, elementType: HTMLElementType, child: LabeledExprSyntax, dynamicVariables: inout [String]) -> String? { + // MARK: Parse innerHTML + static func parse_inner_html(context: some MacroExpansionContext, elementType: HTMLElementType, child: LabeledExprSyntax) -> String? { if let macro:MacroExpansionExprSyntax = child.expression.macroExpansion { - var string:String = parse_macro(context: context, expression: macro, isRoot: false, dynamicVariables: &dynamicVariables).0 + var string:String = expand_macro(context: context, macro: macro) if elementType == .escapeHTML { string.escapeHTML(escapeAttributes: false) } return string - } else if var string:String = parse_literal_value(context: context, elementType: elementType, key: "", argument: child, dynamicVariables: &dynamicVariables)?.value { + } else if var string:String = parse_literal_value(context: context, elementType: elementType, key: "", expression: child.expression)?.value { string.escapeHTML(escapeAttributes: false) return string } else { @@ -143,7 +184,7 @@ private extension HTMLElement { } static func unallowed_expression(context: some MacroExpansionContext, node: LabeledExprSyntax) { context.diagnose(Diagnostic(node: node, message: DiagnosticMsg(id: "unallowedExpression", message: "String Interpolation is required when encoding runtime values."), fixIts: [ - FixIt(message: DiagnosticMsg(id: "useStringInterpolation", message: "Use String Interpolation.", severity: .error), changes: [ + FixIt(message: DiagnosticMsg(id: "useStringInterpolation", message: "Use String Interpolation."), changes: [ FixIt.Change.replace( oldNode: Syntax(node), newNode: Syntax(StringLiteralExprSyntax(content: "\\(\(node))")) @@ -151,15 +192,6 @@ private extension HTMLElement { ]) ])) } - - struct ElementData { - let attributes:String, innerHTML:String - - init(attributes: [String], innerHTML: [String]) { - self.attributes = attributes.isEmpty ? "" : " " + attributes.joined(separator: " ") - self.innerHTML = innerHTML.joined() - } - } static func enumName(elementType: HTMLElementType, key: String) -> String { switch elementType.rawValue + key { @@ -171,102 +203,89 @@ private extension HTMLElement { } } - static func parse_attribute(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, argument: LabeledExprSyntax, dynamicVariables: inout [String]) -> String? { - let expression:ExprSyntax = argument.expression - if var (string, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, argument: argument, dynamicVariables: &dynamicVariables) { + // MARK: Parse Attribute + static func parse_attribute(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, expression: ExprSyntax) -> String? { + if var (string, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, expression: expression) { switch returnType { case .boolean: return string.elementsEqual("true") ? "" : nil - case .string: + case .string, .enumCase: + if returnType == .string && string.isEmpty { + return nil + } string.escapeHTML(escapeAttributes: true) return string case .interpolation: return string } } - func member(_ value: String) -> String { - var string:String = String(value[value.index(after: value.startIndex)...]) - string = HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: string) - return string - } - if let function:FunctionCallExprSyntax = expression.as(FunctionCallExprSyntax.self) { - return member("\(function)") + if let function:FunctionCallExprSyntax = expression.functionCall { + let string:String = "\(function)" + return HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: String(string[string.index(after: string.startIndex)...])) } return nil } - static func get_separator(key: String) -> String { - switch key { - case "accept", "coords", "exportparts", "imagesizes", "imagesrcset", "sizes", "srcset": return "," - default: return " " - } - } - static func parse_literal_value(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, argument: LabeledExprSyntax, dynamicVariables: inout [String]) -> (value: String, returnType: LiteralReturnType)? { - let expression:ExprSyntax = argument.expression + // MARK: Parse Literal Value + static func parse_literal_value(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, expression: ExprSyntax) -> (value: String, returnType: LiteralReturnType)? { if let boolean:String = expression.booleanLiteral?.literal.text { return (boolean, .boolean) } func return_string_or_interpolation() -> (String, LiteralReturnType)? { - if let string:String = expression.stringLiteral?.string { + if let string:String = expression.stringLiteral?.string ?? expression.integerLiteral?.literal.text ?? expression.floatLiteral?.literal.text { return (string, .string) } - if let integer:String = expression.integerLiteral?.literal.text { - return (integer, .string) - } - if let float:String = expression.floatLiteral?.literal.text { - return (float, .string) - } - if let function:FunctionCallExprSyntax = expression.as(FunctionCallExprSyntax.self) { + if let function:FunctionCallExprSyntax = expression.functionCall { switch key { case "height", "width": var value:String = "\(function)" value = String(value[value.index(after: value.startIndex)...]) value = HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: value) - return (value, .string) + return (value, .enumCase) default: if function.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text == "StaticString" { return (function.arguments.first!.expression.stringLiteral!.string, .string) } - return ("\\(\(function))", .interpolation) + return ("\(function)", .interpolation) } } if let member:MemberAccessExprSyntax = expression.memberAccess { - let decl:String = member.declName.baseName.text if let _:ExprSyntax = member.base { - /*if let integer:String = base.integerLiteral?.literal.text { - switch decl { - case "description": - return (integer, .integer) - default: - return (integer, .interpolation) - } - } else {*/ - return ("\\(\(member))", .interpolation) - //} - } else { - return (HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: decl), .string) + return ("\(member)", .interpolation) } + return (HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: member.declName.baseName.text), .enumCase) } - let separator:String = get_separator(key: key) - let string_return_logic:(ExprSyntax, String) -> String = { - if $1.contains(separator) { - context.diagnose(Diagnostic(node: $0, message: DiagnosticMsg(id: "characterNotAllowedInDeclaration", message: "Character \"" + separator + "\" is not allowed when declaring values for \"" + key + "\"."))) - } - return $1 - } - if let value:String = expression.array?.elements.compactMap({ - if let string:String = $0.expression.stringLiteral?.string { - return string_return_logic($0.expression, string) - } - if let string:String = $0.expression.integerLiteral?.literal.text { - return string + if let array:ArrayExprSyntax = expression.array { + let separator:Character, separator_string:String + switch key { + case "accept", "coords", "exportparts", "imagesizes", "imagesrcset", "sizes", "srcset": + separator = "," + break + default: + separator = " " + break } - if let string:String = $0.expression.floatLiteral?.literal.text { - return string + separator_string = String(separator) + var result:String = "" + for element in array.elements { + if let string:String = element.expression.stringLiteral?.string { + if string.contains(separator) { + context.diagnose(Diagnostic(node: element.expression, message: DiagnosticMsg(id: "characterNotAllowedInDeclaration", message: "Character \"\(separator)\" is not allowed when declaring values for \"" + key + "\"."))) + return nil + } + result += string + separator_string + } + if let string:String = element.expression.integerLiteral?.literal.text ?? element.expression.floatLiteral?.literal.text { + result += string + separator_string + } + if let string:String = element.expression.memberAccess?.declName.baseName.text { + result += HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: string) + separator_string + } } - if let string:String = $0.expression.memberAccess?.declName.baseName.text { - return HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: string) + if !result.isEmpty { + result.removeLast() } - return nil - }).joined(separator: separator) { - return (value, .string) + return (result, .string) + } + if let _:DeclReferenceExprSyntax = expression.as(DeclReferenceExprSyntax.self) { + context.diagnose(Diagnostic(node: expression, message: DiagnosticMsg(id: "runtimeValueNotAllowed", message: "Runtime value not allowed here."))) } return nil } @@ -274,59 +293,81 @@ private extension HTMLElement { //context.diagnose(Diagnostic(node: expression, message: DiagnosticMsg(id: "somethingWentWrong", message: "Something went wrong. (" + expression.debugDescription + ")", severity: .warning))) return nil } - var remaining_interpolation:Int = 0 - if let list:StringLiteralSegmentListSyntax = expression.stringLiteral?.segments { - for segment in list { - if let expr:ExpressionSegmentSyntax = segment.as(ExpressionSegmentSyntax.self) { - remaining_interpolation += 1 - if flatten_interpolation(string: &string, remaining_interpolation: &remaining_interpolation, expr: expr) { - remaining_interpolation -= 1 - } - } - } + let interpolation:[ExpressionSegmentSyntax] = expression.stringLiteral?.segments.compactMap({ $0.as(ExpressionSegmentSyntax.self) }) ?? [] + var remaining_interpolation:Int = interpolation.count + for expr in interpolation { + string = flatten_interpolation(context: context, remaining_interpolation: &remaining_interpolation, expr: expr) } if returnType == .interpolation || remaining_interpolation > 0 { - //dynamicVariables.append(string) if !string.contains("\\(") { string = "\\(" + string + ")" + warn_interpolation(context: context, node: expression) } returnType = .interpolation - context.diagnose(Diagnostic(node: expression, message: DiagnosticMsg(id: "unsafeInterpolation", message: "Interpolation may introduce raw HTML.", severity: .warning))) } return (string, returnType) } - static func flatten_interpolation(string: inout String, remaining_interpolation: inout Int, expr: ExpressionSegmentSyntax) -> Bool { // TODO: can still be improved ("\(description \(title))" doesn't get flattened) + // MARK: Flatten Interpolation + static func flatten_interpolation(context: some MacroExpansionContext, remaining_interpolation: inout Int, expr: ExpressionSegmentSyntax) -> String { let expression:ExprSyntax = expr.expressions.first!.expression - if let list:StringLiteralSegmentListSyntax = expression.stringLiteral?.segments { - for segment in list { - if let expr:ExpressionSegmentSyntax = segment.as(ExpressionSegmentSyntax.self) { - remaining_interpolation += 1 - if flatten_interpolation(string: &string, remaining_interpolation: &remaining_interpolation, expr: expr) { - remaining_interpolation -= 1 + var string:String = "\(expr)" + if let stringLiteral:StringLiteralExprSyntax = expression.stringLiteral { + let segments:StringLiteralSegmentListSyntax = stringLiteral.segments + if segments.count(where: { $0.is(StringSegmentSyntax.self) }) == segments.count { + remaining_interpolation = 0 + string = segments.map({ $0.as(StringSegmentSyntax.self)!.content.text }).joined() + } else { + string = "" + for segment in segments { + if let literal:String = segment.as(StringSegmentSyntax.self)?.content.text { + string += literal + } else if let interpolation:ExpressionSegmentSyntax = segment.as(ExpressionSegmentSyntax.self) { + let flattened:String = flatten_interpolation(context: context, remaining_interpolation: &remaining_interpolation, expr: interpolation) + if "\(interpolation)" == flattened { + //string += "\\(\"\(flattened)\".escapingHTML(escapeAttributes: true))" + string += "\(flattened)" + warn_interpolation(context: context, node: interpolation) + } else { + string += flattened + } + } else { + //string += "\\(\"\(segment)\".escapingHTML(escapeAttributes: true))" + warn_interpolation(context: context, node: segment) + string += "\(segment)" } - } else if let fix:String = segment.as(StringSegmentSyntax.self)?.content.text { - string.replace("\(expr)", with: fix) - remaining_interpolation -= 1 } } + } else if let fix:String = expression.integerLiteral?.literal.text ?? expression.floatLiteral?.literal.text { + let target:String = "\(expr)" + remaining_interpolation -= string.ranges(of: target).count + string.replace(target, with: fix) + } else { + //string = "\\(\"\(string)\".escapingHTML(escapeAttributes: true))" + warn_interpolation(context: context, node: expr) } - if let fix:String = expression.integerLiteral?.literal.text ?? expression.floatLiteral?.literal.text { - string.replace("\(expr)", with: fix) - return true - } - return false + return string + } + static func warn_interpolation(context: some MacroExpansionContext, node: some SyntaxProtocol) { + context.diagnose(Diagnostic(node: node, message: DiagnosticMsg(id: "unsafeInterpolation", message: "Interpolation may introduce raw HTML.", severity: .warning))) } } enum LiteralReturnType { - case boolean, string, interpolation + case boolean, string, enumCase, interpolation } // MARK: HTMLElementType enum HTMLElementType : String, CaseIterable { case escapeHTML - case html case custom + + case html, htmlUTF8Bytes, htmlUTF16Bytes, htmlUTF8CString + + #if canImport(Foundation) + case htmlData + #endif + + case htmlByteBuffer case a case abbr @@ -470,7 +511,7 @@ enum HTMLElementType : String, CaseIterable { } // MARK: Misc -extension ExprSyntax { +extension SyntaxProtocol { var booleanLiteral : BooleanLiteralExprSyntax? { self.as(BooleanLiteralExprSyntax.self) } var stringLiteral : StringLiteralExprSyntax? { self.as(StringLiteralExprSyntax.self) } var integerLiteral : IntegerLiteralExprSyntax? { self.as(IntegerLiteralExprSyntax.self) } @@ -478,6 +519,7 @@ extension ExprSyntax { var array : ArrayExprSyntax? { self.as(ArrayExprSyntax.self) } var memberAccess : MemberAccessExprSyntax? { self.as(MemberAccessExprSyntax.self) } var macroExpansion : MacroExpansionExprSyntax? { self.as(MacroExpansionExprSyntax.self) } + var functionCall : FunctionCallExprSyntax? { self.as(FunctionCallExprSyntax.self) } } extension SyntaxChildren.Element { var labeled : LabeledExprSyntax? { self.as(LabeledExprSyntax.self) } diff --git a/Sources/HTMLKitMacros/HTMLKitMacros.swift b/Sources/HTMLKitMacros/HTMLKitMacros.swift index 9be34b1..d20b56a 100644 --- a/Sources/HTMLKitMacros/HTMLKitMacros.swift +++ b/Sources/HTMLKitMacros/HTMLKitMacros.swift @@ -9,7 +9,7 @@ import SwiftCompilerPlugin import SwiftSyntaxMacros import SwiftDiagnostics -// MARK: ErrorDiagnostic +// MARK: DiagnosticMsg struct DiagnosticMsg : DiagnosticMessage { let message:String let diagnosticID:MessageID diff --git a/Sources/HTMLKitUtilities/HTMLKitUtilities.swift b/Sources/HTMLKitUtilities/HTMLKitUtilities.swift index 8045502..4fc4e51 100644 --- a/Sources/HTMLKitUtilities/HTMLKitUtilities.swift +++ b/Sources/HTMLKitUtilities/HTMLKitUtilities.swift @@ -118,7 +118,7 @@ public enum HTMLElementAttribute { case custom(_ id: any ExpressibleByStringLiteral, _ value: (any ExpressibleByStringLiteral)?) - @available(*, deprecated, message: "Inline event handlers are an outdated way to handle events.\nGeneral consensus considers this \"bad practice\" and you shouldn't mix your HTML and JavaScript.\n\nThis will never be removed and remains deprecated to encourage use of other techniques.\n\nLearn more at https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#inline_event_handlers_—_dont_use_these.") + @available(*, deprecated, message: "General consensus considers this \"bad practice\" and you shouldn't mix your HTML and JavaScript. This will never be removed and remains deprecated to encourage use of other techniques. Learn more at https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#inline_event_handlers_—_dont_use_these.") case event(Extra.event, _ value: (any ExpressibleByStringLiteral)? = nil) } public extension HTMLElementAttribute { diff --git a/Tests/HTMLKitTests/HTMLKitTests.swift b/Tests/HTMLKitTests/HTMLKitTests.swift index e848b01..3aa7f00 100644 --- a/Tests/HTMLKitTests/HTMLKitTests.swift +++ b/Tests/HTMLKitTests/HTMLKitTests.swift @@ -8,96 +8,215 @@ import Testing import HTMLKit +#if canImport(Foundation) +import struct Foundation.Data +#endif + +import struct NIOCore.ByteBuffer + +// MARK: Escaping HTML struct HTMLKitTests { @Test func escape_html() { let unescaped:String = "Test" let escaped:String = "<!DOCTYPE html><html>Test</html>" - let expected_result:String = "

\(escaped)

" - #expect(#p("Test") == expected_result) - #expect(#escapeHTML("Test") == escaped) + var expected_result:String = "

\(escaped)

" + + var string:String = #p("Test") + #expect(string == expected_result) + + string = #escapeHTML("Test") + #expect(string == escaped) + + string = #p(#escapeHTML(#html("Test"))) + #expect(string == expected_result) + + string = #p("\(unescaped.escapingHTML(escapeAttributes: false))") + #expect(string == expected_result) - #expect(#p(#escapeHTML(#html("Test"))) == expected_result) + expected_result = "
<p></p>
" + string = #div(attributes: [.title(StaticString("

"))], StaticString("

")).description + #expect(string == expected_result) - let string:String = unescaped.escapingHTML(escapeAttributes: false) - #expect(#p("\(string)") == expected_result) + string = #div(attributes: [.title("

")], StaticString("

")).description + #expect(string == expected_result) + + string = #div(attributes: [.title("

")], "

") + #expect(string == expected_result) } } +// MARK: Representations +extension HTMLKitTests { + func representations() { + let _:StaticString = #html() + let _:String = #html() + let _:[UInt8] = #htmlUTF8Bytes("") + let _:[UInt16] = #htmlUTF16Bytes("") + let _:ContiguousArray = #htmlUTF8CString("") + #if canImport(Foundation) + let _:Data = #htmlData("") + #endif + let _:ByteBuffer = #htmlByteBuffer("") + + //let bro:String = "" + //let _:[UInt8] = #htmlUTF8Bytes("\(bro)") + } + func representation1() -> StaticString { + #html() + } + func representation2() -> String { + #html() + } + func representation3() -> [UInt8] { + #htmlUTF8Bytes("") + } + func representation4() -> [UInt16] { + #htmlUTF16Bytes("") + } + func representation5() -> ContiguousArray { + #htmlUTF8CString("") + } + #if canImport(Foundation) + func representation5() -> Data { + #htmlData("") + } + #endif + func representation6() -> ByteBuffer { + #htmlByteBuffer("") + } +} + +// MARK: Element tests extension HTMLKitTests { @Test func element_html() { - #expect(#html() == "") - #expect(#html(xmlns: "test") == "") + var string:StaticString = #html() + #expect(string == "") + + string = #html(xmlns: "test") + #expect(string == "") } @Test func element_area() { - #expect(#area(coords: [1, 2, 3]) == "") + var string:StaticString = #area(coords: [1, 2, 3]) + #expect(string == "") + + string = #area(coords: []) + #expect(string == "") } @Test func element_audio() { - #expect(#audio(controlslist: .nodownload) == "") + let string:StaticString = #audio(controlslist: .nodownload) + #expect(string == "") } @Test func element_button() { - #expect(#button(type: .submit) == "") - #expect(#button(formenctype: .applicationXWWWFormURLEncoded, formmethod: .get, formtarget: ._blank, popovertargetaction: .hide) == "") - #expect(#button(formenctype: .multipartFormData, formmethod: .post, popovertargetaction: .show) == "") - #expect(#button(formenctype: .textPlain, formmethod: .get, type: .reset) == "") + var string:StaticString = #button(type: .submit) + #expect(string == "") + + string = #button(formenctype: .applicationXWWWFormURLEncoded, formmethod: .get, formtarget: ._blank, popovertargetaction: .hide) + #expect(string == "") + + string = #button(formenctype: .multipartFormData, formmethod: .post, popovertargetaction: .show) + #expect(string == "") + + string = #button(formenctype: .textPlain, formmethod: .get, type: .reset) + #expect(string == "") } @Test func element_canvas() { - #expect(#canvas(height: .percent(4), width: .em(2.69)) == "") + let string:StaticString = #canvas(height: .percent(4), width: .em(2.69)) + #expect(string == "") } @Test func element_form() { - #expect(#form(acceptCharset: ["utf-8"], autocomplete: .on) == "
") + let string:StaticString = #form(acceptCharset: ["utf-8"], autocomplete: .on) + #expect(string == "
") } @Test func element_iframe() { - #expect(#iframe(sandbox: [.allowDownloads, .allowForms]) == "") + let string:StaticString = #iframe(sandbox: [.allowDownloads, .allowForms]) + #expect(string == "") } @Test func element_input() { - #expect(#input(autocomplete: ["email", "password"], type: .text) == "") - #expect(#input(type: .password) == "") - #expect(#input(type: .datetimeLocal) == "") + var string:StaticString = #input(autocomplete: ["email", "password"], type: .text) + #expect(string == "") + + string = #input(type: .password) + #expect(string == "") + + string = #input(type: .datetimeLocal) + #expect(string == "") } @Test func element_img() { - #expect(#img(sizes: ["(max-height: 500px) 1000px", "(min-height: 25rem)"], srcset: ["https://paradigm-app.com", "https://litleagues.com"]) == "") + let string:StaticString = #img(sizes: ["(max-height: 500px) 1000px", "(min-height: 25rem)"], srcset: ["https://paradigm-app.com", "https://litleagues.com"]) + #expect(string == "") } @Test func element_link() { - #expect(#link(as: .document, imagesizes: ["lmno", "p"]) == "") + let string:StaticString = #link(as: .document, imagesizes: ["lmno", "p"]) + #expect(string == "") } @Test func element_ol() { - #expect(#ol() == "
    ") - #expect(#ol(type: .a) == "
      ") - #expect(#ol(type: .A) == "
        ") - #expect(#ol(type: .i) == "
          ") - #expect(#ol(type: .I) == "
            ") - #expect(#ol(type: .one) == "
              ") + var string:StaticString = #ol() + #expect(string == "
                ") + + string = #ol(type: .a) + #expect(string == "
                  ") + + string = #ol(type: .A) + #expect(string == "
                    ") + + string = #ol(type: .i) + #expect(string == "
                      ") + + string = #ol(type: .I) + #expect(string == "
                        ") + + string = #ol(type: .one) + #expect(string == "
                          ") } @Test func element_script() { - #expect(#script() == "") - #expect(#script(type: .importmap) == "") - #expect(#script(type: .module) == "") - #expect(#script(type: .speculationrules) == "") + var string:StaticString = #script() + #expect(string == "") + + string = #script(type: .importmap) + #expect(string == "") + + string = #script(type: .module) + #expect(string == "") + + string = #script(type: .speculationrules) + #expect(string == "") } @Test func element_style() { - #expect(#style(blocking: .render) == "") + let string:StaticString = #style(blocking: .render) + #expect(string == "") } @Test func element_template() { - #expect(#template(shadowrootclonable: .false, shadowrootdelegatesfocus: false, shadowrootmode: .closed, shadowrootserializable: true) == "") + let string:StaticString = #template(shadowrootclonable: .false, shadowrootdelegatesfocus: false, shadowrootmode: .closed, shadowrootserializable: true) + #expect(string == "") } @Test func element_textarea() { - #expect(#textarea(autocomplete: ["email", "password"], dirname: .ltr, rows: 5, wrap: .soft) == "") + let string:StaticString = #textarea(autocomplete: ["email", "password"], dirname: .ltr, rows: 5, wrap: .soft) + #expect(string == "") } @Test func element_th() { - #expect(#th(rowspan: 2, scope: .colgroup) == "") + let string:StaticString = #th(rowspan: 2, scope: .colgroup) + #expect(string == "") } @Test func element_track() { - #expect(#track(default: true, kind: .captions, label: "tesT") == "") + let string:StaticString = #track(default: true, kind: .captions, label: "tesT") + #expect(string == "") } @Test func element_video() { - #expect(#video(controlslist: [.nodownload, .nofullscreen, .noremoteplayback]) == "") - #expect(#video(crossorigin: .anonymous) == "") - #expect(#video(crossorigin: .useCredentials) == "") - #expect(#video(preload: .metadata) == "") + var string:StaticString = #video(controlslist: [.nodownload, .nofullscreen, .noremoteplayback]) + #expect(string == "") + + string = #video(crossorigin: .anonymous) + #expect(string == "") + + string = #video(crossorigin: .useCredentials) + #expect(string == "") + + string = #video(preload: .metadata) + #expect(string == "") } @Test func element_custom() { - var bro:String = #custom(tag: "bro", isVoid: false) + var bro:StaticString = #custom(tag: "bro", isVoid: false) #expect(bro == "") bro = #custom(tag: "bro", isVoid: true) @@ -105,8 +224,9 @@ extension HTMLKitTests { } @Test func element_events() { - let third_thing:String = "doAThirdThing()" - #expect(#div(attributes: [.event(.click, "doThing()"), .event(.change, "doAnotherThing()"), .event(.focus, "\(third_thing)")]) == "
                          ") + let third_thing:StaticString = "doAThirdThing()" + let string:String = #div(attributes: [.event(.click, "doThing()"), .event(.change, "doAnotherThing()"), .event(.focus, "\(third_thing)")]) + #expect(string == "
                          ") } @Test func elements_void() { @@ -115,6 +235,7 @@ extension HTMLKitTests { } } +// MARK: Misc element tests extension HTMLKitTests { @Test func recursive_elements() { let string:StaticString = #div( @@ -136,20 +257,21 @@ extension HTMLKitTests { } /*@Test func nil_values() { - #expect(#a(["yippie", (true ? nil : "yiyo")]) == "yippie") // improper - #expect(#a(["yippie", (false ? nil : "yiyo")]) == "yippieyiyo") // improper - #expect(#a([nil, "sheesh", nil, nil, " capeesh"]) == "sheesh capeesh") + #expect(#a("yippie", (true ? nil : "yiyo")) == "yippie") // improper + #expect(#a("yippie", (false ? nil : "yiyo")) == "yippieyiyo") // improper + #expect(#a(nil, "sheesh", nil, nil, " capeesh") == "sheesh capeesh") - let ss:StaticString = #a([(true ? "Oh yeah" : nil)]) // improper + let ss:StaticString = #a((true ? "Oh yeah" : nil)) // improper #expect(ss == "Oh yeah") }*/ - @Test func multiline_value_type() { - /*#expect(#script([""" + /*@Test func multiline_value_type() { + let string:StaticString = #script(""" bro """ - ]) == "")*/ - } + ) + #expect(string == "") + }*/ /*@Test func not_allowed() { let _:StaticString = #div(attributes: [.id("1"), .id("2"), .id("3"), .id("4")]) @@ -170,36 +292,52 @@ extension HTMLKitTests { rel: ["lets go"], sizes: ["lets,go"] ) - let _:String = #a(1.description) - let bro:String = "yup" + var bro:String = "yup" let _:String = #a(bro) - //let _:String = #div(attributes: [.custom("potof gold1", "\(1)"), .custom("potof gold2", "2")]) + let _:String = #div(attributes: [.custom("potof gold1", "\(1)"), .custom("potof gold2", "2")]) + + let test:[Int] = [1] + bro = #area(coords: test) }*/ } +// MARK: Attribute tests extension HTMLKitTests { @Test func attribute_data() { - #expect(#div(attributes: [.data("id", "5")]) == "
                          ") + let string:StaticString = #div(attributes: [.data("id", "5")]) + #expect(string == "
                          ") } @Test func attribute_hidden() { - #expect(#div(attributes: [.hidden(.true)]) == "") - #expect(#div(attributes: [.hidden(.untilFound)]) == "") + var string:StaticString = #div(attributes: [.hidden(.true)]) + #expect(string == "") + + string = #div(attributes: [.hidden(.untilFound)]) + #expect(string == "") } @Test func attribute_custom() { - #expect(#div(attributes: [.custom("potofgold", "north")]) == "
                          ") - #expect(#div(attributes: [.custom("potofgold", "\(1)")]) == "
                          ") - #expect(#div(attributes: [.custom("potofgold1", "\(1)"), .custom("potofgold2", "2")]) == "
                          ") + var string:StaticString = #div(attributes: [.custom("potofgold", "north")]) + #expect(string == "
                          ") + + string = #div(attributes: [.custom("potofgold", "\(1)")]) + #expect(string == "
                          ") + + string = #div(attributes: [.custom("potofgold1", "\(1)"), .custom("potofgold2", "2")]) + #expect(string == "
                          ") } } +// MARK: 3rd party tests extension HTMLKitTests { enum Shrek : String { case isLove, isLife } @Test func third_party_enum() { - #expect(#a(attributes: [.title(Shrek.isLove.rawValue)]) == "") - #expect(#a(attributes: [.title("\(Shrek.isLife)")]) == "") + var string:String = #a(attributes: [.title(Shrek.isLove.rawValue)]) + #expect(string == "") + + string = #a(attributes: [.title("\(Shrek.isLife)")]) + #expect(string == "") } } @@ -228,10 +366,12 @@ extension HTMLKitTests { #expect(static_string == "
                          ") } @Test func third_party_func() { - #expect(#div(attributes: [.title(HTMLKitTests.spongebobCharacter("patrick"))]) == "
                          ") + let string:String = #div(attributes: [.title(HTMLKitTests.spongebobCharacter("patrick"))]) + #expect(string == "
                          ") } } +// MARK: StaticString Example extension HTMLKitTests { @Test func example_1() { let test:StaticString = #html( @@ -262,6 +402,7 @@ extension HTMLKitTests { } } +// MARK: Dynamic test extension HTMLKitTests { @Test func dynamic() { let charset:String = "utf-8", title:String = "Dynamic" @@ -273,7 +414,7 @@ extension HTMLKitTests { #head( #meta(charset: "\(charset)"), #title("\(title)"), - #meta(content: "description \(title)", name: "description"), + #meta(content: "\("description \(title)")", name: "description"), #meta(content: "\("keywords")", name: "keywords") ), #body( @@ -290,6 +431,7 @@ extension HTMLKitTests { } } +// MARK: Example2 extension HTMLKitTests { @Test func example2() { var test:TestStruct = TestStruct(name: "one", array: ["1", "2", "3"])