Skip to content

Commit abf1e49

Browse files
added initial minification logic and unit test (TODO: need to correctly escape the / character)
1 parent 058a3d9 commit abf1e49

File tree

3 files changed

+143
-1
lines changed

3 files changed

+143
-1
lines changed

Sources/HTMLKitUtilities/HTMLKitUtilities.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,14 @@ extension StringLiteralExprSyntax {
106106
return "\(segments)"
107107
}
108108
}
109+
extension Collection {
110+
package func get(_ index: Index) -> Element? {
111+
return index >= startIndex && index < endIndex ? self[index] : nil
112+
}
113+
}
109114
extension LabeledExprListSyntax {
110115
package func get(_ index: Int) -> Element? {
111-
return index < count ? self[self.index(at: index)] : nil
116+
return self.get(self.index(at: index))
112117
}
113118
}
114119
#endif
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
//
2+
// Minify.swift
3+
//
4+
//
5+
// Created by Evan Anderson on 3/31/25.
6+
//
7+
8+
import SwiftSyntax
9+
10+
extension HTMLKitUtilities {
11+
static let defaultPreservedWhitespaceTags:Set<String> = Set([
12+
"a", "abbr",
13+
"b", "bdi", "bdo", "button",
14+
"cite", "code",
15+
"data", "dd", "dfn", "dt",
16+
"em",
17+
"h1", "h2", "h3", "h4", "h5", "h6",
18+
"i",
19+
"kbd",
20+
"label", "li",
21+
"mark",
22+
"p",
23+
"q",
24+
"rp",
25+
"rt",
26+
"ruby",
27+
"s", "samp", "small", "span", "strong", "sub", "sup",
28+
"td", "time", "title", "tr",
29+
"u",
30+
"var",
31+
"wbr"
32+
].map { "<" + $0 + ">" })
33+
34+
/// Removes whitespace between elements.
35+
public static func minify(
36+
html: String,
37+
preservingWhitespaceForTags: Set<String> = []
38+
) -> String {
39+
var preservedWhitespaceTags:Set<String> = Self.defaultPreservedWhitespaceTags
40+
preservedWhitespaceTags.formUnion(preservingWhitespaceForTags)
41+
var result:String = ""
42+
result.reserveCapacity(html.count)
43+
let tagRegex = "[^/>]+"
44+
let openElementRegex = "(<\(tagRegex)>)"
45+
let openElementRanges = html.ranges(of: try! Regex(openElementRegex))
46+
47+
let closeElementRegex = "(</\(tagRegex)>)"
48+
let closeElementRanges = html.ranges(of: try! Regex(closeElementRegex))
49+
50+
var openingRangeIndex = 0
51+
var ignoredClosingTags:Set<Range<String.Index>> = []
52+
for openingRange in openElementRanges {
53+
let tag = html[openingRange]
54+
result += tag
55+
let closure:(Character) -> Bool = preservedWhitespaceTags.contains(String(tag)) ? { _ in true } : {
56+
!($0.isWhitespace || $0.isNewline)
57+
}
58+
let closestClosingRange = closeElementRanges.first(where: { $0.lowerBound > openingRange.upperBound })
59+
if let nextOpeningRange = openElementRanges.get(openingRangeIndex + 1) {
60+
var i = openingRange.upperBound
61+
var lowerBound = nextOpeningRange.lowerBound
62+
if let closestClosingRange {
63+
if closestClosingRange.upperBound < lowerBound {
64+
lowerBound = closestClosingRange.upperBound
65+
}
66+
if closestClosingRange.lowerBound < nextOpeningRange.lowerBound {
67+
ignoredClosingTags.insert(closestClosingRange)
68+
}
69+
}
70+
// anything after the opening tag, upto the end of the next closing tag
71+
while i < lowerBound {
72+
let char = html[i]
73+
if closure(char) {
74+
result.append(char)
75+
}
76+
html.formIndex(after: &i)
77+
}
78+
// anything after the closing tag and before the next opening tag
79+
while i < nextOpeningRange.lowerBound {
80+
let char = html[i]
81+
if !char.isNewline {
82+
result.append(char)
83+
}
84+
html.formIndex(after: &i)
85+
}
86+
} else if let closestClosingRange {
87+
// anything after the opening tag and before the next closing tag
88+
let slice = html[openingRange.upperBound..<closestClosingRange.lowerBound]
89+
for char in slice {
90+
if closure(char) {
91+
result.append(char)
92+
}
93+
}
94+
}
95+
openingRangeIndex += 1
96+
}
97+
for closingRange in closeElementRanges {
98+
if !ignoredClosingTags.contains(closingRange) {
99+
result += html[closingRange]
100+
}
101+
}
102+
return result
103+
}
104+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// MinifyTests.swift
3+
//
4+
//
5+
// Created by Evan Anderson on 3/31/25.
6+
//
7+
8+
#if compiler(>=6.0)
9+
10+
import Testing
11+
import HTMLKit
12+
13+
struct MinifyTests {
14+
@Test func minifyHTML() {
15+
/////
16+
var expected = "<html><body><p>\ndude&dude </p>r ly<div>what</div></body></html>"
17+
var result:String = HTMLKitUtilities.minify(html: "\n<html>\n <body><p>\ndude&dude </p>r ly\n<div>\nwh at</div></body>\n</html>")
18+
#expect(expected == result)
19+
20+
expected = #"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-icon"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path><circle cx="12" cy="12" r="3"></circle><a href="about:blank#" class="hover:underline tooltip" title="Delete"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-icon"></svg></a><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>"#
21+
result = HTMLKitUtilities.minify(html: #"""
22+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye-icon">
23+
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path>
24+
<circle cx="12" cy="12" r="3"></circle><a href="about:blank#" class="hover:underline tooltip" title="Delete"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-icon"></svg></a>
25+
<path d="M3 6h18"></path>
26+
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
27+
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>
28+
"""#, preservingWhitespaceForTags: ["svg", "path", "circle"])
29+
#expect(expected == result)
30+
}
31+
}
32+
33+
#endif

0 commit comments

Comments
 (0)