From 03f5999cee65366fa9c5c7ef5feaa996b2c6b097 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Fri, 18 Oct 2024 17:23:11 -0400 Subject: [PATCH] =?UTF-8?q?feature/issue=201233=20CSS=20Modules=20?= =?UTF-8?q?=E2=84=A2=EF=B8=8F=20plugin=20(#1285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * initial port from website repo * build and prerender test case * build and develop test cases * README and website documentation * only patch JSDOM within test setup blocks * handle module types for scripts * support any custom identifier * update test case label * testing for nested CSS module * better path detection handling * additional nested and recursive test cases * clean up and final testing * misc refactoring * basic TS support and caveats for other formats --- packages/cli/src/config/rollup.config.js | 12 +- packages/cli/src/lifecycles/prerender.js | 10 +- ...ild.default.workspace-layouts-page.spec.js | 1 - packages/plugin-css-modules/README.md | 126 ++++++ packages/plugin-css-modules/package.json | 38 ++ packages/plugin-css-modules/src/index.js | 303 ++++++++++++++ .../cases/build.default/build.default.spec.js | 211 ++++++++++ .../cases/build.default/expected.header.css | 1 + .../cases/build.default/greenwood.config.js | 7 + .../src/components/header/header.js | 22 + .../src/components/header/header.module.css | 40 ++ .../build.default/src/components/logo/logo.js | 13 + .../src/components/logo/logo.module.css | 8 + .../test/cases/build.default/src/index.html | 10 + .../develop.default/develop.default.spec.js | 235 +++++++++++ .../cases/develop.default/expected.header.css | 40 ++ .../cases/develop.default/greenwood.config.js | 7 + .../src/components/header/header.js | 21 + .../src/components/header/header.module.css | 40 ++ .../src/components/logo/logo.js | 13 + .../src/components/logo/logo.module.css | 8 + .../test/cases/develop.default/src/index.html | 10 + .../expected.footer.css | 1 + .../expected.header.css | 1 + .../greenwood.config.js | 43 ++ .../loaders-build.prerender.spec.js | 390 ++++++++++++++++++ .../src/components/footer/footer.js | 33 ++ .../src/components/footer/footer.module.css | 47 +++ .../src/components/header/header.js | 22 + .../src/components/header/header.module.css | 40 ++ .../src/components/logo/logo.module.css | 8 + .../src/components/logo/logo.ts | 15 + .../loaders-build.prerender/src/index.html | 12 + www/pages/plugins/custom-plugins.md | 1 + 34 files changed, 1778 insertions(+), 11 deletions(-) create mode 100644 packages/plugin-css-modules/README.md create mode 100644 packages/plugin-css-modules/package.json create mode 100644 packages/plugin-css-modules/src/index.js create mode 100644 packages/plugin-css-modules/test/cases/build.default/build.default.spec.js create mode 100644 packages/plugin-css-modules/test/cases/build.default/expected.header.css create mode 100644 packages/plugin-css-modules/test/cases/build.default/greenwood.config.js create mode 100644 packages/plugin-css-modules/test/cases/build.default/src/components/header/header.js create mode 100644 packages/plugin-css-modules/test/cases/build.default/src/components/header/header.module.css create mode 100644 packages/plugin-css-modules/test/cases/build.default/src/components/logo/logo.js create mode 100644 packages/plugin-css-modules/test/cases/build.default/src/components/logo/logo.module.css create mode 100644 packages/plugin-css-modules/test/cases/build.default/src/index.html create mode 100644 packages/plugin-css-modules/test/cases/develop.default/develop.default.spec.js create mode 100644 packages/plugin-css-modules/test/cases/develop.default/expected.header.css create mode 100644 packages/plugin-css-modules/test/cases/develop.default/greenwood.config.js create mode 100644 packages/plugin-css-modules/test/cases/develop.default/src/components/header/header.js create mode 100644 packages/plugin-css-modules/test/cases/develop.default/src/components/header/header.module.css create mode 100644 packages/plugin-css-modules/test/cases/develop.default/src/components/logo/logo.js create mode 100644 packages/plugin-css-modules/test/cases/develop.default/src/components/logo/logo.module.css create mode 100644 packages/plugin-css-modules/test/cases/develop.default/src/index.html create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.footer.css create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.header.css create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/greenwood.config.js create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/footer/footer.js create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/footer/footer.module.css create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.js create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.module.css create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/logo/logo.module.css create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/logo/logo.ts create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/index.html diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index a434c3bc8..b0bad6bfd 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -62,17 +62,19 @@ function greenwoodResourceLoader (compilation, browser = false) { }; // filter first for any bare specifiers - if (await checkResourceExists(idUrl) && extension !== 'js') { - for (const plugin of resourcePlugins) { - if (plugin.shouldResolve && await plugin.shouldResolve(idUrl)) { - idUrl = new URL((await plugin.resolve(idUrl)).url); + if (await checkResourceExists(idUrl) && !id.startsWith('\x00')) { + if (extension !== 'js') { + for (const plugin of resourcePlugins) { + if (plugin.shouldResolve && await plugin.shouldResolve(idUrl)) { + idUrl = new URL((await plugin.resolve(idUrl)).url); + } } } const request = new Request(idUrl, { headers }); - let response = new Response(''); + let response = new Response('', { headers: { 'Content-Type': 'text/javascript' } }); for (const plugin of resourcePlugins) { if (plugin.shouldServe && await plugin.shouldServe(idUrl, request)) { diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index d67ffbce4..785c95d61 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -1,5 +1,5 @@ import fs from 'fs/promises'; -import { checkResourceExists, trackResourcesForRoute } from '../lib/resource-utils.js'; +import { checkResourceExists, trackResourcesForRoute, mergeResponse } from '../lib/resource-utils.js'; import os from 'os'; import { WorkerPool } from '../lib/threadpool.js'; @@ -30,12 +30,12 @@ async function interceptPage(url, request, plugins, body) { }); for (const plugin of plugins) { - if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(url, request, response)) { - response = await plugin.preIntercept(url, request, response); + if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(url, request, response.clone())) { + response = mergeResponse(response, await plugin.preIntercept(url, request, response.clone())); } - if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response)) { - response = await plugin.intercept(url, request, response); + if (plugin.shouldIntercept && await plugin.shouldIntercept(url, request, response.clone())) { + response = mergeResponse(response, await plugin.intercept(url, request, response.clone())); } } diff --git a/packages/cli/test/cases/build.default.workspace-layouts-page/build.default.workspace-layouts-page.spec.js b/packages/cli/test/cases/build.default.workspace-layouts-page/build.default.workspace-layouts-page.spec.js index 7b658a80d..8eb405d04 100644 --- a/packages/cli/test/cases/build.default.workspace-layouts-page/build.default.workspace-layouts-page.spec.js +++ b/packages/cli/test/cases/build.default.workspace-layouts-page/build.default.workspace-layouts-page.spec.js @@ -133,7 +133,6 @@ describe('Build Greenwood With: ', function() { }); }); }); - }); after(function() { diff --git a/packages/plugin-css-modules/README.md b/packages/plugin-css-modules/README.md new file mode 100644 index 000000000..8cabc12ae --- /dev/null +++ b/packages/plugin-css-modules/README.md @@ -0,0 +1,126 @@ +# @greenwood/plugin-css-modules + +## Overview + +A Greenwood plugin for authoring [**CSS Modules ™️**](https://github.com/css-modules/css-modules). It is a modest implementation of [the specification](https://github.com/css-modules/icss). 🙂 + +This is NOT to be confused with [CSS Module _Scripts_](https://web.dev/articles/css-module-scripts), which Greenwood already supports. + +> This package assumes you already have `@greenwood/cli` installed. + +## Installation + +You can use your favorite JavaScript package manager to install this package. + +_examples:_ +```bash +# npm +npm i -D @greenwood/plugin-css-modules + +# yarn +yarn add @greenwood/plugin-css-modules --dev +``` + +## Usage + +Add this plugin to your _greenwood.config.js_. + +```javascript +import { greenwoodPluginCssModules } from '@greenwood/plugin-css-modules'; + +export default { + ... + + plugins: [ + greenwoodPluginCssModules() + ] +} +``` + +Now you can create a CSS file that ends in _.module.css_ + +```css +/* header.module.css */ +.container { + display: flex; + justify-content: space-between; +} + +.navBarMenu { + border: 1px solid #020202; +} + +.navBarMenuItem { + & a { + text-decoration: none; + color: #020202; + } +} + +@media screen and (min-width: 768px) { + .container { + padding: 10px 20px; + } +} +``` + + +And reference that in your (Light DOM) HTML based Web Component + +```js +// header.js +import styles from './header.module.css'; + +export default class Header extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
+ +
+ `; + } +} + +customElements.define('app-header', Header); +``` + +From there, Greenwood will scope your CSS by prefixing with the filename and a hash, and inline that into a ` + + ` + ); + + return new Response(newBody); + } else if (protocol === 'file:' && pathname.endsWith(this.extensions[0]) && cssModulesMap[mapKey]) { + // handle this primarily for SSR / prerendering use case + const cssModule = `export default ${JSON.stringify(cssModulesMap[mapKey].module)}`; + + return new Response(cssModule, { + headers: { + 'Content-Type': this.contentType + } + }); + } + } +} + +// this process all files that have CssModules content used +// and strip out the `import` and replace all the references in class attributes with static values +class StripCssModulesResource extends ResourceInterface { + constructor(compilation, options) { + super(compilation, options); + + this.extensions = ['module.css']; + this.contentType = 'text/javascript'; + } + + async shouldIntercept(url) { + const cssModulesMap = getCssModulesMap(this.compilation); + + for (const [, value] of Object.entries(cssModulesMap)) { + if (url.href === value.importer) { + return true; + } + } + } + + async intercept(url, request, response) { + const { context } = this.compilation; + let contents = await response.text(); + + acornWalk.simple( + acorn.Parser.extend(importAttributes).parse(contents, { + ecmaVersion: 'latest', + sourceType: 'module' + }), + { + ImportDeclaration(node) { + const { specifiers = [], source = {}, start, end } = node; + const { value = '' } = source; + + if ( + value.endsWith('.module.css') && + specifiers.length === 1 + ) { + contents = `${contents.slice(0, start)} \n ${contents.slice(end)}`; + const cssModulesMap = getCssModulesMap({ context }); + + Object.values(cssModulesMap).forEach((value) => { + const { importer, module, identifier } = value; + + if (importer === url.href) { + Object.keys(module).forEach((key) => { + const literalUsageRegex = new RegExp(String.raw`\$\{${identifier}.${key}\}`, 'g'); + // https://stackoverflow.com/a/20851557/417806 + const expressionUsageRegex = new RegExp(String.raw`(((? \n\r\b]))${identifier}\.${key}((?![-\w\d\W])|(?=[ <.,:;!?\n\r\b])))`, 'g'); + + if (literalUsageRegex.test(contents)) { + contents = contents.replace(literalUsageRegex, module[key]); + } else if (expressionUsageRegex.test(contents)) { + contents = contents.replace(expressionUsageRegex, `'${module[key]}'`); + } + }); + } + }); + } + } + } + ); + + return new Response(contents); + } +} + +const greenwoodPluginCssModules = () => { + return [{ + type: 'resource', + name: 'plugin-css-modules:scan', + provider: (compilation, options) => new ScanForCssModulesResource(compilation, options) + }, { + type: 'resource', + name: 'plugin-css-modules-strip-modules', + provider: (compilation, options) => new StripCssModulesResource(compilation, options) + }]; +}; + +export { greenwoodPluginCssModules }; \ No newline at end of file diff --git a/packages/plugin-css-modules/test/cases/build.default/build.default.spec.js b/packages/plugin-css-modules/test/cases/build.default/build.default.spec.js new file mode 100644 index 000000000..9800f9f16 --- /dev/null +++ b/packages/plugin-css-modules/test/cases/build.default/build.default.spec.js @@ -0,0 +1,211 @@ +/* + * Use Case + * Run Greenwood build with CSS Modules plugin. + * + * User Result + * Should generate a Greenwood project with CSS Modules properly transformed. + * + * User Command + * greenwood build + * + * User Config + * import { greenwoodPluginCssModules } import '@greenwood/plugin-css-modules'; + * + * { + * plugins: [ + * greenwoodPluginCssModules() + * ] + * } + * + * User Workspace + * src/ + * components/ + * header/ + * header.js + * header.module.css + * logo/ + * logo.js + * logo.module.css + * index.html + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import fs from 'fs'; +import glob from 'glob-promise'; +import path from 'path'; +import { parse, walk } from 'css-tree'; +import { runSmokeTest } from '../../../../../test/smoke-test.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; +import { implementation } from 'jsdom/lib/jsdom/living/nodes/HTMLStyleElement-impl.js'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Default Configuration for CSS Modules'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + let updateAStyleBlockRef; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function() { + + before(function() { + // JSDOM doesn't support CSS nesting and kind of blows up in the console as it tries to parse it automatically + // https://github.com/jsdom/jsdom/issues/2005#issuecomment-2397495853 + updateAStyleBlockRef = implementation.prototype._updateAStyleBlock; // eslint-disable-line no-underscore-dangle + implementation.prototype._updateAStyleBlock = () => {}; // eslint-disable-line no-underscore-dangle + + runner.setup(outputPath, getSetupFiles(outputPath)); + runner.runCommand(cliPath, 'build'); + }); + + runSmokeTest(['public', 'index'], LABEL); + + describe('index.html with expected CSS and SSR contents', function() { + const EXPECTED_HEADER_CLASS_NAMES = 8; + let dom; + let headerSourceCss; + let expectedHeaderCss; + + before(async function() { + dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html')); + headerSourceCss = await fs.promises.readFile(new URL('./src/components/header/header.module.css', import.meta.url), 'utf-8'); + expectedHeaderCss = await fs.promises.readFile(new URL('./expected.header.css', import.meta.url), 'utf-8'); + }); + + describe('Header component with CSS Modules', () => { + it('should have the expected scoped CSS inlined in a