Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/embedded-javascript-parse-goal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"minimizer-webpack-plugin": patch
---

read `as` on an embedded JavaScript body: the built-in minimizers take `module` as their own option and minify an `event-handler` body as the function it belongs to, so neither production is read as a classic script and no engine is handed the word
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,21 @@ stylesheet, so a minifier claiming `css` is handed one either way and `as` says
which it is. Ignore it at your peril: parsing a declaration list as a stylesheet
finds no rule and returns nothing.

A `<script type="module">` arrives the same way, as **`javascript` with
`as: "module"`**: JavaScript has two productions too, and a module script is one
wherever it sits. The built-in JavaScript minimizers read it as their own
`module` option, so the engine is never handed the word itself; a minify
function of your own claiming `javascript` is handed it and decides. Ignore it
at your peril: a module script read as a classic one is a syntax error the
moment it holds a top-level `await`.

An event handler attribute — `onclick=""` — arrives as **`javascript` with
`as: "event-handler"`**: its value is a function body, the production the
`return` cancelling an event is written in. No JavaScript engine here parses one
on its own, so each built-in minimizer minifies the function that body belongs
to and answers with the body back out of it. A minify function of your own is
handed the body as written and reads it however its engine can.

The nested case needs a minifier that can hand its nested bodies out, which it
also states for itself — webpack's `cssMinify` and `htmlMinify` do:

Expand Down
205 changes: 189 additions & 16 deletions src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,53 @@

const path = require("path");

/**
* Which production of a language an embedded body is written in, as `as` names
* it. A `style=""` is `css` with `as: "block-contents"`; JavaScript's are these
* three, and only the last is one no engine here parses on its own.
*/
const CLASSIC_SCRIPT = "script";
const MODULE_SCRIPT = "module";
const EVENT_HANDLER = "event-handler";

/**
* The function a body handed out as an event handler belongs to. Named past any
* run of `_` the body holds, so nothing in it resolves to the function instead
* of what it meant; the newline ends a line comment the body may close with.
* @param {string} body the handler's text
* @returns {string} the script it is the body of
*/
function asFunction(body) {
const runs = body.match(/_+/g);
const longest = runs
? runs.reduce((widest, run) => Math.max(widest, run.length), 0)
: 0;

return `function ${"_".repeat(longest + 1)}(){${body}\n}`;
}

/**
* The handler body inside the function a minimizer answered with. `undefined`
* for an answer that is not that function — one holding anything else, or one
* that dropped it whole as the unused declaration it is.
* @param {string | undefined} answered what the minimizer answered
* @returns {string | undefined} the body, or undefined
*/
function functionBody(answered) {
if (typeof answered !== "string") return undefined;

// Trimmed first: an engine may end what it writes with a newline, and a
// trailing `;` is the declaration's own rather than the body's.
const written = answered.trim().replace(/;$/, "");
const opened = written.indexOf("{");

return opened !== -1 &&
written.endsWith("}") &&
/^function\s+[^\s(]+\s*\(\s*\)\s*$/.test(written.slice(0, opened))
? written.slice(opened + 1, -1).trim()
: undefined;
}

/**
* The version a package reports. Read by walking up from its resolved entry
* point rather than by requiring `<name>/package.json`, which a package whose
Expand Down Expand Up @@ -207,6 +254,12 @@ async function terserMinify(
minimizerOptions,
extractComments,
) {
// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { EVENT_HANDLER, MODULE_SCRIPT, asFunction, functionBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

/**
* @param {unknown} value value
* @returns {value is EXPECTED_OBJECT} true when value is object or function
Expand Down Expand Up @@ -338,13 +391,18 @@ async function terserMinify(
};

/**
* @param {import("terser").MinifyOptions=} terserOptions terser options
* @param {(import("terser").MinifyOptions & { as?: string })=} terserOptions terser options
* @returns {import("terser").MinifyOptions & { sourceMap: import("terser").SourceMapOptions | undefined } & { compress: import("terser").CompressOptions } & ({ output: import("terser").FormatOptions & { beautify: boolean } } | { format: import("terser").FormatOptions & { beautify: boolean } })} built terser options
*/
const buildTerserOptions = (terserOptions = {}) =>
const buildTerserOptions = ({ as, ...terserOptions } = {}) =>
// Need deep copy objects to avoid https://github.com/terser/terser/issues/366
({
...terserOptions,
// `as` names which production of JavaScript the source is, and is the
// source's own rather than the configuration's, so it overrides `module`.
...(typeof as === "undefined"
? undefined
: { module: as === MODULE_SCRIPT }),
compress:
typeof terserOptions.compress === "boolean"
? terserOptions.compress
Expand Down Expand Up @@ -375,6 +433,12 @@ async function terserMinify(
// toplevel: terserOptions.toplevel
});

// The production is the body's own, and a function body is the one terser has
// no goal symbol for: it is minified as the function it belongs to.
const handler =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === EVENT_HANDLER;

let minify;

try {
Expand Down Expand Up @@ -423,8 +487,19 @@ async function terserMinify(
}

const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
const result = await minify({ [filename]: code }, terserOptions);
const text = Buffer.isBuffer(source) ? source.toString() : source;
const result = await minify(
{ [filename]: handler ? asFunction(text) : text },
terserOptions,
);

if (handler) {
const body = functionBody(result.code);

// A wrap moves every position, so the map terser wrote describes a script
// that is not what comes back.
return { code: body === undefined ? text : body, extractedComments };
}

return {
code: /** @type {string} * */ (result.code),
Expand Down Expand Up @@ -470,6 +545,12 @@ async function uglifyJsMinify(
minimizerOptions,
extractComments,
) {
// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { EVENT_HANDLER, MODULE_SCRIPT, asFunction, functionBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

/**
* @param {unknown} value value
* @returns {boolean} true when value is object or function
Expand Down Expand Up @@ -593,10 +674,14 @@ async function uglifyJsMinify(
};

/**
* @param {import("uglify-js").MinifyOptions & { ecma?: number | string }=} uglifyJsOptions uglify-js options
* @param {import("uglify-js").MinifyOptions & { ecma?: number | string, as?: string }=} uglifyJsOptions uglify-js options
* @returns {import("uglify-js").MinifyOptions & { sourceMap: boolean | import("uglify-js").SourceMapOptions | undefined } & { output: import("uglify-js").OutputOptions & { beautify: boolean } }} uglify-js options
*/
const buildUglifyJsOptions = (uglifyJsOptions = {}) => {
// `as` names which production of JavaScript the source is. An inherited
// `module` is the asset's rather than the source's, so it still goes.
const { as } = uglifyJsOptions;

if (typeof uglifyJsOptions.ecma !== "undefined") {
delete uglifyJsOptions.ecma;
}
Expand All @@ -605,9 +690,16 @@ async function uglifyJsMinify(
delete uglifyJsOptions.module;
}

if (typeof as !== "undefined") {
delete uglifyJsOptions.as;
}

// Need deep copy objects to avoid https://github.com/terser/terser/issues/366
return {
...uglifyJsOptions,
...(typeof as === "undefined"
? undefined
: { module: as === MODULE_SCRIPT }),
// warnings: uglifyJsOptions.warnings,
parse: { ...uglifyJsOptions.parse },
compress:
Expand All @@ -632,6 +724,12 @@ async function uglifyJsMinify(
};
};

// A function body is the production uglify-js has no goal symbol for, so the
// function it belongs to is what is minified.
const handler =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === EVENT_HANDLER;

let minify;

try {
Expand Down Expand Up @@ -659,8 +757,24 @@ async function uglifyJsMinify(
);

const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
const result = await minify({ [filename]: code }, uglifyJsOptions);
const text = Buffer.isBuffer(source) ? source.toString() : source;
const result = await minify(
{ [filename]: handler ? asFunction(text) : text },
uglifyJsOptions,
);

if (handler) {
const body = functionBody(result.code);

// A wrap moves every position, so the map uglify-js wrote describes a
// script that is not what comes back.
return {
code: body === undefined ? text : body,
errors: result.error ? [result.error] : [],
warnings: result.warnings || [],
extractedComments,
};
}

return {
code: result.code,
Expand Down Expand Up @@ -703,6 +817,12 @@ uglifyJsMinify.filter = (name) => JS_FILE_RE.test(name);
* @returns {Promise<MinimizedResult>} minimized result
*/
async function swcMinify(input, sourceMap, minimizerOptions, extractComments) {
// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { EVENT_HANDLER, MODULE_SCRIPT, asFunction, functionBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

/**
* @param {unknown} value value
* @returns {boolean} true when value is object or function
Expand Down Expand Up @@ -803,13 +923,18 @@ async function swcMinify(input, sourceMap, minimizerOptions, extractComments) {
};

/**
* @param {import("@swc/core").JsMinifyOptions=} swcOptions swc options
* @param {(import("@swc/core").JsMinifyOptions & { as?: string })=} swcOptions swc options
* @returns {import("@swc/core").JsMinifyOptions & { extractComments?: false | true | "some" | "all" | { regex: string } } & { sourceMap: undefined | boolean } & { compress: import("@swc/core").TerserCompressOptions }} built swc options
*/
const buildSwcOptions = (swcOptions = {}) =>
const buildSwcOptions = ({ as, ...swcOptions } = {}) =>
// Need deep copy objects to avoid https://github.com/terser/terser/issues/366
({
...swcOptions,
// `as` names which production of JavaScript the source is, and is the
// source's own rather than the configuration's, so it overrides `module`.
...(typeof as === "undefined"
? undefined
: { module: as === MODULE_SCRIPT }),
compress:
typeof swcOptions.compress === "boolean"
? swcOptions.compress
Expand All @@ -834,6 +959,12 @@ async function swcMinify(input, sourceMap, minimizerOptions, extractComments) {
sourceMap: undefined,
});

// A function body is the production swc has no goal symbol for, so the
// function it belongs to is what is minified.
const handler =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === EVENT_HANDLER;

let swc;

try {
Expand Down Expand Up @@ -883,10 +1014,21 @@ async function swcMinify(input, sourceMap, minimizerOptions, extractComments) {
}

const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
const text = Buffer.isBuffer(source) ? source.toString() : source;
const result =
/** @type {import("@swc/core").Output & { extractedComments?: string[] }} */
(await swc.minify(code, swcOptions));
(await swc.minify(handler ? asFunction(text) : text, swcOptions));

if (handler) {
const body = functionBody(result.code);

// A wrap moves every position, so the map swc wrote describes a script that
// is not what comes back.
return {
code: body === undefined ? text : body,
extractedComments: result.extractedComments || [],
};
}

let map;

Expand Down Expand Up @@ -937,13 +1079,27 @@ swcMinify.filter = (name) => JS_FILE_RE.test(name);
* @returns {Promise<MinimizedResult>} minimized result
*/
async function esbuildMinify(input, sourceMap, minimizerOptions) {
// Self-require rather than the bindings above: a minify function reaches a
// worker as its source, where this module's own scope is gone.
const { EVENT_HANDLER, MODULE_SCRIPT, asFunction, functionBody } =
// eslint-disable-next-line import/no-self-import
require("./utils.js");

/**
* @param {import("esbuild").TransformOptions & { ecma?: string | number, module?: boolean }=} esbuildOptions esbuild options
* @param {import("esbuild").TransformOptions & { ecma?: string | number, module?: boolean, as?: string }=} esbuildOptions esbuild options
* @returns {import("esbuild").TransformOptions} built esbuild options
*/
const buildEsbuildOptions = (esbuildOptions = {}) => {
delete esbuildOptions.ecma;

// `as` names which production of JavaScript the source is, and is the
// source's own rather than the configuration's, so it overrides `module`.
if (typeof esbuildOptions.as !== "undefined") {
esbuildOptions.module = esbuildOptions.as === MODULE_SCRIPT;

delete esbuildOptions.as;
}

if (esbuildOptions.module) {
esbuildOptions.format = "esm";
}
Expand All @@ -959,6 +1115,12 @@ async function esbuildMinify(input, sourceMap, minimizerOptions) {
};
};

// A function body is the production esbuild has no goal symbol for, so the
// function it belongs to is what is minified.
const handler =
typeof minimizerOptions !== "undefined" &&
minimizerOptions.as === EVENT_HANDLER;

let esbuild;

try {
Expand All @@ -977,15 +1139,21 @@ async function esbuildMinify(input, sourceMap, minimizerOptions) {
}

const [[filename, source]] = Object.entries(input);
const code = Buffer.isBuffer(source) ? source.toString() : source;
const text = Buffer.isBuffer(source) ? source.toString() : source;

esbuildOptions.sourcefile = filename;

const result = await esbuild.transform(code, esbuildOptions);
const result = await esbuild.transform(
handler ? asFunction(text) : text,
esbuildOptions,
);
const body = handler ? functionBody(result.code) : undefined;

return {
code: result.code,
map: result.map ? JSON.parse(result.map) : undefined,
code: handler ? (body === undefined ? text : body) : result.code,
// A wrap moves every position, so the map esbuild wrote describes a script
// that is not what comes back.
map: handler || !result.map ? undefined : JSON.parse(result.map),
warnings:
result.warnings.length > 0
? result.warnings.map((item) => {
Expand Down Expand Up @@ -3388,11 +3556,16 @@ function memoize(fn) {
}

module.exports = {
CLASSIC_SCRIPT,
EVENT_HANDLER,
MODULE_SCRIPT,
asFunction,
cleanCssMinify,
cssnanoMinify,
cssoMinify,
esbuildMinify,
esbuildMinifyCss,
functionBody,
getEcmaVersion,
getMinimizerOptionsAt,
htmlMinifierTerser,
Expand Down
Loading
Loading