From e7d47e5be139ba041159735e52a728515eb90c03 Mon Sep 17 00:00:00 2001 From: Gaubee <GaubeeBangeel@Gmail.com> Date: Mon, 21 Apr 2025 17:11:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A7=20add=20vscode=20recommend=20?= =?UTF-8?q?=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- .vscode/extensions.json | 5 +++++ .vscode/settings.recommend.json | 8 ++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.recommend.json diff --git a/.gitignore b/.gitignore index 246e8b2..c98486e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules scripts/dist packages/*/dist -.turbo \ No newline at end of file +.turbo +.vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..a5824a5 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "biomejs.biome" + ] +} \ No newline at end of file diff --git a/.vscode/settings.recommend.json b/.vscode/settings.recommend.json new file mode 100644 index 0000000..d83931f --- /dev/null +++ b/.vscode/settings.recommend.json @@ -0,0 +1,8 @@ +{ + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, +} \ No newline at end of file From 0dbfb4276a155976a6058f9b8d1afd704a759f56 Mon Sep 17 00:00:00 2001 From: Gaubee <GaubeeBangeel@Gmail.com> Date: Mon, 21 Apr 2025 17:19:13 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8=20[minify-literals]=20better=20cs?= =?UTF-8?q?sText=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPlaceholder now supports "strict mode" (return `string[]`), whereas it originally returne `string` ("loose mode"). This better supports CSSminify. The following Template literals syntax patterns are supported: ``` 1. selector ${selector} { } 2. key selector { ${key}: value; } 3. rule [selector {}] ${rule} [selector {}] 4. number-literal selector{ key: ${param}px; } 5. value selector { key: ${value}; key: ${value} } 6. param selector{ key: fun(${param}[, ${param}]); } ``` --- packages/minify-literals/lib/index.test.ts | 63 ++++++++- packages/minify-literals/lib/index.ts | 10 +- packages/minify-literals/lib/strategy.test.ts | 9 +- packages/minify-literals/lib/strategy.ts | 123 ++++++++++++++++-- .../package.json | 2 +- 5 files changed, 186 insertions(+), 21 deletions(-) diff --git a/packages/minify-literals/lib/index.test.ts b/packages/minify-literals/lib/index.test.ts index 5607a98..6a2b042 100644 --- a/packages/minify-literals/lib/index.test.ts +++ b/packages/minify-literals/lib/index.test.ts @@ -12,11 +12,68 @@ import { } from "./"; import { defaultMinifyOptions, defaultStrategy } from "./strategy"; +/** + * get code snippet from function body + */ +const fn_body = (fn: Function, tabSize = 2) => { + const fn_str = fn.toString(); + const body_str = fn_str + .slice(fn_str.indexOf("{") + 1, fn_str.lastIndexOf("}")) + // remove trailing whitespace + .trimEnd() + // remove empty lines + .replace(/^([\s\t]*$\r?\n)+/gm, "") + // use spaces uniformly: current indentStyle is "tab",and tab-width == 2(spaces) + .replace(/^\t+/gm, (tabs) => " ".repeat(tabs.length * tabSize)); + /// remove indent + const indent = body_str.match(/\s+/)?.[0]; + if (!indent) { + return body_str.trim(); + } + const indent_reg = new RegExp(`^\\s{${indent.length}}`, "gm"); + return body_str.replace(indent_reg, ""); +}; // https://github.com/explodingcamera/esm/issues/1 describe("handle key value pairs correctly", () => { - it("should minify html", async () => { - const source = `const css = css\`:host{\${"color"}: \${"red"}}\``; - expect((await minifyHTMLLiterals(source))?.code).toMatch('const css = css`:host{${"color"}:${"red"}}`'); + it("should minify css", async () => { + const source = fn_body((css = String.raw) => { + const cssText = css`:host{ + ${"color"}: ${"red"}; + }`; + }); + + expect((await minifyHTMLLiterals(source))?.code).toMatch( + fn_body((css = String.raw) => { + const cssText = css`:host{${"color"}:${"red"}}`; + }), + ); + }); + + it("should minify css with unit", async () => { + const source = fn_body((css = String.raw) => { + const cssText = css`:host{ + ${"width"}: ${10}px; + }`; + }); + + expect((await minifyHTMLLiterals(source))?.code).toMatch( + fn_body((css = String.raw) => { + const cssText = css`:host{${"width"}:${10}px}`; + }), + ); + }); + it("should minify css within css-function", async () => { + const source = fn_body((css = String.raw) => { + const cssText = css`:host{ + ${"width"}: calc(${10}px + ${"var(--data)"}); + }`; + }); + + expect((await minifyHTMLLiterals(source))?.code).toMatch( + fn_body((css = String.raw) => { + const cssText = css`:host{${"width"}:calc(${10}px + ${"var(--data)"})}`; + }), + ); }); }); diff --git a/packages/minify-literals/lib/index.ts b/packages/minify-literals/lib/index.ts index 93d0699..a09f923 100644 --- a/packages/minify-literals/lib/index.ts +++ b/packages/minify-literals/lib/index.ts @@ -210,9 +210,13 @@ export function defaultShouldMinifyCSS(template: Template) { */ export const defaultValidation: Validation = { ensurePlaceholderValid(placeholder) { - if (typeof placeholder !== "string" || !placeholder.length) { - throw new Error("getPlaceholder() must return a non-empty string"); + if (typeof placeholder === "string" && placeholder.length > 0) { + return; } + if (Array.isArray(placeholder) && placeholder.every((ph) => ph.length > 0)) { + return; + } + throw new Error("getPlaceholder() must return a non-empty string | string[]"); }, ensureHTMLPartsValid(parts, htmlParts) { if (parts.length !== htmlParts.length) { @@ -291,7 +295,7 @@ export async function minifyHTMLLiterals(source: string, options: Options = {}): if (!(minifyHTML || minifyCSS)) return; - const placeholder = strategy.getPlaceholder(template.parts); + const placeholder = strategy.getPlaceholder(template.parts, template.tag); if (validate) { validate.ensurePlaceholderValid(placeholder); } diff --git a/packages/minify-literals/lib/strategy.test.ts b/packages/minify-literals/lib/strategy.test.ts index 7ac991d..929f0bf 100644 --- a/packages/minify-literals/lib/strategy.test.ts +++ b/packages/minify-literals/lib/strategy.test.ts @@ -26,10 +26,12 @@ describe("strategy", () => { }); it('should append "_" if placeholder exists in templates', () => { - const regularPlaceholder = defaultStrategy.getPlaceholder(parts); + const regularPlaceholder = defaultStrategy.getPlaceholder(parts) as string; + expect(regularPlaceholder).toBeTypeOf("string"); const oneUnderscore = defaultStrategy.getPlaceholder([ { text: regularPlaceholder, start: 0, end: regularPlaceholder.length }, - ]); + ]) as string; + expect(oneUnderscore).toBeTypeOf("string"); expect(oneUnderscore).not.toEqual(regularPlaceholder); expect(oneUnderscore.includes("_")).toEqual(true); @@ -53,7 +55,8 @@ describe("strategy", () => { }); it("should return a value that is preserved by html-minifier when splitting", async () => { - const placeholder = defaultStrategy.getPlaceholder(parts); + const placeholder = defaultStrategy.getPlaceholder(parts) as string; + expect(placeholder).toBeTypeOf("string"); const minHtml = await defaultStrategy.minifyHTML( ` <style> diff --git a/packages/minify-literals/lib/strategy.ts b/packages/minify-literals/lib/strategy.ts index dbc9b3a..8e36cf0 100644 --- a/packages/minify-literals/lib/strategy.ts +++ b/packages/minify-literals/lib/strategy.ts @@ -1,6 +1,7 @@ import CleanCSS from "clean-css"; import { type Options as HTMLOptions, minify } from "html-minifier-terser"; import type { TemplatePart } from "parse-literals"; +import { randomBytes, randomInt } from "node:crypto"; /** * A strategy on how to minify HTML and optionally CSS. @@ -20,7 +21,7 @@ export interface Strategy<O = any, C = any> { * @param parts the parts to get a placeholder for * @returns the placeholder */ - getPlaceholder(parts: TemplatePart[]): string; + getPlaceholder(parts: TemplatePart[], tag?: string): string | string[]; /** * Combines the parts' HTML text strings together into a single string using * the provided placeholder. The placeholder indicates where a template @@ -30,7 +31,7 @@ export interface Strategy<O = any, C = any> { * @param placeholder the placeholder to use between parts * @returns the combined parts' text strings */ - combineHTMLStrings(parts: TemplatePart[], placeholder: string): string; + combineHTMLStrings(parts: TemplatePart[], placeholder: string | string[]): string; /** * Minfies the provided HTML string. * @@ -56,7 +57,7 @@ export interface Strategy<O = any, C = any> { * @param placeholder the placeholder to split by * @returns an array of html strings */ - splitHTMLByPlaceholder(html: string, placeholder: string): string[]; + splitHTMLByPlaceholder(html: string, placeholder: string | string[]): string[]; } /** @@ -89,7 +90,87 @@ export const defaultMinifyOptions: HTMLOptions = { * <code>clean-css</code> to minify CSS. */ export const defaultStrategy: Strategy<HTMLOptions, CleanCSS.Options> = { - getPlaceholder(parts) { + getPlaceholder(parts, tag) { + const isCss = tag?.toLowerCase().includes("css"); + if (isCss) { + // use strict mode to avoid issues with CSS minification + const random = `tmp_${randomBytes(6).toString("hex")}`; + const placeholder: string[] = []; + const comment = /\/\*[\s\S]*?\*\//g; + for (let i = 1; i < parts.length; i++) { + const beforeFull = parts[i - 1]!.text; + const beforeCss = beforeFull.replace(comment, ""); + const afterFull = parts[i]!.text; + const afterCss = afterFull.replace(comment, ""); + /** + * 1. selector + * ${selector} { + * } + * + * 2. key + * selector { + * ${key}: value; + * } + * + * 3. rule + * [selector {}] + * ${rule} + * [selector {}] + * + * 4. number-literal + * selector{ + * key: ${param}px; + * } + * + * 5. value + * selector { + * key: ${value}; + * key: ${value} + * } + * + * 6. param + * selector{ + * key: fun(${param}[, ${param}]); + * } + */ + + const isSelector = /^\s*\{/.test(afterCss); + if (isSelector) { + placeholder.push(`#${random}`); + continue; + } + const isKey = /^\s*:/.test(afterCss); + if (isKey) { + placeholder.push(`--${random}`); + continue; + } + const isRule = /\}\s*$/.test(beforeCss) || beforeCss.trim().length === 0; + if (isRule) { + return `@${random}();`; + } + const isUnit = /^\w+/.test(afterCss); + if (isUnit) { + let num: string; + while (true) { + num = `${randomInt(281474976710655)}`; + if (!beforeFull.includes(num)) { + break; + } + } + placeholder.push(num); + continue; + } + const isValue = /:\s*$/.test(beforeCss); + if (isValue) { + placeholder.push(`var(--${random})`); + continue; + } + + // isParams + placeholder.push(`var(--${random})`); + } + return placeholder; + } // Using @ and (); will cause the expression not to be removed in CSS. // However, sometimes the semicolon can be removed (ex: inline styles). // In those cases, we want to make sure that the HTML splitting also @@ -104,7 +185,10 @@ export const defaultStrategy: Strategy<HTMLOptions, CleanCSS.Options> = { }, combineHTMLStrings(parts, placeholder) { - return parts.map((part) => part.text).join(placeholder); + if (typeof placeholder === "string") { + return parts.map((part) => part.text).join(placeholder); + } + return parts.map((part, i) => part.text + (placeholder[i] ?? "")).join(""); }, async minifyHTML(html, options = {}) { @@ -194,13 +278,30 @@ export const defaultStrategy: Strategy<HTMLOptions, CleanCSS.Options> = { return output.styles; }, splitHTMLByPlaceholder(html, placeholder) { - const parts = html.split(placeholder); - // Make the last character (a semicolon) optional. See above. - if (placeholder.endsWith(";")) { - const withoutSemicolon = placeholder.substring(0, placeholder.length - 1); - for (let i = parts.length - 1; i >= 0; i--) { - parts.splice(i, 1, ...(parts[i]?.split(withoutSemicolon) ?? [])); + let parts: string[]; + if (typeof placeholder === "string") { + parts = html.split(placeholder); + // Make the last character (a semicolon) optional. See above. + if (placeholder.endsWith(";")) { + const withoutSemicolon = placeholder.substring(0, placeholder.length - 1); + for (let i = parts.length - 1; i >= 0; i--) { + parts.splice(i, 1, ...(parts[i]?.split(withoutSemicolon) ?? [])); + } + } + } else { + parts = []; + // strice mode + let pos = 0; + let index = -1; + for (const ph of placeholder) { + index = html.indexOf(ph, pos); + if (index === -1) { + throw new Error(`placeholder ${ph} not found in html ${html}`); + } + parts.push(html.slice(pos, index)); + pos = index + ph.length; } + parts.push(html.slice(pos)); } return parts; diff --git a/packages/rollup-plugin-minify-template-literals/package.json b/packages/rollup-plugin-minify-template-literals/package.json index fe26398..689cdef 100644 --- a/packages/rollup-plugin-minify-template-literals/package.json +++ b/packages/rollup-plugin-minify-template-literals/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@rollup/pluginutils": "^5.1.4", - "minify-literals": "^1.0.0" + "minify-literals": "workspace:^*" }, "devDependencies": { "rollup": "^4.34.9"