Skip to content

Commit d80752f

Browse files
can now escape html via #escapeHTML macro or .escapeHTML(attribute:) function, and...
- added some compiler warnings when interpolation can potentially introduce html elements - fixed a case where creating a `StaticString` wasn't allowed, even when it was - benchmark updates
1 parent efc69a4 commit d80752f

File tree

8 files changed

+199
-89
lines changed

8 files changed

+199
-89
lines changed

Benchmarks/Benchmarks/UnitTests/UnitTests.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ struct UnitTests {
3131
"Vaux" : VauxTests()
3232
]
3333
@Test func staticHTML() {
34-
let expected_value:String = #html([
35-
#head([
36-
#title(["StaticView"])
37-
]),
38-
#body([
39-
#h1(["Swift HTML Benchmarks"])
40-
])
41-
])
34+
let expected_value:String = #html(
35+
#head(
36+
#title("StaticView")
37+
),
38+
#body(
39+
#h1("Swift HTML Benchmarks")
40+
)
41+
)
4242
for (key, value) in libraries {
4343
var string:String = value.staticHTML()
4444
if key == "Swim" {

Benchmarks/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ let package = Package(
1111
dependencies: [
1212
// dsls
1313
.package(url: "https://github.com/ordo-one/package-benchmark", from: "1.27.0"),
14-
.package(url: "https://github.com/RandomHashTags/swift-htmlkit", from: "0.4.0"),
14+
.package(name: "swift-htmlkit", path: "../"),
1515
.package(url: "https://github.com/sliemeobn/elementary", from: "0.3.4"),
1616
.package(url: "https://github.com/vapor-community/HTMLKit", from: "2.8.1"),
1717
.package(url: "https://github.com/pointfreeco/swift-html", from: "0.4.1"),

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import CompilerPluginSupport
77
let package = Package(
88
name: "swift-htmlkit",
99
platforms: [
10-
.macOS(.v10_15)
10+
.macOS(.v13)
1111
],
1212
products: [
1313
.library(

Sources/HTMLKit/HTMLKit.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ public extension String {
1818
static func == (left: Self, right: StaticString) -> Bool { left == right.string }
1919
}
2020

21+
@freestanding(expression)
22+
public macro escapeHTML<T: ExpressibleByStringLiteral>(_ innerHTML: T...) -> T = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement")
23+
2124
// MARK: Elements
2225
@freestanding(expression)
2326
public macro html<T: ExpressibleByStringLiteral>(attributes: [HTMLElementAttribute] = [], xmlns: T? = nil, _ innerHTML: T...) -> T = #externalMacro(module: "HTMLKitMacros", type: "HTMLElement")

Sources/HTMLKitMacros/HTMLElement.swift

Lines changed: 119 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -28,34 +28,53 @@ private extension HTMLElement {
2828
if key == "acceptCharset" {
2929
key = "accept-charset"
3030
}
31-
if let string:String = parse_attribute(context: context, elementType: elementType, key: key, expression: child.expression) {
31+
if var string:String = parse_attribute(context: context, elementType: elementType, key: key, argument: child) {
32+
string.escapeHTML(attribute: true)
3233
attributes.append(key + (string.isEmpty ? "" : "=\\\"" + string + "\\\""))
3334
}
3435
}
3536
// inner html
36-
} else if let macro:MacroExpansionExprSyntax = child.expression.macroExpansion {
37-
innerHTML.append(parse_element_macro(context: context, expression: macro))
38-
} else if let string:String = child.expression.stringLiteral?.string {
39-
innerHTML.append(string)
40-
} else if let function:FunctionCallExprSyntax = child.expression.as(FunctionCallExprSyntax.self), function.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text == "StaticString" {
41-
innerHTML.append(function.arguments.first!.expression.stringLiteral!.string)
42-
} else {
43-
context.diagnose(Diagnostic(node: child, message: ErrorDiagnostic(id: "unallowedExpression", message: "Expression not allowed.")))
37+
} else if let inner_html:String = parse_inner_html(context: context, elementType: elementType, child: child) {
38+
innerHTML.append(inner_html)
4439
}
4540
}
4641
}
4742
return ElementData(attributes: attributes, innerHTML: innerHTML)
4843
}
44+
static func parse_inner_html(context: some MacroExpansionContext, elementType: HTMLElementType, child: LabeledExprSyntax) -> String? {
45+
if let macro:MacroExpansionExprSyntax = child.expression.macroExpansion {
46+
var string:String = parse_element_macro(context: context, expression: macro)
47+
if elementType == .escapeHTML {
48+
string.escapeHTML(attribute: false)
49+
}
50+
return string
51+
} else if var string:String = child.expression.stringLiteral?.string {
52+
string.escapeHTML(attribute: false)
53+
return string
54+
} else if let function:FunctionCallExprSyntax = child.expression.as(FunctionCallExprSyntax.self), function.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text == "StaticString" {
55+
return function.arguments.first!.expression.stringLiteral!.string.escapingHTML(attribute: false)
56+
} else {
57+
context.diagnose(Diagnostic(node: child, message: ErrorDiagnostic(id: "unallowedExpression", message: "Expression not allowed. String interpolation is required when encoding runtime values."), fixIts: [
58+
FixIt(message: SimpleDiagnosticMessage(id: "useStringInterpolation", message: "Use String Interpolation.", severity: .error), changes: [
59+
FixIt.Change.replace(
60+
oldNode: Syntax(child),
61+
newNode: Syntax(StringLiteralExprSyntax(content: "\\(\(child))"))
62+
)
63+
])
64+
]))
65+
return nil
66+
}
67+
}
4968
static func parse_global_attributes(context: some MacroExpansionContext, elementType: HTMLElementType, array: ArrayExprSyntax) -> [String] {
5069
var keys:Set<String> = [], attributes:[String] = []
5170
for element in array.elements {
52-
let function:FunctionCallExprSyntax = element.expression.as(FunctionCallExprSyntax.self)!, key_element:ExprSyntax = function.arguments.first!.expression
71+
let function:FunctionCallExprSyntax = element.expression.as(FunctionCallExprSyntax.self)!, key_argument:LabeledExprSyntax = function.arguments.first!, key_element:ExprSyntax = key_argument.expression
5372
var key:String = function.calledExpression.memberAccess!.declName.baseName.text, value:String? = nil
5473
switch key {
5574
case "custom", "data":
56-
var (literalValue, returnType):(String, LiteralReturnType) = parse_literal_value(elementType: elementType, key: key, expression: function.arguments.last!.expression)!
57-
if returnType == .interpolation {
58-
literalValue = "\\(" + literalValue + ")"
75+
var (literalValue, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, argument: function.arguments.last!)!
76+
if returnType == .string {
77+
literalValue.escapeHTML(attribute: true)
5978
}
6079
value = literalValue
6180
if key == "custom" {
@@ -66,10 +85,10 @@ private extension HTMLElement {
6685
break
6786
case "event":
6887
key = "on" + key_element.memberAccess!.declName.baseName.text
69-
value = function.arguments.last!.expression.stringLiteral!.string
88+
value = function.arguments.last!.expression.stringLiteral!.string.escapingHTML(attribute: true)
7089
break
7190
default:
72-
if let string:String = parse_attribute(context: context, elementType: elementType, key: key, expression: key_element) {
91+
if let string:String = parse_attribute(context: context, elementType: elementType, key: key, argument: key_argument) {
7392
value = string
7493
}
7594
break
@@ -90,6 +109,12 @@ private extension HTMLElement {
90109
static func parse_element_macro(context: some MacroExpansionContext, expression: MacroExpansionExprSyntax) -> String {
91110
guard let elementType:HTMLElementType = HTMLElementType(rawValue: expression.macroName.text) else { return "\(expression)" }
92111
let childs:SyntaxChildren = expression.arguments.children(viewMode: .all)
112+
if elementType == .escapeHTML {
113+
return childs.compactMap({
114+
guard let child:LabeledExprSyntax = $0.labeled else { return nil }
115+
return parse_inner_html(context: context, elementType: elementType, child: child)
116+
}).joined()
117+
}
93118
let tag:String, isVoid:Bool
94119
var children:Slice<SyntaxChildren>
95120
if elementType == .custom {
@@ -129,34 +154,14 @@ private extension HTMLElement {
129154
}
130155
}
131156

132-
static func parse_attribute(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, expression: ExprSyntax) -> String? {
133-
if let (string, returnType):(String, LiteralReturnType) = parse_literal_value(elementType: elementType, key: key, expression: expression) {
157+
static func parse_attribute(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, argument: LabeledExprSyntax) -> String? {
158+
let expression:ExprSyntax = argument.expression
159+
if let (string, returnType):(String, LiteralReturnType) = parse_literal_value(context: context, elementType: elementType, key: key, argument: argument) {
134160
switch returnType {
135161
case .boolean: return string.elementsEqual("true") ? "" : nil
136162
case .string: return string
137-
case .interpolation: return "\\(" + string + ")"
138-
}
139-
}
140-
let separator:String = get_separator(key: key)
141-
let string_return_logic:(ExprSyntax, String) -> String = {
142-
if $1.contains(separator) {
143-
context.diagnose(Diagnostic(node: $0, message: ErrorDiagnostic(id: "characterNotAllowedInDeclaration", message: "Character \"" + separator + "\" is not allowed when declaring values for \"" + key + "\".")))
163+
case .interpolation: return string
144164
}
145-
return $1
146-
}
147-
if let value:String = expression.array?.elements.compactMap({
148-
if let string:String = $0.expression.stringLiteral?.string {
149-
return string_return_logic($0.expression, string)
150-
}
151-
if let string:String = $0.expression.integerLiteral?.literal.text {
152-
return string
153-
}
154-
if let string:String = $0.expression.memberAccess?.declName.baseName.text {
155-
return HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: string)
156-
}
157-
return nil
158-
}).joined(separator: separator) {
159-
return value
160165
}
161166
func member(_ value: String) -> String {
162167
var string:String = String(value[value.index(after: value.startIndex)...])
@@ -174,48 +179,89 @@ private extension HTMLElement {
174179
default: return " "
175180
}
176181
}
177-
static func parse_literal_value(elementType: HTMLElementType, key: String, expression: ExprSyntax) -> (value: String, returnType: LiteralReturnType)? {
182+
static func parse_literal_value(context: some MacroExpansionContext, elementType: HTMLElementType, key: String, argument: LabeledExprSyntax) -> (value: String, returnType: LiteralReturnType)? {
183+
let expression:ExprSyntax = argument.expression
178184
if let boolean:String = expression.booleanLiteral?.literal.text {
179185
return (boolean, .boolean)
180186
}
181-
if let string:String = expression.stringLiteral?.string {
182-
return (string, .string)
183-
}
184-
if let integer:String = expression.integerLiteral?.literal.text {
185-
return (integer, .string)
186-
}
187-
if let float:String = expression.floatLiteral?.literal.text {
188-
return (float, .string)
189-
}
190-
if let function:FunctionCallExprSyntax = expression.as(FunctionCallExprSyntax.self) {
191-
switch key {
192-
case "height", "width":
193-
var value:String = "\(function)"
194-
value = String(value[value.index(after: value.startIndex)...])
195-
value = HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: value)
196-
return (value, .string)
197-
default:
198-
return ("\(function)", .interpolation)
187+
func return_string_or_interpolation() -> (String, LiteralReturnType)? {
188+
if let string:String = expression.stringLiteral?.string {
189+
return (string, .string)
199190
}
200-
}
201-
if let member:MemberAccessExprSyntax = expression.memberAccess {
202-
let decl:String = member.declName.baseName.text
203-
if let base:ExprSyntax = member.base {
204-
if let integer:String = base.integerLiteral?.literal.text {
205-
switch decl {
206-
case "description":
207-
return (integer, .string)
208-
default:
209-
return (integer, .interpolation)
191+
if let integer:String = expression.integerLiteral?.literal.text {
192+
return (integer, .string)
193+
}
194+
if let float:String = expression.floatLiteral?.literal.text {
195+
return (float, .string)
196+
}
197+
if let function:FunctionCallExprSyntax = expression.as(FunctionCallExprSyntax.self) {
198+
switch key {
199+
case "height", "width":
200+
var value:String = "\(function)"
201+
value = String(value[value.index(after: value.startIndex)...])
202+
value = HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: value)
203+
return (value, .string)
204+
default:
205+
if function.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text == "StaticString" {
206+
return (function.arguments.first!.expression.stringLiteral!.string, .string)
210207
}
208+
return ("\(function)", .interpolation)
209+
}
210+
}
211+
if let member:MemberAccessExprSyntax = expression.memberAccess {
212+
let decl:String = member.declName.baseName.text
213+
if let _:ExprSyntax = member.base {
214+
/*if let integer:String = base.integerLiteral?.literal.text {
215+
switch decl {
216+
case "description":
217+
return (integer, .integer)
218+
default:
219+
return (integer, .interpolation)
220+
}
221+
} else {*/
222+
return ("\(member)", .interpolation)
223+
//}
211224
} else {
212-
return ("\(member)", .interpolation)
225+
return (HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: decl), .string)
226+
}
227+
}
228+
let separator:String = get_separator(key: key)
229+
let string_return_logic:(ExprSyntax, String) -> String = {
230+
if $1.contains(separator) {
231+
context.diagnose(Diagnostic(node: $0, message: ErrorDiagnostic(id: "characterNotAllowedInDeclaration", message: "Character \"" + separator + "\" is not allowed when declaring values for \"" + key + "\".")))
213232
}
214-
} else {
215-
return (HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: decl), .string)
233+
return $1
216234
}
235+
if let value:String = expression.array?.elements.compactMap({
236+
if let string:String = $0.expression.stringLiteral?.string {
237+
return string_return_logic($0.expression, string)
238+
}
239+
if let string:String = $0.expression.integerLiteral?.literal.text {
240+
return string
241+
}
242+
if let string:String = $0.expression.floatLiteral?.literal.text {
243+
return string
244+
}
245+
if let string:String = $0.expression.memberAccess?.declName.baseName.text {
246+
return HTMLElementAttribute.Extra.htmlValue(enumName: enumName(elementType: elementType, key: key), for: string)
247+
}
248+
return nil
249+
}).joined(separator: separator) {
250+
return (value, .string)
251+
}
252+
return nil
217253
}
218-
return nil
254+
guard var (string, returnType):(String, LiteralReturnType) = return_string_or_interpolation() else {
255+
//context.diagnose(Diagnostic(node: expression, message: ErrorDiagnostic(id: "somethingWentWrong", message: "Something went wrong. (" + expression.debugDescription + ")", severity: .warning)))
256+
return nil
257+
}
258+
if returnType == .interpolation {
259+
string = "\\(" + string + ")"
260+
}
261+
if string.contains("\\(") {
262+
context.diagnose(Diagnostic(node: expression, message: ErrorDiagnostic(id: "unsafeInterpolation", message: "Interpolation may introduce raw HTML elements.", severity: .warning)))
263+
}
264+
return (string, returnType)
219265
}
220266
}
221267

@@ -225,6 +271,7 @@ enum LiteralReturnType {
225271

226272
// MARK: HTMLElementType
227273
enum HTMLElementType : String {
274+
case escapeHTML
228275
case html
229276
case custom
230277

Sources/HTMLKitMacros/HTMLKitMacros.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,31 @@ import SwiftDiagnostics
1313
struct ErrorDiagnostic : DiagnosticMessage {
1414
let message:String
1515
let diagnosticID:MessageID
16-
let severity:DiagnosticSeverity = DiagnosticSeverity.error
16+
let severity:DiagnosticSeverity
1717

18-
init(id: String, message: String) {
18+
init(id: String, message: String, severity: DiagnosticSeverity = .error) {
1919
self.message = message
2020
self.diagnosticID = MessageID(domain: "HTMLKitMacros", id: id)
21+
self.severity = severity
2122
}
2223
}
2324

25+
// MARK: SimpleDiagnosticMessage
26+
struct SimpleDiagnosticMessage : DiagnosticMessage, Error {
27+
let message:String
28+
let diagnosticID:MessageID
29+
let severity:DiagnosticSeverity
30+
31+
init(id: String, message: String, severity: DiagnosticSeverity) {
32+
self.message = message
33+
self.diagnosticID = MessageID(domain: "HTMLKitMacros", id: id)
34+
self.severity = severity
35+
}
36+
}
37+
extension SimpleDiagnosticMessage : FixItMessage {
38+
var fixItID : MessageID { diagnosticID }
39+
}
40+
2441

2542
@main
2643
struct HTMLKitMacros : CompilerPlugin {

0 commit comments

Comments
 (0)