| 1 | package build |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "compress/gzip" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // The comment strippers the release build runs over every stylesheet |
| 10 | // and script, and the compressor the size gate measures with. Both |
| 11 | // come from rastrillo's internal/designsystem/weight.go, where they |
| 12 | // computed the figures on its Getting started page; here what they |
| 13 | // produce is what ships, so the stripped output is held to being the |
| 14 | // same stylesheet and the same script, not only a smaller one. |
| 15 | |
| 16 | // gzipLevel is the level every figure is compressed at: 6, gzip's own |
| 17 | // default and the level a server or edge that compresses on the fly |
| 18 | // typically uses. A fixed level, so the figure does not depend on |
| 19 | // whatever DefaultCompression happens to mean in a given Go release. |
| 20 | const gzipLevel = 6 |
| 21 | |
| 22 | // gzipped is the compressed length of b at gzipLevel. |
| 23 | func gzipped(b []byte) int { |
| 24 | var buf bytes.Buffer |
| 25 | zw, err := gzip.NewWriterLevel(&buf, gzipLevel) |
| 26 | if err != nil { |
| 27 | // Only an out-of-range level errors, and gzipLevel is a constant |
| 28 | // inside the range. |
| 29 | panic("build: " + err.Error()) |
| 30 | } |
| 31 | zw.Write(b) |
| 32 | zw.Close() |
| 33 | return buf.Len() |
| 34 | } |
| 35 | |
| 36 | // stripCSSComments removes every /* */ comment outside a string. A |
| 37 | // comment on a line of its own takes the line with it, so the file is |
| 38 | // one with no comments, not one full of blank lines. |
| 39 | func stripCSSComments(src []byte) []byte { |
| 40 | var out bytes.Buffer |
| 41 | for i := 0; i < len(src); { |
| 42 | c := src[i] |
| 43 | switch { |
| 44 | case c == '"' || c == '\'': |
| 45 | j := endOfQuoted(src, i) |
| 46 | out.Write(src[i:j]) |
| 47 | i = j |
| 48 | case c == '/' && i+1 < len(src) && src[i+1] == '*': |
| 49 | i = dropComment(&out, src, i, blockCommentEnd(src, i)) |
| 50 | default: |
| 51 | out.WriteByte(c) |
| 52 | i++ |
| 53 | } |
| 54 | } |
| 55 | return out.Bytes() |
| 56 | } |
| 57 | |
| 58 | // stripJSComments removes every // and /* */ comment from a script while |
| 59 | // leaving everything that only looks like one alone: a // inside a |
| 60 | // string, a /* inside a regular expression, a comment-shaped run inside |
| 61 | // a template literal and the code inside that literal's ${}. That is |
| 62 | // three lexical states plus the one ambiguity JavaScript is famous for, |
| 63 | // whether a / starts a regular expression or divides; see regexAllowed. |
| 64 | // |
| 65 | // The result is served, so it is held to being a script a browser |
| 66 | // parses to the same program: TestStrippedScriptsEvaluateAsWritten and |
| 67 | // TestEveryShippedScriptStripsToTheSameShape run both halves in Node. |
| 68 | func stripJSComments(src []byte) []byte { |
| 69 | var out bytes.Buffer |
| 70 | // braces is one entry per open { since the innermost template |
| 71 | // literal's ${: when it reaches zero on a }, that } closes the ${ |
| 72 | // and the scan is back inside the literal. |
| 73 | var braces []int |
| 74 | // parens is one entry per open (, true where it opened the head of |
| 75 | // an if, while, for or with. A / after the ) closing one of those |
| 76 | // starts the statement it governs, so it is a regex: if (x) /re/. |
| 77 | // After any other ) it divides: (a + b) / 2. ctlEnd is the length |
| 78 | // of out just past the last ) that closed such a head. |
| 79 | var parens []bool |
| 80 | ctlEnd := -1 |
| 81 | for i := 0; i < len(src); { |
| 82 | c := src[i] |
| 83 | switch { |
| 84 | case c == '(': |
| 85 | parens = append(parens, controlHead(out.Bytes())) |
| 86 | out.WriteByte(c) |
| 87 | i++ |
| 88 | case c == ')': |
| 89 | head := false |
| 90 | if n := len(parens); n > 0 { |
| 91 | head, parens = parens[n-1], parens[:n-1] |
| 92 | } |
| 93 | out.WriteByte(c) |
| 94 | i++ |
| 95 | if head { |
| 96 | ctlEnd = out.Len() |
| 97 | } |
| 98 | case c == '"' || c == '\'': |
| 99 | j := endOfQuoted(src, i) |
| 100 | out.Write(src[i:j]) |
| 101 | i = j |
| 102 | case c == '`': |
| 103 | j, opened := endOfTemplate(src, i+1) |
| 104 | out.Write(src[i:j]) |
| 105 | i = j |
| 106 | if opened { |
| 107 | braces = append(braces, 0) |
| 108 | } |
| 109 | case c == '{' && len(braces) > 0: |
| 110 | braces[len(braces)-1]++ |
| 111 | out.WriteByte(c) |
| 112 | i++ |
| 113 | case c == '}' && len(braces) > 0 && braces[len(braces)-1] == 0: |
| 114 | braces = braces[:len(braces)-1] |
| 115 | j, opened := endOfTemplate(src, i+1) |
| 116 | out.Write(src[i:j]) |
| 117 | i = j |
| 118 | if opened { |
| 119 | braces = append(braces, 0) |
| 120 | } |
| 121 | case c == '}' && len(braces) > 0: |
| 122 | braces[len(braces)-1]-- |
| 123 | out.WriteByte(c) |
| 124 | i++ |
| 125 | case c == '/' && i+1 < len(src) && src[i+1] == '/': |
| 126 | end := i + 2 |
| 127 | for end < len(src) && src[end] != '\n' { |
| 128 | end++ |
| 129 | } |
| 130 | i = dropComment(&out, src, i, end) |
| 131 | case c == '/' && i+1 < len(src) && src[i+1] == '*': |
| 132 | i = dropComment(&out, src, i, blockCommentEnd(src, i)) |
| 133 | case c == '/' && regexAllowed(out.Bytes(), ctlEnd): |
| 134 | j := endOfRegex(src, i) |
| 135 | out.Write(src[i:j]) |
| 136 | i = j |
| 137 | default: |
| 138 | out.WriteByte(c) |
| 139 | i++ |
| 140 | } |
| 141 | } |
| 142 | return out.Bytes() |
| 143 | } |
| 144 | |
| 145 | // dropComment skips src[start:end], a comment, and returns where the |
| 146 | // scan resumes. A comment alone on its line takes the line with it. A |
| 147 | // block comment spanning lines inside code leaves a newline behind |
| 148 | // rather than nothing: JavaScript's automatic semicolons read line |
| 149 | // breaks, and a comment that held two statements apart must still. One |
| 150 | // inside a line leaves a space unless a space is already beside it: a |
| 151 | // comment separates tokens, and with nothing in its place a+/**/++b |
| 152 | // would read a++ +b, a different sum that parses just as well. |
| 153 | func dropComment(out *bytes.Buffer, src []byte, start, end int) int { |
| 154 | b := out.Bytes() |
| 155 | lineStart := bytes.LastIndexByte(b, '\n') + 1 |
| 156 | aloneBefore := len(bytes.TrimLeft(b[lineStart:], " \t")) == 0 |
| 157 | rest := end |
| 158 | for rest < len(src) && (src[rest] == ' ' || src[rest] == '\t' || src[rest] == '\r') { |
| 159 | rest++ |
| 160 | } |
| 161 | aloneAfter := rest == len(src) || src[rest] == '\n' |
| 162 | if aloneBefore && aloneAfter { |
| 163 | out.Truncate(lineStart) |
| 164 | if rest < len(src) { |
| 165 | rest++ // the line's own newline |
| 166 | } |
| 167 | return rest |
| 168 | } |
| 169 | if bytes.IndexByte(src[start:end], '\n') >= 0 { |
| 170 | out.WriteByte('\n') |
| 171 | } else if end < len(src) && !isSpaceByte(src[end]) && len(b) > 0 && !isSpaceByte(b[len(b)-1]) { |
| 172 | out.WriteByte(' ') |
| 173 | } |
| 174 | return end |
| 175 | } |
| 176 | |
| 177 | // blockCommentEnd is the index just past the */ closing the comment |
| 178 | // that starts at i, or the end of src for one that never closes. |
| 179 | func blockCommentEnd(src []byte, i int) int { |
| 180 | if j := bytes.Index(src[i+2:], []byte("*/")); j >= 0 { |
| 181 | return i + 2 + j + 2 |
| 182 | } |
| 183 | return len(src) |
| 184 | } |
| 185 | |
| 186 | // endOfQuoted is the index just past the string literal opening at i, |
| 187 | // honouring backslash escapes. |
| 188 | func endOfQuoted(src []byte, i int) int { |
| 189 | q := src[i] |
| 190 | for j := i + 1; j < len(src); j++ { |
| 191 | switch src[j] { |
| 192 | case '\\': |
| 193 | j++ |
| 194 | case q: |
| 195 | return j + 1 |
| 196 | } |
| 197 | } |
| 198 | return len(src) |
| 199 | } |
| 200 | |
| 201 | // endOfTemplate scans template-literal text from i and returns the |
| 202 | // index just past where it stops: past the closing backtick, or past a |
| 203 | // ${, in which case opened is true and code follows. |
| 204 | func endOfTemplate(src []byte, i int) (int, bool) { |
| 205 | for j := i; j < len(src); j++ { |
| 206 | switch src[j] { |
| 207 | case '\\': |
| 208 | j++ |
| 209 | case '`': |
| 210 | return j + 1, false |
| 211 | case '$': |
| 212 | if j+1 < len(src) && src[j+1] == '{' { |
| 213 | return j + 2, true |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 | return len(src), false |
| 218 | } |
| 219 | |
| 220 | // endOfRegex is the index just past the regular-expression literal |
| 221 | // opening at i, flags included. A / inside a [class] does not close it. |
| 222 | func endOfRegex(src []byte, i int) int { |
| 223 | inClass := false |
| 224 | for j := i + 1; j < len(src); j++ { |
| 225 | switch c := src[j]; { |
| 226 | case c == '\\': |
| 227 | j++ |
| 228 | case c == '\n': |
| 229 | return j // not a regex after all; give the line back |
| 230 | case c == '[': |
| 231 | inClass = true |
| 232 | case c == ']': |
| 233 | inClass = false |
| 234 | case c == '/' && !inClass: |
| 235 | j++ |
| 236 | for j < len(src) && isWordByte(src[j]) { |
| 237 | j++ |
| 238 | } |
| 239 | return j |
| 240 | } |
| 241 | } |
| 242 | return len(src) |
| 243 | } |
| 244 | |
| 245 | // regexAllowed says whether a / after the code written so far starts a |
| 246 | // regular expression rather than dividing. The rule is the usual one: a |
| 247 | // regex can follow an operator, an opening bracket, a comma or a |
| 248 | // semicolon, the ) of a control-flow head (ctlEnd, see stripJSComments), |
| 249 | // or one of the keywords that take an expression; a division follows a |
| 250 | // value: a name, a number, any other closing bracket, and x++ or x--. |
| 251 | // |
| 252 | // A } is taken for the end of an object, so a / after it divides. A |
| 253 | // regex opening a statement straight after a block's } would be read |
| 254 | // wrong; no shipped script writes one, and |
| 255 | // TestEveryShippedScriptStripsToTheSameShape would say if one did. |
| 256 | func regexAllowed(before []byte, ctlEnd int) bool { |
| 257 | b := bytes.TrimRight(before, " \t\r\n") |
| 258 | if len(b) == 0 { |
| 259 | return true |
| 260 | } |
| 261 | last := b[len(b)-1] |
| 262 | if last == ')' { |
| 263 | return len(b) == ctlEnd |
| 264 | } |
| 265 | if (last == '+' || last == '-') && len(b) > 1 && b[len(b)-2] == last { |
| 266 | return false |
| 267 | } |
| 268 | if strings.IndexByte("(,=:[!&|?{};+-*%<>~^", last) >= 0 { |
| 269 | return true |
| 270 | } |
| 271 | if !isWordByte(last) { |
| 272 | return false |
| 273 | } |
| 274 | switch lastWord(b) { |
| 275 | case "return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield", "await": |
| 276 | return true |
| 277 | } |
| 278 | return false |
| 279 | } |
| 280 | |
| 281 | // controlHead says whether a ( written after before opens the head of |
| 282 | // an if, while, for or with, including for await (…), whose head is |
| 283 | // a for's with one more word in front of the parenthesis. |
| 284 | func controlHead(before []byte) bool { |
| 285 | b := bytes.TrimRight(before, " \t\r\n") |
| 286 | w := lastWord(b) |
| 287 | if w == "await" { |
| 288 | w = lastWord(bytes.TrimRight(b[:len(b)-len(w)], " \t\r\n")) |
| 289 | return w == "for" |
| 290 | } |
| 291 | switch w { |
| 292 | case "if", "while", "for", "with": |
| 293 | return true |
| 294 | } |
| 295 | return false |
| 296 | } |
| 297 | |
| 298 | // lastWord is the identifier or keyword b ends with, if it ends with one. |
| 299 | func lastWord(b []byte) string { |
| 300 | j := len(b) |
| 301 | for j > 0 && isWordByte(b[j-1]) { |
| 302 | j-- |
| 303 | } |
| 304 | return string(b[j:]) |
| 305 | } |
| 306 | |
| 307 | func isSpaceByte(c byte) bool { return c == ' ' || c == '\t' || c == '\r' || c == '\n' } |
| 308 | |
| 309 | func isWordByte(c byte) bool { |
| 310 | return c == '_' || c == '$' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= 0x80 |
| 311 | } |
| 312 | |