v0.24.5
This commit is contained in:
commit
5e23e9af02
240 files changed
+99957
No files matched your search
@@ -0,0 +1,65 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let input = std::str::from_utf8(body).context("CSS is not UTF-8")?;
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut pending_space = false;
|
||||
while let Some(ch) = chars.next() {
|
||||
if let Some(q) = quote {
|
||||
out.push(ch);
|
||||
if ch == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
out.push(next);
|
||||
}
|
||||
} else if ch == q {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(ch, '\'' | '"') {
|
||||
if pending_space && needs_space(out.chars().last(), Some(ch)) {
|
||||
out.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
quote = Some(ch);
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
if ch == '/' && chars.peek() == Some(&'*') {
|
||||
chars.next();
|
||||
let mut previous = '\0';
|
||||
while let Some(next) = chars.next() {
|
||||
if previous == '*' && next == '/' {
|
||||
break;
|
||||
}
|
||||
previous = next;
|
||||
}
|
||||
pending_space = true;
|
||||
continue;
|
||||
}
|
||||
if ch.is_whitespace() {
|
||||
pending_space = true;
|
||||
continue;
|
||||
}
|
||||
if pending_space && needs_space(out.chars().last(), Some(ch)) {
|
||||
out.push(' ');
|
||||
}
|
||||
pending_space = false;
|
||||
if matches!(ch, '{' | '}' | ':' | ';' | ',' | '>') && out.ends_with(' ') {
|
||||
out.pop();
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
let trimmed_len = out.trim_end().len();
|
||||
out.truncate(trimmed_len);
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
fn needs_space(previous: Option<char>, next: Option<char>) -> bool {
|
||||
let Some(previous) = previous else { return false; };
|
||||
let Some(next) = next else { return false; };
|
||||
!matches!(previous, '{' | '}' | ':' | ';' | ',' | '>')
|
||||
&& !matches!(next, '{' | '}' | ':' | ';' | ',' | '>')
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Conservative HTML minification.
|
||||
///
|
||||
/// HTML whitespace can be semantically significant, especially between inline
|
||||
/// elements, so this transformer deliberately preserves all whitespace. It only
|
||||
/// removes ordinary comments outside raw-text elements. Conditional comments and
|
||||
/// comment-looking bytes inside script/style/textarea/pre are preserved.
|
||||
pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let input = std::str::from_utf8(body).context("HTML is not UTF-8")?;
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut cursor = 0usize;
|
||||
let mut raw_element: Option<String> = None;
|
||||
|
||||
while cursor < bytes.len() {
|
||||
if bytes[cursor] == b'<' {
|
||||
if raw_element.is_none() && starts_with_ascii_ci(bytes, cursor, b"<!--[if") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
|
||||
return Ok(body.to_vec());
|
||||
};
|
||||
let end = end + 3;
|
||||
out.push_str(&input[cursor..end]);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if raw_element.is_none() && bytes[cursor..].starts_with(b"<!--") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
|
||||
return Ok(body.to_vec());
|
||||
};
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(end) = find_tag_end(bytes, cursor + 1) {
|
||||
let tag = &input[cursor..=end];
|
||||
out.push_str(tag);
|
||||
if let Some((name, closing, self_closing)) = tag_name(tag) {
|
||||
if closing {
|
||||
if raw_element.as_deref() == Some(name.as_str()) {
|
||||
raw_element = None;
|
||||
}
|
||||
} else if !self_closing
|
||||
&& raw_element.is_none()
|
||||
&& matches!(name.as_str(), "script" | "style" | "textarea" | "pre")
|
||||
{
|
||||
raw_element = Some(name);
|
||||
}
|
||||
}
|
||||
cursor = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let ch = input[cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("cursor is inside UTF-8 input");
|
||||
out.push(ch);
|
||||
cursor += ch.len_utf8();
|
||||
}
|
||||
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.get(start..)?
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
.map(|offset| start + offset)
|
||||
}
|
||||
|
||||
fn starts_with_ascii_ci(haystack: &[u8], start: usize, needle: &[u8]) -> bool {
|
||||
haystack
|
||||
.get(start..start.saturating_add(needle.len()))
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(needle))
|
||||
}
|
||||
|
||||
fn find_tag_end(bytes: &[u8], mut cursor: usize) -> Option<usize> {
|
||||
let mut quote: Option<u8> = None;
|
||||
while cursor < bytes.len() {
|
||||
match (quote, bytes[cursor]) {
|
||||
(Some(active), ch) if ch == active => quote = None,
|
||||
(None, b'"' | b'\'') => quote = Some(bytes[cursor]),
|
||||
(None, b'>') => return Some(cursor),
|
||||
_ => {}
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn tag_name(tag: &str) -> Option<(String, bool, bool)> {
|
||||
let inner = tag.strip_prefix('<')?.strip_suffix('>')?.trim();
|
||||
if inner.starts_with('!') || inner.starts_with('?') {
|
||||
return None;
|
||||
}
|
||||
let closing = inner.starts_with('/');
|
||||
let inner = if closing { inner[1..].trim_start() } else { inner };
|
||||
let self_closing = inner.ends_with('/');
|
||||
let name = inner
|
||||
.split(|ch: char| ch.is_ascii_whitespace() || ch == '/' || ch == '>')
|
||||
.next()?
|
||||
.to_ascii_lowercase();
|
||||
(!name.is_empty()).then_some((name, closing, self_closing))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_semantic_inline_whitespace() {
|
||||
let input = b"<span>Hello</span> <span>world</span>";
|
||||
assert_eq!(minify(input).unwrap().as_slice(), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_only_safe_comments() {
|
||||
let input = b"<div><!-- remove --><b>x</b></div>";
|
||||
assert_eq!(minify(input).unwrap(), b"<div><b>x</b></div>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_comment_syntax_inside_script() {
|
||||
let input = b"<script>const value = '<!--keep-->';</script>";
|
||||
assert_eq!(minify(input).unwrap().as_slice(), input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Conservative JavaScript minification without token rewriting.
|
||||
///
|
||||
/// JavaScript `/` is context-sensitive (division vs. RegExp literal), so this
|
||||
/// minifier never guesses. Outside strings/templates/comments it collapses
|
||||
/// horizontal whitespace only until the first slash on a physical line; the
|
||||
/// remainder of that line is copied verbatim. This still removes common
|
||||
/// indentation/alignment whitespace while preserving RegExp bodies, ASI-sensitive
|
||||
/// line terminators and comment text exactly.
|
||||
pub fn minify(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let input = std::str::from_utf8(body).context("JavaScript is not UTF-8")?;
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut block_comment = false;
|
||||
let mut line_comment = false;
|
||||
let mut slash_sensitive = false;
|
||||
let mut horizontal_space = false;
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if line_comment {
|
||||
out.push(ch);
|
||||
if ch == '\n' || ch == '\r' {
|
||||
line_comment = false;
|
||||
slash_sensitive = false;
|
||||
horizontal_space = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if block_comment {
|
||||
out.push(ch);
|
||||
if ch == '*' && chars.peek() == Some(&'/') {
|
||||
chars.next();
|
||||
out.push('/');
|
||||
block_comment = false;
|
||||
}
|
||||
if ch == '\n' || ch == '\r' {
|
||||
slash_sensitive = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(active) = quote {
|
||||
out.push(ch);
|
||||
if ch == '\\' {
|
||||
if let Some(next) = chars.next() {
|
||||
out.push(next);
|
||||
}
|
||||
} else if ch == active {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if slash_sensitive {
|
||||
out.push(ch);
|
||||
if ch == '\n' || ch == '\r' {
|
||||
slash_sensitive = false;
|
||||
horizontal_space = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, '\'' | '"' | '`') {
|
||||
if horizontal_space {
|
||||
out.push(' ');
|
||||
horizontal_space = false;
|
||||
}
|
||||
quote = Some(ch);
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '/' {
|
||||
if horizontal_space {
|
||||
out.push(' ');
|
||||
horizontal_space = false;
|
||||
}
|
||||
out.push(ch);
|
||||
match chars.peek() {
|
||||
Some(&'/') => {
|
||||
chars.next();
|
||||
out.push('/');
|
||||
line_comment = true;
|
||||
}
|
||||
Some(&'*') => {
|
||||
chars.next();
|
||||
out.push('*');
|
||||
block_comment = true;
|
||||
}
|
||||
_ => slash_sensitive = true,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(ch, ' ' | '\t' | '\u{000b}' | '\u{000c}') {
|
||||
horizontal_space = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ch == '\r' || ch == '\n' {
|
||||
horizontal_space = false;
|
||||
slash_sensitive = false;
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
if horizontal_space {
|
||||
out.push(' ');
|
||||
horizontal_space = false;
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
|
||||
let trimmed_len = out.trim_end_matches([' ', '\t']).len();
|
||||
out.truncate(trimmed_len);
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_regex_literals_and_asi_sensitive_newlines() {
|
||||
let input = b"const r = /a b[/*]/; // keep\nreturn value;\n";
|
||||
let output = String::from_utf8(minify(input).unwrap()).unwrap();
|
||||
assert!(output.contains("/a b[/*]/; // keep"));
|
||||
assert!(output.contains("return value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_template_literal_whitespace() {
|
||||
let input = b"const x = `a b ${ value }`;\n";
|
||||
let output = String::from_utf8(minify(input).unwrap()).unwrap();
|
||||
assert_eq!(output, "const x = `a b ${ value }`;\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub fn compact(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let value: serde_json::Value = serde_json::from_slice(body).context("parse JSON")?;
|
||||
serde_json::to_vec(&value).context("serialize compact JSON")
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,67 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Conservative SVG optimization. Whitespace is preserved because it can be
|
||||
/// visible inside text/tspan nodes. Only XML comments outside CDATA sections are
|
||||
/// removed; malformed comment/CDATA markup is left unchanged.
|
||||
pub fn optimize(body: &[u8]) -> Result<Vec<u8>> {
|
||||
let input = std::str::from_utf8(body).context("SVG is not UTF-8")?;
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut cursor = 0usize;
|
||||
|
||||
while cursor < bytes.len() {
|
||||
if bytes[cursor..].starts_with(b"<![CDATA[") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 9, b"]]>") else {
|
||||
return Ok(body.to_vec());
|
||||
};
|
||||
let end = end + 3;
|
||||
out.push_str(&input[cursor..end]);
|
||||
cursor = end;
|
||||
continue;
|
||||
}
|
||||
if bytes[cursor..].starts_with(b"<!--") {
|
||||
let Some(end) = find_bytes(bytes, cursor + 4, b"-->") else {
|
||||
return Ok(body.to_vec());
|
||||
};
|
||||
cursor = end + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
let ch = input[cursor..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("cursor is inside UTF-8 input");
|
||||
out.push(ch);
|
||||
cursor += ch.len_utf8();
|
||||
}
|
||||
|
||||
Ok(out.into_bytes())
|
||||
}
|
||||
|
||||
fn find_bytes(haystack: &[u8], start: usize, needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.get(start..)?
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
.map(|offset| start + offset)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keeps_text_spacing_between_tspans() {
|
||||
let input = b"<text><tspan>A</tspan> <tspan>B</tspan></text>";
|
||||
assert_eq!(optimize(input).unwrap().as_slice(), input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_comments_without_touching_cdata() {
|
||||
let input = b"<svg><!--drop--><![CDATA[<!--keep-->]]><path/></svg>";
|
||||
assert_eq!(
|
||||
optimize(input).unwrap(),
|
||||
b"<svg><![CDATA[<!--keep-->]]><path/></svg>"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user