From 307d1ac61edccee71c2b7c7b0fd569490fa54960 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 5 Oct 2024 20:26:33 -0400 Subject: [PATCH 01/14] initial port from website repo --- packages/cli/src/config/rollup.config.js | 12 +- packages/cli/src/lifecycles/prerender.js | 10 +- packages/plugin-css-modules/README.md | 46 +++ packages/plugin-css-modules/package.json | 37 ++ packages/plugin-css-modules/src/index.js | 321 ++++++++++++++++++ .../greenwood.config.js | 8 + .../loaders-build.prerender.spec.js | 84 +++++ .../src/components/header/header.js | 76 +++++ .../src/components/header/header.module.css | 152 +++++++++ .../loaders-build.prerender/src/index.html | 10 + 10 files changed, 746 insertions(+), 10 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/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/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/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/plugin-css-modules/README.md b/packages/plugin-css-modules/README.md new file mode 100644 index 000000000..741dc4adb --- /dev/null +++ b/packages/plugin-css-modules/README.md @@ -0,0 +1,46 @@ +# @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() + ] +} +``` + +## Usage + +This plugin aims to cover a representative majority of the specification, though if you find missing capabilities please consider submitting an issue and / or PR! + + +1. First... + +## Options diff --git a/packages/plugin-css-modules/package.json b/packages/plugin-css-modules/package.json new file mode 100644 index 000000000..b06961541 --- /dev/null +++ b/packages/plugin-css-modules/package.json @@ -0,0 +1,37 @@ +{ + "name": "@greenwood/plugin-css-modules", + "version": "0.30.0-alpha.6", + "description": "A Greenwood plugin for authoring CSS Modules", + "repository": "https://github.com/ProjectEvergreen/greenwood", + "homepage": "https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-css-modules", + "author": "Owen Buckley ", + "license": "MIT", + "keywords": [ + "Greenwood", + "Static Site Generator", + "Full Stack Web Development", + "Web Components", + "CSS Modules" + ], + "main": "src/index.js", + "type": "module", + "files": [ + "src/" + ], + "publishConfig": { + "access": "public" + }, + "peerDependencies": { + "@greenwood/cli": "^0.4.0" + }, + "dependencies": { + "acorn": "^8.0.1", + "acorn-import-attributes": "^1.9.5", + "acorn-walk": "^8.0.0", + "css-tree": "^3.0.0", + "node-html-parser": "^1.2.21" + }, + "devDependencies": { + "@greenwood/cli": "^0.30.0-alpha.6" + } +} diff --git a/packages/plugin-css-modules/src/index.js b/packages/plugin-css-modules/src/index.js new file mode 100644 index 000000000..b8da6b452 --- /dev/null +++ b/packages/plugin-css-modules/src/index.js @@ -0,0 +1,321 @@ +/* + * + * A plugin for enabling CSS Modules. :tm: + * + */ +import fs from 'fs'; +import htmlparser from 'node-html-parser'; +import { parse, walk } from 'css-tree'; +import { ResourceInterface } from '@greenwood/cli/src/lib/resource-interface.js'; +import * as acornWalk from 'acorn-walk'; +import * as acorn from 'acorn'; +import { hashString } from '@greenwood/cli/src/lib/hashing-utils.js'; +import { importAttributes } from 'acorn-import-attributes'; // comes from Greenwood + +function getCssModulesMap(compilation) { + const locationUrl = new URL('./__css-modules-map.json', compilation.context.scratchDir); + let cssModulesMap = {}; + + if (fs.existsSync(locationUrl.pathname)) { + cssModulesMap = JSON.parse(fs.readFileSync(locationUrl.pathname)); + } + + return cssModulesMap; +} + +function walkAllImportsForCssModules(scriptUrl, sheets, compilation) { + const scriptContents = fs.readFileSync(scriptUrl, 'utf-8'); + + acornWalk.simple( + acorn.Parser.extend(importAttributes).parse(scriptContents, { + ecmaVersion: '2020', + sourceType: 'module' + }), + { + ImportDeclaration(node) { + const { specifiers = [], source = {} } = node; + const { value = '' } = source; + + // console.log({ value, specifiers }); + // TODO bare specifiers support? + if ( + value.endsWith('.module.css') && + specifiers.length === 1 && + specifiers[0].local.name === 'styles' + ) { + // console.log('WE GOT A WINNER!!!', value); + const cssModuleUrl = new URL(value, scriptUrl); + const scope = cssModuleUrl.pathname.split('/').pop().split('.')[0]; + const cssContents = fs.readFileSync(cssModuleUrl, 'utf-8'); + const hash = hashString(cssContents); + const classNameMap = {}; + let scopedCssContents = cssContents; + + const ast = parse(cssContents, { + // positions: true, + onParseError(error) { + console.log(error.formattedMessage); + } + }); + + walk(ast, { + enter: function (node) { + // drill down from a SelectorList to its first Selector + // and check its first child to see if it is a ClassSelector + // and if so, hash that initial class selector + if (node.type === 'SelectorList') { + if (node.children?.head?.data?.type === 'Selector') { + if (node.children?.head?.data?.children?.head?.data?.type === 'ClassSelector') { + const { name } = node.children.head.data.children.head.data; + const scopedClassName = `${scope}-${hash}-${name}`; + classNameMap[name] = scopedClassName; + + /* + * bit of a hacky solution since as we are walking class names one at a time, if we have multiple uses of .heading (for example) + * then by the end we could have .my-component-111-header.my-component-111-header.etc, since we want to replace all instances (e.g. the g flag in Regex) + * + * csstree supports loc so we _could_ target the class replacement down to start / end points, but that unfortunately slows things down a lot + */ + // TODO this is a pretty ugly find / replace technique... + // will definitely want to refactor and test this well + if ( + scopedCssContents.indexOf(`.${scopedClassName} `) < 0 && + scopedCssContents.indexOf(`.${scopedClassName} {`) < 0 + ) { + scopedCssContents = scopedCssContents.replace( + new RegExp(String.raw`.${name} `, 'g'), + `.${scope}-${hash}-${name} ` + ); + scopedCssContents = scopedCssContents.replace( + new RegExp(String.raw`.${name},`, 'g'), + `.${scope}-${hash}-${name},` + ); + scopedCssContents = scopedCssContents.replace( + new RegExp(String.raw`.${name}:`, 'g'), + `.${scope}-${hash}-${name}:` + ); + } + } + } + } + } + }); + + // TODO could we convert this module into an instance of CSSStylesheet to grab values? + // https://web.dev/articles/constructable-stylesheets + // or just use postcss-modules plugin? + const cssModulesMap = getCssModulesMap(compilation); + // console.log('UPDATE MAP!', { cssModulesMap, cssModuleUrl, scriptUrl }); + fs.writeFileSync( + new URL('./__css-modules-map.json', compilation.context.scratchDir), + JSON.stringify({ + ...cssModulesMap, + [`${cssModuleUrl.href}`]: { + module: classNameMap, + contents: scopedCssContents, + importer: scriptUrl + } + }) + ); + // globalThis.cssModulesMap.set(cssModuleUrl.href, { + // module: classNameMap, + // contents: scopedCssContents + // }) + // console.log( + // 'after update', + // getCssModulesMap(compilation) + // ); + // sheets.push(cssContents); + } else if (node.source.value.endsWith('.js')) { + // console.log('go recursive for', { scriptUrl, value }); + const recursiveScriptUrl = new URL(value, scriptUrl); + + if (fs.existsSync(recursiveScriptUrl)) { + walkAllImportsForCssModules(recursiveScriptUrl, sheets, compilation); + } + } + } + } + ); +} + +class CssModulesResource extends ResourceInterface { + constructor(compilation, options) { + super(compilation, options); + + this.extensions = ['module.css']; + this.contentType = 'text/javascript'; + + // // console.log('constructor???') + if (!fs.existsSync(this.compilation.context.scratchDir.pathname)) { + // // console.log('!!!!!!!!! make it!'); + fs.mkdirSync(this.compilation.context.scratchDir.pathname, { recursive: true }); + fs.writeFileSync( + new URL('./__css-modules-map.json', this.compilation.context.scratchDir).pathname, + JSON.stringify({}) + ); + } + } + + // this happens 'first' as the HTML is returned, to find viable references to CSS Modules + // better way than just checking for /? + async shouldIntercept(url) { + const { pathname, protocol } = url; + const mapKey = `${protocol}//${pathname}`; + const cssModulesMap = getCssModulesMap(this.compilation); + + return ( + url.pathname.endsWith('/') || + (protocol === 'file:' && pathname.endsWith(this.extensions[0]) && cssModulesMap[mapKey]) + ); + } + + async intercept(url, request, response) { + const { pathname, protocol } = url; + const mapKey = `${protocol}//${pathname}`; + const cssModulesMap = getCssModulesMap(this.compilation); + + if (url.pathname.endsWith('/')) { + const body = await response.text(); + const dom = htmlparser.parse(body, { script: true }); + const scripts = dom.querySelectorAll('head script'); + const sheets = []; // TODO use a map here? + + for (const script of scripts) { + const type = script.getAttribute('type'); + const src = script.getAttribute('src'); + // TODO handle module shims + if (src && ['module', 'module-shim'].includes(type)) { + // console.log('check this file for CSS Modules', src); + // await resolveForRelativeUrl(new URL(src, import.meta.url this.compilation.context.userWorkspace) + const scriptUrl = new URL( + `./${src.replace(/\.\.\//g, '').replace(/\.\//g, '')}`, + this.compilation.context.userWorkspace + ); + walkAllImportsForCssModules(scriptUrl, sheets, this.compilation); + } + } + + const cssModulesMap = getCssModulesMap(this.compilation); + // console.log({ cssModulesMap }); + + // for(const cssModule of cssModulesMap) { + // // console.log({ cssModule }); + // } + Object.keys(cssModulesMap).forEach((key) => { + sheets.push(cssModulesMap[key].contents); + }); + + const newBody = body.replace( + '', + ` + + + ` + ); + + return new Response(newBody); + } else if ( + url.pathname.endsWith('/') || + (protocol === 'file:' && pathname.endsWith(this.extensions[0]) && cssModulesMap[mapKey]) + ) { + // TODO do we even need this???? + const cssModule = `export default ${JSON.stringify(cssModulesMap[mapKey].module)}`; + + return new Response(cssModule, { + headers: { + 'Content-Type': this.contentType + } + }); + } + } +} + +class StripCssModulesResource extends ResourceInterface { + constructor(compilation, options) { + super(compilation, options); + + this.extensions = ['module.css']; + this.contentType = 'text/javascript'; + } + + async shouldServe(url) { + const cssModulesMap = getCssModulesMap(this.compilation); + + for (const [, value] of Object.entries(cssModulesMap)) { + if (url.href === value.importer) { + return true; + } + } + } + + async serve(url) { + console.log('serve???', { url }); + const { context } = this.compilation; + let contents = await fs.promises.readFile(url); // response.clone().text(); + + acornWalk.simple( + acorn.Parser.extend(importAttributes).parse(contents, { + ecmaVersion: '2020', + sourceType: 'module' + }), + { + ImportDeclaration(node) { + const { specifiers = [], source = {}, start, end } = node; + const { value = '' } = source; + + if ( + value.endsWith('.module.css') && + specifiers.length === 1 && + specifiers[0].local.name === 'styles' + ) { + // console.log('WE GOT A WINNER!!!', value); + contents = `${contents.slice(0, start)} \n ${contents.slice(end)}`; + const cssModulesMap = getCssModulesMap({ context }); + + Object.values(cssModulesMap).forEach((value) => { + const { importer, module } = value; + // console.log('$$$$$$$', { importer, url }); + + if (importer === url.href) { + Object.keys(module).forEach((key) => { + contents = contents.replace( + new RegExp(String.raw`\$\{styles.${key}\}`, 'g'), + module[key] + ); + }); + + Object.keys(module).forEach((key) => { + contents = contents.replace( + // (((? \n\r\b]))styles\.compactMenuSectionListItem((?![-\w\d\W])|(?=[ <.,:;!?\n\r\b]))) + new RegExp(String.raw`(((? \n\r\b]))styles\.${key}((?![-\w\d\W])|(?=[ <.,:;!?\n\r\b])))`, 'g'), + `'${module[key]}'` + ); + }); + } + }); + } + } + } + ); + + // console.log({ contents }); + return new Response(contents); // { headers: response.headers }); + } +} + +const greenwoodPluginCssModules = () => { + return [{ + type: 'resource', + name: 'plugin-css-modules', + provider: (compilation, options) => new CssModulesResource(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/loaders-build.prerender/greenwood.config.js b/packages/plugin-css-modules/test/cases/loaders-build.prerender/greenwood.config.js new file mode 100644 index 000000000..2bcaf77a5 --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/greenwood.config.js @@ -0,0 +1,8 @@ +import { greenwoodPluginCssModules } from '../../../src/index.js'; + +export default { + prerender: true, + plugins: [ + greenwoodPluginCssModules() + ] +}; \ No newline at end of file diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js b/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js new file mode 100644 index 000000000..fd237c3d3 --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js @@ -0,0 +1,84 @@ +/* + * 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 + * index.html + */ +import chai from 'chai'; +import fs from 'fs'; +import glob from 'glob-promise'; +import path from 'path'; +import { runSmokeTest } from '../../../../../test/smoke-test.js'; +import { getSetupFiles, getOutputTeardownFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Build Greenwood With: ', function() { + const LABEL = 'Default Configuration for CSS Modules and pre-rendering'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + let runner; + + before(function() { + this.context = { + publicDir: path.join(outputPath, 'public') + }; + runner = new Runner(false, true); + }); + + describe(LABEL, function() { + + before(function() { + runner.setup(outputPath, getSetupFiles(outputPath)); + runner.runCommand(cliPath, 'build'); + }); + + runSmokeTest(['public', 'index'], LABEL); + + describe('Page referencing external nested CSS file', function() { + it('should not have any references to CSS modules in the JavaScript bundle', function() { + const jsFiles = glob.sync(path.join(this.context.publicDir, 'header*.*.js')); + const js = fs.readFileSync(jsFiles[0], 'utf-8'); + + expect(jsFiles.length).to.equal(1); + + expect(js).to.not.contain('from"/header.module'); + }); + + it('should have transformed class names in the JavaScript bundle', function() { + const jsFiles = glob.sync(path.join(this.context.publicDir, 'header*.*.js')); + const js = fs.readFileSync(jsFiles[0], 'utf-8'); + + expect(js).to.contain('class="header-'); + }); + }); + }); + + after(function() { + runner.teardown(getOutputTeardownFiles(outputPath)); + }); + +}); \ No newline at end of file diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.js b/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.js new file mode 100644 index 000000000..4eb7cf6c7 --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.js @@ -0,0 +1,76 @@ +import styles from './header.module.css'; + +export default class Header extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
+
+ + + + + +
+ +
+
+ + + + +
+
+
+ `; + } +} + +customElements.define('app-header', Header); \ No newline at end of file diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.module.css b/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.module.css new file mode 100644 index 000000000..d4b191ac6 --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/header/header.module.css @@ -0,0 +1,152 @@ +.container { + display: flex; + justify-content: space-between; + padding: 0 var(--size-4) var(--size-2); + margin: 0; + border-bottom: 2px dotted var(--color-gray); +} + +.logoLink { + display: flex; +} + +.logoLink svg.greenwood-logo-full { + width: 100%; +} + +.logoLink svg.greenwood-logo-full g.letters { + fill: var(--color-logo-shade-dark); +} + +.navBar { + display: flex; + align-items: center; + gap: var(--size-5); +} + +.navBarMenu { + display: flex; + gap: var(--size-5); + list-style-type: none; +} + +.navBarMenuItem { + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + cursor: pointer; + font-size: var(--font-size-8); + padding: 0; +} + +.navBarMenuItem a { + text-decoration: none; + color: var(--color-black); +} + +.navBarMenuItem a:hover { + text-decoration: underline; +} + +.socialTray { + display: flex; + gap: var(--size-3); + list-style-type: none; + background-color: var(--color-gray); + width: fit-content; + border: var(--border-size-1) solid #4d4d4d45; + border-radius: var(--radius-6); + padding: var(--size-1); + align-items: center; + justify-content: center; + cursor: pointer; +} + +.socialIcon { + display: flex; + align-items: center; + line-height: 100%; +} + +.socialIcon svg { + fill: var(--color-secondary); +} + +.mobileMenuIcon { + display: none; + border: none; + background-color: transparent; +} + +.mobileMenuBackdrop { + height: 100vh; + width: 100vw; + margin: 0; + background-color: #f5f5f58e; + padding: var(--size-5) var(--size-6); + text-align: right; +} + +.mobileMenuCloseButton { + background: transparent; + font-size: var(--font-size-5); + cursor: pointer; + border: none; + padding: 0 12px; +} + +.mobileMenuList { + text-align: left; + margin: var(--size-4) 0 0; +} + +.mobileMenuListItem { + list-style-type: none; + margin: 10px 0; + font-size: var(--font-size-5); +} + +.mobileMenuListItem a { + color: var(--color-black); + text-decoration: none; +} + +@media screen and (min-width: 480px) { + .container { + padding: 0 var(--size-10) var(--size-2); + } +} + +@media (max-width: 600px) { + .navBar { + display: flex; + align-items: center; + gap: var(--size-2); + } + + .navBarMenu { + display: none; + } + + .mobileMenuIcon { + display: flex; + cursor: pointer; + } +} + +@media screen and (min-width: 768px) { + .logoLink svg.greenwood-logo-full { + width: 60%; + } + + .socialTray { + padding: var(--size-1) var(--size-2); + } +} + +@media screen and (min-width: 1024px) { + .logoLink svg.greenwood-logo-full { + width: 70%; + } +} diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/index.html b/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/index.html new file mode 100644 index 000000000..16a28423a --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/src/index.html @@ -0,0 +1,10 @@ + + + + + + + +

My Website

+ + \ No newline at end of file From af147ac7b3fbd847591d376c27c8d391e735cd20 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Mon, 7 Oct 2024 15:25:32 -0400 Subject: [PATCH 02/14] build and prerender test case --- packages/plugin-css-modules/src/index.js | 1 + .../expected.footer.css | 1 + .../expected.header.css | 1 + .../loaders-build.prerender.spec.js | 305 +++++++++++++++++- .../src/components/footer/footer.js | 33 ++ .../src/components/footer/footer.module.css | 47 +++ .../src/components/header/header.js | 72 +---- .../src/components/header/header.module.css | 132 +------- .../loaders-build.prerender/src/index.html | 2 + 9 files changed, 396 insertions(+), 198 deletions(-) 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/src/components/footer/footer.js create mode 100644 packages/plugin-css-modules/test/cases/loaders-build.prerender/src/components/footer/footer.module.css diff --git a/packages/plugin-css-modules/src/index.js b/packages/plugin-css-modules/src/index.js index b8da6b452..a4291bb35 100644 --- a/packages/plugin-css-modules/src/index.js +++ b/packages/plugin-css-modules/src/index.js @@ -289,6 +289,7 @@ class StripCssModulesResource extends ResourceInterface { Object.keys(module).forEach((key) => { contents = contents.replace( + // https://stackoverflow.com/a/20851557/417806 // (((? \n\r\b]))styles\.compactMenuSectionListItem((?![-\w\d\W])|(?=[ <.,:;!?\n\r\b]))) new RegExp(String.raw`(((? \n\r\b]))styles\.${key}((?![-\w\d\W])|(?=[ <.,:;!?\n\r\b])))`, 'g'), `'${module[key]}'` diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.footer.css b/packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.footer.css new file mode 100644 index 000000000..4fdea229c --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.footer.css @@ -0,0 +1 @@ +.footer-[placeholder]-footer{background-color:var(--color-tertiary);padding:var(--size-fluid-1) var(--size-fluid-3);display:flex;flex-direction:row;flex-wrap:wrap;}.footer-[placeholder]-logo{padding-left:var(--size-fluid-1);display:flex;flex-direction:column;flex-basis:100%;flex:1;justify-content:center;color:var(--color-gray);}.footer-[placeholder]-logo svg{width:150px}.footer-[placeholder]-logo svg g.letters{fill:var(--color-white)}.footer-[placeholder]-socialTray{display:flex;gap:var(--size-2);list-style-type:none;background-color:var(--color-secondary);border:var(--border-size-1) solid var(--color-black);border-radius:var(--radius-6);padding:var(--size-2) var(--size-3);align-items:center;justify-content:center;cursor:pointer;}.footer-[placeholder]-socialIcon{display:flex;align-items:center;}.footer-[placeholder]-socialIcon svg{fill:var(--color-gray)} \ No newline at end of file diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.header.css b/packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.header.css new file mode 100644 index 000000000..6164c7c47 --- /dev/null +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/expected.header.css @@ -0,0 +1 @@ +.header-[placeholder]-container{display:flex;justify-content:space-between;padding:0 var(--size-4) var(--size-2);margin:0;border-bottom:2px dotted var(--color-gray);}.header-[placeholder]-navBarMenu{display:flex}.header-[placeholder]-navBarMenuItem{& a{text-decoration:none;color:var(--color-black);}}.header-[placeholder]-navBarMenu,.header-[placeholder]-navBarMenuItem{color:red}@media screen and (min-width:480px){.header-[placeholder]-container{padding:0 var(--size-10) var(--size-2)}}@media screen and (min-width:768px){.header-[placeholder]-navBarMenu{display:none}.header-[placeholder]-navBarMenuItem{display:flex;cursor:pointer;}} \ No newline at end of file diff --git a/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js b/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js index fd237c3d3..6a5f97624 100644 --- a/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js +++ b/packages/plugin-css-modules/test/cases/loaders-build.prerender/loaders-build.prerender.spec.js @@ -1,6 +1,6 @@ /* * Use Case - * Run Greenwood build with CSS Modules plugin. + * Run Greenwood build with CSS Modules plugin and pre-rendering. * * User Result * Should generate a Greenwood project with CSS Modules properly transformed. @@ -20,24 +20,34 @@ * User Workspace * src/ * components/ + * footer/ + * footer.js + * footer.module.css * header/ * header.js * header.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'; +// 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 +import { implementation } from 'jsdom/lib/jsdom/living/nodes/HTMLStyleElement-impl.js'; +implementation.prototype._updateAStyleBlock = () => {}; // eslint-disable-line no-underscore-dangle + const expect = chai.expect; describe('Build Greenwood With: ', function() { - const LABEL = 'Default Configuration for CSS Modules and pre-rendering'; + const LABEL = 'Default Configuration for CSS Modules with pre-rendering'; const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); const outputPath = fileURLToPath(new URL('.', import.meta.url)); let runner; @@ -58,23 +68,294 @@ describe('Build Greenwood With: ', function() { runSmokeTest(['public', 'index'], LABEL); - describe('Page referencing external nested CSS file', function() { - it('should not have any references to CSS modules in the JavaScript bundle', function() { - const jsFiles = glob.sync(path.join(this.context.publicDir, 'header*.*.js')); - const js = fs.readFileSync(jsFiles[0], 'utf-8'); + describe('index.html with expected CSS and SSR contents', function() { + const EXPECTED_HEADER_CLASS_NAMES = 8; + const EXPECTED_FOOTER_CLASS_NAMES = 7; + let dom; + let headerSourceCss; + let footerSourceCss; + let expectedHeaderCss; + let expectedFooterCss; + + 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'); + footerSourceCss = await fs.promises.readFile(new URL('./src/components/footer/footer.module.css', import.meta.url), 'utf-8'); + expectedHeaderCss = await fs.promises.readFile(new URL('./expected.header.css', import.meta.url), 'utf-8'); + expectedFooterCss = await fs.promises.readFile(new URL('./expected.footer.css', import.meta.url), 'utf-8'); + }); + + describe('Header component with CSS Modules', () => { + it('should have the expected scoped CSS inlined in a