kempo-css 2.1.9 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/bin/shake.js +26 -0
- package/package.json +13 -1
- package/src/shake.js +226 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [2.2.0] - 2026-04-18
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `shake` — new CSS tree-shaking utility that produces minimal, variable-free CSS for email or other constrained contexts
|
|
9
|
+
- Resolves all CSS custom properties to flat values (no `var()` in output)
|
|
10
|
+
- Tree-shakes unused rules by matching selectors against provided HTML
|
|
11
|
+
- Strips dark mode overrides, theme attribute selectors, and `:root` variable blocks
|
|
12
|
+
- Supports optional theme file to override default variable values
|
|
13
|
+
- Programmatic API: `import shake from 'kempo-css/shake'`
|
|
14
|
+
- CLI: `kempo-css-shake --html <file> [--theme <theme.css>]`
|
|
15
|
+
- Added `css-tree` and `htmlparser2` as production dependencies
|
|
16
|
+
- Added `bin` and `exports` fields to package.json
|
|
17
|
+
|
|
5
18
|
## [2.1.5] - 2026-04-13
|
|
6
19
|
- Added `.td-u` (underline) and `.td-lt` (line-through) text decoration utility classes
|
|
7
20
|
- Added Text Decoration section to typography docs
|
package/bin/shake.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import shake from '../src/shake.js';
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const flagIndex = (flag) => {
|
|
8
|
+
const i = args.indexOf(flag);
|
|
9
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const htmlPath = flagIndex('--html') || args.find(a => !a.startsWith('--'));
|
|
13
|
+
const themePath = flagIndex('--theme');
|
|
14
|
+
|
|
15
|
+
if(!htmlPath){
|
|
16
|
+
console.error('Usage: kempo-css-shake --html <file.html> [--theme <theme.css>]');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const html = readFileSync(resolve(htmlPath), 'utf-8');
|
|
21
|
+
const options = {};
|
|
22
|
+
if(themePath){
|
|
23
|
+
options.theme = resolve(themePath);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process.stdout.write(shake(html, options));
|
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kempo-css",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "scripts/build.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./scripts/build.js",
|
|
9
|
+
"./shake": "./src/shake.js"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"kempo-css-shake": "bin/shake.js"
|
|
13
|
+
},
|
|
7
14
|
"scripts": {
|
|
15
|
+
"shake": "node bin/shake.js",
|
|
8
16
|
"build": "node scripts/build.js",
|
|
9
17
|
"build:watch": "node scripts/build.js --watch",
|
|
10
18
|
"test": "npx kempo-test",
|
|
@@ -23,5 +31,9 @@
|
|
|
23
31
|
"repository": {
|
|
24
32
|
"type": "git",
|
|
25
33
|
"url": "https://github.com/dustinpoissant/kempo-css"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"css-tree": "^3.2.1",
|
|
37
|
+
"htmlparser2": "^12.0.0"
|
|
26
38
|
}
|
|
27
39
|
}
|
package/src/shake.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { resolve, dirname } from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import * as csstree from 'css-tree';
|
|
5
|
+
import { Parser } from 'htmlparser2';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const kempoPath = resolve(__dirname, 'kempo.css');
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Variable Extraction — only from top-level :root rules
|
|
13
|
+
*/
|
|
14
|
+
const extractRootVars = css => {
|
|
15
|
+
const vars = new Map();
|
|
16
|
+
const ast = csstree.parse(css);
|
|
17
|
+
ast.children.forEach(node => {
|
|
18
|
+
if(node.type !== 'Rule') return;
|
|
19
|
+
const selector = csstree.generate(node.prelude);
|
|
20
|
+
if(selector !== ':root') return;
|
|
21
|
+
node.block.children.forEach(child => {
|
|
22
|
+
if(child.type === 'Declaration' && child.property.startsWith('--')){
|
|
23
|
+
vars.set(child.property, csstree.generate(child.value));
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
return vars;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
Resolve all var() references to flat values via multi-pass
|
|
32
|
+
*/
|
|
33
|
+
const resolveAllVars = vars => {
|
|
34
|
+
const resolved = new Map(vars);
|
|
35
|
+
let changed = true;
|
|
36
|
+
let passes = 0;
|
|
37
|
+
while(changed && passes < 30){
|
|
38
|
+
changed = false;
|
|
39
|
+
passes++;
|
|
40
|
+
for(const [key, val] of resolved){
|
|
41
|
+
const newVal = val.replace(
|
|
42
|
+
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g,
|
|
43
|
+
(match, name, fallback) => {
|
|
44
|
+
if(name === key) return match;
|
|
45
|
+
const ref = resolved.get(name);
|
|
46
|
+
if(ref !== undefined && !ref.includes(`var(${key})`)){
|
|
47
|
+
changed = true;
|
|
48
|
+
return ref;
|
|
49
|
+
}
|
|
50
|
+
if(fallback !== undefined){
|
|
51
|
+
changed = true;
|
|
52
|
+
return fallback.trim();
|
|
53
|
+
}
|
|
54
|
+
return match;
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
if(newVal !== val) resolved.set(key, newVal);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return resolved;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/*
|
|
64
|
+
HTML Token Collection
|
|
65
|
+
*/
|
|
66
|
+
const collectHTMLTokens = html => {
|
|
67
|
+
const tags = new Set(['html', 'body']);
|
|
68
|
+
const classes = new Set();
|
|
69
|
+
const ids = new Set();
|
|
70
|
+
const attrs = new Set();
|
|
71
|
+
const parser = new Parser({
|
|
72
|
+
onopentag(name, attributes){
|
|
73
|
+
tags.add(name.toLowerCase());
|
|
74
|
+
if(attributes.class){
|
|
75
|
+
attributes.class.split(/\s+/).forEach(c => {
|
|
76
|
+
if(c) classes.add(c);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if(attributes.id) ids.add(attributes.id);
|
|
80
|
+
Object.keys(attributes).forEach(a => attrs.add(a.toLowerCase()));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
parser.write(html);
|
|
84
|
+
parser.end();
|
|
85
|
+
return { tags, classes, ids, attrs };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
Selector Matching — check if any part of a selector matches HTML tokens
|
|
90
|
+
*/
|
|
91
|
+
const selectorMatchesHTML = (selectorStr, tokens) => {
|
|
92
|
+
const parts = selectorStr.split(',');
|
|
93
|
+
return parts.some(part => {
|
|
94
|
+
const trimmed = part.trim();
|
|
95
|
+
if(!trimmed) return false;
|
|
96
|
+
if(trimmed === ':root' || trimmed === '*') return true;
|
|
97
|
+
const cleaned = trimmed
|
|
98
|
+
.replace(/::[\w-]+/g, '')
|
|
99
|
+
.replace(/:[\w-]+(\([^)]*\))?/g, '');
|
|
100
|
+
const segments = cleaned.split(/[\s>+~]+/).filter(Boolean);
|
|
101
|
+
return segments.every(segment => {
|
|
102
|
+
const tagMatch = segment.match(/^([a-zA-Z][\w-]*)/);
|
|
103
|
+
const classMatches = [...segment.matchAll(/\.([\w-]+)/g)].map(m => m[1]);
|
|
104
|
+
const idMatches = [...segment.matchAll(/#([\w-]+)/g)].map(m => m[1]);
|
|
105
|
+
const attrMatches = [...segment.matchAll(/\[([\w-]+)/g)].map(m => m[1].toLowerCase());
|
|
106
|
+
if(tagMatch && !tokens.tags.has(tagMatch[1].toLowerCase())) return false;
|
|
107
|
+
if(classMatches.length && classMatches.some(c => !tokens.classes.has(c))) return false;
|
|
108
|
+
if(idMatches.length && idMatches.some(id => !tokens.ids.has(id))) return false;
|
|
109
|
+
if(attrMatches.length && attrMatches.some(a => !tokens.attrs.has(a))) return false;
|
|
110
|
+
return true;
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
Inline var() references in a CSS string using resolved values
|
|
117
|
+
*/
|
|
118
|
+
const inlineVars = (css, vars) => {
|
|
119
|
+
let result = css;
|
|
120
|
+
let prev;
|
|
121
|
+
do {
|
|
122
|
+
prev = result;
|
|
123
|
+
result = result.replace(
|
|
124
|
+
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g,
|
|
125
|
+
(match, name, fallback) => {
|
|
126
|
+
const val = vars.get(name);
|
|
127
|
+
if(val !== undefined) return val;
|
|
128
|
+
if(fallback !== undefined) return fallback.trim();
|
|
129
|
+
return match;
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
} while(result !== prev);
|
|
133
|
+
return result;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
Main shake function
|
|
138
|
+
*/
|
|
139
|
+
export default (html, { theme } = {}) => {
|
|
140
|
+
const kempoCss = readFileSync(kempoPath, 'utf-8');
|
|
141
|
+
const vars = extractRootVars(kempoCss);
|
|
142
|
+
if(theme){
|
|
143
|
+
const themeCSS = typeof theme === 'string' && theme.includes('{')
|
|
144
|
+
? theme
|
|
145
|
+
: readFileSync(resolve(theme), 'utf-8');
|
|
146
|
+
extractRootVars(themeCSS).forEach((val, key) => vars.set(key, val));
|
|
147
|
+
}
|
|
148
|
+
const resolved = resolveAllVars(vars);
|
|
149
|
+
const tokens = collectHTMLTokens(html);
|
|
150
|
+
const ast = csstree.parse(kempoCss);
|
|
151
|
+
|
|
152
|
+
/* Remove :root rules that are purely variable declarations */
|
|
153
|
+
csstree.walk(ast, {
|
|
154
|
+
visit: 'Rule',
|
|
155
|
+
enter(node, item, list){
|
|
156
|
+
const selector = csstree.generate(node.prelude);
|
|
157
|
+
if(selector === ':root'){
|
|
158
|
+
let allVars = true;
|
|
159
|
+
node.block.children.forEach(child => {
|
|
160
|
+
if(child.type !== 'Declaration' || !child.property.startsWith('--')){
|
|
161
|
+
allVars = false;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
if(allVars){
|
|
165
|
+
list.remove(item);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
/* Strip variable declarations from mixed :root blocks */
|
|
169
|
+
const toRemove = [];
|
|
170
|
+
node.block.children.forEach((child, childItem) => {
|
|
171
|
+
if(child.type === 'Declaration' && child.property.startsWith('--')){
|
|
172
|
+
toRemove.push(childItem);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
toRemove.forEach(childItem => node.block.children.remove(childItem));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
/* Remove theme-specific override rules (dark/light/auto attribute selectors) */
|
|
182
|
+
csstree.walk(ast, {
|
|
183
|
+
visit: 'Rule',
|
|
184
|
+
enter(node, item, list){
|
|
185
|
+
const selector = csstree.generate(node.prelude);
|
|
186
|
+
if(/\[theme=/.test(selector)){
|
|
187
|
+
list.remove(item);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/* Remove prefers-color-scheme media queries */
|
|
193
|
+
csstree.walk(ast, {
|
|
194
|
+
visit: 'Atrule',
|
|
195
|
+
enter(node, item, list){
|
|
196
|
+
if(node.name !== 'media') return;
|
|
197
|
+
const query = csstree.generate(node.prelude);
|
|
198
|
+
if(query.includes('prefers-color-scheme')){
|
|
199
|
+
list.remove(item);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/* Tree-shake: remove rules whose selectors don't match the HTML */
|
|
205
|
+
csstree.walk(ast, {
|
|
206
|
+
visit: 'Rule',
|
|
207
|
+
enter(node, item, list){
|
|
208
|
+
const selector = csstree.generate(node.prelude);
|
|
209
|
+
if(!selectorMatchesHTML(selector, tokens)){
|
|
210
|
+
list.remove(item);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/* Remove now-empty @media blocks */
|
|
216
|
+
csstree.walk(ast, {
|
|
217
|
+
visit: 'Atrule',
|
|
218
|
+
enter(node, item, list){
|
|
219
|
+
if(node.name === 'media' && node.block && node.block.children.size === 0){
|
|
220
|
+
list.remove(item);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return inlineVars(csstree.generate(ast), resolved);
|
|
226
|
+
};
|