Convert CSS Modules to scoped styles (#38)

* Convert CSS Modules to scoped styles

* Update README

* Move class scoping into HTML walker

* Fix SSR styles test

* Fix mustache tags

* Update PostCSS plugin name

* Add JSDoc comment

* Update test
This commit is contained in:
Drew Powers 2021-03-30 10:11:21 -06:00 committed by GitHub
parent d267fa461b
commit ee6ef81cf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 238 additions and 84 deletions

View file

@ -10,14 +10,6 @@ npm install astro
TODO: astro boilerplate
### 💧 Partial Hydration
By default, Astro outputs zero client-side JS. If you'd like to include an interactive component in the client output, you may use any of the following techniques.
- `MyComponent:load` will render `MyComponent` on page load
- `MyComponent:idle` will use `requestIdleCallback` to render `MyComponent` as soon as main thread is free
- `MyComponent:visible` will use an `IntersectionObserver` to render `MyComponent` when the element enters the viewport
## 🧞 Development
Add a `dev` npm script to your `/package.json` file:
@ -36,6 +28,53 @@ Then run:
npm run dev
```
### 💧 Partial Hydration
By default, Astro outputs zero client-side JS. If you'd like to include an interactive component in the client output, you may use any of the following techniques.
- `MyComponent:load` will render `MyComponent` on page load
- `MyComponent:idle` will use `requestIdleCallback` to render `MyComponent` as soon as main thread is free
- `MyComponent:visible` will use an `IntersectionObserver` to render `MyComponent` when the element enters the viewport
### 💅 Styling
If youve used [Svelte][svelte]s styles before, Astro works almost the same way. In any `.astro` file, start writing styles in a `<style>` tag like so:
```astro
<style>
.scoped {
font-weight: bold;
}
</style>
<div class="scoped">Im a scoped style</div>
```
#### Sass
Astro also supports [Sass][sass] out-of-the-box; no configuration needed:
```astro
<style lang="scss">
@use "../tokens" as *;
.title {
color: $color.gray;
}
</style>
<h1 class="title">Title</h1>
```
Supports:
- `lang="scss"`: load as the `.scss` extension
- `lang="sass"`: load as the `.sass` extension (no brackets; indent-style)
### Autoprefixer
We also automatically add browser prefixes using [Autoprefixer][autoprefixer]. By default, Astro loads the default values, but you may also specify your own by placing a [Browserslist][browserslist] file in your project root.
## 🚀 Build & Deployment
Add a `build` npm script to your `/package.json` file:
@ -56,3 +95,8 @@ npm run build
```
Now upload the contents of `/_site_` to your favorite static site host.
[autoprefixer]: https://github.com/postcss/autoprefixer
[browserslist]: https://github.com/browserslist/browserslist
[sass]: https://sass-lang.com/
[svelte]: https://svelte.dev

View file

@ -27,7 +27,7 @@ export let version: string = '3.1.2';
color: $white;
background-color: $dark-blue;
body.is-nav-open & {
:global(body.is-nav-open) & {
height: $nav-height * 2;
}

View file

@ -996,7 +996,7 @@
"deepmerge": "^4.2.2",
"domhandler": "^4.0.0",
"es-module-lexer": "^0.4.1",
"esbuild": "^0.9.6",
"esbuild": "^0.10.1",
"find-up": "^5.0.0",
"github-slugger": "^1.3.0",
"gray-matter": "^4.0.2",
@ -1008,7 +1008,6 @@
"micromark-extension-gfm": "^0.3.3",
"node-fetch": "^2.6.1",
"postcss": "^8.2.8",
"postcss-modules": "^4.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"sass": "^1.32.8",
@ -2371,9 +2370,9 @@
"dev": true
},
"esbuild": {
"version": "0.9.6",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.9.6.tgz",
"integrity": "sha512-F6vASxU0wT/Davt9aj2qtDwDNSkQxh9VbyO56M7PDWD+D/Vgq/rmUDGDQo7te76W5auauVojjnQr/wTu3vpaUA==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.10.2.tgz",
"integrity": "sha512-/5vsZD7wTJJHC3yNXLUjXNvUDwqwNoIMvFvLd9tcDQ9el5l13pspYm3yufavjIeYvNtAbo+6N/6uoWx9dGA6ug==",
"dev": true
},
"escalade": {
@ -4253,6 +4252,14 @@
"picomatch": "^2.2.2",
"resolve": "^1.20.0",
"rollup": "^2.34.0"
},
"dependencies": {
"esbuild": {
"version": "0.9.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.9.7.tgz",
"integrity": "sha512-VtUf6aQ89VTmMLKrWHYG50uByMF4JQlVysb8dmg6cOgW8JnFCipmz7p+HNBl+RR3LLCuBxFGVauAe2wfnF9bLg==",
"dev": true
}
}
},
"source-map": {

View file

@ -56,7 +56,6 @@
"micromark-extension-gfm": "^0.3.3",
"node-fetch": "^2.6.1",
"postcss": "^8.2.8",
"postcss-modules": "^4.0.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"rollup": "^2.43.1",

View file

@ -0,0 +1,79 @@
import { Plugin } from 'postcss';
interface AstroScopedOptions {
className: string;
}
interface Selector {
start: number;
end: number;
value: string;
}
const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
/**
* Scope Selectors
* Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`)
* @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax).
* @param {string} className The CSS class to apply.
*/
export function scopeSelectors(selector: string, className: string) {
const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
const selectors: Selector[] = [];
let ss = selector; // final output
// Pass 1: parse selector string; extract top-level selectors
let start = 0;
let lastValue = '';
for (let n = 0; n < ss.length; n++) {
const isEnd = n === selector.length - 1;
if (isEnd || CSS_SEPARATORS.has(selector[n])) {
lastValue = selector.substring(start, isEnd ? undefined : n);
if (!lastValue) continue;
selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
start = n + 1;
}
}
// Pass 2: starting from end, transform selectors w/ scoped class
for (let i = selectors.length - 1; i >= 0; i--) {
const { start, end, value } = selectors[i];
const head = ss.substring(0, start);
const tail = ss.substring(end);
// replace '*' with className
if (value === '*') {
ss = head + c + tail;
continue;
}
// leave :global() alone!
if (value.startsWith(':global(')) {
ss = head + ss.substring(start, end).replace(':global(', '').replace(')', '') + tail;
continue;
}
// scope everything else
let newSelector = ss.substring(start, end);
const pseudoIndex = newSelector.indexOf(':');
if (pseudoIndex > 0) {
// if theres a pseudoclass (:focus)
ss = head + newSelector.substring(start, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail;
} else {
ss = head + newSelector + c + tail;
}
}
return ss;
}
/** PostCSS Scope plugin */
export default function astroScopedStyles(options: AstroScopedOptions): Plugin {
return {
postcssPlugin: '@astro/postcss-scoped-styles',
Rule(rule) {
rule.selector = scopeSelectors(rule.selector, options.className);
},
};
}

View file

@ -2,11 +2,11 @@ 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';
import astroScopedStyles from './postcss-scoped-styles/index.js';
type StyleType = 'css' | 'scss' | 'sass' | 'postcss';
@ -28,6 +28,8 @@ const getStyleType: Map<string, StyleType> = new Map([
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 {
@ -42,14 +44,14 @@ function hashFromFilename(filename: string): string {
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> {
/** 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;
@ -81,51 +83,31 @@ async function transformStyle(code: string, { type, filename, fileID }: { type?:
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}">`);
throw new Error(`Unsupported: <style lang="${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(),
])
css = await postcss([astroScopedStyles({ className: scopedClass }), autoprefixer()])
.process(css, { from: filename, to: undefined })
.then((result) => result.css);
return {
css,
cssModules,
type: styleType,
};
return { css, 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
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') : '';
@ -136,22 +118,42 @@ export default function ({ filename, fileID }: { filename: string; fileID: strin
transformStyle(code, {
type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
filename,
fileID,
scopedClass,
})
);
return;
}
// Find the root node to inject the <style> tag in later
// 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)
}
for (let attr of node.attributes) {
if (attr.name !== 'class') continue;
elementNodes.push(node);
// 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}'`;
}
}
}
},
},
@ -170,7 +172,7 @@ export default function ({ filename, fileID }: { filename: string; fileID: strin
transformStyle(code, {
type: (langAttr && langAttr.value[0] && langAttr.value[0].data) || undefined,
filename,
fileID,
scopedClass,
})
);
@ -182,7 +184,6 @@ export default function ({ filename, fileID }: { filename: string; fileID: strin
},
},
async finalize() {
const allCssModules: Record<string, string> = {}; // note: this may theoretically have conflicts, but when written, it shouldnt because were 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) {
@ -192,11 +193,6 @@ export default function ({ filename, fileID }: { filename: string; fileID: strin
// 1. transform <style> tags
styleTransforms.forEach((result, n) => {
if (styleNodes[n].attributes) {
// 1a. Add to global CSS Module class list for step 2
for (const [k, v] of result.cssModules) {
allCssModules[k] = v;
}
// 1b. Inject final CSS
const isHeadStyle = !styleNodes[n].content;
if (isHeadStyle) {
@ -221,34 +217,8 @@ export default function ({ filename, fileID }: { filename: string; fileID: strin
});
// 2. inject finished <style> tags into root node
// TODO: pull out into <link> tags for deduping
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') {
// This class is standard HTML (`class="foo"`). Replace only the classes that match
const elementClassNames = (attr.value[k].raw as string)
.split(' ')
.map((c) => {
let className = c.trim();
return allCssModules[className] || className; // if className matches exactly, replace; otherwise keep original
})
.join(' ');
attr.value[k].raw = elementClassNames;
attr.value[k].data = elementClassNames;
} else if (attr.value[k].type === 'MustacheTag' && attr.value[k]) {
// This class is an expression, so its more difficult (`className={'some' + 'expression'}`). We pass all CSS Module names to the expression, and let it find a match, if any
attr.value[k].content = `(${attr.value[k].content}).split(' ').map((className) => (${JSON.stringify(allCssModules)})[className] || className).join(' ')`;
}
}
}
}
},
};
}

View file

@ -0,0 +1,28 @@
import { suite } from 'uvu';
import * as assert from 'uvu/assert';
import { scopeSelectors } from '../lib/compiler/optimize/postcss-scoped-styles/index.js';
const ScopedStyles = suite('Astro PostCSS Scoped Styles Plugin');
const className = '.astro-abcd1234';
// Note: assume all selectors have no unnecessary spaces (i.e. must be minified)
const tests = {
'.class': `.class${className}`,
h1: `h1${className}`,
'.nav h1': `.nav${className} h1${className}`,
'.class+.class': `.class${className}+.class${className}`,
'.class~:global(a)': `.class${className}~a`,
'.class *': `.class${className} ${className}`,
'.class>*': `.class${className}>${className}`,
'.class :global(*)': `.class${className} *`,
'.class:not(.is-active)': `.class${className}:not(.is-active)`, // Note: the :not() selector can NOT contain multiple classes, so this is correct; if this causes issues for some people then its worth a discussion
};
ScopedStyles('Scopes correctly', () => {
for (const [given, expected] of Object.entries(tests)) {
assert.equal(scopeSelectors(given, className), expected);
}
});
ScopedStyles.run();

View file

@ -47,4 +47,24 @@ StylesSSR('Has correct CSS classes', async () => {
}
});
StylesSSR('CSS Module support in .astro', async () => {
const result = await runtime.load('/');
const $ = doc(result.contents);
let scopedClass;
// test 1: <style> tag in <head> is transformed
const css = $('style')
.html()
.replace(/\.astro-[A-Za-z0-9-]+/, (match) => {
scopedClass = match;
return match;
}); // remove class hash (should be deterministic / the same every time, but even still dont cause this test to flake)
assert.equal(css, `.wrapper${scopedClass}{margin-left:auto;margin-right:auto;max-width:1200px}`);
// test 2: element received .astro-XXXXXX class (this selector will succeed if transformed correctly)
const wrapper = $(`.wrapper${scopedClass}`);
assert.equal(wrapper.length, 1);
});
StylesSSR.run();

View file

@ -7,9 +7,16 @@ import SvelteScoped from '../components/SvelteScoped.svelte';
<html>
<head>
<meta charset="UTF-8" />
<style lang="scss">
.wrapper {
margin-left: auto;
margin-right: auto;
max-width: 1200px;
}
</style>
</head>
<body>
<div>
<div class="wrapper">
<ReactCSS />
<VueCSS />
<SvelteScoped />