summaryrefslogtreecommitdiff
path: root/packages/astro/src/compiler/transform/styles.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/astro/src/compiler/transform/styles.ts')
-rw-r--r--packages/astro/src/compiler/transform/styles.ts290
1 files changed, 290 insertions, 0 deletions
diff --git a/packages/astro/src/compiler/transform/styles.ts b/packages/astro/src/compiler/transform/styles.ts
new file mode 100644
index 000000000..efabf11fe
--- /dev/null
+++ b/packages/astro/src/compiler/transform/styles.ts
@@ -0,0 +1,290 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import { createRequire } from 'module';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import autoprefixer from 'autoprefixer';
+import postcss, { Plugin } from 'postcss';
+import postcssKeyframes from 'postcss-icss-keyframes';
+import findUp from 'find-up';
+import sass from 'sass';
+import type { RuntimeMode } from '../../@types/astro';
+import type { TransformOptions, Transformer } from '../../@types/transformer';
+import type { TemplateNode } from 'astro-parser';
+import { debug } from '../../logger.js';
+import astroScopedStyles, { NEVER_SCOPED_TAGS } from './postcss-scoped-styles/index.js';
+
+type StyleType = 'css' | 'scss' | 'sass' | 'postcss';
+
+declare global {
+ interface ImportMeta {
+ /** https://nodejs.org/api/esm.html#esm_import_meta_resolve_specifier_parent */
+ resolve(specifier: string, parent?: string): Promise<any>;
+ }
+}
+
+const getStyleType: Map<string, StyleType> = new Map([
+ ['.css', 'css'],
+ ['.pcss', 'postcss'],
+ ['.sass', 'sass'],
+ ['.scss', 'scss'],
+ ['css', 'css'],
+ ['sass', 'sass'],
+ ['scss', 'scss'],
+ ['text/css', 'css'],
+ ['text/sass', 'sass'],
+ ['text/scss', 'scss'],
+]);
+
+/** 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;
+}
+
+interface StylesMiniCache {
+ nodeModules: Map<string, string>; // filename: node_modules location
+ tailwindEnabled?: boolean; // cache once per-run
+}
+
+/** Simple cache that only exists in memory per-run. Prevents the same lookups from happening over and over again within the same build or dev server session. */
+const miniCache: StylesMiniCache = {
+ nodeModules: new Map<string, string>(),
+};
+
+export interface TransformStyleOptions {
+ type?: string;
+ filename: string;
+ scopedClass: string;
+ mode: RuntimeMode;
+}
+
+/** given a class="" string, does it contain a given class? */
+function hasClass(classList: string, className: string): boolean {
+ if (!className) return false;
+ for (const c of classList.split(' ')) {
+ if (className === c.trim()) return true;
+ }
+ return false;
+}
+
+/** Convert styles to scoped CSS */
+async function transformStyle(code: string, { type, filename, scopedClass, mode }: TransformStyleOptions): 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 = miniCache.nodeModules.get(filename);
+ if (cachedNodeModulesDir) {
+ includePaths.push(cachedNodeModulesDir);
+ } else {
+ const nodeModulesDir = await findUp('node_modules', { type: 'directory', cwd: path.dirname(filename) });
+ if (nodeModulesDir) {
+ miniCache.nodeModules.set(filename, nodeModulesDir);
+ includePaths.push(nodeModulesDir);
+ }
+ }
+
+ // 1. Preprocess (currently only Sass supported)
+ let css = '';
+ switch (styleType) {
+ case 'css': {
+ css = code;
+ break;
+ }
+ case 'sass':
+ case 'scss': {
+ css = sass.renderSync({ data: code, includePaths }).css.toString('utf8');
+ break;
+ }
+ default: {
+ throw new Error(`Unsupported: <style lang="${styleType}">`);
+ }
+ }
+
+ // 2. Post-process (PostCSS)
+ const postcssPlugins: Plugin[] = [];
+
+ // 2a. Tailwind (only if project uses Tailwind)
+ if (miniCache.tailwindEnabled) {
+ try {
+ const require = createRequire(import.meta.url);
+ const tw = require.resolve('tailwindcss', { paths: [import.meta.url, process.cwd()] });
+ postcssPlugins.push(require(tw) as any);
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error(err);
+ throw new Error(`tailwindcss not installed. Try running \`npm install tailwindcss\` and trying again.`);
+ }
+ }
+
+ // 2b. Astro scoped styles (always on)
+ postcssPlugins.push(astroScopedStyles({ className: scopedClass }));
+
+ // 2c. Scoped @keyframes
+ postcssPlugins.push(
+ postcssKeyframes({
+ generateScopedName(keyframesName) {
+ return `${keyframesName}-${scopedClass}`;
+ },
+ })
+ );
+
+ // 2d. Autoprefixer (always on)
+ postcssPlugins.push(autoprefixer());
+
+ // 2e. Run PostCSS
+ css = await postcss(postcssPlugins)
+ .process(css, { from: filename, to: undefined })
+ .then((result) => result.css);
+
+ return { css, type: styleType };
+}
+
+/** Transform <style> tags */
+export default function transformStyles({ compileOptions, filename, fileID }: TransformOptions): Transformer {
+ const styleNodes: TemplateNode[] = []; // <style> tags to be updated
+ const styleTransformPromises: Promise<StyleTransformResult>[] = []; // async style transform results to be finished in finalize();
+ const scopedClass = `astro-${hashFromFilename(fileID)}`; // this *should* generate same hash from fileID every time
+
+ // find Tailwind config, if first run (cache for subsequent runs)
+ if (miniCache.tailwindEnabled === undefined) {
+ const tailwindNames = ['tailwind.config.js', 'tailwind.config.mjs'];
+ for (const loc of tailwindNames) {
+ const tailwindLoc = path.join(fileURLToPath(compileOptions.astroConfig.projectRoot), loc);
+ if (fs.existsSync(tailwindLoc)) {
+ miniCache.tailwindEnabled = true; // Success! We have a Tailwind config file.
+ debug(compileOptions.logging, 'tailwind', 'Found config. Enabling.');
+ break;
+ }
+ }
+ if (miniCache.tailwindEnabled !== true) miniCache.tailwindEnabled = false; // We couldn‘t find one; mark as false
+ debug(compileOptions.logging, 'tailwind', 'No config found. Skipping.');
+ }
+
+ 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,
+ mode: compileOptions.mode,
+ })
+ );
+ return;
+ }
+
+ // 2. 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') {
+ // don‘t add same scopedClass twice
+ if (!hasClass(attr.value[k].data, scopedClass)) {
+ // string literal
+ attr.value[k].raw += ' ' + scopedClass;
+ attr.value[k].data += ' ' + scopedClass;
+ }
+ } else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) {
+ // don‘t add same scopedClass twice (this check is a little more basic, but should suffice)
+ if (!attr.value[k].expression.codeChunks[0].includes(`' ${scopedClass}'`)) {
+ // MustacheTag
+ // FIXME: this won't work when JSX element can appear in attributes (rare but possible).
+ attr.value[k].expression.codeChunks[0] = `(${attr.value[k].expression.codeChunks[0]}) + ' ${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 we‘re 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,
+ mode: compileOptions.mode,
+ })
+ );
+ },
+ },
+ },
+ },
+ async finalize() {
+ const styleTransforms = await Promise.all(styleTransformPromises);
+
+ styleTransforms.forEach((result, n) => {
+ if (styleNodes[n].attributes) {
+ // 1. Replace with 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;
+ }
+
+ // 2. Update <style> attributes
+ const styleTypeIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'type');
+ // add type="text/css"
+ 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' }] });
+ }
+ // remove lang="*"
+ const styleLangIndex = styleNodes[n].attributes.findIndex(({ name }: any) => name === 'lang');
+ if (styleLangIndex !== -1) styleNodes[n].attributes.splice(styleLangIndex, 1);
+ // TODO: add data-astro for later
+ // styleNodes[n].attributes.push({ name: 'data-astro', type: 'Attribute', value: true });
+ }
+ });
+ },
+ };
+}