mirror of
https://github.com/oxc-project/oxc.git
synced 2026-09-14 19:36:11 +08:00
9ef028c2ad
Closes #17068. Supersedes #18095 / #18118. Adds `CodegenOptions::ascii_only` (and `codegen.asciiOnly` in `oxc_minify_napi`): escape every non-ASCII character in string literals, untagged template literals, regexp literals, directives and identifier names so the output is 7-bit clean — like esbuild `--charset=ascii` / terser `format.ascii_only`. Off by default; the default path is untouched (codegen bench and conformance unchanged). ```js // Input let café = "naïve 😀"; let re = /[–—]/u; // Output with ascii_only: true let caf\u00E9 = "na\u00EFve \u{1F600}"; let re = /[\u2013\u2014]/u; ``` Characters above U+FFFF use ES2015 code-point escapes. Regex patterns use escaped surrogate pairs instead; RegExp.prototype.source reflects the escaped spelling. Tagged template quasis, JSX names/text/attribute strings, hashbangs and preserved comments may still contain non-ASCII characters. JavaScript expressions inside tagged templates and JSX are escaped normally. Performance; with the option disabled, codegen codspeed benchmarks are withing noise (<1%). With the option on, performance is only affected for files that contain non ascii characters. --- Our use case is a large Vite/Rolldown app served through proxies that mangle non-ASCII JS (previously handled by terser's `ascii_only`); #17068 also lists deno_core snapshots and Chrome extension content scripts. AI-assisted (Claude Code); I reviewed the code, wrote the tests, and have been running this in production builds via a vendored `oxc_codegen`. --------- Co-authored-by: Samuel Attard <6634592+MarshallOfSound@users.noreply.github.com> Co-authored-by: Cameron Clark <cameron.clark@hey.com> Co-authored-by: Dunqing <dengqing0821@gmail.com>
371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
import { Worker } from "node:worker_threads";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { minify, minifySync } from "../index";
|
|
|
|
describe("simple", () => {
|
|
const code = "/*! legal comment */\nfunction foo() { var bar; bar(undefined) } foo();";
|
|
|
|
it("matches output", () => {
|
|
const ret = minifySync("test.js", code, { sourcemap: true });
|
|
expect(ret.code).toEqual("function foo(){var e;e(void 0)}foo();");
|
|
expect(ret.errors.length).toBe(0);
|
|
expect(ret.map).toMatchObject({
|
|
names: ["bar"],
|
|
sources: ["test.js"],
|
|
sourcesContent: [code],
|
|
version: 3,
|
|
});
|
|
});
|
|
|
|
it("can turn off everything", () => {
|
|
const ret = minifySync("test.js", code, {
|
|
compress: false,
|
|
mangle: false,
|
|
codegen: { removeWhitespace: false },
|
|
});
|
|
expect(ret.code).toBe("function foo() {\n\tvar bar;\n\tbar(undefined);\n}\nfoo();\n");
|
|
});
|
|
|
|
it.each([
|
|
// spellchecker:disable-next-line
|
|
[true, undefined, "export let caf\\u00E9=`na\\u00EFve \\u2615`;"],
|
|
// spellchecker:disable-next-line
|
|
[true, true, "export let caf\\u00E9=`na\\u00EFve \\u2615`;"],
|
|
// spellchecker:disable-next-line
|
|
[true, false, 'export let caf\\u00E9 = "na\\u00EFve \\u2615";\n'],
|
|
[false, true, "export let café=`naïve ☕`;"],
|
|
[false, false, 'export let café = "naïve ☕";\n'],
|
|
[undefined, true, "export let café=`naïve ☕`;"],
|
|
[undefined, false, 'export let café = "naïve ☕";\n'],
|
|
] as const)(
|
|
"codegen.asciiOnly=%s, removeWhitespace=%s",
|
|
(asciiOnly, removeWhitespace, expected) => {
|
|
const ret = minifySync("test.js", "export let café = 'naïve ☕';", {
|
|
codegen: { asciiOnly, removeWhitespace },
|
|
});
|
|
expect(ret.code).toBe(expected);
|
|
expect(ret.errors.length).toBe(0);
|
|
},
|
|
);
|
|
|
|
it("preserves Unicode legal comments with codegen.asciiOnly", () => {
|
|
const ret = minifySync("test.js", "/*! café 😀 */\nexport let x = 'é';", {
|
|
codegen: { asciiOnly: true, legalComments: "inline" },
|
|
});
|
|
expect(ret.code).toBe("/*! café 😀 */\nexport let x=`\\u00E9`;");
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
|
|
it("defaults to esnext", () => {
|
|
const code = "try { foo } catch (e) {}";
|
|
const ret = minifySync("test.js", code);
|
|
expect(ret.code).toBe("try{foo}catch{}");
|
|
});
|
|
|
|
it("returns parser error", () => {
|
|
const code = "const";
|
|
const ret = minifySync("test.js", code);
|
|
expect(ret.code).toBe("");
|
|
expect(ret.errors.length).toBe(1);
|
|
});
|
|
|
|
it("supports drop_labels option", () => {
|
|
const code = "PURE: { foo(); bar(); } OTHER: { baz(); }";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: { dropLabels: ["PURE"] },
|
|
});
|
|
expect(ret.code).toBe("OTHER:baz();");
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("treeshake options", () => {
|
|
it("respects annotations by default", () => {
|
|
const code = "/* @__PURE__ */ foo(); bar();";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: {},
|
|
});
|
|
// The @__PURE__ annotated call should be removed
|
|
expect(ret.code).toBe("bar();");
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
|
|
it("can disable annotations", () => {
|
|
const code = "/* @__PURE__ */ foo(); bar();";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: {
|
|
treeshake: {
|
|
annotations: false,
|
|
},
|
|
},
|
|
});
|
|
// With annotations disabled, @__PURE__ is not respected
|
|
expect(ret.code).toBe("foo(),bar();");
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
|
|
it("supports manual pure functions", () => {
|
|
const code = "foo(); bar(); baz();";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: {
|
|
treeshake: {
|
|
manualPureFunctions: ["foo", "baz"],
|
|
},
|
|
},
|
|
});
|
|
// foo and baz should be removed as they're marked as pure, bar should remain
|
|
expect(ret.code).toBe("bar();");
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
|
|
it("supports propertyReadSideEffects as boolean", () => {
|
|
const code = "const x = obj.prop; foo();";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: {
|
|
treeshake: {
|
|
propertyReadSideEffects: false,
|
|
},
|
|
},
|
|
});
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
|
|
it('supports propertyReadSideEffects as "always"', () => {
|
|
const code = "const x = obj.prop; foo();";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: {
|
|
treeshake: {
|
|
propertyReadSideEffects: "always",
|
|
},
|
|
},
|
|
});
|
|
expect(ret.errors.length).toBe(0);
|
|
});
|
|
|
|
it("rejects invalid propertyReadSideEffects string value", () => {
|
|
const code = "const x = obj.prop; foo();";
|
|
const ret = minifySync("test.js", code, {
|
|
compress: {
|
|
treeshake: {
|
|
propertyReadSideEffects: "invalid" as any,
|
|
},
|
|
},
|
|
});
|
|
expect(ret.errors.length).toBe(1);
|
|
expect(ret.errors[0].message).toContain("Invalid propertyReadSideEffects value");
|
|
});
|
|
});
|
|
|
|
describe("async minify", () => {
|
|
const code = "/*! legal comment */\nfunction foo() { var bar; bar(undefined) } foo();";
|
|
|
|
it("produces same result as sync", async () => {
|
|
const syncResult = minifySync("test.js", code, { sourcemap: true });
|
|
const asyncResult = await minify("test.js", code, { sourcemap: true });
|
|
|
|
expect(asyncResult.code).toEqual(syncResult.code);
|
|
expect(asyncResult.errors.length).toBe(syncResult.errors.length);
|
|
expect(asyncResult.map).toMatchObject(syncResult.map!);
|
|
});
|
|
});
|
|
|
|
describe("identifier mangling", () => {
|
|
it("mangles class private members", () => {
|
|
const ret = minifySync(
|
|
"test.js",
|
|
"class Foo { #privateField = 1; method() { return this.#privateField; } }",
|
|
{ compress: false, mangle: true },
|
|
);
|
|
|
|
expect(ret.errors).toHaveLength(0);
|
|
expect(ret.code).toBe("class Foo{#e=1;method(){return this.#e}}");
|
|
});
|
|
});
|
|
|
|
describe("property mangling", () => {
|
|
it("is independent from identifier mangling and returns a reusable cache", () => {
|
|
const ret = minifySync("test.js", "let local; obj._often; obj._rare; obj._often;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/ },
|
|
});
|
|
|
|
expect(ret.errors).toHaveLength(0);
|
|
expect(ret.code).toBe("let local;obj.e;obj.t;obj.e;");
|
|
expect(ret.mangleCache).toEqual({ _often: "e", _rare: "t" });
|
|
});
|
|
|
|
it("keeps quoted occurrences unless quoted is enabled", () => {
|
|
const source = "obj._field; obj['_field'];";
|
|
expect(
|
|
minifySync("test.js", source, {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/ },
|
|
}).code,
|
|
).toBe("obj.e;obj[`_field`];");
|
|
expect(
|
|
minifySync("test.js", source, {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/, quoted: true },
|
|
}).code,
|
|
).toBe("obj.e;obj.e;");
|
|
});
|
|
|
|
it("honors false entries and duplicate custom targets", () => {
|
|
const ret = minifySync("test.js", "obj._first; obj._second; obj._keep;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: {
|
|
include: /^_/,
|
|
cache: { _first: "A", _second: "A", _keep: false },
|
|
},
|
|
});
|
|
|
|
expect(ret.code).toBe("obj.A;obj.A;obj._keep;");
|
|
expect(ret.mangleCache).toEqual({ _first: "A", _second: "A", _keep: false });
|
|
});
|
|
|
|
it("keeps cache keys out of automatic targets across round trips", () => {
|
|
const first = minifySync("test.js", "obj.foo;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /./, cache: { e: false } },
|
|
});
|
|
expect(first.code).toBe("obj.t;");
|
|
expect(first.mangleCache).toEqual({ e: false, foo: "t" });
|
|
|
|
const second = minifySync("test.js", "obj.e; obj.foo;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /./, cache: first.mangleCache },
|
|
});
|
|
expect(second.code).toBe("obj.e;obj.t;");
|
|
expect(second.mangleCache).toEqual(first.mangleCache);
|
|
});
|
|
|
|
it("keeps every property readable in debug mode", () => {
|
|
const ret = minifySync("test.js", "obj._alpha; obj._beta; obj._gamma;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/, debug: true },
|
|
});
|
|
expect(ret.code).toBe("obj._$_alpha$_;obj._$_beta$_;obj._$_gamma$_;");
|
|
});
|
|
|
|
it("honors exclude and reserved names", () => {
|
|
const ret = minifySync("test.js", "obj._skipOne; obj._reserved; obj._mangle;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: {
|
|
include: /^_/,
|
|
exclude: /^_skip(?:One|Two)$/,
|
|
reserved: ["_reserved"],
|
|
},
|
|
});
|
|
expect(ret.errors).toHaveLength(0);
|
|
expect(ret.code).toBe("obj._skipOne;obj._reserved;obj.e;");
|
|
});
|
|
|
|
it("preserves the case-insensitive RegExp flag", () => {
|
|
const ret = minifySync("test.js", "obj._FIELD; obj._other;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_field$/i },
|
|
});
|
|
expect(ret.errors).toHaveLength(0);
|
|
expect(ret.code).toBe("obj.e;obj._other;");
|
|
});
|
|
|
|
it("works through the async API", async () => {
|
|
const ret = await minify("test.js", "obj._field; obj._field;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/ },
|
|
});
|
|
expect(ret.errors).toHaveLength(0);
|
|
expect(ret.code).toBe("obj.e;obj.e;");
|
|
expect(ret.mangleCache).toEqual({ _field: "e" });
|
|
});
|
|
|
|
it("does not mangle properties when parsing has errors", () => {
|
|
const ret = minifySync("test.js", "const invalid; obj._field;", {
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/ },
|
|
});
|
|
expect(ret.errors).toHaveLength(1);
|
|
expect(ret.code).toBe("const invalid;obj._field;");
|
|
expect(ret.mangleCache).toBeUndefined();
|
|
});
|
|
|
|
it("returns cache entries in stable lexical order", () => {
|
|
const ret = minifySync(
|
|
"test.js",
|
|
"obj._zeta; obj._alpha; obj._middle; obj._beta; obj._omega;",
|
|
{
|
|
compress: false,
|
|
mangle: false,
|
|
mangleProps: { include: /^_/ },
|
|
},
|
|
);
|
|
expect(Object.keys(ret.mangleCache!)).toEqual([
|
|
"_alpha",
|
|
"_beta",
|
|
"_middle",
|
|
"_omega",
|
|
"_zeta",
|
|
]);
|
|
});
|
|
|
|
it("requires RegExp filters", () => {
|
|
expect(() =>
|
|
minifySync("test.js", "obj._field;", {
|
|
mangleProps: { include: "^_" as any },
|
|
}),
|
|
).toThrow(/RegExp/);
|
|
});
|
|
|
|
it.each([
|
|
[{ include: /^(?!_keep)/ }, "Invalid mangleProps.include regex"],
|
|
[{ include: /^_/g }, "Invalid mangleProps.include regex"],
|
|
[{ include: /^_/, cache: { _field: true as any } }, "expected a string or false"],
|
|
[
|
|
{ include: /^_/, cache: { ["__proto__"]: false } },
|
|
"this original name cannot be used as a cache key",
|
|
],
|
|
[{ include: /^_/, cache: { _field: "not-valid" } }, "must be an IdentifierName"],
|
|
[{ include: /^_/, cache: { _field: "__proto__" } }, "must be an IdentifierName"],
|
|
[
|
|
{ include: /^_/, cache: { _field: "constructor" } },
|
|
"other than '__proto__', 'constructor', or 'prototype'",
|
|
],
|
|
[
|
|
{ include: /^_/, cache: { _field: "prototype" } },
|
|
"other than '__proto__', 'constructor', or 'prototype'",
|
|
],
|
|
])("rejects invalid options", (mangleProps, message) => {
|
|
const ret = minifySync("test.js", "obj._field;", { mangleProps: mangleProps as any });
|
|
expect(ret.errors).toHaveLength(1);
|
|
expect(ret.errors[0].message).toContain(message);
|
|
expect(ret.mangleCache).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("worker", () => {
|
|
it("should run", async () => {
|
|
const code = await new Promise((resolve, reject) => {
|
|
const worker = new Worker("./test/worker.mjs");
|
|
worker.on("error", (err) => {
|
|
reject(err);
|
|
});
|
|
worker.on("exit", (code) => {
|
|
resolve(code);
|
|
});
|
|
});
|
|
expect(code).toBe(0);
|
|
});
|
|
});
|