0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2025-03-24 23:21:57 -05:00
astro/src/compiler/optimize/styles.ts
2021-03-30 10:37:04 -06:00

223 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import crypto from 'crypto';
import path from 'path';
import autoprefixer from 'autoprefixer';
import postcss from 'postcss';
import findUp from 'find-up';
import sass from 'sass';
import { Optimizer } from '../../@types/optimizer';
import type { TemplateNode } from '../../parser/interfaces';
import astroScopedStyles from './postcss-scoped-styles/index.js';
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',
};
/** HTML tags that should never get scoped classes */
const NEVER_SCOPED_TAGS = new Set<string>(['html', 'head', 'body', 'script', 'style', 'link', 'meta']);
/** 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;
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>();
/** Convert styles to scoped CSS */
async function transformStyle(code: string, { type, filename, scopedClass }: { type?: string; filename: string; scopedClass: 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;
}
default: {
throw new Error(`Unsupported: <style lang="${styleType}">`);
}
}
css = await postcss([astroScopedStyles({ className: scopedClass }), autoprefixer()])
.process(css, { from: filename, to: undefined })
.then((result) => result.css);
return { css, type: styleType };
}
export default function ({ filename, fileID }: { filename: string; fileID: string }): Optimizer {
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
const scopedClass = `astro-${hashFromFilename(fileID)}`; // this *should* generate same hash from fileID every time
return {
visitors: {
html: {
Element: {
enter(node) {
// 1. if <style> tag, transform it and continue to next node
if (node.name === 'style') {
// Same as ast.css (below)
const code = Array.isArray(node.children) ? node.children.map(({ data }: any) => data).join('\n') : '';
if (!code) return;
const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang');
styleNodes.push(node);
styleTransformPromises.push(
transformStyle(code, {
type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
filename,
scopedClass,
})
);
return;
}
// 2. find the root node to inject the <style> tag in later
// TODO: remove this when we are injecting <link> tags into <head>
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 wont for Component subtrees).
} else if (!rootNode) {
rootNode = node; // If no <head> (yet), then take the first element we come to and assume its the “root” (but if we find a <head> later, then override this per the above)
}
// 3. add scoped HTML classes
if (NEVER_SCOPED_TAGS.has(node.name)) return; // only continue if this is NOT a <script> tag, etc.
// Note: currently we _do_ scope web components/custom elements. This seems correct?
if (!node.attributes) node.attributes = [];
const classIndex = node.attributes.findIndex(({ name }: any) => name === 'class');
if (classIndex === -1) {
// 3a. element has no class="" attribute; add one and append scopedClass
node.attributes.push({ start: -1, end: -1, type: 'Attribute', name: 'class', value: [{ type: 'Text', raw: scopedClass, data: scopedClass }] });
} else {
// 3b. element has class=""; append scopedClass
const attr = node.attributes[classIndex];
for (let k = 0; k < attr.value.length; k++) {
if (attr.value[k].type === 'Text') {
// string literal
attr.value[k].raw += ' ' + scopedClass;
attr.value[k].data += ' ' + scopedClass;
} else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) {
// MustacheTag
attr.value[k].content = `(${attr.value[k].content}) + ' ${scopedClass}'`;
}
}
}
},
},
},
// CSS: compile styles, apply CSS Modules scoping
css: {
Style: {
enter(node) {
// Same as ast.html (above)
// Note: this is duplicated from html because of the compiler were using; in a future version we should combine these
if (!node.content || !node.content.styles) return;
const code = node.content.styles;
const langAttr = (node.attributes || []).find(({ name }: any) => name === 'lang');
styleNodes.push(node);
styleTransformPromises.push(
transformStyle(code, {
type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
filename,
scopedClass,
})
);
// TODO: we should delete the old untransformed <style> node after were 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 well need to actually delete the node (or transform it to the processed version)
},
},
},
},
async finalize() {
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 theres a bug in our code
}
// 1. transform <style> tags
styleTransforms.forEach((result, n) => {
if (styleNodes[n].attributes) {
// 1b. Inject final CSS
const isHeadStyle = !styleNodes[n].content;
if (isHeadStyle) {
// Note: <style> tags in <head> have different attributes/rules, because of the parser. Unknown why
(styleNodes[n].children as any) = [{ ...(styleNodes[n].children as any)[0], data: result.css }];
} else {
styleNodes[n].content.styles = result.css;
}
// 3b. Update <style> attributes
const styleTypeIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'type');
if (styleTypeIndex !== -1) {
styleNodes[n].attributes[styleTypeIndex].value[0].raw = 'text/css';
styleNodes[n].attributes[styleTypeIndex].value[0].data = 'text/css';
} else {
styleNodes[n].attributes.push({ name: 'type', type: 'Attribute', value: [{ type: 'Text', raw: 'text/css', data: 'text/css' }] });
}
const styleLangIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'lang');
if (styleLangIndex !== -1) styleNodes[n].attributes.splice(styleLangIndex, 1);
}
});
// 2. inject finished <style> tags into root node
// TODO: pull out into <link> tags for deduping
rootNode.children = [...styleNodes, ...(rootNode.children || [])];
},
};
}