Files
thor/minify.odin
T

332 lines
7.9 KiB
Odin

package main
import ts "treesitter"
import "core:log"
import "core:strings"
PRESERVE_TAGS :: [?]string{"pre", "code", "textarea"}
Range :: struct {
start: u32,
end: u32,
}
// minify_html collapses insignificant whitespace, strips comments, and minifies
// inline <style> bodies (via minify_css). The caller owns html_parser and
// css_parser and passes them in; both may be reused across calls. On success it
// returns (owned string, true) and the caller must free the string. On failure
// (nil html_parser, parse failure, or syntax errors) it returns ("", false) and
// the caller should keep the original source unchanged.
minify_html :: proc(
html_parser: ts.Parser,
css_parser: ts.Parser,
source: string,
) -> (
minified: string,
ok: bool,
) {
if html_parser == nil {
return minified, ok
}
source_c := strings.clone_to_cstring(source)
defer delete(source_c)
tree := ts.parser_parse_string(html_parser, nil, source_c, u32(len(source)))
if tree == nil {
ok = false
return minified, ok
}
defer ts.tree_delete(tree)
root := ts.tree_root_node(tree)
if ts.node_has_error(root) {
log.warnf("minify: HTML parse errors, skipping minification")
ok = false
return minified, ok
}
comments: [dynamic]Range
defer delete(comments)
preserves: [dynamic]Range
defer delete(preserves)
styles: [dynamic]Range
defer delete(styles)
collect_html_ranges(root, source, &comments, &preserves, &styles)
sb := strings.builder_make()
ci := 0
pi := 0
si := 0
i := 0
last_written: u8 = 0
for i < len(source) {
if pi < len(preserves) && u32(i) >= preserves[pi].start {
p := preserves[pi]
segment := source[i:p.end]
strings.write_string(&sb, segment)
if len(segment) > 0 {
last_written = segment[len(segment) - 1]
}
i = int(p.end)
pi += 1
continue
}
if si < len(styles) && u32(i) >= styles[si].start {
s := styles[si]
seg := source[i:s.end]
if minified, mok := minify_css(css_parser, seg); mok {
strings.write_string(&sb, minified)
if len(minified) > 0 {
last_written = minified[len(minified) - 1]
}
delete(minified)
} else {
// CSS minify skipped/failed: keep the style body verbatim.
strings.write_string(&sb, seg)
if len(seg) > 0 {
last_written = seg[len(seg) - 1]
}
}
i = int(s.end)
si += 1
continue
}
if ci < len(comments) && u32(i) >= comments[ci].start {
i = int(comments[ci].end)
ci += 1
continue
}
c := source[i]
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
j := i + 1
for j < len(source) {
c2 := source[j]
if c2 != ' ' && c2 != '\t' && c2 != '\n' && c2 != '\r' {
break
}
j += 1
}
next: u8 = 0
if j < len(source) {
next = source[j]
}
if last_written != '>' || next != '<' {
strings.write_byte(&sb, ' ')
last_written = ' '
}
i = j
} else {
strings.write_byte(&sb, c)
last_written = c
i += 1
}
}
minified, ok = strings.to_string(sb), true
return minified, ok
}
collect_html_ranges :: proc(
node: ts.Node,
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 {
child := ts.node_named_child(node, u32(i))
type_str := string(ts.node_type(child))
if type_str == "comment" {
append(
comments,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} 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(
preserves,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else if type_str == "element" {
tag := html_tag_name(child, source)
if is_preserve_tag(tag) {
append(
preserves,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else {
collect_html_ranges(child, source, comments, preserves, styles)
}
} else {
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 {
child_count := ts.node_named_child_count(element)
for i in 0 ..< child_count {
child := ts.node_named_child(element, u32(i))
if string(ts.node_type(child)) == "start_tag" {
tag_child_count := ts.node_named_child_count(child)
for j in 0 ..< tag_child_count {
tag_child := ts.node_named_child(child, u32(j))
if string(ts.node_type(tag_child)) == "tag_name" {
start := ts.node_start_byte(tag_child)
end := ts.node_end_byte(tag_child)
return source[start:end]
}
}
}
}
return ""
}
is_preserve_tag :: proc(tag: string) -> bool {
for t in PRESERVE_TAGS {
if tag == t do return true
}
return false
}
CSS_DELIMS :: [?]u8{'{', '}', ':', ';', ','}
is_css_delim :: proc(c: u8) -> bool {
for d in CSS_DELIMS {
if c == d do return true
}
return false
}
// minify_css collapses insignificant whitespace and strips comments. The caller
// owns `parser` and passes it in; it may be reused across calls. On success it
// returns (owned string, true) and the caller must free the string. On failure
// (nil parser, parse failure, or syntax errors) it returns ("", false) and the
// caller should keep the original source unchanged.
minify_css :: proc(parser: ts.Parser, source: string) -> (minified: string, ok: bool) {
if parser == nil {
return minified, ok
}
source_c := strings.clone_to_cstring(source)
defer delete(source_c)
tree := ts.parser_parse_string(parser, nil, source_c, u32(len(source)))
if tree == nil {
ok = false
return minified, ok
}
defer ts.tree_delete(tree)
root := ts.tree_root_node(tree)
if ts.node_has_error(root) {
log.warnf("minify: CSS parse errors, skipping minification")
ok = false
return minified, ok
}
comments: [dynamic]Range
defer delete(comments)
collect_css_comments(root, &comments)
sb := strings.builder_make()
ci := 0
i := 0
last_written: u8 = 0
for i < len(source) {
if ci < len(comments) && u32(i) >= comments[ci].start {
i = int(comments[ci].end)
ci += 1
continue
}
c := source[i]
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
j := i + 1
for j < len(source) {
c2 := source[j]
if c2 != ' ' && c2 != '\t' && c2 != '\n' && c2 != '\r' {
break
}
j += 1
}
next: u8 = 0
if j < len(source) {
next = source[j]
}
// 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 = ' '
}
i = j
} else {
strings.write_byte(&sb, c)
last_written = c
i += 1
}
}
minified, ok = strings.to_string(sb), true
return minified, ok
}
collect_css_comments :: proc(node: ts.Node, comments: ^[dynamic]Range) {
child_count := ts.node_named_child_count(node)
for i in 0 ..< child_count {
child := ts.node_named_child(node, u32(i))
if string(ts.node_type(child)) == "comment" {
append(
comments,
Range{start = ts.node_start_byte(child), end = ts.node_end_byte(child)},
)
} else {
collect_css_comments(child, comments)
}
}
}