Skip to main content

ssg/plugins/assets/
js_minify.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! The in-tree JavaScript minifier.
5//!
6//! A single tokenising pass. String, template and regex literals are
7//! copied through byte for byte; only the whitespace and comments between
8//! tokens are touched. The earlier two-pass version tracked quotes in its
9//! first pass only, so the second collapsed whitespace inside literals:
10//! `"0px 0px -15% 0px"` became `"0px 0px-15%0px"`, `'$ '` became `'$'`,
11//! template literals lost their indentation, and a regex such as `/[/*]/`
12//! opened a block comment that swallowed the rest of the script (#799).
13
14/// Keywords after which `/` starts a regular expression, not a division.
15const REGEX_PREFIX_KEYWORDS: &[&str] = &[
16    "return",
17    "typeof",
18    "case",
19    "in",
20    "of",
21    "new",
22    "delete",
23    "void",
24    "throw",
25    "do",
26    "else",
27    "yield",
28    "await",
29    "instanceof",
30];
31
32/// Minifies JavaScript by removing comments and collapsing whitespace.
33///
34/// ssg's own implementation rather than a dependency. It is deliberately
35/// conservative: it does not rename, reorder or rewrite anything, so it
36/// cannot change what a script does. String, template and regex literals
37/// are copied verbatim, and the division operator is left alone.
38///
39/// # Examples
40///
41/// ```
42/// use ssg::plugins::minify_js;
43///
44/// let out = minify_js("const m = { rootMargin: \"0px 0px -15% 0px\" };");
45/// assert_eq!(out, "const m={rootMargin:\"0px 0px -15% 0px\"};");
46/// ```
47#[must_use]
48pub fn minify_js(js: &str) -> String {
49    let src: Vec<char> = js.chars().collect();
50    let mut m = Minifier {
51        src: &src,
52        out: String::with_capacity(js.len()),
53        gap: Gap::None,
54        after_literal: false,
55    };
56    let mut i = 0;
57    while i < src.len() {
58        i = m.step(i);
59    }
60    m.out
61}
62
63/// Whitespace pending between two tokens.
64#[derive(Clone, Copy, PartialEq, Eq)]
65enum Gap {
66    None,
67    Space,
68    Newline,
69}
70
71struct Minifier<'a> {
72    src: &'a [char],
73    out: String,
74    gap: Gap,
75    /// The last token emitted was a string, template or regex literal.
76    after_literal: bool,
77}
78
79impl Minifier<'_> {
80    /// Consumes the token at `i` and returns the index after it.
81    fn step(&mut self, i: usize) -> usize {
82        let c = self.src[i];
83        let next = self.src.get(i + 1).copied();
84        match c {
85            '/' if next == Some('/') => {
86                skip_while(self.src, i, |c| !is_line_break(c))
87            }
88            '/' if next == Some('*') => self.skip_block_comment(i),
89            '\'' | '"' => self.copy_literal(i, string_end(self.src, i)),
90            '`' => self.copy_literal(i, template_end(self.src, i)),
91            '/' if self.regex_allowed() => {
92                self.copy_literal(i, regex_end(self.src, i))
93            }
94            c if c.is_whitespace() => {
95                self.widen_gap(is_line_break(c));
96                i + 1
97            }
98            _ => {
99                self.flush_gap(c);
100                self.out.push(c);
101                self.after_literal = false;
102                i + 1
103            }
104        }
105    }
106
107    /// Records whitespace; a line break outranks a space because it can
108    /// end a statement.
109    fn widen_gap(&mut self, line_break: bool) {
110        if line_break {
111            self.gap = Gap::Newline;
112        } else if self.gap == Gap::None {
113            self.gap = Gap::Space;
114        }
115    }
116
117    /// A block comment separates tokens like the whitespace it contains.
118    fn skip_block_comment(&mut self, i: usize) -> usize {
119        let mut j = i + 2;
120        while j < self.src.len()
121            && !(self.src[j] == '*' && self.src.get(j + 1) == Some(&'/'))
122        {
123            j += 1;
124        }
125        let breaks = self.src[i..j.min(self.src.len())]
126            .iter()
127            .any(|&c| is_line_break(c));
128        self.widen_gap(breaks);
129        (j + 2).min(self.src.len())
130    }
131
132    fn copy_literal(&mut self, start: usize, end: usize) -> usize {
133        self.flush_gap(self.src[start]);
134        self.out.extend(&self.src[start..end]);
135        self.after_literal = true;
136        end
137    }
138
139    /// Emits the pending whitespace if dropping it would join two tokens,
140    /// or a line break if dropping it would change where a statement
141    /// ends.
142    fn flush_gap(&mut self, next: char) {
143        let gap = std::mem::replace(&mut self.gap, Gap::None);
144        let Some(prev) = self.out.chars().next_back() else {
145            return;
146        };
147        if gap == Gap::Newline
148            && self.may_end_statement()
149            && !continues_statement(next)
150        {
151            self.out.push('\n');
152        } else if gap != Gap::None && needs_separator(prev, next) {
153            self.out.push(if gap == Gap::Newline { '\n' } else { ' ' });
154        }
155    }
156
157    /// Whether the last token emitted can end a statement, so that a line
158    /// break after it may be where automatic semicolon insertion puts one:
159    /// a value (identifier, keyword, number, literal, `)`, `]`, `}`) or a
160    /// postfix `++`/`--`. `var a = {}\nfoo()` lost its line break and
161    /// became `var a={}foo()`, a syntax error.
162    fn may_end_statement(&self) -> bool {
163        self.after_literal
164            || self.out.ends_with("++")
165            || self.out.ends_with("--")
166            || self
167                .out
168                .chars()
169                .next_back()
170                .is_some_and(|c| is_word(c) || matches!(c, ')' | ']' | '}'))
171    }
172
173    /// Whether a `/` here opens a regex: after an operator, an opening
174    /// bracket, a keyword or nothing at all. After a value (an
175    /// identifier, a number, a literal, `)`, `]` or `}`) it divides.
176    fn regex_allowed(&self) -> bool {
177        if self.after_literal {
178            return false;
179        }
180        match self.out.chars().next_back() {
181            None => true,
182            Some(c) if is_word(c) => {
183                REGEX_PREFIX_KEYWORDS.contains(&trailing_word(&self.out))
184            }
185            Some(')' | ']' | '}') => false,
186            // `a++ / b`: a postfix operator ends a value.
187            Some(_) => !(self.out.ends_with("++") || self.out.ends_with("--")),
188        }
189    }
190}
191
192fn is_word(c: char) -> bool {
193    c.is_alphanumeric() || c == '_' || c == '$'
194}
195
196const fn is_line_break(c: char) -> bool {
197    matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}')
198}
199
200/// Whether whitespace between `prev` and `next` is significant: two words
201/// would merge, `a - -b` would become a decrement, `a / /re/` a comment,
202/// and `1 .toString()` a malformed number.
203fn needs_separator(prev: char, next: char) -> bool {
204    (is_word(prev) && is_word(next))
205        || (prev == next && matches!(prev, '+' | '-' | '/'))
206        || (prev.is_ascii_digit() && next == '.')
207}
208
209/// Whether a line may start with `next` and still continue the statement
210/// before it, so the line break carries no meaning: member access,
211/// separators, closers and binary operators that cannot start a statement.
212/// Anything else (an identifier, a literal, `(`, `[`, `+`, `-`, `/`, `!`,
213/// `{`, a template) keeps its line break, which is never wrong.
214const fn continues_statement(next: char) -> bool {
215    matches!(
216        next,
217        '.' | ','
218            | ';'
219            | ')'
220            | ']'
221            | '}'
222            | '?'
223            | ':'
224            | '='
225            | '*'
226            | '%'
227            | '&'
228            | '|'
229            | '^'
230            | '<'
231            | '>'
232    )
233}
234
235/// The identifier or number at the end of `out`.
236fn trailing_word(out: &str) -> &str {
237    let start = out
238        .char_indices()
239        .rev()
240        .take_while(|&(_, c)| is_word(c))
241        .last()
242        .map_or(out.len(), |(i, _)| i);
243    &out[start..]
244}
245
246/// The first index at or after `i` whose character fails `keep`.
247fn skip_while(src: &[char], i: usize, keep: impl Fn(char) -> bool) -> usize {
248    src[i..]
249        .iter()
250        .position(|&c| !keep(c))
251        .map_or(src.len(), |n| i + n)
252}
253
254/// Index just past the string literal opening at `i`. An unterminated
255/// string stops at the line break, which JavaScript forbids inside one.
256const fn string_end(src: &[char], i: usize) -> usize {
257    let quote = src[i];
258    let mut j = i + 1;
259    while j < src.len() {
260        match src[j] {
261            '\\' => j += 2,
262            c if c == quote => return j + 1,
263            c if is_line_break(c) => return j,
264            _ => j += 1,
265        }
266    }
267    src.len()
268}
269
270/// Index just past the template literal opening at `i`, stepping over
271/// every `${…}` substitution, which may hold strings and templates of its
272/// own.
273fn template_end(src: &[char], i: usize) -> usize {
274    let mut j = i + 1;
275    while j < src.len() {
276        match src[j] {
277            '\\' => j += 2,
278            '`' => return j + 1,
279            '$' if src.get(j + 1) == Some(&'{') => {
280                j = substitution_end(src, j + 2);
281            }
282            _ => j += 1,
283        }
284    }
285    src.len()
286}
287
288/// Index just past the `}` closing a `${` substitution whose body starts
289/// at `i`.
290fn substitution_end(src: &[char], i: usize) -> usize {
291    let mut depth = 1usize;
292    let mut j = i;
293    while j < src.len() {
294        match src[j] {
295            '{' => depth += 1,
296            '}' if depth == 1 => return j + 1,
297            '}' => depth -= 1,
298            '\'' | '"' => j = string_end(src, j) - 1,
299            '`' => j = template_end(src, j) - 1,
300            _ => {}
301        }
302        j += 1;
303    }
304    src.len()
305}
306
307/// Index just past the regex literal opening at `i`. A `/` inside a
308/// character class does not close it; a line break ends the scan, since
309/// a regex cannot span lines.
310const fn regex_end(src: &[char], i: usize) -> usize {
311    let mut j = i + 1;
312    let mut in_class = false;
313    while j < src.len() {
314        match src[j] {
315            '\\' => j += 1,
316            '[' => in_class = true,
317            ']' => in_class = false,
318            '/' if !in_class => return j + 1,
319            c if is_line_break(c) => return j,
320            _ => {}
321        }
322        j += 1;
323    }
324    src.len()
325}
326
327#[cfg(test)]
328#[path = "js_minify_tests.rs"]
329mod tests;