From 07eedadf2f89e0b67653681e959020dc4bb619f8 Mon Sep 17 00:00:00 2001 From: RandomHashTags Date: Fri, 29 Nov 2024 06:09:11 -0600 Subject: [PATCH] escapeHTML macro now accepts an `encoding` parameter to escape correctly - defaults to `string` and the parent encoding if contained in a #html macro --- .gitignore | 3 + Sources/HTMLKit/HTMLKit.swift | 5 +- Sources/HTMLKitUtilities/ParseData.swift | 60 +++++++----- Sources/HTMLKitUtilities/TranslateHTML.swift | 3 +- Tests/HTMLKitTests/AttributeTests.swift | 22 ++++- Tests/HTMLKitTests/ElementTests.swift | 98 +++++++------------- Tests/HTMLKitTests/EncodingTests.swift | 72 ++++++++------ Tests/HTMLKitTests/EscapeHTMLTests.swift | 98 ++++++++++++++++++++ 8 files changed, 239 insertions(+), 122 deletions(-) create mode 100644 Tests/HTMLKitTests/EscapeHTMLTests.swift diff --git a/.gitignore b/.gitignore index 1f232da..119ec0b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ Events.* EncodingTests.d EncodingTests.o EncodingTests.swiftdeps* +EscapeHTMLTests.d +EscapeHTMLTests.o +EscapeHTMLTests.swiftdeps* HTMX.d HTMX.o HTMX.swiftdeps* diff --git a/Sources/HTMLKit/HTMLKit.swift b/Sources/HTMLKit/HTMLKit.swift index 6c4252d..145ec5d 100644 --- a/Sources/HTMLKit/HTMLKit.swift +++ b/Sources/HTMLKit/HTMLKit.swift @@ -19,7 +19,10 @@ public extension StringProtocol { } @freestanding(expression) -public macro escapeHTML(_ innerHTML: CustomStringConvertible...) -> String = #externalMacro(module: "HTMLKitMacros", type: "EscapeHTML") +public macro escapeHTML( + encoding: HTMLEncoding = .string, + _ innerHTML: CustomStringConvertible... +) -> String = #externalMacro(module: "HTMLKitMacros", type: "EscapeHTML") // MARK: HTML Representation @freestanding(expression) diff --git a/Sources/HTMLKitUtilities/ParseData.swift b/Sources/HTMLKitUtilities/ParseData.swift index f24a70e..6ab97b8 100644 --- a/Sources/HTMLKitUtilities/ParseData.swift +++ b/Sources/HTMLKitUtilities/ParseData.swift @@ -11,19 +11,27 @@ import SwiftSyntaxMacros public extension HTMLKitUtilities { // MARK: Escape HTML - static func escapeHTML(expansion: MacroExpansionExprSyntax, context: some MacroExpansionContext) -> String { - return expansion.arguments.children(viewMode: .all).compactMap({ - guard let child:LabeledExprSyntax = $0.labeled, - // TODO: fix the below encoding? - var c:CustomStringConvertible = HTMLKitUtilities.parseInnerHTML(context: context, encoding: .string, child: child, lookupFiles: []) else { - return nil - } - if var element:HTMLElement = c as? HTMLElement { - element.escaped = true - c = element + static func escapeHTML(expansion: MacroExpansionExprSyntax, encoding: HTMLEncoding = .string, context: some MacroExpansionContext) -> String { + var encoding:HTMLEncoding = encoding + let children:SyntaxChildren = expansion.arguments.children(viewMode: .all) + var inner_html:String = "" + inner_html.reserveCapacity(children.count) + for e in children { + if let child:LabeledExprSyntax = e.labeled { + if let key:String = child.label?.text { + if key == "encoding" { + encoding = parseEncoding(expression: child.expression) ?? .string + } + } else if var c:CustomStringConvertible = HTMLKitUtilities.parseInnerHTML(context: context, encoding: encoding, child: child, lookupFiles: []) { + if var element:HTMLElement = c as? HTMLElement { + element.escaped = true + c = element + } + inner_html += String(describing: c) + } } - return String(describing: c) - }).joined() + } + return inner_html } // MARK: Expand #html @@ -82,16 +90,7 @@ public extension HTMLKitUtilities { if let child:LabeledExprSyntax = element.labeled { if let key:String = child.label?.text { if key == "encoding" { - if let key:String = child.expression.memberAccess?.declName.baseName.text { - encoding = HTMLEncoding(rawValue: key) ?? .string - } else if let custom:FunctionCallExprSyntax = child.expression.functionCall { - let logic:String = custom.arguments.first!.expression.stringLiteral!.string - if custom.arguments.count == 1 { - encoding = .custom(logic) - } else { - encoding = .custom(logic, stringDelimiter: custom.arguments.last!.expression.stringLiteral!.string) - } - } + encoding = parseEncoding(expression: child.expression) ?? .string } else if key == "lookupFiles" { lookupFiles = Set(child.expression.array!.elements.compactMap({ $0.expression.stringLiteral?.string })) } else if key == "attributes" { @@ -127,6 +126,21 @@ public extension HTMLKitUtilities { return ElementData(encoding, global_attributes, attributes, innerHTML, trailingSlash) } + // MARK: Parse Encoding + static func parseEncoding(expression: ExprSyntax) -> HTMLEncoding? { + if let key:String = expression.memberAccess?.declName.baseName.text { + return HTMLEncoding(rawValue: key) + } else if let custom:FunctionCallExprSyntax = expression.functionCall { + guard let logic:String = custom.arguments.first?.expression.stringLiteral?.string else { return nil } + if custom.arguments.count == 1 { + return .custom(logic) + } else { + return .custom(logic, stringDelimiter: custom.arguments.last!.expression.stringLiteral!.string) + } + } + return nil + } + // MARK: Parse Global Attributes static func parseGlobalAttributes( context: some MacroExpansionContext, @@ -170,7 +184,7 @@ public extension HTMLKitUtilities { ) -> CustomStringConvertible? { if let expansion:MacroExpansionExprSyntax = child.expression.macroExpansion { if expansion.macroName.text == "escapeHTML" { - return escapeHTML(expansion: expansion, context: context) + return escapeHTML(expansion: expansion, encoding: encoding, context: context) } return "" // TODO: fix? } else if let element:HTMLElement = parse_element(context: context, encoding: encoding, expr: child.expression) { diff --git a/Sources/HTMLKitUtilities/TranslateHTML.swift b/Sources/HTMLKitUtilities/TranslateHTML.swift index 4a4b6f6..1c2b91c 100644 --- a/Sources/HTMLKitUtilities/TranslateHTML.swift +++ b/Sources/HTMLKitUtilities/TranslateHTML.swift @@ -8,7 +8,7 @@ #if canImport(Foundation) import Foundation -public enum TranslateHTML { // TODO: finish +private enum TranslateHTML { // TODO: finish public static func translate(string: String) -> String { var result:String = "" result.reserveCapacity(string.count) @@ -59,4 +59,5 @@ extension TranslateHTML { } } } + #endif \ No newline at end of file diff --git a/Tests/HTMLKitTests/AttributeTests.swift b/Tests/HTMLKitTests/AttributeTests.swift index c57f224..b6dd3ac 100644 --- a/Tests/HTMLKitTests/AttributeTests.swift +++ b/Tests/HTMLKitTests/AttributeTests.swift @@ -9,12 +9,15 @@ import Testing import HTMLKit struct AttributeTests { + // MARK: ariarole @Test func ariarole() { //let array:String = HTMLElementType.allCases.map({ "case \"\($0)\": return \($0)(rawValue: rawValue)" }).joined(separator: "\n") //print(array) let string:StaticString = #html(div(attributes: [.role(.widget)])) #expect(string == "
") } + + // MARK: ariaattribute @Test func ariaattribute() { var string:StaticString = #html(div(attributes: [.ariaattribute(.atomic(true))])) #expect(string == "
") @@ -28,6 +31,8 @@ struct AttributeTests { string = #html(div(attributes: [.ariaattribute(.controls(["testing", "123", "yup"]))])) #expect(string == "
") } + + // MARK: attributionsrc @Test func attributionsrc() { var string:StaticString = #html(a(attributionsrc: [])) #expect(string == "") @@ -35,10 +40,20 @@ struct AttributeTests { string = #html(a(attributionsrc: ["https://github.com/RandomHashTags", "https://litleagues.com"])) #expect(string == "") } + + // MARK: class + @Test func class_attribute() { + let string:StaticString = #html(a(attributes: [.class(["womp", "donk", "g2-esports"])])) + #expect(string == "") + } + + // MARK: data @Test func data() { let string:StaticString = #html(div(attributes: [.data("id", "5")])) #expect(string == "
") } + + // MARK: hidden @Test func hidden() { var string:StaticString = #html(div(attributes: [.hidden(.true)])) #expect(string == "") @@ -47,7 +62,8 @@ struct AttributeTests { #expect(string == "") } - @Test func _custom() { + // MARK: custom + @Test func custom_attribute() { var string:StaticString = #html(div(attributes: [.custom("potofgold", "north")])) #expect(string == "
") @@ -58,11 +74,15 @@ struct AttributeTests { #expect(string == "
") } + // MARK: trailingSlash @Test func trailingSlash() { var string:StaticString = #html(meta(attributes: [.trailingSlash])) #expect(string == "") string = #html(custom(tag: "slash", isVoid: true, attributes: [.trailingSlash])) #expect(string == "") + + string = #html(custom(tag: "slash", isVoid: false, attributes: [.trailingSlash])) + #expect(string == "") } } \ No newline at end of file diff --git a/Tests/HTMLKitTests/ElementTests.swift b/Tests/HTMLKitTests/ElementTests.swift index c173fc7..36f6eca 100644 --- a/Tests/HTMLKitTests/ElementTests.swift +++ b/Tests/HTMLKitTests/ElementTests.swift @@ -9,51 +9,6 @@ import Testing import HTMLKit struct ElementTests { - // MARK: Escape - @Test func escape_html() { - let unescaped:String = "Test" - let escaped:String = "<!DOCTYPE html><html>Test</html>" - var expected_result:String = "

\(escaped)

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

")], StaticString("

"))) - #expect(string == expected_result) - - string = #html(div(attributes: [.title("

")], "

")) - #expect(string == expected_result) - - string = #html(p("What's 9 + 10? \"21\"!")) - #expect(string == "

What's 9 + 10? "21"!

") - - string = #html(option(value: "bad boy ")) - expected_result = "" - #expect(string == expected_result) - } -} - - - -// MARK: Elements - - - - -extension ElementTests { // MARK: html @Test func _html() { var string:StaticString = #html(html()) @@ -63,12 +18,21 @@ extension ElementTests { #expect(string == "") } - // MARK: HTMLKit.element + // MARK: HTMLKit. @Test func with_library_decl() { let string:StaticString = #html(html(HTMLKit.body())) #expect(string == "") } +} + + + +// MARK: Elements + + + +extension ElementTests { // MARK: a // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a @Test func _a() { @@ -412,7 +376,7 @@ extension ElementTests { } // MARK: custom - @Test func _custom() { + @Test func custom_element() { var bro:StaticString = #html(custom(tag: "bro", isVoid: false)) #expect(bro == "") @@ -475,27 +439,29 @@ extension ElementTests { }*/ /*@Test func not_allowed() { - let _:StaticString = #div(attributes: [.id("1"), .id("2"), .id("3"), .id("4")]) - let _:StaticString = #a( - attributes: [ - .class(["lets go"]) - ], - attributionSrc: ["lets go"], - ping: ["lets go"] + let _:StaticString = #html(div(attributes: [.id("1"), .id("2"), .id("3"), .id("4")])) + let _:StaticString = #html( + a( + attributes: [ + .class(["lets go"]) + ], + attributionsrc: ["lets go"], + ping: ["lets go"] + ) ) - let _:StaticString = #input( - accept: ["lets,go"], - autocomplete: ["lets go"] + let _:StaticString = #html( + input( + accept: ["lets,go"], + autocomplete: ["lets go"] + ) ) - let _:StaticString = #link( - imagesizes: ["lets,go"], - imagesrcset: ["lets,go"], - rel: ["lets go"], - sizes: ["lets,go"] + let _:StaticString = #html( + link( + imagesizes: ["lets,go"], + imagesrcset: ["lets,go"], + rel: .stylesheet, + size: "lets,go" + ) ) - let _:String = #div(attributes: [.custom("potof gold1", "\(1)"), .custom("potof gold2", "2")]) - - let _:StaticString = #div(attributes: [.trailingSlash]) - let _:StaticString = #html(custom(tag: "slash", isVoid: false, attributes: [.trailingSlash])) }*/ } \ No newline at end of file diff --git a/Tests/HTMLKitTests/EncodingTests.swift b/Tests/HTMLKitTests/EncodingTests.swift index 4b1478f..5eeb63b 100644 --- a/Tests/HTMLKitTests/EncodingTests.swift +++ b/Tests/HTMLKitTests/EncodingTests.swift @@ -6,43 +6,57 @@ // #if canImport(Foundation) - import Foundation +#endif + import HTMLKit import Testing struct EncodingTests { - @Test func encoding_utf8Array() { - var expected_result:String = #html(option(attributes: [.class(["row"])], value: "wh'at?")) - var uint8Array:[UInt8] = #html(encoding: .utf8Bytes, - option(attributes: [.class(["row"])], value: "wh'at?") - ) - #expect(String(data: Data(uint8Array), encoding: .utf8) == expected_result) + let backslash:UInt8 = 92 - expected_result = #html(div(attributes: [.htmx(.request(js: false, timeout: nil, credentials: "true", noHeaders: nil))])) - uint8Array = #html(encoding: .utf8Bytes, - div(attributes: [.htmx(.request(js: false, timeout: nil, credentials: "true", noHeaders: nil))]) - ) - #expect(String(data: Data(uint8Array), encoding: .utf8) == expected_result) + #if canImport(Foundation) - let set:Set = Set(HTMXTests.dictionary_json_results(tag: "div", closingTag: true, attribute: "hx-headers", delimiter: "'", ["womp":"womp", "ding dong":"d1tched", "EASY":"C,L.a;P!"]).map({ - $0.data(using: .utf8) - })) - uint8Array = #html(encoding: .utf8Bytes, - div(attributes: [.htmx(.headers(js: false, ["womp":"womp", "ding dong":"d1tched", "EASY":"C,L.a;P!"]))]) - ) - #expect(set.contains(Data(uint8Array))) - } + // MARK: utf8Array + @Test func encoding_utf8Array() { + var expected_result:String = #html(option(attributes: [.class(["row"])], value: "wh'at?")) + var uint8Array:[UInt8] = #html(encoding: .utf8Bytes, + option(attributes: [.class(["row"])], value: "wh'at?") + ) + #expect(String(data: Data(uint8Array), encoding: .utf8) == expected_result) + #expect(uint8Array.firstIndex(of: backslash) == nil) - @Test func encoding_foundationData() { - let expected_result:String = #html(option(attributes: [.class(["row"])], value: "what?")) + expected_result = #html(div(attributes: [.htmx(.request(js: false, timeout: nil, credentials: "true", noHeaders: nil))])) + uint8Array = #html(encoding: .utf8Bytes, + div(attributes: [.htmx(.request(js: false, timeout: nil, credentials: "true", noHeaders: nil))]) + ) + #expect(String(data: Data(uint8Array), encoding: .utf8) == expected_result) + #expect(uint8Array.firstIndex(of: backslash) == nil) - let foundationData:Data = #html(encoding: .foundationData, - option(attributes: [.class(["row"])], value: "what?") - ) - #expect(foundationData == expected_result.data(using: .utf8)) - } + let set:Set = Set(HTMXTests.dictionary_json_results(tag: "div", closingTag: true, attribute: "hx-headers", delimiter: "'", ["womp":"womp", "ding dong":"d1tched", "EASY":"C,L.a;P!"]).map({ + $0.data(using: .utf8) + })) + uint8Array = #html(encoding: .utf8Bytes, + div(attributes: [.htmx(.headers(js: false, ["womp":"womp", "ding dong":"d1tched", "EASY":"C,L.a;P!"]))]) + ) + #expect(set.contains(Data(uint8Array))) + #expect(uint8Array.firstIndex(of: backslash) == nil) + } + + // MARK: foundationData + @Test func encoding_foundationData() { + let expected_result:String = #html(option(attributes: [.class(["row"])], value: "what?")) + let foundationData:Data = #html(encoding: .foundationData, + option(attributes: [.class(["row"])], value: "what?") + ) + #expect(foundationData == expected_result.data(using: .utf8)) + #expect(foundationData.firstIndex(of: backslash) == nil) + } + + #endif + + // MARK: custom @Test func encoding_custom() { let expected_result:String = "" let result:String = #html(encoding: .custom(#""$0""#, stringDelimiter: "!"), @@ -50,6 +64,4 @@ struct EncodingTests { ) #expect(result == expected_result) } -} - -#endif \ No newline at end of file +} \ No newline at end of file diff --git a/Tests/HTMLKitTests/EscapeHTMLTests.swift b/Tests/HTMLKitTests/EscapeHTMLTests.swift new file mode 100644 index 0000000..6929b55 --- /dev/null +++ b/Tests/HTMLKitTests/EscapeHTMLTests.swift @@ -0,0 +1,98 @@ +// +// EscapeHTMLTests.swift +// +// +// Created by Evan Anderson on 11/29/24. +// + +#if canImport(Foundation) +import Foundation +#endif + +import HTMLKit +import Testing + +struct EscapeHTMLTests { + let backslash:UInt8 = 92 + + // MARK: macro + @Test func escape_macro() { + var expected_result:String = "<!DOCTYPE html><html>Test</html>" + var escaped:String = #escapeHTML(html("Test")) + #expect(escaped == expected_result) + + escaped = #html(#escapeHTML(html("Test"))) + #expect(escaped == expected_result) + + expected_result = #escapeHTML("<>\"") + escaped = #escapeHTML(encoding: .utf8Bytes, "<>\"") + #expect(escaped == expected_result) + + expected_result = #escapeHTML("<>\"") + escaped = #escapeHTML(encoding: .utf16Bytes, "<>\"") + #expect(escaped == expected_result) + + expected_result = #escapeHTML("<>\"") + escaped = #escapeHTML(encoding: .utf8CString, "<>\"") + #expect(escaped == expected_result) + + expected_result = #escapeHTML("<>\"") + escaped = #escapeHTML(encoding: .foundationData, "<>\"") + #expect(escaped == expected_result) + + expected_result = #escapeHTML("<>\"") + escaped = #escapeHTML(encoding: .byteBuffer, "<>\"") + #expect(escaped == expected_result) + } + + // MARK: string + @Test func escape_encoding_string() throws { + let unescaped:String = #html(html("Test")) + let escaped:String = #escapeHTML(html("Test")) + var expected_result:String = "

\(escaped)

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

")], StaticString("

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

")], "

")) + #expect(string == expected_result) + + string = #html(p("What's 9 + 10? \"21\"!")) + #expect(string == "

What's 9 + 10? "21"!

") + + string = #html(option(value: "bad boy ")) + expected_result = "" + #expect(string == expected_result) + } + + #if canImport(Foundation) + // MARK: utf8Array + @Test func escape_encoding_utf8Array() { + var expected_result:String = #html(option(value: "juice WRLD <<<&>>> 999")) + var value:[UInt8] = #html(encoding: .utf8Bytes, option(value: "juice WRLD <<<&>>> 999")) + #expect(String(data: Data(value), encoding: .utf8) == expected_result) + #expect(value.firstIndex(of: backslash) == nil) + + expected_result = #html(option(#escapeHTML(option(value: "juice WRLD <<<&>>> 999")))) + value = #html(encoding: .utf8Bytes, option(#escapeHTML(option(value: "juice WRLD <<<&>>> 999")))) + #expect(String(data: Data(value), encoding: .utf8) == expected_result) + #expect(value.firstIndex(of: backslash) == nil) + } + #endif +} \ No newline at end of file