mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 10:11:55 -04:00
fix: Inline CSS now gets minified.
This commit is contained in:
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
|
- [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error`
|
||||||
- when limit reached, the template fails and stops rendering.
|
- when limit reached, the template fails and stops rendering.
|
||||||
|
- [ ] Brainstorm plugin/tool/(postccs/less/tailwind/sass) support.
|
||||||
|
|
||||||
## General
|
## General
|
||||||
|
|
||||||
|
|||||||
+52
-5
@@ -38,13 +38,16 @@ minify_html :: proc(source: string) -> string {
|
|||||||
defer delete(comments)
|
defer delete(comments)
|
||||||
preserves: [dynamic]Range
|
preserves: [dynamic]Range
|
||||||
defer delete(preserves)
|
defer delete(preserves)
|
||||||
|
styles: [dynamic]Range
|
||||||
|
defer delete(styles)
|
||||||
|
|
||||||
collect_html_ranges(root, source, &comments, &preserves)
|
collect_html_ranges(root, source, &comments, &preserves, &styles)
|
||||||
|
|
||||||
sb := strings.builder_make()
|
sb := strings.builder_make()
|
||||||
|
|
||||||
ci := 0
|
ci := 0
|
||||||
pi := 0
|
pi := 0
|
||||||
|
si := 0
|
||||||
i := 0
|
i := 0
|
||||||
last_written: u8 = 0
|
last_written: u8 = 0
|
||||||
|
|
||||||
@@ -61,6 +64,21 @@ minify_html :: proc(source: string) -> string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if si < len(styles) && u32(i) >= styles[si].start {
|
||||||
|
s := styles[si]
|
||||||
|
// NOTE: minify_css may return either a fresh allocation or the
|
||||||
|
// input slice unchanged (on parser failure), so the result is not
|
||||||
|
// freed here — matching this package's one-shot allocation style.
|
||||||
|
minified := minify_css(source[i:s.end])
|
||||||
|
strings.write_string(&sb, minified)
|
||||||
|
if len(minified) > 0 {
|
||||||
|
last_written = minified[len(minified) - 1]
|
||||||
|
}
|
||||||
|
i = int(s.end)
|
||||||
|
si += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if ci < len(comments) && u32(i) >= comments[ci].start {
|
if ci < len(comments) && u32(i) >= comments[ci].start {
|
||||||
i = int(comments[ci].end)
|
i = int(comments[ci].end)
|
||||||
ci += 1
|
ci += 1
|
||||||
@@ -101,6 +119,7 @@ collect_html_ranges :: proc(
|
|||||||
source: string,
|
source: string,
|
||||||
comments: ^[dynamic]Range,
|
comments: ^[dynamic]Range,
|
||||||
preserves: ^[dynamic]Range,
|
preserves: ^[dynamic]Range,
|
||||||
|
styles: ^[dynamic]Range,
|
||||||
) {
|
) {
|
||||||
child_count := ts.node_named_child_count(node)
|
child_count := ts.node_named_child_count(node)
|
||||||
for i in 0 ..< child_count {
|
for i in 0 ..< child_count {
|
||||||
@@ -112,7 +131,17 @@ collect_html_ranges :: proc(
|
|||||||
comments,
|
comments,
|
||||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
)
|
)
|
||||||
} else if type_str == "script_element" || type_str == "style_element" {
|
} else if type_str == "style_element" {
|
||||||
|
// Preserve the <style> tags but minify the CSS body (the raw_text
|
||||||
|
// child) via the CSS-aware pass.
|
||||||
|
raw, ok := style_raw_text(child)
|
||||||
|
if ok {
|
||||||
|
append(
|
||||||
|
styles,
|
||||||
|
Range{start = ts.node_start_byte(raw), end = ts.node_end_byte(raw)},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if type_str == "script_element" {
|
||||||
append(
|
append(
|
||||||
preserves,
|
preserves,
|
||||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
@@ -125,14 +154,27 @@ collect_html_ranges :: proc(
|
|||||||
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
collect_html_ranges(child, source, comments, preserves)
|
collect_html_ranges(child, source, comments, preserves, styles)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
collect_html_ranges(child, source, comments, preserves)
|
collect_html_ranges(child, source, comments, preserves, styles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// style_raw_text returns the raw_text (CSS body) child of a style_element.
|
||||||
|
// ok is false for an empty <style></style>, which has no raw_text child.
|
||||||
|
style_raw_text :: proc(style_element: ts.Node) -> (raw: ts.Node, ok: bool) {
|
||||||
|
child_count := ts.node_named_child_count(style_element)
|
||||||
|
for i in 0 ..< child_count {
|
||||||
|
child := ts.node_named_child(style_element, u32(i))
|
||||||
|
if string(ts.node_type(child)) == "raw_text" {
|
||||||
|
return child, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}, false
|
||||||
|
}
|
||||||
|
|
||||||
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
html_tag_name :: proc(element: ts.Node, source: string) -> string {
|
||||||
child_count := ts.node_named_child_count(element)
|
child_count := ts.node_named_child_count(element)
|
||||||
for i in 0 ..< child_count {
|
for i in 0 ..< child_count {
|
||||||
@@ -224,7 +266,12 @@ minify_css :: proc(source: string) -> string {
|
|||||||
next = source[j]
|
next = source[j]
|
||||||
}
|
}
|
||||||
|
|
||||||
if !is_css_delim(last_written) && !is_css_delim(next) {
|
// Emit a single separating space, but never a leading space
|
||||||
|
// (last_written == 0), a doubled space (last_written == ' ',
|
||||||
|
// which happens when a stripped comment sits between two
|
||||||
|
// whitespace runs), or one adjacent to a delimiter.
|
||||||
|
if last_written != 0 && last_written != ' ' &&
|
||||||
|
!is_css_delim(last_written) && !is_css_delim(next) {
|
||||||
strings.write_byte(&sb, ' ')
|
strings.write_byte(&sb, ' ')
|
||||||
last_written = ' '
|
last_written = ' '
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#+test
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "core:strings"
|
||||||
|
import "core:sync"
|
||||||
|
import "core:testing"
|
||||||
|
import "treesitter"
|
||||||
|
|
||||||
|
// The treesitter parser store is normally initialized by main() via
|
||||||
|
// treesitter.init_persistent(); the test runner never calls main. init_once
|
||||||
|
// ensures it happens exactly once no matter which test thread arrives first,
|
||||||
|
// and preloads the builtin parsers on that single thread — after which the
|
||||||
|
// concurrent tests only *read* the cache (the unlocked writes in ensure_parser
|
||||||
|
// are what corrupt it, not reads).
|
||||||
|
@(private = "file")
|
||||||
|
init_once: sync.Once
|
||||||
|
|
||||||
|
setup_treesitter :: proc() {
|
||||||
|
sync.once_do(&init_once, proc() {
|
||||||
|
treesitter.init_persistent()
|
||||||
|
treesitter.ensure_parser("html")
|
||||||
|
treesitter.ensure_parser("css")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The builtin parsers are cached and shared per-language, and tree-sitter
|
||||||
|
// parsers are not reentrant — concurrent parser_parse_string corrupts their
|
||||||
|
// internal subtree pool. The multi-threaded test runner would otherwise race,
|
||||||
|
// so serialize parser use behind this mutex. (Production renders on a single
|
||||||
|
// thread, so it needs no such lock.)
|
||||||
|
@(private = "file")
|
||||||
|
parse_mu: sync.Mutex
|
||||||
|
|
||||||
|
ts_minify_html :: proc(s: string) -> string {
|
||||||
|
sync.mutex_lock(&parse_mu)
|
||||||
|
defer sync.mutex_unlock(&parse_mu)
|
||||||
|
return minify_html(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
ts_minify_css :: proc(s: string) -> string {
|
||||||
|
sync.mutex_lock(&parse_mu)
|
||||||
|
defer sync.mutex_unlock(&parse_mu)
|
||||||
|
return minify_css(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- minify_html: inline <style> bodies get CSS-minified ---
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_minify_html_inline_style :: proc(t: ^testing.T) {
|
||||||
|
setup_treesitter()
|
||||||
|
input := `<html><head><style>
|
||||||
|
body {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
</style></head><body><p>hi</p></body></html>`
|
||||||
|
result := ts_minify_html(input)
|
||||||
|
|
||||||
|
// The CSS body is collapsed by minify_css (delimiters lose surrounding
|
||||||
|
// whitespace)...
|
||||||
|
testing.expect(t, strings.contains(result, "body{color:red;}"))
|
||||||
|
// ...while the <style> tags themselves survive.
|
||||||
|
testing.expect(t, strings.contains(result, "<style>"))
|
||||||
|
testing.expect(t, strings.contains(result, "</style>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_minify_html_empty_style :: proc(t: ^testing.T) {
|
||||||
|
setup_treesitter()
|
||||||
|
// An empty <style></style> has no raw_text child; must not crash.
|
||||||
|
input := `<html><head><style></style></head><body></body></html>`
|
||||||
|
result := ts_minify_html(input)
|
||||||
|
testing.expect(t, strings.contains(result, "<style></style>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- minify_css: whitespace around stripped comments / leading edge ---
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_minify_css_leading_and_comment :: proc(t: ^testing.T) {
|
||||||
|
setup_treesitter()
|
||||||
|
// Leading whitespace and a comment between two whitespace runs must not
|
||||||
|
// leave a leading space or a doubled space.
|
||||||
|
testing.expect_value(t, ts_minify_css("\n/* c */\na {}"), "a{}")
|
||||||
|
testing.expect_value(t, ts_minify_css(" a {}"), "a{}")
|
||||||
|
testing.expect_value(t, ts_minify_css("a {}\n/* trailing */"), "a{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@(test)
|
||||||
|
test_minify_html_script_preserved :: proc(t: ^testing.T) {
|
||||||
|
setup_treesitter()
|
||||||
|
// <script> stays byte-for-byte (whitespace-significant JS).
|
||||||
|
input := "<html><body><script>\nconst s = \"a b\";\n</script></body></html>"
|
||||||
|
result := ts_minify_html(input)
|
||||||
|
testing.expect(t, strings.contains(result, "const s = \"a b\";"))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user