Skip to content

Commit 0dcb377

Browse files
committed
convert css classname minifier into a plugin
1 parent 0147936 commit 0dcb377

5 files changed

+177
-57
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
dist
22
mock
3+
classnames.json

classnames.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import webpack from "webpack";
2+
import loaderUtils from "loader-utils";
3+
import * as fs from "fs";
4+
5+
/**
6+
* Webpack plugin to create minimal css classnames.
7+
*
8+
* Make sure to include `CssModuleClassNameMinifierPlugin.getLocalIdent` in your options to css-loader.
9+
*
10+
* To create minimal classnames, we simply use the fewest characters possible without deriving
11+
* a name from the original class name. Therefore, this plugin have to create a file
12+
* to keep track of all css names within the project to ensure names used by the prerender
13+
* matches the app, with also added benefit of creating stable class names across builds.
14+
*
15+
* You should check in the generated list of classnames.
16+
*
17+
* When classnames in the app changes, you can remove the list for it to regenerate.
18+
*/
19+
20+
class ClassnameGenerator {
21+
state = [0];
22+
CHARS = "_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-";
23+
next() {
24+
this.state[this.state.length - 1] += 1;
25+
for (let i = this.state.length - 1; i; i--) {
26+
if (this.state[i] === 64) {
27+
this.state[i] = 0;
28+
this.state[i - 1] += 1;
29+
}
30+
}
31+
// note class name cannot start with digit or dash
32+
if (this.state[0] === 53) {
33+
this.state[0] = 0;
34+
this.state.unshift(0);
35+
}
36+
return this.state.map((d) => this.CHARS[d]).join("");
37+
}
38+
}
39+
40+
class ClassnameStore {
41+
private nameGenerator = new ClassnameGenerator();
42+
private nameMap = new Map();
43+
private classnames: string[] = [];
44+
private loaded = new Set<string>();
45+
private visited = new Set<string>();
46+
47+
private generating = false;
48+
private sessions = 0;
49+
50+
init(storeFileName: string) {
51+
if (this.sessions === 0) {
52+
if (fs.existsSync(storeFileName)) {
53+
JSON.parse(fs.readFileSync(storeFileName, "utf8")).forEach(
54+
(name: string) => {
55+
if (!this.nameMap.has(name)) {
56+
this.nameMap.set(name, this.nameGenerator.next());
57+
this.classnames.push(name);
58+
this.loaded.add(name);
59+
} else {
60+
console.warn(
61+
`Found duplicated class names from {this.storeFileName}, please regenerate by removing ${storeFileName}.`
62+
);
63+
}
64+
}
65+
);
66+
} else {
67+
this.generating = true;
68+
}
69+
}
70+
this.sessions += 1;
71+
}
72+
73+
getName(name: string) {
74+
if (!this.nameMap.has(name)) {
75+
this.nameMap.set(name, this.nameGenerator.next());
76+
this.classnames.push(name);
77+
}
78+
this.visited.add(name);
79+
return this.nameMap.get(name);
80+
}
81+
82+
finalize(storeFileName: string) {
83+
this.sessions -= 1;
84+
if (this.sessions === 0) {
85+
const deleted = Array.from(this.loaded).filter(
86+
(name) => !this.visited.has(name)
87+
);
88+
if (this.generating) {
89+
console.log(`saving all class name into ${storeFileName}.`);
90+
} else if (deleted.length > 0) {
91+
console.log(
92+
"\x1b[1m",
93+
"\x1b[33m",
94+
`${deleted.length} class names including "${deleted[0]}" have been removed from the list, you can regenerate the list by removing ${storeFileName}.`,
95+
"\x1b[0m"
96+
);
97+
}
98+
fs.writeFileSync(storeFileName, JSON.stringify(this.classnames, null, 2));
99+
}
100+
}
101+
}
102+
103+
const store = new ClassnameStore();
104+
105+
function getReadableCSSModuleLocalIdent(
106+
context: unknown,
107+
localIdentName: unknown,
108+
localName: string,
109+
options: unknown
110+
) {
111+
return loaderUtils
112+
.interpolateName(context, "[path][name]__" + localName, options)
113+
.replace(/\./g, "_");
114+
}
115+
116+
function getMinimalCSSModuleLocalIdent(
117+
context: unknown,
118+
localIdentName: unknown,
119+
localName: string,
120+
options: unknown
121+
) {
122+
if (store == null) {
123+
throw new Error(
124+
`CssModuleClassNameMinifierPlugin is not initialized, make sure to include the plugin in your webpack configuration.`
125+
);
126+
}
127+
const codeName = loaderUtils.interpolateName(
128+
context,
129+
"[path][name]__" + localName,
130+
options
131+
);
132+
return store.getName(codeName);
133+
}
134+
135+
let getLocalIdent = getMinimalCSSModuleLocalIdent;
136+
137+
type Options = {
138+
storeFilename?: string;
139+
minify?: boolean;
140+
};
141+
142+
export class CssModuleClassNameMinifierPlugin {
143+
constructor(private options?: Options) {}
144+
145+
apply(compiler: webpack.Compiler) {
146+
const storeFileName = this.options?.storeFilename ?? "classnames.json";
147+
const minify =
148+
this.options?.minify ?? compiler.options.mode !== "development";
149+
150+
if (minify) {
151+
compiler.hooks.environment.tap("CssModuleClassNameMinifierPlugin", () => {
152+
store.init(storeFileName);
153+
});
154+
155+
compiler.hooks.done.tap("CssModuleClassNameMinifierPlugin", () => {
156+
store.finalize(storeFileName);
157+
});
158+
getLocalIdent = getMinimalCSSModuleLocalIdent;
159+
} else {
160+
getLocalIdent = getReadableCSSModuleLocalIdent;
161+
}
162+
}
163+
164+
static getLocalIdent(
165+
context: unknown,
166+
localIdentName: unknown,
167+
localName: string,
168+
options: unknown
169+
) {
170+
return getLocalIdent(context, localIdentName, localName, options);
171+
}
172+
}

scripts/css-module-identifiers.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.

scripts/webpack.config.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import webpack from "webpack";
2-
import {
3-
getReadableCSSModuleLocalIdent,
4-
getMinimalCSSModuleLocalIdent,
5-
} from "./css-module-identifiers";
2+
import { CssModuleClassNameMinifierPlugin } from "./css-module-classname-minifier";
63
import { resolve } from "path";
74
import postcssNormalize from "postcss-normalize";
85
import MiniCssExtractPlugin from "mini-css-extract-plugin";
@@ -52,9 +49,7 @@ function createBaseConfig({
5249
importLoaders: 1,
5350
sourceMap: devMode,
5451
modules: {
55-
getLocalIdent: devMode
56-
? getReadableCSSModuleLocalIdent
57-
: getMinimalCSSModuleLocalIdent,
52+
getLocalIdent: CssModuleClassNameMinifierPlugin.getLocalIdent,
5853
},
5954
},
6055
},
@@ -110,6 +105,7 @@ function createBaseConfig({
110105
new MiniCssExtractPlugin({
111106
filename: "styles.[contenthash].css",
112107
}),
108+
new CssModuleClassNameMinifierPlugin(),
113109
],
114110
optimization: {
115111
minimizer: devMode ? [] : [`...` as const, new CssMinimizerPlugin()],

0 commit comments

Comments
 (0)