From 4251efc17312de2904570c14d59ad45704b9c646 Mon Sep 17 00:00:00 2001 From: Spencer Brower <6729162+sbrow@users.noreply.github.com> Date: Sun, 30 Aug 2026 14:26:35 -0400 Subject: [PATCH] fix: Inline CSS now gets minified. --- TODOS.md | 1 + minify.odin | 57 ++++++++++++++++++++++++++--- minify_test.odin | 94 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 minify_test.odin diff --git a/TODOS.md b/TODOS.md index bb8be87..405b009 100644 --- a/TODOS.md +++ b/TODOS.md @@ -4,6 +4,7 @@ - [ ] instead of `warnings: [dynamic]Error` we should use `warnings: [dynamic; 8]Error` - when limit reached, the template fails and stops rendering. +- [ ] Brainstorm plugin/tool/(postccs/less/tailwind/sass) support. ## General diff --git a/minify.odin b/minify.odin index 855e1c5..3a60662 100644 --- a/minify.odin +++ b/minify.odin @@ -38,13 +38,16 @@ minify_html :: proc(source: string) -> string { defer delete(comments) preserves: [dynamic]Range 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() ci := 0 pi := 0 + si := 0 i := 0 last_written: u8 = 0 @@ -61,6 +64,21 @@ minify_html :: proc(source: string) -> string { 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 { i = int(comments[ci].end) ci += 1 @@ -101,6 +119,7 @@ collect_html_ranges :: proc( source: string, comments: ^[dynamic]Range, preserves: ^[dynamic]Range, + styles: ^[dynamic]Range, ) { child_count := ts.node_named_child_count(node) for i in 0 ..< child_count { @@ -112,7 +131,17 @@ collect_html_ranges :: proc( comments, 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 , 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 { child_count := ts.node_named_child_count(element) for i in 0 ..< child_count { @@ -224,7 +266,12 @@ minify_css :: proc(source: string) -> string { 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, ' ') last_written = ' ' } diff --git a/minify_test.odin b/minify_test.odin new file mode 100644 index 0000000..8d8bc79 --- /dev/null +++ b/minify_test.odin @@ -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
hi