diff options
author | 2021-03-30 10:11:21 -0600 | |
---|---|---|
committer | 2021-03-30 10:11:21 -0600 | |
commit | ee6ef81cf38acc357141143319addf39a99d3e19 (patch) | |
tree | 1fd6032709f74253dcb5a30630da70d2e4373a40 | |
parent | d267fa461b73586118437e190b92b9956f9add73 (diff) | |
download | astro-ee6ef81cf38acc357141143319addf39a99d3e19.tar.gz astro-ee6ef81cf38acc357141143319addf39a99d3e19.tar.zst astro-ee6ef81cf38acc357141143319addf39a99d3e19.zip |
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
-rw-r--r-- | README.md | 60 | ||||
-rw-r--r-- | examples/snowpack/astro/components/Nav.astro | 2 | ||||
-rw-r--r-- | examples/snowpack/package-lock.json | 17 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | src/compiler/optimize/postcss-scoped-styles/index.ts | 79 | ||||
-rw-r--r-- | src/compiler/optimize/styles.ts | 106 | ||||
-rw-r--r-- | test/astro-scoped-styles.test.js | 28 | ||||
-rw-r--r-- | test/astro-styles-ssr.test.js | 20 | ||||
-rw-r--r-- | test/fixtures/astro-styles-ssr/astro/pages/index.astro | 9 |
9 files changed, 238 insertions, 84 deletions
@@ -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 diff --git a/examples/snowpack/astro/components/Nav.astro b/examples/snowpack/astro/components/Nav.astro index 3582bd0a0..a5f14d656 100644 --- a/examples/snowpack/astro/components/Nav.astro +++ b/examples/snowpack/astro/components/Nav.astro @@ -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; } diff --git a/examples/snowpack/package-lock.json b/examples/snowpack/package-lock.json index d9b20cf23..a52da1c88 100644 --- a/examples/snowpack/package-lock.json +++ b/examples/snowpack/package-lock.json @@ -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": { diff --git a/package.json b/package.json index e5d2b2785..1c44c9ab9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/compiler/optimize/postcss-scoped-styles/index.ts b/src/compiler/optimize/postcss-scoped-styles/index.ts new file mode 100644 index 000000000..7949f63b5 --- /dev/null +++ b/src/compiler/optimize/postcss-scoped-styles/index.ts @@ -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); + }, + }; +} diff --git a/src/compiler/optimize/styles.ts b/src/compiler/optimize/styles.ts index 563e9b355..6f0cd9361 100644 --- a/src/compiler/optimize/styles.ts +++ b/src/compiler/optimize/styles.ts @@ -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(' ')`; - } - } - } - } }, }; } diff --git a/test/astro-scoped-styles.test.js b/test/astro-scoped-styles.test.js new file mode 100644 index 000000000..d6ae7a02e --- /dev/null +++ b/test/astro-scoped-styles.test.js @@ -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(); diff --git a/test/astro-styles-ssr.test.js b/test/astro-styles-ssr.test.js index 9145e65a9..9ee67f69b 100644 --- a/test/astro-styles-ssr.test.js +++ b/test/astro-styles-ssr.test.js @@ -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(); diff --git a/test/fixtures/astro-styles-ssr/astro/pages/index.astro b/test/fixtures/astro-styles-ssr/astro/pages/index.astro index 30591da72..7333fac21 100644 --- a/test/fixtures/astro-styles-ssr/astro/pages/index.astro +++ b/test/fixtures/astro-styles-ssr/astro/pages/index.astro @@ -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 /> |