mirror of
https://github.com/sbrow/thor.git
synced 2026-10-25 02:01:56 -04:00
303 lines
6.9 KiB
Odin
303 lines
6.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 :: proc(source: string) -> string {
|
|
gc := ts.ensure_parser("html")
|
|
if gc == nil {
|
|
return source
|
|
}
|
|
|
|
source_c := strings.clone_to_cstring(source)
|
|
defer delete(source_c)
|
|
|
|
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
|
if tree == nil {
|
|
return source
|
|
}
|
|
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")
|
|
return source
|
|
}
|
|
|
|
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]
|
|
// 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
|
|
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
|
|
}
|
|
}
|
|
|
|
return strings.to_string(sb)
|
|
}
|
|
|
|
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 :: proc(source: string) -> string {
|
|
gc := ts.ensure_parser("css")
|
|
if gc == nil {
|
|
return source
|
|
}
|
|
|
|
source_c := strings.clone_to_cstring(source)
|
|
defer delete(source_c)
|
|
|
|
tree := ts.parser_parse_string(gc.parser, nil, source_c, u32(len(source)))
|
|
if tree == nil {
|
|
return source
|
|
}
|
|
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")
|
|
return source
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
return strings.to_string(sb)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|