From 9e792aef0b1e95037f20374574aa5a19ebbed467 Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Sat, 12 Nov 2022 12:40:48 -0500 Subject: [PATCH] Enhancement/issue 763 custom css minification and bundling (#980) * Enhancement/issue 971 refactor bundling and optimizations (#974) * add cloud IDE caveat to puppeteer renderer plugin readme (#967) * init commit of refactoring for script tags with a src * initial CSS optimizing * sync optimized link tags in final output * refactored for shared reources * handle inline script bundling * support serving custom resources using Greenwood plugins in Rollup configuration without needing extra rollup plugin * non resource related Rollup plugins supported * custom resource plugins and rollup plugins working together * handle empty input for Rollup * updated lock file * handle inline style tag bundling and optimizing * default optimization spec passing * refactor merging app and page templates * clarifying corrections in spec files * inline optimization config working * none optimization support * none optimization support * none and static optimization overrides * refactor html rendering and optimizing * refactoring and more CLI specs passing * add missing variable * SSR specs and optimizing resource bundling * minor refactoring and logging * resolving some plugin specs * restore develop command related GraphQL specs * custom graphql query spec * all specs passing * drop rollup plugin deps from import typescript plugin * all Greenwood commands and specs passing * restore static router with custom prerender * restore postcss-import * refactor shared resources to a Map and handle dupes * restore local packages workaround for local Rollup bundling * better monorepo Rollup facade modules detection * switch console log * remove console logging * update plugin related docs * local solution for windows support * refactor errant object assign * full cross platform URL support * fix lint * fix extra bundles when custom prerendering * clean up stale or already tracked TODOs * add nested head tag smoke tests * check for app template validation for HUD display * misc refactoring and TODOs cleanup * restore static router (again) * standardize passing correct reference for prerender scripts * clean up data-gwd-opt markers from final HTML * v0.27.0-alpha.0 * (WIP) swap PostCSS in CLI with custom AST solution * refactor website CSS * basic implementation of minified CSS from AST * support relative @import statements * refactor AST parsing to recursive function * support deeply nested @import and CSS custom properties * fix missing declaration semicolon * correctly close Rule brackets * general on leave refactoring * more selector support * all specs passing * support percentage * test for percentage * support url and @import url * add important support * custom implementation for handling matchers in attribute selectors * restore website prism styles * nth and lang selectors support * improve support for matching selector types * add error logging for CSS parsing --- packages/cli/package.json | 4 +- packages/cli/src/lifecycles/bundle.js | 9 +- .../plugins/resource/plugin-standard-css.js | 194 ++++++- .../build.config-optimization-default.spec.js | 15 +- .../fixtures/expected.css | 43 ++ .../src/pages/index.html | 2 +- .../src/styles/main.css | 90 ++++ .../src/styles/theme.css | 6 +- .../src/system/variables.css | 16 + .../build.config-optimization-inline.spec.js | 4 +- ...uild.config-optimization-overrides.spec.js | 2 +- ...d.default.workspace-javascript-css.spec.js | 4 +- ...lt.workspace-template-page-and-app.spec.js | 8 +- packages/plugin-postcss/README.md | 1 - packages/plugin-postcss/package.json | 6 +- packages/plugin-postcss/src/index.js | 20 - .../test/cases/default/default.spec.js | 2 +- .../options.extend-config.spec.js | 2 +- www/styles/home.css | 4 - www/templates/app.html | 6 +- yarn.lock | 510 +----------------- 21 files changed, 396 insertions(+), 552 deletions(-) create mode 100644 packages/cli/test/cases/build.config.optimization-default/fixtures/expected.css create mode 100644 packages/cli/test/cases/build.config.optimization-default/src/styles/main.css create mode 100644 packages/cli/test/cases/build.config.optimization-default/src/system/variables.css diff --git a/packages/cli/package.json b/packages/cli/package.json index e0b78ca52..10e74844c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,7 +29,7 @@ "acorn": "^8.0.1", "acorn-walk": "^8.0.0", "commander": "^2.20.0", - "cssnano": "^5.0.11", + "css-tree": "^2.2.1", "es-module-shims": "^1.2.0", "front-matter": "^4.0.2", "koa": "^2.13.0", @@ -37,8 +37,6 @@ "markdown-toc": "^1.2.0", "node-fetch": "^2.6.1", "node-html-parser": "^1.2.21", - "postcss": "^8.3.11", - "postcss-import": "^13.0.0", "rehype-raw": "^5.0.0", "rehype-stringify": "^8.0.0", "remark-frontmatter": "^2.0.0", diff --git a/packages/cli/src/lifecycles/bundle.js b/packages/cli/src/lifecycles/bundle.js index ab71a0b96..39d4de559 100644 --- a/packages/cli/src/lifecycles/bundle.js +++ b/packages/cli/src/lifecycles/bundle.js @@ -88,6 +88,12 @@ async function bundleStyleResources(compilation, optimizationPlugins) { const url = resource.sourcePathURL.pathname; let optimizedStyles = await fs.promises.readFile(url, 'utf-8'); + for (const plugin of optimizationPlugins) { + optimizedStyles = await plugin.shouldIntercept(url, optimizedStyles) + ? (await plugin.intercept(url, optimizedStyles)).body + : optimizedStyles; + } + for (const plugin of optimizationPlugins) { optimizedStyles = await plugin.shouldOptimize(url, optimizedStyles) ? await plugin.optimize(url, optimizedStyles) @@ -127,7 +133,8 @@ const bundleCompilation = async (compilation) => { }).map((plugin) => { return plugin.provider(compilation); }).filter((provider) => { - return provider.shouldOptimize && provider.optimize; + return provider.shouldIntercept && provider.intercept + || provider.shouldOptimize && provider.optimize; }); // centrally register all static resources compilation.graph.map((page) => { diff --git a/packages/cli/src/plugins/resource/plugin-standard-css.js b/packages/cli/src/plugins/resource/plugin-standard-css.js index a6f53c9f9..262263b68 100644 --- a/packages/cli/src/plugins/resource/plugin-standard-css.js +++ b/packages/cli/src/plugins/resource/plugin-standard-css.js @@ -5,12 +5,189 @@ * */ import fs from 'fs'; +import { parse, walk } from 'css-tree'; import path from 'path'; -import cssnano from 'cssnano'; -import postcss from 'postcss'; -import postcssImport from 'postcss-import'; import { ResourceInterface } from '../../lib/resource-interface.js'; +function bundleCss(body, url) { + const ast = parse(body, { + onParseError(error) { + console.log(error.formattedMessage); + } + }); + let optimizedCss = ''; + + walk(ast, { + enter: function (node, item) { // eslint-disable-line complexity + const { type, name, value } = node; + + if ((type === 'String' || type === 'Url') && this.atrulePrelude && this.atrule.name === 'import') { + const { value } = node; + + if (value.indexOf('.') === 0) { + const importContents = fs.readFileSync(path.resolve(path.dirname(url), value), 'utf-8'); + + optimizedCss += bundleCss(importContents, url); + } + } else if (type === 'Atrule' && name !== 'import') { + optimizedCss += `@${name} `; + } else if (type === 'TypeSelector') { + optimizedCss += name; + } else if (type === 'IdSelector') { + optimizedCss += `#${name}`; + } else if (type === 'ClassSelector') { + optimizedCss += `.${name}`; + } else if (type === 'PseudoClassSelector') { + optimizedCss += `:${name}`; + + switch (name) { + + case 'lang': + case 'not': + case 'nth-child': + case 'nth-last-child': + case 'nth-of-type': + case 'nth-last-of-type': + optimizedCss += '('; + break; + default: + break; + + } + } else if (type === 'Function') { + optimizedCss += `${name}(`; + } else if (type === 'MediaFeature') { + optimizedCss += ` (${name}:`; + } else if (type === 'PseudoElementSelector') { + optimizedCss += `::${name}`; + } else if (type === 'Block') { + optimizedCss += '{'; + } else if (type === 'AttributeSelector') { + optimizedCss += '['; + } else if (type === 'Combinator') { + optimizedCss += name; + } else if (type === 'Nth') { + const { nth } = node; + + switch (nth.type) { + + case 'AnPlusB': + if (nth.a) { + optimizedCss += nth.a === '-1' ? '-n' : `${nth.a}n`; + } + if (nth.b) { + optimizedCss += nth.a ? `+${nth.b}` : nth.b; + } + break; + default: + break; + + } + } else if (type === 'Declaration') { + optimizedCss += `${node.property}:`; + } else if (type === 'Url' && this.atrule?.name !== 'import') { + optimizedCss += `url('${node.value}')`; + } else if (type === 'Identifier' || type === 'Hash' || type === 'Dimension' || type === 'Number' || (type === 'String' && (this.atrule?.type !== 'import')) || type === 'Operator' || type === 'Raw' || type === 'Percentage') { // eslint-disable-line max-len + if (item && item.prev && type !== 'Operator' && item.prev.data.type !== 'Operator') { + optimizedCss += ' '; + } + + switch (type) { + + case 'Dimension': + optimizedCss += `${value}${node.unit}`; + break; + case 'Percentage': + optimizedCss += `${value}%`; + break; + case 'Hash': + optimizedCss += `#${value}`; + break; + case 'Identifier': + optimizedCss += name; + break; + case 'Number': + optimizedCss += value; + break; + case 'Operator': + optimizedCss += value; + break; + case 'String': + optimizedCss += `'${value}'`; + break; + case 'Raw': + optimizedCss += `${value.trim()}`; + break; + default: + break; + + } + } + }, + leave: function(node, item) { + switch (node.type) { + + case 'Atrule': + if (node.name !== 'import') { + optimizedCss += '}'; + } + break; + case 'Rule': + optimizedCss += '}'; + break; + case 'Function': + case 'MediaFeature': + optimizedCss += ')'; + break; + case 'PseudoClassSelector': + switch (node.name) { + + case 'lang': + case 'not': + case 'nth-child': + case 'nth-last-child': + case 'nth-last-of-type': + case 'nth-of-type': + optimizedCss += ')'; + break; + default: + break; + + } + break; + case 'Declaration': + if (node.important) { + optimizedCss += '!important'; + } + + optimizedCss += ';'; + break; + case 'Selector': + if (item.next) { + optimizedCss += ','; + } + break; + case 'AttributeSelector': + if (node.matcher) { + // TODO better way to do this? + // https://github.com/csstree/csstree/issues/207 + const name = node.name.name; + const value = node.value.type === 'Identifier' ? node.value.name : `'${node.value.value}'`; + + optimizedCss = optimizedCss.replace(`${name}${value}`, `${name}${node.matcher}${value}`); + } + optimizedCss += ']'; + break; + default: + break; + + } + } + }); + + return optimizedCss; +} + class StandardCssResource extends ResourceInterface { constructor(compilation, options) { super(compilation, options); @@ -41,15 +218,8 @@ class StandardCssResource extends ResourceInterface { async optimize(url, body) { return new Promise(async (resolve, reject) => { - try { - const { outputDir, userWorkspace } = this.compilation.context; - const workspaceUrl = url.replace(outputDir, userWorkspace); - const contents = body || await fs.promises.readFile(url, 'utf-8'); - const css = (await postcss([cssnano]) - .use(postcssImport()) - .process(contents, { from: workspaceUrl })).css; - - resolve(css); + try { + resolve(bundleCss(body, url)); } catch (e) { reject(e); } diff --git a/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js b/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js index fb9a381e4..713bde998 100644 --- a/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js +++ b/packages/cli/test/cases/build.config.optimization-default/build.config-optimization-default.spec.js @@ -20,6 +20,7 @@ * theme.css */ import chai from 'chai'; +import fs from 'fs'; import glob from 'glob-promise'; import { JSDOM } from 'jsdom'; import path from 'path'; @@ -33,6 +34,7 @@ describe('Build Greenwood With: ', function() { const LABEL = 'Default Optimization Configuration'; const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const expectedCss = fs.readFileSync(path.join(outputPath, './fixtures/expected.css'), 'utf-8').replace(/\n/g, ''); let runner; before(function() { @@ -79,7 +81,7 @@ describe('Build Greenwood With: ', function() { describe(' tag and preloading', function() { it('should contain one style.css in the output directory', async function() { - expect(await glob.promise(`${path.join(this.context.publicDir, 'styles')}/theme.*.css`)).to.have.lengthOf(1); + expect(await glob.promise(`${path.join(this.context.publicDir, 'styles')}/main.*.css`)).to.have.lengthOf(1); }); it('should have the expected tag in the ', function() { @@ -96,9 +98,18 @@ describe('Build Greenwood With: ', function() { .filter(link => link.getAttribute('as') === 'style'); expect(preloadLinkTags.length).to.be.equal(1); - expect(preloadLinkTags[0].href).to.match(/\/styles\/theme.*.css/); + expect(preloadLinkTags[0].href).to.match(/\/styles\/main.*.css/); expect(preloadLinkTags[0].getAttribute('crossorigin')).to.equal('anonymous'); }); + + // test custom CSS bundling + it('should have the expect preload CSS content in the file', async function() { + const cssFiles = await glob.promise(path.join(this.context.publicDir, 'styles/*.css')); + const customCss = await fs.promises.readFile(cssFiles[0], 'utf-8'); + + expect(cssFiles.length).to.be.equal(1); + expect(customCss).to.be.equal(expectedCss); + }); }); }); }); diff --git a/packages/cli/test/cases/build.config.optimization-default/fixtures/expected.css b/packages/cli/test/cases/build.config.optimization-default/fixtures/expected.css new file mode 100644 index 000000000..c99671ff2 --- /dev/null +++ b/packages/cli/test/cases/build.config.optimization-default/fixtures/expected.css @@ -0,0 +1,43 @@ +:root,:host{--primary-color:#16f;--secondary-color:#ff7;} + +@font-face {font-family:'Source Sans Pro';font-style:normal;font-weight:400;font-display:swap;src:local('Source Sans Pro Regular'),local('SourceSansPro-Regular'),url('/assets/fonts/source-sans-pro-v13-latin-regular.woff2')format('woff2'),url('/assets/fonts/source-sans-pro-v13-latin-regular.woff')format('woff'),url('/assets/fonts/source-sans-pro-v13-latin-regular.ttf')format('truetype');} + +*{margin:0;padding:0;font-family:'Comic Sans',sans-serif;} + +body{background-color:green;} + +h1,h2{color:var(--primary-color);border:0.5px solid #dddde1;} + +#foo,.bar{color:var(--secondary-color);} + +div>p{display:none;} + +a[title]{color:purple;} + +@media screen and (max-width:992px){body{background-color:blue;}} + +p::first-line{color:blue;width:100%!important;} + +pre[class*='language-']{color:#ccc;background:none;} + +dd:only-of-type{background-color:bisque;} + +:not(pre)>code[class*='language-']{background:#2d2d2d;} + +li:nth-child(-n+3){border:2px solid orange;margin-bottom:1px;} + +li:nth-child(even){background-color:lightyellow;} + +li:nth-last-child(5n){border:2px solid orange;margin-top:1px;} + +dd:nth-last-of-type(odd){border:2px solid orange;} + +p:nth-of-type(2n+1){color:red;} + +*:lang(en-US){outline:2px solid deeppink;} + +p~ul{font-weight:bold;} + +a[href*='greenwood'],a[href$='.pdf']{color:orange;} + +[title~=flower],a[href^='https'],[lang|=en]{text-decoration:underline;} \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.optimization-default/src/pages/index.html b/packages/cli/test/cases/build.config.optimization-default/src/pages/index.html index 57ef25887..8944d68ba 100644 --- a/packages/cli/test/cases/build.config.optimization-default/src/pages/index.html +++ b/packages/cli/test/cases/build.config.optimization-default/src/pages/index.html @@ -3,7 +3,7 @@ - + diff --git a/packages/cli/test/cases/build.config.optimization-default/src/styles/main.css b/packages/cli/test/cases/build.config.optimization-default/src/styles/main.css new file mode 100644 index 000000000..3dbae9c37 --- /dev/null +++ b/packages/cli/test/cases/build.config.optimization-default/src/styles/main.css @@ -0,0 +1,90 @@ +@import './theme.css'; + +* { + margin: 0; + padding: 0; + font-family: 'Comic Sans', sans-serif; +} + +body { + background-color: green; +} + +h1, h2 { + color: var(--primary-color); + border: 0.5px solid #dddde1; +} + +#foo, .bar { + color: var(--secondary-color); +} + +div > p { + display: none; +} + +a[title] { + color: purple; +} + +@media screen and (max-width: 992px) { + body { + background-color: blue; + } +} + +p::first-line { + color: blue; + width: 100%!important; +} + +pre[class*="language-"] { + color: #ccc; + background: none; +} + +dd:only-of-type { + background-color: bisque; +} + +:not(pre) > code[class*="language-"] { + background: #2d2d2d; +} + +li:nth-child(-n+3) { + border: 2px solid orange; + margin-bottom: 1px; +} + +li:nth-child(even) { + background-color: lightyellow; +} + +li:nth-last-child(5n) { + border: 2px solid orange; + margin-top: 1px; +} + +dd:nth-last-of-type(odd) { + border: 2px solid orange; +} + +p:nth-of-type(2n + 1) { + color: red; +} + +*:lang(en-US) { + outline: 2px solid deeppink; +} + +p ~ ul { + font-weight: bold; +} + +a[href*="greenwood"], a[href$=".pdf"] { + color: orange; +} + +[title~=flower], a[href^="https"], [lang|=en] { + text-decoration: underline; +} \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.optimization-default/src/styles/theme.css b/packages/cli/test/cases/build.config.optimization-default/src/styles/theme.css index 2f3a9deff..36b39ca02 100644 --- a/packages/cli/test/cases/build.config.optimization-default/src/styles/theme.css +++ b/packages/cli/test/cases/build.config.optimization-default/src/styles/theme.css @@ -1,5 +1 @@ -* { - margin: 0; - padding: 0; - font-family: 'Comic Sans', sans-serif; -} \ No newline at end of file +@import url('../system/variables.css'); \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.optimization-default/src/system/variables.css b/packages/cli/test/cases/build.config.optimization-default/src/system/variables.css new file mode 100644 index 000000000..f010eba84 --- /dev/null +++ b/packages/cli/test/cases/build.config.optimization-default/src/system/variables.css @@ -0,0 +1,16 @@ +:root, :host { + --primary-color: #16f; + --secondary-color: #ff7; +} + +/* source-sans-pro-regular - latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local('Source Sans Pro Regular'), local('SourceSansPro-Regular'), + url('/assets/fonts/source-sans-pro-v13-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */ + url('/assets/fonts/source-sans-pro-v13-latin-regular.woff') format('woff'), /* Modern Browsers */ + url('/assets/fonts/source-sans-pro-v13-latin-regular.ttf') format('truetype'); /* Safari, Android, iOS */ +} \ No newline at end of file diff --git a/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js b/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js index a882c1418..e5fc05f75 100644 --- a/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js +++ b/packages/cli/test/cases/build.config.optimization-inline/build.config-optimization-inline.spec.js @@ -138,13 +138,13 @@ describe('Build Greenwood With: ', function() { it('should contain the expected CSS content inlined for theme.css', function() { const styleTags = dom.window.document.querySelectorAll('head style'); - expect(styleTags[0].textContent).to.contain('*{font-family:Comic Sans,sans-serif;margin:0;padding:0}'); + expect(styleTags[0].textContent).to.contain('*{margin:0;padding:0;font-family:\'Comic Sans\',sans-serif;}'); }); it('should contain the expected CSS content inlined for page.css', function() { const styleTags = dom.window.document.querySelectorAll('head style'); - expect(styleTags[1].textContent).to.contain('body{color:red}'); + expect(styleTags[1].textContent).to.contain('body{color:red;}'); }); }); }); diff --git a/packages/cli/test/cases/build.config.optimization-overrides/build.config-optimization-overrides.spec.js b/packages/cli/test/cases/build.config.optimization-overrides/build.config-optimization-overrides.spec.js index 22067cd67..253f89e62 100644 --- a/packages/cli/test/cases/build.config.optimization-overrides/build.config-optimization-overrides.spec.js +++ b/packages/cli/test/cases/build.config.optimization-overrides/build.config-optimization-overrides.spec.js @@ -153,7 +153,7 @@ describe('Build Greenwood With: ', function() { it('should have an inline