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:
parent
d267fa461b
commit
ee6ef81cf3
9 changed files with 238 additions and 84 deletions
60
README.md
60
README.md
|
@ -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 you‘ve 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">I’m 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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
17
examples/snowpack/package-lock.json
generated
17
examples/snowpack/package-lock.json
generated
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
79
src/compiler/optimize/postcss-scoped-styles/index.ts
Normal file
79
src/compiler/optimize/postcss-scoped-styles/index.ts
Normal 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 there‘s 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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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 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);
|
||||
// 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 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) {
|
||||
|
@ -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 it’s 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(' ')`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
28
test/astro-scoped-styles.test.js
Normal file
28
test/astro-scoped-styles.test.js
Normal 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 it‘s worth a discussion
|
||||
};
|
||||
|
||||
ScopedStyles('Scopes correctly', () => {
|
||||
for (const [given, expected] of Object.entries(tests)) {
|
||||
assert.equal(scopeSelectors(given, className), expected);
|
||||
}
|
||||
});
|
||||
|
||||
ScopedStyles.run();
|
|
@ -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 don‘t 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();
|
||||
|
|
|
@ -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 />
|
||||
|
|
Loading…
Reference in a new issue