Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enhancement/issue 546 optimization overrides #645

Merged
merged 14 commits into from
Jul 11, 2021
Merged
58 changes: 29 additions & 29 deletions packages/cli/src/config/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ function greenwoodHtmlPlugin(compilation) {

// handle <script type="module" src="some/path.js"></script>
if (!isRemoteUrl(parsedAttributes.src) && parsedAttributes.type === 'module' && parsedAttributes.src && !mappedScripts.get(parsedAttributes.src)) {
if (optimization === 'static') {
// console.debug('dont emit ', parsedAttributes.src);
if (optimization === 'static' || parsedAttributes['data-gwd-opt'] === 'static') {
// dont need to bundle / emit this one
} else {
const { src } = parsedAttributes;
const absoluteSrc = `${path.normalize(src.replace(/\.\.\//g, '').replace('./', ''))}`;
Expand Down Expand Up @@ -295,8 +295,8 @@ function greenwoodHtmlPlugin(compilation) {
}));
},

// crawl through all entry HTML files and map bundled JavaScript and CSS filenames
// back to original <script> / <link> tags and update to their bundled filename in the HTML
// crawl through all entry HTML files and map Rollup bundled JavaScript and CSS filenames
// back to their original <script> / <link> tags, and update the HTML to their new bundled filenames
generateBundle(outputOptions, bundles) {
for (const bundleId of Object.keys(bundles)) {
try {
Expand Down Expand Up @@ -351,13 +351,13 @@ function greenwoodHtmlPlugin(compilation) {

newHtml = newHtml.replace(src, newSrc);

if (optimization !== 'none' && optimization !== 'inline') {
if (!parsedAttributes['data-gwd-opt'] && optimization === 'default') {
newHtml = newHtml.replace('<head>', `
<head>
<link rel="modulepreload" href="${newSrc}" as="script">
`);
}
} else if (optimization === 'static' && newHtml.indexOf(pathToMatch) > 0) {
} else if ((parsedAttributes['data-gwd-opt'] === 'static' || optimization === 'static') && newHtml.indexOf(pathToMatch) > 0) {
newHtml = newHtml.replace(scriptTag, '');
}
}
Expand All @@ -378,7 +378,7 @@ function greenwoodHtmlPlugin(compilation) {

newHtml = newHtml.replace(href, newHref);

if (optimization !== 'none' && optimization !== 'inline') {
if (!parsedAttributes['data-gwd-opt'] && (optimization !== 'none' && optimization !== 'inline')) {
newHtml = newHtml.replace('<head>', `
<head>
<link rel="preload" href="${newHref}" as="style" crossorigin="anonymous"></link>
Expand Down Expand Up @@ -421,13 +421,15 @@ function greenwoodHtmlPlugin(compilation) {
const parsedAttributes = parseTagForAttributes(scriptTag);
const isScriptSrcTag = parsedAttributes.src && parsedAttributes.type === 'module';

if (optimization === 'inline' && isScriptSrcTag && !isRemoteUrl(parsedAttributes.src)) {
// handle <script type="module" src="..."></script>
if ((parsedAttributes['data-gwd-opt'] === 'inline' || optimization === 'inline') && isScriptSrcTag && !isRemoteUrl(parsedAttributes.src)) {
const src = parsedAttributes.src;
const basePath = src.indexOf(tokenNodeModules) >= 0
? process.cwd()
: outputDir;
const outputPath = path.join(basePath, src);
const js = fs.readFileSync(outputPath, 'utf-8');
scratchFiles[src] = true;

html = html.replace(`<script ${scriptTag.rawAttrs}></script>`, `
<script type="module">
Expand Down Expand Up @@ -464,27 +466,25 @@ function greenwoodHtmlPlugin(compilation) {
}
});

if (optimization === 'inline') {
headLinks
.forEach((linkTag) => {
const linkTagAttributes = parseTagForAttributes(linkTag);
const isLocalLinkTag = linkTagAttributes.rel === 'stylesheet'
&& !isRemoteUrl(linkTagAttributes.href);

if (isLocalLinkTag) {
const href = linkTagAttributes.href;
const outputPath = path.join(outputDir, href);
const css = fs.readFileSync(outputPath, 'utf-8');

html = html.replace(`<link ${linkTag.rawAttrs}>`, `
<style>
${css}
</style>
`);
}
});
}

headLinks
.forEach((linkTag) => {
const parsedAttributes = parseTagForAttributes(linkTag);
const isLocalLinkTag = parsedAttributes.rel === 'stylesheet'
&& !isRemoteUrl(parsedAttributes.href);

if (isLocalLinkTag && (parsedAttributes['data-gwd-opt'] === 'inline' || optimization === 'inline')) {
const href = parsedAttributes.href;
const outputPath = path.join(outputDir, href);
const css = fs.readFileSync(outputPath, 'utf-8');
scratchFiles[href] = true;

html = html.replace(`<link ${linkTag.rawAttrs}>`, `
<style>
${css}
</style>
`);
}
});
// mark each HTML's sourcemap file (generated by Rollup) to be cleaned up
// would be nice if we could just prevent Rollup from generating sourcemaps for just our input files in the first place (GFI)
// https://github.com/ProjectEvergreen/greenwood/issues/659
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('Build Greenwood With: ', function() {
});

describe('<script> tag and preloading', function() {
it('should contain one unminifed javasccript file in the output directory', async function() {
it('should contain one unminifed javascript file in the output directory', async function() {
expect(jsFiles).to.have.lengthOf(1);
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* Use Case
* Run Greenwood build command with various override settings for optimization settings.
*
* User Result
* Should generate a Greenwood build that respects optimization setting overrides for all <script> and <link> tags.
*
* User Command
* greenwood build
*
* User Config
* Default
*
* Custom Workspace
* src/
* components/
* footer.js
* header.js
* pages/
* index.html
* styles/
* theme.css
*/
const expect = require('chai').expect;
const glob = require('glob-promise');
const { JSDOM } = require('jsdom');
const path = require('path');
const { getSetupFiles, getOutputTeardownFiles } = require('../../../../../test/utils');
const Runner = require('gallinago').Runner;

describe('Build Greenwood With: ', function() {
const LABEL = 'Optimization Overrides';
const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
const outputPath = __dirname;
let runner;

before(async function() {
this.context = {
publicDir: path.join(outputPath, 'public')
};
runner = new Runner();
});

describe(LABEL, function() {

before(async function() {
await runner.setup(outputPath, getSetupFiles(outputPath));
await runner.runCommand(cliPath, 'build');
});

describe('Cumulative output based on all override settings', function() {
let dom;

before(async function() {
dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
});

it('should emit no Javascript files to the output directory', async function() {
const jsFiles = await glob.promise(path.join(this.context.publicDir, '**/*.js'));

expect(jsFiles).to.have.lengthOf(0);
});

it('should emit no CSS files to the output directory', async function() {
const cssFiles = await glob.promise(path.join(this.context.publicDir, '**/*.css'));

expect(cssFiles).to.have.lengthOf(0);
});

it('should have one <script> tag in the <head>', function() {
const scriptTags = dom.window.document.querySelectorAll('head script');

expect(scriptTags.length).to.be.equal(1);
});

// one of these tags comes from puppeteer
it('should have two <style> tags in the <head>', function() {
const styleTags = dom.window.document.querySelectorAll('head style');

expect(styleTags.length).to.be.equal(2);
});

it('should have no <link> tags in the <head>', function() {
const linkTags = dom.window.document.querySelectorAll('head link');

expect(linkTags.length).to.be.equal(0);
});
});

describe('JavaScript <script> tag and static optimization override for <app-header>', function() {
let dom;

before(async function() {
dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
});

it('should contain no <link> tags in the <head>', function() {
const headerLinkTags = Array.from(dom.window.document.querySelectorAll('head link'))
.filter(link => link.getAttribute('href').indexOf('header') >= 0);

expect(headerLinkTags.length).to.be.equal(0);
});

it('should have no <script> tags in the <head>', function() {
const headerScriptTags = Array.from(dom.window.document.querySelectorAll('head script'))
.filter(script => script.getAttribute('src') && script.getAttribute('src').indexOf('header') >= 0);

expect(headerScriptTags.length).to.be.equal(0);
});

it('should contain the expected content from <app-header> in the <body>', function() {
const headerScriptTags = dom.window.document.querySelectorAll('body header');

expect(headerScriptTags.length).to.be.equal(1);
expect(headerScriptTags[0].textContent).to.be.equal('This is the header component.');
});
});

describe('JavaScript <script> tag and inline optimization override for <app-footer>', function() {
let dom;

before(async function() {
dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
});

it('should contain no <link> tags in the <head>', function() {
const footerLinkTags = Array.from(dom.window.document.querySelectorAll('head link'))
.filter(link => link.getAttribute('href').indexOf('footer') >= 0);

expect(footerLinkTags.length).to.be.equal(0);
});

it('should have an inline <script> tag in the <head>', function() {
const footerScriptTags = Array.from(dom.window.document.querySelectorAll('head script'))
.filter((script) => {
// eslint-disable-next-line max-len
return script.textContent.indexOf('const e=document.createElement("template");e.innerHTML="<footer>This is the footer component.</footer>";class t extends HTMLElement{constructor(){super(),this.attachShadow({mode:"open"})}connectedCallback(){this.shadowRoot.appendChild(e.content.cloneNode(!0))}}customElements.define("app-footer",t);') >= 0
&& !script.getAttribute('src');
});

expect(footerScriptTags.length).to.be.equal(1);
});

it('should contain the expected content from <app-footer> in the <body>', function() {
const footer = dom.window.document.querySelectorAll('body footer');

expect(footer.length).to.be.equal(1);
expect(footer[0].textContent).to.be.equal('This is the footer component.');
});
});

describe('CSS <link> tag and inline optimization override for theme.css', function() {
let dom;

before(async function() {
dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, './index.html'));
});

it('should contain no <link> tags in the <head>', function() {
const themeLinkTags = Array.from(dom.window.document.querySelectorAll('head link'))
.filter(link => link.getAttribute('href').indexOf('theme') >= 0);

expect(themeLinkTags.length).to.be.equal(0);
});

it('should have an inline <style> tag in the <head>', function() {
const themeStyleTags = Array.from(dom.window.document.querySelectorAll('head style'))
.filter(style => style.textContent.indexOf('*{color:#00f}') >= 0);

expect(themeStyleTags.length).to.be.equal(1);
});
});
});

after(function() {
runner.teardown(getOutputTeardownFiles(outputPath));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const template = document.createElement('template');

template.innerHTML = '<footer>This is the footer component.</footer>';

class FooterComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}

customElements.define('app-footer', FooterComponent);
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const template = document.createElement('template');

template.innerHTML = `
<header>This is the header component.</header>
`;

class HeaderComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}

connectedCallback() {
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}

customElements.define('app-header', HeaderComponent);
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en" prefix="og:http://ogp.me/ns#">

<head>
<script type="module" src="/components/header.js" data-gwd-opt="static"></script>
<script type="module" src="/components/footer.js" data-gwd-opt="inline"></script>
<link rel="stylesheet" href="/styles/theme.css" data-gwd-opt="inline"/>
</head>

<body>
<app-header></app-header>
<app-footer></app-footer>
</body>

</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
* {
color: blue;
}
2 changes: 1 addition & 1 deletion www/components/footer/footer.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

& h4 {
width: 90%;
margin: 0 auto;
margin: 0 auto!important;
padding: 0;
text-align: center;
}
Expand Down
16 changes: 15 additions & 1 deletion www/pages/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,28 @@ Greenwood provides a number of different ways to send hints to Greenwood as to h
|`none` | With this setting, _none_ of your JS or CSS will be minified or hinted at all. | The best choice if you want to handle everything yourself through custom [Resource plugins](/plugins/resource/). |
|`static` | Only for `<script>` tags, but this setting will remove `<script>` tags from your HTML. | If your Web Components only need a single render just to emit some static HTML, or are otherwise not dynamic or needed at runtime, this will really speed up your site's performance by dropping uncessary HTTP requests. |

> _These settings are currently considered expiremental. Additional improvements and considerations include adding [`none` override support](https://github.com/ProjectEvergreen/greenwood/discussions/545#discussioncomment-957320), [SSR + hydration](https://github.com/ProjectEvergreen/greenwood/discussions/576), and [side effect free templates and pages](https://github.com/ProjectEvergreen/greenwood/discussions/644)._

#### Example
```js
module.exports = {
optimization: 'inline'
}
```

> _These settings are currently expiremental, and more fine grained control and intelligent based defaults will be coming soon!_
#### Overrides
Additionally, you can apply overrides on a per `<link>` or `<script>` tag basis by addding a custom `data-gwd-opt` attribute to your HTML. The following is supported for JavaScript and CSS.

```html
<!-- Javascript -->
<script type="module" src="/path/to/file1.js" data-gwd-opt="static"></script>
<script type="module" src="/path/to/file2.js" data-gwd-opt="inline"></script>

<!-- CSS -->
<link rel="stylesheet" href="/path/to/file1.css" data-gwd-opt="inline"/>
```

> _Just be mindful that style encapsulation provided by ShadowDOM (e.g. `:host`) for custom elements will now have their styles inlined in the `<head>` and mixed with all other global styles, and thus may collide and [be suceptible to the cascade](https://github.com/ProjectEvergreen/greenwood/pull/645#issuecomment-873125192) depending on their degree of specificity. Increasing specificity of selectors or using only global styles will help resolve this._

### Prerender

Expand Down
Loading