/// import { mkdir, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import remapping from "@jridgewell/remapping"; import { type ESTreeMap, SKIP, walk } from "astray"; import type { Plugin } from "esbuild"; import type { SourceLocation } from "estree"; import MagicString from "magic-string"; import { parse } from "meriyah"; type ESTreeMapExtra = { [K in keyof M]: M[K] & { // Added via meriyah "loc" option loc: SourceLocation; // Added via meriyah "ranges" option start: number; end: number; }; }; interface MinifyOptions { taggedOnly?: boolean; keepComments?: boolean; } // Same encode/decode as esbuild // https://github.com/evanw/esbuild/blob/4dfd1b6ae07892f1e8f5a6712fc67301e19a1b24/lib/shared/stdio_protocol.ts#L353-L391 const encoder = new TextEncoder(); const decoder = new TextDecoder(); export const encodeUTF8 = (text: string): Uint8Array => encoder.encode(text); export const decodeUTF8 = (bytes: Uint8Array): string => decoder.decode(bytes); export function stripWhitespace(html: string, keepComments?: boolean): string { let out = html // Reduce whitespace to a single space .replace(/\s+/gm, " ") // Remove space between tags .replace(/> <") // Remove space between edge and start/end tags .replace(/^ $/g, ">"); if (!keepComments) { // Remove comments, repeatedly until none remain let prev: string; do { prev = out; out = out.replace(/(?:)+/g, ""); } while (out !== prev); } return out; } export function minify(code: string, opts: MinifyOptions = {}): MagicString { const out = new MagicString(code); const ignoreLines: number[] = []; const ast = parse(code, { next: true, loc: true, ranges: true, module: true, onComment(type, value, _start, _end, loc) { if (type === "MultiLine" && value.trim() === "! minify-templates-ignore") { ignoreLines.push(loc.end.line + 1); } }, }); walk(ast, { TemplateLiteral(node) { return ignoreLines.includes(node.loc.start.line) || (opts.taggedOnly && node.path?.parent?.type !== "TaggedTemplateExpression") ? SKIP : undefined; }, TemplateElement(node) { const { start, end } = node.loc; if (start.line !== end.line || start.column !== end.column) { out.overwrite(node.start, node.end, stripWhitespace(node.value.raw, opts.keepComments)); } }, }); return out; } export const minifyTemplates = (opts: MinifyOptions = {}): Plugin => ({ name: "minify-templates", setup(build) { if (build.initialOptions.write !== false) return; build.onEnd((result) => { // eslint-disable-next-line unicorn/no-array-for-each result.outputFiles?.forEach((file, fileIndex, outputFiles) => { if (!/\.[mc]?js$/.test(file.path)) return; const src = decodeUTF8(file.contents); const out = minify(src, opts); // eslint-disable-next-line no-param-reassign outputFiles[fileIndex].contents = encodeUTF8(out.toString()); const matchingMapIndex = outputFiles.findIndex( (outputFile) => outputFile.path === `${file.path}.map`, ); if (matchingMapIndex !== -1) { const mapFile = outputFiles[matchingMapIndex]; const remapped = remapping( [ // Our source map from minifying { ...out.generateDecodedMap({ source: file.path, file: mapFile.path, hires: true, }), version: 3, }, // esbuild generated source map decodeUTF8(mapFile.contents), ], // Don't load other source maps; referenced files are the original source () => null, ); // eslint-disable-next-line no-param-reassign outputFiles[matchingMapIndex].contents = encodeUTF8(remapped.toString()); } }); }); }, }); export const writeFiles = (): Plugin => ({ name: "write-files", setup(build) { if (build.initialOptions.write !== false) return; build.onEnd(async (result) => { if (!result.outputFiles) return; await Promise.all( result.outputFiles.map((file) => mkdir(dirname(file.path), { recursive: true }).then(() => writeFile(file.path, file.contents, "utf8"), ), ), ); }); }, });