1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
|
import crypto from 'crypto';
import path from 'path';
import autoprefixer from 'autoprefixer';
import postcss from 'postcss';
import postcssModules from 'postcss-modules';
import findUp from 'find-up';
import sass from 'sass';
import { Optimizer } from '../../@types/optimizer';
import type { TemplateNode } from '../../parser/interfaces';
type StyleType = 'css' | 'scss' | 'sass' | 'postcss';
const getStyleType: Map<string, StyleType> = new Map([
['.css', 'css'],
['.pcss', 'postcss'],
['.sass', 'sass'],
['.scss', 'scss'],
['css', 'css'],
['postcss', 'postcss'],
['sass', 'sass'],
['scss', 'scss'],
['text/css', 'css'],
['text/postcss', 'postcss'],
['text/sass', 'sass'],
['text/scss', 'scss'],
]);
const SASS_OPTIONS: Partial<sass.Options> = {
outputStyle: 'compressed',
};
/** Should be deterministic, given a unique filename */
function hashFromFilename(filename: string): string {
const hash = crypto.createHash('sha256');
return hash
.update(filename.replace(/\\/g, '/'))
.digest('base64')
.toString()
.replace(/[^A-Za-z0-9-]/g, '')
.substr(0, 8);
}
export interface StyleTransformResult {
css: string;
cssModules: Map<string, string>;
type: StyleType;
}
// cache node_modules resolutions for each run. saves looking up the same directory over and over again. blown away on exit.
const nodeModulesMiniCache = new Map<string, string>();
async function transformStyle(code: string, { type, filename, fileID }: { type?: string; filename: string; fileID: string }): Promise<StyleTransformResult> {
let styleType: StyleType = 'css'; // important: assume CSS as default
if (type) {
styleType = getStyleType.get(type) || styleType;
}
// add file path to includePaths
let includePaths: string[] = [path.dirname(filename)];
// include node_modules to includePaths (allows @use-ing node modules, if it can be located)
const cachedNodeModulesDir = nodeModulesMiniCache.get(filename);
if (cachedNodeModulesDir) {
includePaths.push(cachedNodeModulesDir);
} else {
const nodeModulesDir = await findUp('node_modules', { type: 'directory', cwd: path.dirname(filename) });
if (nodeModulesDir) {
nodeModulesMiniCache.set(filename, nodeModulesDir);
includePaths.push(nodeModulesDir);
}
}
let css = '';
switch (styleType) {
case 'css': {
css = code;
break;
}
case 'sass':
case 'scss': {
css = sass.renderSync({ ...SASS_OPTIONS, data: code, includePaths }).css.toString('utf8');
break;
}
case 'postcss': {
css = code; // TODO
break;
}
default: {
throw new Error(`Unsupported: <style type="${styleType}">`);
}
}
const cssModules = new Map<string, string>();
css = await postcss([
postcssModules({
generateScopedName(name: string) {
return `${name}__${hashFromFilename(fileID)}`;
},
getJSON(_: string, json: any) {
Object.entries(json).forEach(([k, v]: any) => {
if (k !== v) cssModules.set(k, v);
});
},
}),
autoprefixer(),
])
.process(css, { from: filename, to: undefined })
.then((result) => result.css);
return {
css,
cssModules,
type: styleType,
};
}
export default function ({ filename, fileID }: { filename: string; fileID: string }): Optimizer {
const elementNodes: TemplateNode[] = []; // elements that need CSS Modules class names
const styleNodes: TemplateNode[] = []; // <style> tags to be updated
const styleTransformPromises: Promise<StyleTransformResult>[] = []; // async style transform results to be finished in finalize();
let rootNode: TemplateNode; // root node which needs <style> tags
return {
visitors: {
html: {
Element: {
enter(node) {
// Find the root node to inject the <style> tag in later
if (node.name === 'head') {
rootNode = node; // If this is <head>, this is what we want. Always take this if found. However, this may not always exist (it won’t for Component subtrees).
} else if (!rootNode) {
rootNode = node; // If no <head> (yet), then take the first element we come to and assume it‘s the “root” (but if we find a <head> later, then override this per the above)
}
for (let attr of node.attributes) {
if (attr.name !== 'class') continue;
elementNodes.push(node);
}
},
},
},
// CSS: compile styles, apply CSS Modules scoping
css: {
Style: {
enter(node) {
const code = node.content.styles;
const typeAttr = (node.attributes || []).find(({ name }: { name: string }) => name === 'lang');
styleNodes.push(node);
styleTransformPromises.push(transformStyle(code, { type: (typeAttr && typeAttr.value[0] && typeAttr.value[0].raw) || undefined, filename, fileID }));
// TODO: we should delete the old untransformed <style> node after we’re done.
// However, the svelte parser left it in ast.css, not ast.html. At the final step, this just gets ignored, so it will be deleted, in a sense.
// If we ever end up scanning ast.css for something else, then we’ll need to actually delete the node (or transform it to the processed version)
},
},
},
},
async finalize() {
const allCssModules = new Map<string, string>(); // note: this may theoretically have conflicts, but when written, it shouldn’t because we’re processing everything per-component (if we change this to run across the whole document at once, revisit this)
const styleTransforms = await Promise.all(styleTransformPromises);
if (!rootNode) {
throw new Error(`No root node found`); // TODO: remove this eventually; we should always find it, but for now alert if there’s a bug in our code
}
// 1. transform <style> tags
styleTransforms.forEach((result, n) => {
if (styleNodes[n].attributes) {
// Add to global CSS Module class list for step 2
for (const [k, v] of result.cssModules) {
allCssModules.set(k, v);
}
// Update original <style> node with finished results
styleNodes[n].attributes = styleNodes[n].attributes.map((attr: any) => {
if (attr.name === 'type') {
attr.value[0].raw = 'text/css';
attr.value[0].data = 'text/css';
}
return attr;
});
}
styleNodes[n].content.styles = result.css;
});
// 2. inject finished <style> tags into root node
rootNode.children = [...styleNodes, ...(rootNode.children || [])];
// 3. update HTML classes
for (let i = 0; i < elementNodes.length; i++) {
if (!elementNodes[i].attributes) continue;
const node = elementNodes[i];
for (let j = 0; j < node.attributes.length; j++) {
if (node.attributes[j].name !== 'class') continue;
const attr = node.attributes[j];
for (let k = 0; k < attr.value.length; k++) {
if (attr.value[k].type !== 'Text') continue;
const elementClassNames = (attr.value[k].raw as string)
.split(' ')
.map((c) => {
let className = c.trim();
return allCssModules.get(className) || className; // if className matches exactly, replace; otherwise keep original
})
.join(' ');
attr.value[k].raw = elementClassNames;
attr.value[k].data = elementClassNames;
}
}
}
},
};
}
|