Skip to content

Commit 21b5142

Browse files
committed
feat(terser): support directory inputs
1 parent 21ad77a commit 21b5142

File tree

9 files changed

+196
-32
lines changed

9 files changed

+196
-32
lines changed

packages/terser/src/BUILD.bazel

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
load("@bazel_skylib//:bzl_library.bzl", "bzl_library")
16+
load("@build_bazel_rules_nodejs//:defs.bzl", "nodejs_binary")
1617

1718
package(default_visibility = ["//visibility:public"])
1819

@@ -38,3 +39,9 @@ filegroup(
3839
"terser_minified.bzl",
3940
],
4041
)
42+
43+
nodejs_binary(
44+
name = "terser",
45+
data = ["@npm//terser"],
46+
entry_point = "index.js",
47+
)

packages/terser/src/index.from_src.bzl

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ load(
2222

2323
def terser_minified(**kwargs):
2424
_terser_minified(
25-
# Override to point to the one installed by build_bazel_rules_nodejs in the root
26-
terser_bin = "@npm//terser/bin:terser",
25+
# Override to point to the one we declare locally
26+
terser_bin = "@npm_bazel_terser//:terser",
2727
**kwargs
2828
)

packages/terser/src/index.js

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,76 @@
11
#!/usr/bin/env node
2+
/**
3+
* @fileoverview wraps the terser CLI to support minifying a directory
4+
* Terser doesn't support it; see https://github.com/terser/terser/issues/75
5+
* TODO: maybe we should generalize this to a package which would be useful outside
6+
* bazel; however we would have to support the full terser CLI and not make
7+
* assumptions about how the argv looks.
8+
*/
9+
const fs = require('fs');
10+
const path = require('path');
11+
const child_process = require('child_process');
212

3-
// Pass-through require, ensures that the nodejs_binary will load the version of terser
4-
// from @bazel/terser package.json, not some other version the user depends on.
5-
require('terser/bin/uglifyjs');
13+
// Run Bazel with --define=VERBOSE_LOGS=1 to enable this logging
14+
const VERBOSE_LOGS = !!process.env['VERBOSE_LOGS'];
615

7-
// TODO: add support for minifying multiple files (eg. a TreeArtifact) in a single execution
8-
// Under Node 12 it should use the worker threads API to saturate all local cores
16+
function log_verbose(...m) {
17+
if (VERBOSE_LOGS) console.error('[terser/index.js]', ...m);
18+
}
19+
20+
// Peek at the arguments to find any directories declared as inputs
21+
let argv = process.argv.slice(2);
22+
// terser_minified.bzl always passes the inputs first,
23+
// then --output [out], then remaining args
24+
// We want to keep those remaining ones to pass to terser
25+
// Avoid a dependency on a library like minimist; keep it simple.
26+
const outputArgIndex = argv.findIndex((arg) => arg.startsWith('--'));
27+
28+
const inputs = argv.slice(0, outputArgIndex);
29+
const output = argv[outputArgIndex + 1];
30+
const residual = argv.slice(outputArgIndex + 2);
31+
32+
log_verbose(`Running terser/index.js
33+
inputs: ${inputs}
34+
output: ${output}
35+
residual: ${residual}`);
36+
37+
function isDirectory(input) {
38+
return fs.lstatSync(path.join(process.cwd(), input)).isDirectory();
39+
}
40+
41+
function terserDirectory(input) {
42+
if (!fs.existsSync(output)) {
43+
fs.mkdirSync(output);
44+
}
45+
46+
fs.readdirSync(input).forEach(f => {
47+
if (f.endsWith('.js')) {
48+
const inputFile = path.join(input, path.basename(f));
49+
const outputFile = path.join(output, path.basename(f));
50+
// We don't want to implement a command-line parser for terser
51+
// so we invoke its CLI as child processes, just altering the input/output
52+
// arguments. See discussion: https://github.com/bazelbuild/rules_nodejs/issues/822
53+
// FIXME: this causes unlimited concurrency, which will definitely eat all the RAM on your
54+
// machine;
55+
// we need to limit the concurrency with something like the p-limit package.
56+
// TODO: under Node 12 it should use the worker threads API to saturate all local cores
57+
child_process.fork(
58+
// __dirname will be the path to npm_bazel_terser (Bazel's name for our src/ directory)
59+
// and our node_modules are installed in the ../npm directory since they're external
60+
// Note that the fork API doesn't do any module resolution.
61+
path.join(__dirname, '../npm/node_modules/terser/bin/uglifyjs'),
62+
[inputFile, '--output', outputFile, ...residual]);
63+
}
64+
});
65+
}
66+
67+
if (!inputs.find(isDirectory)) {
68+
// Inputs were only files
69+
// Just use terser CLI exactly as it works outside bazel
70+
require('terser/bin/uglifyjs');
71+
} else if (inputs.length > 1) {
72+
// We don't know how to merge multiple input dirs to one output dir
73+
throw new Error('terser_minified only allows a single input when minifying a directory');
74+
} else {
75+
terserDirectory(inputs[0]);
76+
}

packages/terser/src/terser_minified.bzl

Lines changed: 60 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,30 @@
1414

1515
"Rule to run the terser binary under bazel"
1616

17+
_DOC = """Run the terser minifier.
18+
19+
Typical example:
20+
```python
21+
load("@npm_bazel_terser//:index.bzl", "terser_minified")
22+
23+
terser_minified(
24+
name = "out.min",
25+
src = "input.js",
26+
config_file = "terser_config.json",
27+
)
28+
```
29+
30+
Note that the `name` attribute determines what the resulting files will be called.
31+
So the example above will output `out.min.js` and `out.min.js.map` (since `sourcemap` defaults to `true`).
32+
"""
33+
1734
_TERSER_ATTRS = {
1835
"src": attr.label(
19-
doc = """A JS file, or a rule producing .js as its default output
36+
doc = """A JS file, or a rule producing .js files as its default output
2037
2138
Note that you can pass multiple files to terser, which it will bundle together.
2239
If you want to do this, you can pass a filegroup here.""",
2340
allow_files = [".js"],
24-
mandatory = True,
2541
),
2642
"config_file": attr.label(
2743
doc = """A JSON file containing Terser minify() options.
@@ -67,6 +83,14 @@ so that it only affects the current build.
6783
doc = "Whether to produce a .js.map output",
6884
default = True,
6985
),
86+
"src_dir": attr.label(
87+
doc = """A directory containing some .js files.
88+
89+
Each `.js` file will be run through terser, and the rule will output a directory of minified files.
90+
The output will be a directory named the same as the "name" attribute.
91+
Any files not ending in `.js` will be ignored.
92+
""",
93+
),
7094
"terser_bin": attr.label(
7195
doc = "An executable target that runs Terser",
7296
default = Label("@npm//@bazel/terser/bin:terser"),
@@ -75,7 +99,10 @@ so that it only affects the current build.
7599
),
76100
}
77101

78-
def _terser_outs(sourcemap):
102+
def _terser_outs(src_dir, sourcemap):
103+
if src_dir:
104+
# Tree artifact outputs must be declared with ctx.actions.declare_directory
105+
return {}
79106
result = {"minified": "%{name}.js"}
80107
if sourcemap:
81108
result["sourcemap"] = "%{name}.js.map"
@@ -86,10 +113,28 @@ def _terser(ctx):
86113

87114
# CLI arguments; see https://www.npmjs.com/package/terser#command-line-usage
88115
args = ctx.actions.args()
89-
args.add_all([src.path for src in ctx.files.src])
90-
91-
outputs = [ctx.outputs.minified]
92-
args.add_all(["--output", ctx.outputs.minified.path])
116+
inputs = []
117+
outputs = [getattr(ctx.outputs, o) for o in dir(ctx.outputs)]
118+
119+
if ctx.attr.src and ctx.attr.src_dir:
120+
fail("Only one of src and src_dir attributes should be specified")
121+
if not ctx.attr.src and not ctx.attr.src_dir:
122+
fail("Either src or src_dir is required")
123+
if ctx.attr.src:
124+
for src in ctx.files.src:
125+
if src.is_directory:
126+
fail("Directories should be specified in the src_dir attribute, not src")
127+
args.add(src.path)
128+
inputs.extend(ctx.files.src)
129+
else:
130+
for src in ctx.files.src_dir:
131+
if not src.is_directory:
132+
fail("Individual files should be specifed in the src attribute, not src_dir")
133+
args.add(src.path)
134+
inputs.extend(ctx.files.src_dir)
135+
outputs.append(ctx.actions.declare_directory(ctx.label.name))
136+
137+
args.add_all(["--output", outputs[0].path])
93138

94139
debug = ctx.attr.debug or "DEBUG" in ctx.var.keys()
95140
if debug:
@@ -111,6 +156,7 @@ def _terser(ctx):
111156
args.add_all(["--source-map", ",".join(source_map_opts)])
112157

113158
opts = ctx.actions.declare_file("_%s.minify_options.json" % ctx.label.name)
159+
inputs.append(opts)
114160
ctx.actions.expand_template(
115161
template = ctx.file.config_file,
116162
output = opts,
@@ -123,30 +169,19 @@ def _terser(ctx):
123169
args.add_all(["--config-file", opts.path])
124170

125171
ctx.actions.run(
126-
inputs = ctx.files.src + [opts],
172+
inputs = inputs,
127173
outputs = outputs,
128174
executable = ctx.executable.terser_bin,
129175
arguments = [args],
130-
progress_message = "Minifying JavaScript %s [terser]" % (ctx.outputs.minified.short_path),
176+
progress_message = "Minifying JavaScript %s [terser]" % (outputs[0].short_path),
131177
)
132178

133-
terser_minified = rule(
134-
doc = """Run the terser minifier.
135-
136-
Typical example:
137-
```python
138-
load("@npm_bazel_terser//:index.bzl", "terser_minified")
179+
return [
180+
DefaultInfo(files = depset(outputs)),
181+
]
139182

140-
terser_minified(
141-
name = "out.min",
142-
src = "input.js",
143-
config_file = "terser_config.json",
144-
)
145-
```
146-
147-
Note that the `name` attribute determines what the resulting files will be called.
148-
So the example above will output `out.min.js` and `out.min.js.map` (since `sourcemap` defaults to `true`).
149-
""",
183+
terser_minified = rule(
184+
doc = _DOC,
150185
implementation = _terser,
151186
attrs = _TERSER_ATTRS,
152187
outputs = _terser_outs,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
load("@npm_bazel_jasmine//:index.from_src.bzl", "jasmine_node_test")
2+
load("@npm_bazel_terser//:index.from_src.bzl", "terser_minified")
3+
load(":rule.bzl", "declare_directory")
4+
5+
# Check that filegroups work
6+
declare_directory(
7+
name = "dir",
8+
srcs = glob(["input*.js"]),
9+
)
10+
11+
terser_minified(
12+
name = "out.min",
13+
# TODO: support sourcemaps too
14+
sourcemap = False,
15+
src_dir = "dir",
16+
)
17+
18+
jasmine_node_test(
19+
name = "test",
20+
srcs = ["spec.js"],
21+
data = [":out.min"],
22+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
function a() {
2+
var somelongname = 'a';
3+
console.log(somelongname);
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
function b() {
2+
var someotherlongname = 'b';
3+
console.log(someotherlongname);
4+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"Minimal test fixture to create a directory output"
2+
3+
def _impl(ctx):
4+
dir = ctx.actions.declare_directory(ctx.label.name)
5+
ctx.actions.run_shell(
6+
inputs = ctx.files.srcs,
7+
outputs = [dir],
8+
# RBE requires that we mkdir, but outside RBE it might already exist
9+
command = "mkdir -p {0}; cp $@ {0}".format(dir.path),
10+
arguments = [s.path for s in ctx.files.srcs],
11+
)
12+
return [
13+
DefaultInfo(files = depset([dir])),
14+
]
15+
16+
declare_directory = rule(_impl, attrs = {"srcs": attr.label_list(allow_files = True)})
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
const fs = require('fs');
2+
3+
describe('terser on a directory', () => {
4+
it('should produce an output for each input', () => {
5+
expect(fs.existsSync(require.resolve(__dirname + '/out.min/input1.js'))).toBeTruthy();
6+
expect(fs.existsSync(require.resolve(__dirname + '/out.min/input2.js'))).toBeTruthy();
7+
});
8+
});

0 commit comments

Comments
 (0)