Skip to content

✨ [minify-literals] better cssText support #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
node_modules
scripts/dist
packages/*/dist
.turbo
.turbo
.vscode/settings.json
5 changes: 5 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"recommendations": [
"biomejs.biome"
]
}
8 changes: 8 additions & 0 deletions .vscode/settings.recommend.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
}
63 changes: 60 additions & 3 deletions packages/minify-literals/lib/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)"})}`;
}),
);
});
});

Expand Down
10 changes: 7 additions & 3 deletions packages/minify-literals/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand Down
9 changes: 6 additions & 3 deletions packages/minify-literals/lib/strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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>
Expand Down
123 changes: 112 additions & 11 deletions packages/minify-literals/lib/strategy.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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.
*
Expand All @@ -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[];
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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 = {}) {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
},
"dependencies": {
"@rollup/pluginutils": "^5.1.4",
"minify-literals": "^1.0.0"
"minify-literals": "workspace:^*"
},
"devDependencies": {
"rollup": "^4.34.9"
Expand Down