htmlMinify.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. /*
  2. MIT License http://www.opensource.org/licenses/mit-license.php
  3. Author sheo13666q @sheo13666q
  4. */
  5. "use strict";
  6. /** @import { DeferredEmbeddedSource, HtmlPrintOptions } from "../html/syntax" */
  7. /**
  8. * What a renderer made of one embedded body: the minified text, and anything it
  9. * has to report about it. A bare string is the text alone.
  10. * @typedef {{ code?: string, warnings?: (Error | string)[], errors?: (Error | string)[] }} EmbeddedSourceResult
  11. */
  12. /**
  13. * Minifies source a document embeds; may answer asynchronously.
  14. * @typedef {(source: string, info: { type: string, hostType: string, as?: string }) => Promise<string | EmbeddedSourceResult | undefined> | string | EmbeddedSourceResult | undefined} AsyncEmbeddedSourceRenderer
  15. */
  16. /** @import { RawSourceMap } from "webpack-sources" */
  17. /**
  18. * A `minify` function for `minimizer-webpack-plugin` (passed as its `minify`
  19. * option): safely serializes one HTML asset's minimized form. Every node is
  20. * rebuilt from its parsed form to the same DOM (text whitespace and attribute
  21. * values preserved); the transforms are dropping inert comments (conditional /
  22. * SSI comments are kept) and rewriting opening tags to their shortest equivalent
  23. * spelling. HTML minification thus reuses that plugin's pipeline — caching and
  24. * worker-thread parallelization.
  25. *
  26. * The parser is read from the public `webpack.html.syntax` API inside the body, not
  27. * imported at module scope: `minimizer-webpack-plugin` ships this function to its
  28. * worker pool as source (a top-level import wouldn't survive), and `require("webpack")`
  29. * re-resolves in the worker — via the installed package, or the dev self-link.
  30. * @param {{ [file: string]: string }} input a single `{ filename: code }` entry
  31. * @param {(RawSourceMap | undefined)=} sourceMap input source map (unused: safe serialize keeps positions token-coarse, so no map is produced yet)
  32. * @param {Omit<HtmlPrintOptions, "renderEmbeddedSource" | "deferEmbeddedSource"> & { css?: { convertLengthUnits?: boolean, rewriteCustomProperties?: boolean }, minifyConditionalComments?: boolean, minifySrcdoc?: boolean, renderEmbeddedSource?: AsyncEmbeddedSourceRenderer }=} minimizerOptions minimizer options — `environment` and `css` (`optimization.minimize.css`, whole) reach the CSS minifier this runs over inline CSS; the rest is `optimization.minimize.html`. The two stand apart rather than merged: both languages name a `quotes` and a `comments`, and one flat object would hand each the other's answer. `renderEmbeddedSource` minifies source this document embeds and may be asynchronous — one parse serves both it and the output — and `minifySrcdoc` takes an `<iframe srcdoc>` back off it, since it minifies one already
  33. * @returns {Promise<{ code: string, warnings?: (Error | string)[], errors?: (Error | string)[] }>} the minified HTML, and what a renderer reported over what it embeds
  34. */
  35. const htmlMinify = async (input, sourceMap, minimizerOptions = {}) => {
  36. const webpack = /** @type {typeof import("../index")} */ (
  37. // eslint-disable-next-line import/no-extraneous-dependencies -- webpack self-require, re-resolved inside the worker
  38. require(/** @type {string} */ ("webpack"))
  39. );
  40. // TODO widen the value rewrites the switches below name: a boolean attribute
  41. // spelled any other way than its own name, an enumerated value the spec does
  42. // not name.
  43. const {
  44. askEmbeddedRenderer,
  45. collectEmbeddedDiagnostics,
  46. embeddedText,
  47. SourceProcessor,
  48. NodeType,
  49. NS_HTML,
  50. decodeEntities,
  51. escapeAttribute,
  52. pickTransforms
  53. } = webpack.html.syntax;
  54. const [[, code]] = Object.entries(input);
  55. // The nested CSS minifier gets the abilities and options a `.css` asset
  56. // gets, or the inline copy of a declaration would disagree with it.
  57. const {
  58. environment,
  59. css = {},
  60. collapseWhitespace,
  61. mergeStyles,
  62. removeEmptyAttributes,
  63. removeEmptyElements,
  64. removeRedundantAttributes,
  65. minifyConditionalComments,
  66. minifySrcdoc,
  67. sortAttributes,
  68. sortTokenLists,
  69. removeImpliedTags,
  70. renderEmbeddedSource
  71. } = minimizerOptions;
  72. // A `<iframe srcdoc>` the pre-pass below owns is not offered, or the same
  73. // document would be minified twice — once each, and the pre-pass keeps the
  74. // delimiter and reaches a value written bare, which the printer cannot.
  75. const ownsSrcdoc = Boolean(minifySrcdoc);
  76. // One list for the whole run: a nested document reaches this same collector,
  77. // so what a renderer reports inside an `<iframe srcdoc>` is reported once at
  78. // the top rather than lost at the level that heard it.
  79. /** @type {EmbeddedSourceResult[]} */
  80. const reported = [];
  81. // One set of options, handed to the document pass and to each conditional
  82. // comment's body below.
  83. const printOptions = {
  84. mode: /** @type {"minify"} */ ("minify"),
  85. environment,
  86. convertLengthUnits: css.convertLengthUnits,
  87. rewriteCustomProperties: css.rewriteCustomProperties,
  88. // Each language picks its own switches out of its own options object.
  89. cssTransforms: webpack.css.syntax.pickTransforms(css),
  90. transforms: pickTransforms(minimizerOptions),
  91. collapseWhitespace,
  92. mergeStyles,
  93. removeEmptyAttributes,
  94. removeEmptyElements,
  95. removeRedundantAttributes,
  96. sortAttributes,
  97. sortTokenLists,
  98. removeImpliedTags
  99. };
  100. // Handed each body the print offers, with what it reported kept: how the
  101. // element or attribute around one is written is decided from the answer, and
  102. // a body declined or thrown on is spelled as an untapped run spells it.
  103. const renderer =
  104. renderEmbeddedSource === undefined
  105. ? undefined
  106. : (/** @type {string} */ _source, /** @type {EXPECTED_ANY} */ hole) =>
  107. askEmbeddedRenderer(renderEmbeddedSource, hole, reported).then(
  108. embeddedText
  109. );
  110. /**
  111. * @param {string} text a whole document
  112. * @returns {Promise<string>} the same document, printed
  113. */
  114. const printDocument = async (text) =>
  115. (
  116. await new SourceProcessor().processAsync(text, {
  117. ...printOptions,
  118. // The pre-pass below minifies the document an `<iframe srcdoc>` holds,
  119. // so one is not offered here as well.
  120. deferSrcdoc: !ownsSrcdoc,
  121. renderEmbeddedSource: renderer
  122. })
  123. ).code;
  124. // Recursion goes through this local name, never the module's own: the plugin
  125. // ships this function to its workers as source, where that binding is absent.
  126. // A marker cannot cross a parse — the HTML preprocessor turns its NUL into
  127. // U+FFFD — so each nested document is finished before it is spliced in.
  128. /**
  129. * @param {string} text a whole document
  130. * @returns {Promise<string>} the same document, minified
  131. */
  132. const minifyDocument = async (text) => {
  133. // Put back into the source, not the output: the printer respells an
  134. // attribute value, so the recorded text is not what the output holds.
  135. let source = text;
  136. // Both are markup this parse cannot start a second one over, so both are
  137. // located in one walk and spliced afterwards. Their ranges never nest: a
  138. // `srcdoc` is an attribute value and a conditional comment's body is a
  139. // comment, neither of which this parse reads as markup.
  140. const wantsSrcdoc = Boolean(minifySrcdoc) && /srcdoc\s*=/i.test(source);
  141. const wantsConditional =
  142. Boolean(minifyConditionalComments) && /<!--\[if\s/i.test(source);
  143. if (wantsSrcdoc || wantsConditional) {
  144. /** @type {{ start: number, end: number, srcdoc: boolean, value: string }[]} */
  145. const found = [];
  146. /** @type {{ [type: number]: (path: EXPECTED_ANY) => void }} */
  147. const visitors = {};
  148. if (wantsSrcdoc) {
  149. visitors[NodeType.Element] = (path) => {
  150. // An `<iframe>` in foreign content is an SVG or MathML element that
  151. // happens to share the name, and its `srcdoc` is not this attribute.
  152. if (path.tagName() !== "iframe" || path.namespace() !== NS_HTML) {
  153. return;
  154. }
  155. for (const attribute of path.attributes()) {
  156. if (attribute.name !== "srcdoc" || attribute.value === "") continue;
  157. found.push({
  158. start: attribute.valueStart,
  159. end: attribute.valueEnd,
  160. srcdoc: true,
  161. value: attribute.value
  162. });
  163. }
  164. };
  165. }
  166. if (wantsConditional) {
  167. visitors[NodeType.Comment] = (path) => {
  168. const comment = path.source();
  169. const opened = comment.indexOf("]>");
  170. const closes = comment.lastIndexOf("<![endif]");
  171. if (
  172. !/^<!--\[if\s/i.test(comment) ||
  173. opened === -1 ||
  174. closes <= opened
  175. ) {
  176. return;
  177. }
  178. const start = path.start() + opened + 2;
  179. const end = path.start() + closes;
  180. found.push({
  181. start,
  182. end,
  183. srcdoc: false,
  184. value: comment.slice(opened + 2, closes)
  185. });
  186. };
  187. }
  188. new SourceProcessor().use(visitors).process(source, {});
  189. // An element's attributes are visited where the element is, so the two
  190. // kinds do not arrive interleaved by position on their own.
  191. found.sort((a, b) => a.start - b.start);
  192. // Back to front, so an earlier splice does not move a later range.
  193. for (let i = found.length - 1; i >= 0; i--) {
  194. const { start, end, srcdoc, value } = found[i];
  195. // Sequential: a later splice must not move an earlier range.
  196. const inner = await minifyDocument(
  197. srcdoc ? decodeEntities(value, true) : value
  198. );
  199. let written = inner;
  200. if (srcdoc) {
  201. // A document is not something an unquoted value can hold — a space
  202. // or a `>` in it would end the attribute — so one written bare gets
  203. // quotes.
  204. const delimiter = source.charCodeAt(start - 1);
  205. const quoted = delimiter === 34 || delimiter === 39;
  206. const escaped = escapeAttribute(inner, quoted ? delimiter : 34, true);
  207. written = quoted ? escaped : `"${escaped}"`;
  208. }
  209. source = `${source.slice(0, start)}${written}${source.slice(end)}`;
  210. }
  211. }
  212. // `process` parses once, and with `mode: "minify"` its walk also prints the
  213. // safely minified serialization — no second parse. No `source` is named, so no
  214. // map is built: the HTML serialize is token-coarse and only the code is used.
  215. return printDocument(source);
  216. };
  217. const result = { code: await minifyDocument(code) };
  218. return reported.length === 0
  219. ? result
  220. : { ...result, ...collectEmbeddedDiagnostics(reported) };
  221. };
  222. // Worker-safe (see the body's in-worker `require`), so it may run in the shared
  223. // worker-thread pool alongside terser.
  224. htmlMinify.supportsWorkerThreads = () => true;
  225. /**
  226. * The language this minifies, for a caller dispatching source that carries no
  227. * filename — HTML a module embeds in a JavaScript string literal.
  228. * @returns {string[]} the languages
  229. */
  230. htmlMinify.getTypes = () => ["html"];
  231. /**
  232. * The languages this can offer a caller through `renderEmbeddedSource` — an
  233. * inline `<style>`, a `<script>`, an `<svg>` subtree and the document an
  234. * `<iframe srcdoc>` holds, less the one `minifySrcdoc` keeps to itself. A
  235. * `style=""` is not among them: how the attribute is written is decided by
  236. * reading the minified text back, so it stays with the built-in CSS minifier,
  237. * which is webpack's own `cssMinify`.
  238. * @param {{ minifySrcdoc?: boolean }=} minimizerOptions the options this will run with
  239. * @returns {string[]} the languages
  240. */
  241. htmlMinify.getEmbeddedTypes = (minimizerOptions = {}) => {
  242. /** @type {string[]} */
  243. const languages =
  244. /** @type {typeof import("../index")} */
  245. // eslint-disable-next-line import/no-extraneous-dependencies -- webpack self-require, as the body does
  246. require(/** @type {string} */ ("webpack")).html.syntax.EMBEDDED_LANGUAGES;
  247. return minimizerOptions.minifySrcdoc
  248. ? languages.filter((language) => language !== "html")
  249. : languages;
  250. };
  251. // When several minify functions share one `minimizer-webpack-plugin` instance,
  252. // each asset is dispatched only to the ones whose `filter` accepts it — this
  253. // claims HTML, so terser (JS), cssMinify and this can coexist in a single
  254. // plugin / worker pool.
  255. /**
  256. * @param {string} name asset filename
  257. * @returns {boolean} true for HTML assets
  258. */
  259. htmlMinify.filter = (name) => /\.html(\?.*)?$/i.test(name);
  260. module.exports = htmlMinify;