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 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.1.9",
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
+ };