Skip to content

Commit cbbbd97

Browse files
committed
feat(build): yeti.min.js is minified by esbuild, with a source map beside it
The old minifier only took the comments out, so a file called .min.js read as unminified to anyone who opened it. esbuild shortens the names and drops the whitespace, with the target at esnext so nothing is transpiled, and writes yeti.min.js.map pointing at the readable yeti.js, which is what keeps a stack trace pointing at code a person recognises. The banner goes back on its own line and the map is shifted by that line. Nothing a user needs changes: the source tree still loads unbuilt and yeti.js is still the plain concatenation.
1 parent cd47efc commit cbbbd97

8 files changed

Lines changed: 599 additions & 179 deletions

File tree

‎bin/build.js‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { writeIde } from './gen-ide.js';
1212
import { writeTypes } from './gen-types.js';
1313
import { writeLlms } from './gen-llms.js';
1414
import { minifyCss } from './lib/minify-css.js';
15-
import { minifyJs } from './lib/minify-js.js';
15+
import { minifyJs, bannered } from './lib/minify-js.js';
1616
import { LAYER_STATEMENT } from './lib/layers.js';
1717

1818
export function readPackage(root) {
@@ -51,21 +51,22 @@ export function build({ root, pkg = readPackage(root) }) {
5151
// One file with every module, for a page that would rather load one
5252
// script than pick. The modules import nothing and export nothing, so
5353
// each goes in its own block, which keeps their top-level names apart.
54-
// Minified in this same pre-write step, beside the stylesheet: the strip
55-
// throws on anything it cannot read, and that has to stop the build with
54+
// Minified in this same pre-write step, beside the stylesheet: esbuild
55+
// throws on anything it cannot parse, and that has to stop the build with
5656
// a named error rather than surface as an uncaught throw after dist/ has
57-
// already been wiped below.
57+
// already been wiped below. What is minified is the exact text of the
58+
// readable file, banner and all, so the source map points into yeti.js.
5859
const modules = walkFiles(srcDir).filter((f) => f.endsWith('.js')).sort((a, b) => path.basename(a).localeCompare(path.basename(b)));
5960
let allJs = null;
6061
let allJsMinified = null;
6162
if (modules.length) {
6263
const parts = modules.map((file) => `// ${path.basename(file)}\n{\n${fs.readFileSync(file, 'utf8').trim()}\n}\n`);
6364
allJs = `// Yeti ${pkg.version}: every optional module in one file. Load with <script type="module">.\n\n${parts.join('\n')}`;
6465
try {
65-
allJsMinified = minifyJs(allJs);
66+
allJsMinified = minifyJs(`${bundled.header}${allJs}`, { sourcefile: 'yeti.js' });
6667
} catch (e) {
67-
// The strip's error is an offset into the concatenated bundle, which
68-
// names no file; re-running it module by module finds the one that
68+
// esbuild's error is a line in the concatenated bundle, which names
69+
// no file; re-running it module by module finds the one that
6970
// broke, so this error reads like every other one build() reports.
7071
const broken = modules.find((file) => {
7172
try { minifyJs(fs.readFileSync(file, 'utf8')); return false; } catch { return true; }
@@ -114,9 +115,11 @@ export function build({ root, pkg = readPackage(root) }) {
114115
}
115116
if (allJs !== null) {
116117
write('yeti.js', `${bundled.header}${allJs}`);
117-
// The banner goes back on after minifying, same as the stylesheet:
118-
// the strip takes every comment out, including one it did not write.
119-
write('yeti.min.js', `${bundled.header}${allJsMinified}`);
118+
// The banner goes back on after minifying, same as the stylesheet, on
119+
// its own line; the map is shifted by that line so it still points at
120+
// yeti.js exactly. The map is named on the last line, as browsers expect.
121+
write('yeti.min.js', `${bundled.header}${allJsMinified.js}\n//# sourceMappingURL=yeti.min.js.map\n`);
122+
write('yeti.min.js.map', `${JSON.stringify(bannered(allJsMinified.map, { file: 'yeti.min.js' }))}\n`);
120123
}
121124

122125
const schema = loadSchema(path.join(root, 'schema', 'manifest.schema.json'));

‎bin/lib/minify-js.js‎

Lines changed: 49 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,55 @@
1-
// dist/yeti.min.js. lightningcss minifies CSS only, and a real JS minifier
2-
// would be a second dependency for a few hundred bytes, so this does the one
3-
// safe thing: it removes comments and blank lines. Nothing is renamed and
4-
// nothing is reformatted, so a stack trace from the minified bundle still
5-
// points at code a person recognises.
1+
// dist/yeti.min.js. esbuild minifies the module bundle the way lightningcss
2+
// minifies the stylesheet: smaller, never different. The target is esnext, so
3+
// nothing is transpiled; Yeti's floor is Baseline 2025 and every browser at
4+
// that floor reads the source as written. Names are shortened and whitespace
5+
// goes, which is what a file called .min.js is expected to be, and the source
6+
// map beside it is what keeps a stack trace pointing at code a person
7+
// recognises: the map's one source is dist/yeti.js, the readable bundle.
68
//
7-
// It reads character by character instead of matching a regular expression,
8-
// because a comment marker inside a string ("https://…"), a template or a
9-
// regex literal is not a comment and no regular expression can tell the
10-
// difference. Anything it cannot finish reading throws: a build that stops is
11-
// better than a module quietly cut in half.
9+
// Neither this nor esbuild is anything a user needs. The source tree loads
10+
// unbuilt and dist/yeti.js is the plain concatenation; this writes the one
11+
// optional file beside them.
12+
import { transformSync } from 'esbuild';
1213

13-
// After one of these words a slash opens a regex literal; after a name, a
14-
// number, a closing bracket or a closing paren it divides.
15-
const REGEX_AFTER = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'case', 'do', 'else', 'yield', 'await', 'throw']);
16-
17-
/** The index just past the string or template literal that starts at `start`. */
18-
function endOfString(source, start) {
19-
const quote = source[start];
20-
let i = start + 1;
21-
while (i < source.length) {
22-
const ch = source[i];
23-
if (ch === '\\') { i += 2; continue; }
24-
if (ch === quote) return i + 1;
25-
// A template's ${ … } holds real code, which may hold another string
26-
// or another template, so read it rather than hunting for the next
27-
// backtick, which could be inside one of them.
28-
if (quote === '`' && ch === '$' && source[i + 1] === '{') { i = endOfExpression(source, i + 2); continue; }
29-
if (quote !== '`' && ch === '\n') break;
30-
i += 1;
31-
}
32-
throw new Error(`unterminated ${quote === '`' ? 'template' : 'string'} at index ${start}`);
33-
}
34-
35-
/** The index just past the closing brace of a template's ${ … }. */
36-
function endOfExpression(source, start) {
37-
let depth = 1;
38-
let i = start;
39-
while (i < source.length) {
40-
const ch = source[i];
41-
if (ch === '"' || ch === "'" || ch === '`') { i = endOfString(source, i); continue; }
42-
if (ch === '{') depth += 1;
43-
if (ch === '}' && --depth === 0) return i + 1;
44-
i += 1;
45-
}
46-
throw new Error(`unterminated template expression at index ${start}`);
47-
}
48-
49-
/** The index just past the regex literal and its flags that start at `start`. */
50-
function endOfRegex(source, start) {
51-
let i = start + 1;
52-
let inClass = false;
53-
while (i < source.length) {
54-
const ch = source[i];
55-
if (ch === '\\') { i += 2; continue; }
56-
if (ch === '[') inClass = true;
57-
else if (ch === ']') inClass = false;
58-
else if (ch === '/' && !inClass) {
59-
i += 1;
60-
while (i < source.length && /[a-z]/.test(source[i])) i += 1;
61-
return i;
62-
} else if (ch === '\n') break;
63-
i += 1;
64-
}
65-
throw new Error(`unterminated regex at index ${start}`);
66-
}
67-
68-
/** Strips comments and blank lines. Throws rather than guess at anything it cannot read. */
69-
export function minifyJs(source) {
70-
let out = '';
71-
let i = 0;
72-
// The last character written and the identifier it ends: together they say
73-
// whether the next slash opens a regex literal or divides.
74-
let lastChar = '';
75-
let lastWord = '';
76-
const push = (text) => {
77-
out += text;
78-
const trimmed = text.trimEnd();
79-
if (!trimmed) return;
80-
lastChar = trimmed.at(-1);
81-
lastWord = trimmed.match(/[A-Za-z0-9_$]+$/)?.[0] ?? '';
82-
};
83-
while (i < source.length) {
84-
const ch = source[i];
85-
const next = source[i + 1];
86-
if (ch === '/' && next === '/') {
87-
while (i < source.length && source[i] !== '\n') i += 1;
88-
continue;
89-
}
90-
if (ch === '/' && next === '*') {
91-
const end = source.indexOf('*/', i + 2);
92-
if (end === -1) throw new Error(`unterminated block comment at index ${i}`);
93-
i = end + 2;
94-
continue;
95-
}
96-
if (ch === '"' || ch === "'" || ch === '`') {
97-
const end = endOfString(source, i);
98-
push(source.slice(i, end));
99-
i = end;
100-
continue;
101-
}
102-
if (ch === '/' && regexAllowed(lastChar, lastWord)) {
103-
const end = endOfRegex(source, i);
104-
push(source.slice(i, end));
105-
i = end;
106-
continue;
107-
}
108-
push(ch);
109-
i += 1;
14+
/**
15+
* Minifies a bundle. `source` is the exact text of the readable file the map
16+
* should point at, banner included, so every position in the map is a
17+
* position in that file. The banner itself is dropped from the output, since
18+
* esbuild would keep it on the first line ahead of the code; build() puts it
19+
* back on its own line and shifts the map by that line.
20+
* Returns { js, map }: the minified code with no trailing newline, and the
21+
* source map as an object with `sources` naming `sourcefile`.
22+
* Throws when esbuild cannot parse the source, with esbuild's message, which
23+
* names the line and column.
24+
*/
25+
export function minifyJs(source, { sourcefile = 'yeti.js' } = {}) {
26+
let result;
27+
try {
28+
result = transformSync(source, {
29+
minify: true,
30+
format: 'esm',
31+
target: 'esnext',
32+
charset: 'utf8',
33+
legalComments: 'none',
34+
sourcemap: 'external',
35+
sourcesContent: false,
36+
sourcefile,
37+
});
38+
} catch (e) {
39+
const first = e.errors?.[0];
40+
const where = first?.location ? ` at ${first.location.line}:${first.location.column}` : '';
41+
throw new Error(`${first?.text ?? e.message}${where}`);
11042
}
111-
// A line that held only a comment is now blank, and a stripped trailing
112-
// comment leaves the spaces that were in front of it.
113-
const lines = out.split('\n').map((line) => line.trimEnd()).filter((line) => line !== '');
114-
return lines.length ? `${lines.join('\n')}\n` : '';
43+
const map = JSON.parse(result.map);
44+
return { js: result.code.trimEnd(), map };
11545
}
11646

117-
function regexAllowed(lastChar, lastWord) {
118-
if (!lastChar) return true;
119-
if (/[)\]}]/.test(lastChar)) return false;
120-
if (/[A-Za-z0-9_$]/.test(lastChar)) return REGEX_AFTER.has(lastWord);
121-
return true;
47+
/**
48+
* The map for a minified file whose first line is a banner the minifier never
49+
* saw: one empty generated line in front of every mapping, and the file names
50+
* filled in. `mappings` is a list of generated lines separated by semicolons,
51+
* so an empty first line is one leading semicolon.
52+
*/
53+
export function bannered(map, { file }) {
54+
return { ...map, file, mappings: `;${map.mappings}` };
12255
}

‎docs/guides/install.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ Yeti's rules live in cascade layers, so anything you write outside a layer wins
3838

3939
## The minified copies
4040

41-
`dist/yeti.min.css` is the same stylesheet with the whitespace and the comments taken out, and `dist/yeti.min.js` is the same module bundle with its comments taken out — in both, save for the one-line licence banner every shipped file carries. Link them instead when you are counting bytes:
41+
`dist/yeti.min.css` is the same stylesheet with the whitespace and the comments taken out, and `dist/yeti.min.js` is the same module bundle minified, names shortened and whitespace gone, with `yeti.min.js.map` beside it so a stack trace still points at a line of `yeti.js` — in both, save for the one-line licence banner every shipped file carries. Link them instead when you are counting bytes:
4242

4343
```html
4444
<link rel="stylesheet" href="node_modules/yeti-css/dist/yeti.min.css">

0 commit comments

Comments
 (0)