Repository: mapbox/batfish
Pull request #164 (2017-09-02 20:29:01)

Page-specific CSS
Use the latest version of react-helmet (with synchronous tags) to introduce a system for page-specific CSS.

Page-specific CSS should be 

- Processed through the user's PostCSS plugins.
- Minified for the production build.
- Inlined in the page's static HTML.
- Included only in the JS bundles that use it, not in the site-wide stylesheet.

I added another example to illustrate how to import and use a page-specific stylesheet.

@lshig for review.

davidtheclark: Thanks for the review, @lshig, and for fixing that typo for me.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c003b91..19cae70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,10 @@
 # Changelog
 
+## Head
+
+- [Add] Enable page-specific CSS.
+  See ["Page-specific CSS" documentation](docs/advanced-usage.md#page-specific-css).
+
 ## 0.10.4
 
 - [Fix] Restructure directories to be more friendly for Flow-using consumers.
diff --git a/README.md b/README.md
index c90d6ae..2e22179 100644
--- a/README.md
+++ b/README.md
@@ -316,6 +316,9 @@ This optimization ensures that the loading of an external stylesheet does not bl
 
 Assets referenced by `url()`s in your stylesheets will be hashed and copied to Batfish's [`outputDirectory`].
 
+You can also add page-specific CSS (processed through the same PostCSS pipeline), if you find yourself adding lots of CSS rules that are not used on multiple pages.
+Read more about ["Page-specific CSS"].
+
 **If you want to bypass this CSS system and use your own, just do it.**
 You can use the [`webpackLoaders`] and [`webpackPlugins`] configuration options to do whatever you need.
 
@@ -412,3 +415,5 @@ Please let us know what you think!
 [`route-to`]: docs/batfish-modules.md#route-to
 
 [`prefix-url`]: docs/batfish-modules.md#prefix-url
+
+["page-specific css"]: docs/advanced-usage.md#page-specific-css
diff --git a/bin/batfish.js b/bin/batfish.js
index a9827d2..47891ee 100755
--- a/bin/batfish.js
+++ b/bin/batfish.js
@@ -1,5 +1,4 @@
 #!/usr/bin/env node
-// @flow
 'use strict';
 
 const meow = require('meow');
diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md
index 6eca27d..9a2ebb0 100644
--- a/docs/advanced-usage.md
+++ b/docs/advanced-usage.md
@@ -8,6 +8,7 @@
 - [Markdown within JS](#markdown-within-js)
 - [Route change listeners](#route-change-listeners)
 - [Analyzing bundles](#analyzing-bundles)
+- [Page-specific CSS](#page-specific-css)
 
 ## Draft pages
 
@@ -139,4 +140,36 @@ Batfish's `start` and `end` commands output [Webpack's `stats.json`](https://web
 [webpack-bundle-analyzer](https://github.com/th0r/webpack-bundle-analyzer) and [webpack.github.io/analyse](https://webpack.github.io/analyse/) are two great tools that you can feed your `stats.json` to.
 There are also others out there in the Webpack ecosystem.
 
+## Page-specific CSS
+
+Most of the time, you should add CSS to your site with the [`stylesheets`] configuration option.
+However, if you are adding a _lot_ of CSS that is _not widely used_, you might choose to add it to one page at a time, instead of adding it to the full site's stylesheet.
+Batfish includes a way to to this.
+
+If you `import` a `.css` file within your [`pagesDirectory`], you will get a React component (with no props) that you can render within the page.
+When the component mounts, the stylesheet's content (processed through PostCSS, using your [`postcssPlugins`]) will be inserted into a `<style>` tag in the `<head>` of the document.
+When the component unmounts, that `<style>` tag will be removed.
+
+Like other React components, this one will only be added to the JS bundle of the page that uses it (unless you use it in a number of pages); and it will be rendered into the page's HTML during static rendering.
+So that's how you can page-specific CSS, when the fancy strikes.
+
+Example:
+
+```js
+import React from 'react';
+import SpecialStyles from './special-styles.css';
+
+export default class SomePage extends React.Component {
+  render() {
+    return (
+      <div>
+        <SpecialStyles />
+        <h1>Some page</h1>
+        {/* ... */}
+      </div>
+    );
+  }
+}
+```
+
 [`route-change-listeners`]: ./batfish-modules.md#route-change-listeners
diff --git a/examples/page-specific-css/README.md b/examples/page-specific-css/README.md
new file mode 100644
index 0000000..532756e
--- /dev/null
+++ b/examples/page-specific-css/README.md
@@ -0,0 +1,3 @@
+# Page-specific CSS
+
+- Shows how you can import and mount [page-specific CSS](../../docs/advanced-usage.md#page-specific-css).
diff --git a/examples/page-specific-css/package.json b/examples/page-specific-css/package.json
new file mode 100644
index 0000000..a78255f
--- /dev/null
+++ b/examples/page-specific-css/package.json
@@ -0,0 +1,7 @@
+{
+  "private": true,
+  "scripts": {
+    "batfish": "../../bin/example-batfish"
+  },
+  "dependencies": {}
+}
diff --git a/examples/page-specific-css/src/pages/another.css b/examples/page-specific-css/src/pages/another.css
new file mode 100644
index 0000000..dbc0f14
--- /dev/null
+++ b/examples/page-specific-css/src/pages/another.css
@@ -0,0 +1,4 @@
+body {
+  background: pink;
+  color: gray;
+}
diff --git a/examples/page-specific-css/src/pages/another.md b/examples/page-specific-css/src/pages/another.md
new file mode 100644
index 0000000..e9eca5d
--- /dev/null
+++ b/examples/page-specific-css/src/pages/another.md
@@ -0,0 +1,10 @@
+---
+prependJs:
+- "import AnotherCss from './another.css'"
+---
+
+{{ <AnotherCss /> }}
+
+This is another page.
+
+Go to [the home page](/).
diff --git a/examples/page-specific-css/src/pages/index.css b/examples/page-specific-css/src/pages/index.css
new file mode 100644
index 0000000..855a6d4
--- /dev/null
+++ b/examples/page-specific-css/src/pages/index.css
@@ -0,0 +1,4 @@
+body {
+  background: lightblue;
+  color: red;
+}
diff --git a/examples/page-specific-css/src/pages/index.js b/examples/page-specific-css/src/pages/index.js
new file mode 100644
index 0000000..0da048c
--- /dev/null
+++ b/examples/page-specific-css/src/pages/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import HomeCss from './index.css';
+
+export default class Home extends React.Component {
+  render() {
+    return (
+      <div>
+        <HomeCss />
+        <div style={{ padding: '2em' }}>
+          <p>This is the home page.</p>
+          <p>
+            Go to <a href="/another/">another page</a>.
+          </p>
+        </div>
+      </div>
+    );
+  }
+}
diff --git a/flow-typed/webpack.js b/flow-typed/webpack.js
index ddc2d82..81c748d 100644
--- a/flow-typed/webpack.js
+++ b/flow-typed/webpack.js
@@ -38,7 +38,7 @@ declare type webpack$Rule = {
   exclude?: webpack$Condition,
   loader?: string,
   options?: Object,
-  use?: string | Array<{ loader: string, options: Object }>
+  use?: string | Array<{ loader: string, options?: Object }>
 };
 
 declare type webpack$Configuration = {|
@@ -57,6 +57,7 @@ declare type webpack$Configuration = {|
     [id: string]: string
   },
   resolveLoader?: {|
+    alias: { [id: string]: string },
     modules?: Array<string>
   |},
   resolve?: {|
diff --git a/package-lock.json b/package-lock.json
index 1941b5a..3c3d1ae 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "@mapbox/batfish",
-  "version": "0.9.4",
+  "version": "0.10.4",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -9716,9 +9716,9 @@
       }
     },
     "react-helmet": {
-      "version": "5.1.3",
-      "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.1.3.tgz",
-      "integrity": "sha1-zUBiZZOinuz2hLbTjXEfRMSBiK8=",
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.2.0.tgz",
+      "integrity": "sha1-qBgR3yExOm1VxfBYxK66XW89l6c=",
       "dev": true,
       "requires": {
         "deep-equal": "1.0.1",
diff --git a/package.json b/package.json
index f780006..4d214eb 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
     "pretest": "npm run lint",
     "test": "jest",
     "build-webpack-modules": "del modules && babel src/webpack/public --out-dir modules --quiet",
-    "build-dist": "del dist && cd src && cpy '**/*.*' ../dist --parents && cd - && flow-remove-types dist/ --out-dir dist/ --quiet && cpy src/node/index.js dist/node --rename index.js.flow",
+    "build-dist": "del dist && cd src && cpy '**/*.*' ../dist --parents && cd .. && flow-remove-types dist/ --out-dir dist/ --quiet && cpy src/node/index.js dist/node --rename index.js.flow",
     "build": "run-p build-webpack-modules build-dist",
     "prepublishOnly": "npm run build"
   },
@@ -132,6 +132,7 @@
     "indefinite": "^1.0.1",
     "is-glob": "^4.0.0",
     "json-loader": "^0.5.4",
+    "loader-utils": "^1.1.0",
     "lodash": "^4.17.4",
     "meow": "^3.7.0",
     "minimatch": "^3.0.4",
@@ -164,7 +165,7 @@
   "peerDependencies": {
     "react": "^15.5.0",
     "react-dom": "^15.5.0",
-    "react-helmet": "^5.1.3"
+    "react-helmet": "^5.2.0"
   },
   "devDependencies": {
     "@mapbox/vnu-validate-html": "^0.1.0",
@@ -189,7 +190,7 @@
     "prettier": "^1.5.2",
     "react": "^15.5.4",
     "react-dom": "^15.5.4",
-    "react-helmet": "^5.1.3",
+    "react-helmet": "^5.2.0",
     "remark-cli": "^4.0.0",
     "remark-validate-links": "^7.0.0"
   },
diff --git a/src/node/compile-stylesheets.js b/src/node/compile-stylesheets.js
index 3176c5c..64b4274 100644
--- a/src/node/compile-stylesheets.js
+++ b/src/node/compile-stylesheets.js
@@ -6,12 +6,9 @@ const path = require('path');
 const chalk = require('chalk');
 const fs = require('fs');
 const _ = require('lodash');
-const url = require('url');
 const pify = require('pify');
 const globby = require('globby');
 const postcss = require('postcss');
-const postcssUrl = require('postcss-url');
-const postcssCsso = require('postcss-csso');
 const isAbsoluteUrl = require('is-absolute-url');
 const mkdirp = require('mkdirp');
 const pTry = require('p-try');
@@ -19,9 +16,9 @@ const hasha = require('hasha');
 const Concat = require('concat-with-sourcemaps');
 const constants = require('./constants');
 const postcssAbsoluteUrls = require('./postcss-absolute-urls');
-const joinUrlParts = require('./join-url-parts');
 const rethrowPostcssError = require('./rethrow-postcss-error');
 const errorTypes = require('./error-types');
+const getPostcssPlugins = require('./get-postcss-plugins');
 
 type StylesheetData = {|
   +locator: string,
@@ -89,39 +86,11 @@ function compileStylesheets(
         });
     };
 
-    const postcssPlugins = [
-      // Copy all url-referenced assets to the outputDirectory.
-      postcssUrl({
-        url: 'copy',
-        assetsPath: './',
-        useHash: true
-      }),
-      // Rewrite urls so they are root-relative. This way they'll work both from
-      // inlined CSS (in the static build) and the stylesheet itself.
-      postcssUrl({
-        url: asset => {
-          const parsedUrl = url.parse(asset.url);
-          if (parsedUrl.protocol) {
-            return asset.url;
-          }
-          return joinUrlParts(
-            batfishConfig.siteBasePath,
-            constants.PUBLIC_PATH_ASSETS,
-            asset.url
-          );
-        }
-      })
-    ].concat(batfishConfig.postcssPlugins);
-
-    if (batfishConfig.production) {
-      postcssPlugins.push(postcssCsso());
-    }
-
     const processSingleStylesheetFromFs = (
       filename: string
     ): Promise<StylesheetData> => {
       return pify(fs.readFile)(filename, 'utf8').then(css => {
-        return postcss(postcssPlugins)
+        return postcss(getPostcssPlugins(batfishConfig))
           .process(css, {
             from: filename,
             to: cssTarget,
diff --git a/src/node/create-webpack-config-base.js b/src/node/create-webpack-config-base.js
index 8109931..9440f36 100644
--- a/src/node/create-webpack-config-base.js
+++ b/src/node/create-webpack-config-base.js
@@ -8,6 +8,7 @@ const WebpackChunkHash = require('webpack-chunk-hash');
 const writeContextModule = require('./write-context-module');
 const joinUrlParts = require('./join-url-parts');
 const constants = require('./constants');
+const getPostcssPlugins = require('./get-postcss-plugins');
 
 // Cache it to ensure we don't do unnecessary work within one process.
 let cachedConfig;
@@ -92,6 +93,62 @@ function createWebpackConfigBase(
       );
     }
 
+    let moduleRules = [
+      {
+        test: /\.jsx?$/,
+        exclude: batfishConfig.babelExclude,
+        use: [babelLoaderConfig]
+      },
+      {
+        test: new RegExp(
+          _.escapeRegExp(batfishConfig.pagesDirectory) + '.*\\.md$'
+        ),
+        use: [
+          babelLoaderConfig,
+          {
+            loader: '@mapbox/jsxtreme-markdown-loader',
+            options: jsxtremeMarkdownOptions
+          }
+        ]
+      },
+      // Static assets are copied into assets/ with an added hash,
+      // and when you require() them you'll get the proper
+      // filename (with hash).
+      {
+        test: fileLoaderTest,
+        loader: 'file-loader',
+        options: {
+          hash: 'sha512',
+          digest: 'hex',
+          name: batfishConfig.production
+            ? '[name]-[hash].[ext]'
+            : '[name].[ext]'
+        }
+      },
+      // JSON!
+      {
+        test: /\.json$/,
+        use: 'json-loader'
+      }
+    ];
+    if (batfishConfig.pageSpecificCss) {
+      moduleRules.push({
+        test: new RegExp(
+          _.escapeRegExp(batfishConfig.pagesDirectory) + '.*\\.css$'
+        ),
+        use: [
+          babelLoaderConfig,
+          {
+            loader: 'react-helmet-postcss-loader',
+            options: { postcssPlugins: getPostcssPlugins(batfishConfig) }
+          }
+        ]
+      });
+    }
+    if (batfishConfig.webpackLoaders) {
+      moduleRules = moduleRules.concat(batfishConfig.webpackLoaders);
+    }
+
     const config: webpack$Configuration = {
       output: {
         path: batfishConfig.outputDirectory,
@@ -106,8 +163,14 @@ function createWebpackConfigBase(
       performance: {
         hints: batfishConfig.verbose ? 'warning' : false
       },
-      // Register local loaders
       resolveLoader: {
+        // Register local loaders.
+        alias: {
+          'react-helmet-postcss-loader': path.join(
+            __dirname,
+            './react-helmet-postcss-loader.js'
+          )
+        },
         // Loader names need to be strings, and to allow them to be looked
         // up within batfish's module dependencies, not just the project's,
         // we need this.
@@ -117,44 +180,7 @@ function createWebpackConfigBase(
         alias: aliases
       },
       module: {
-        rules: [
-          {
-            test: /\.jsx?$/,
-            exclude: batfishConfig.babelExclude,
-            use: [babelLoaderConfig]
-          },
-          {
-            test: new RegExp(
-              _.escapeRegExp(batfishConfig.pagesDirectory) + '.*.md$'
-            ),
-            use: [
-              babelLoaderConfig,
-              {
-                loader: '@mapbox/jsxtreme-markdown-loader',
-                options: jsxtremeMarkdownOptions
-              }
-            ]
-          },
-          // Static assets are copied into assets/ with an added hash,
-          // and when you require() them you'll get the proper
-          // filename (with hash).
-          {
-            test: fileLoaderTest,
-            loader: 'file-loader',
-            options: {
-              hash: 'sha512',
-              digest: 'hex',
-              name: batfishConfig.production
-                ? '[name]-[hash].[ext]'
-                : '[name].[ext]'
-            }
-          },
-          // JSON!
-          {
-            test: /\.json$/,
-            use: 'json-loader'
-          }
-        ].concat(batfishConfig.webpackLoaders || [])
+        rules: moduleRules
       },
       plugins: [
         // Define global variables available in source JS.
diff --git a/src/node/get-postcss-plugins.js b/src/node/get-postcss-plugins.js
new file mode 100644
index 0000000..395c8a4
--- /dev/null
+++ b/src/node/get-postcss-plugins.js
@@ -0,0 +1,48 @@
+// @flow
+'use strict';
+
+const postcssUrl = require('postcss-url');
+const postcssCsso = require('postcss-csso');
+const url = require('url');
+const joinUrlParts = require('./join-url-parts');
+const constants = require('./constants');
+
+function getPostcssPlugins(
+  batfishConfig: BatfishConfiguration
+): Array<Function> {
+  let list = [
+    // Copy all url-referenced assets to the outputDirectory.
+    postcssUrl({
+      url: 'copy',
+      assetsPath: './',
+      useHash: true
+    }),
+    // Rewrite urls so they are root-relative. This way they'll work both from
+    // inlined CSS (in the static build) and the stylesheet itself.
+    postcssUrl({
+      url: asset => {
+        const parsedUrl = url.parse(asset.url);
+        if (parsedUrl.protocol) {
+          return asset.url;
+        }
+        return joinUrlParts(
+          batfishConfig.siteBasePath,
+          constants.PUBLIC_PATH_ASSETS,
+          asset.url
+        );
+      }
+    })
+  ];
+
+  if (batfishConfig.postcssPlugins) {
+    list = batfishConfig.postcssPlugins.concat(list);
+  }
+
+  if (batfishConfig.production) {
+    list.push(postcssCsso());
+  }
+
+  return list;
+}
+
+module.exports = getPostcssPlugins;
diff --git a/src/node/react-helmet-postcss-loader.js b/src/node/react-helmet-postcss-loader.js
new file mode 100644
index 0000000..b4047ed
--- /dev/null
+++ b/src/node/react-helmet-postcss-loader.js
@@ -0,0 +1,49 @@
+// @flow
+'use strict';
+
+const postcss = require('postcss');
+const prettier = require('prettier');
+const loaderUtils = require('loader-utils');
+const rethrowPostcssError = require('./rethrow-postcss-error');
+
+function generateModule(css: string): string {
+  const js = `
+    import React from 'react';
+    import Helmet from 'react-helmet';
+
+    const css = \`${css}\`;
+
+    export default class HeadCss extends React.Component {
+      shouldComponentUpdate() {
+        return false;
+      }
+      render() {
+        return (
+          <Helmet defer={false}>
+            <style>{css}</style>
+          </Helmet>
+        );
+      }
+    }
+  `;
+  return prettier.format(js.trim());
+}
+
+// Transform CSS into a simple JS module that writes the CSS to a <style>
+// tag in the <head> of the document.
+function reactHelmetPostcssLoader(css: string) {
+  const callback = this.async();
+  const options: {
+    postcssPlugins?: Array<Function>
+  } = loaderUtils.getOptions(this);
+
+  postcss(options.postcssPlugins)
+    .process(css)
+    .catch(rethrowPostcssError)
+    .then(result => {
+      callback(null, generateModule(result.css));
+    })
+    .catch(callback);
+}
+
+module.exports = reactHelmetPostcssLoader;
diff --git a/src/node/validate-config.js b/src/node/validate-config.js
index 9c1b29e..07ca8d1 100644
--- a/src/node/validate-config.js
+++ b/src/node/validate-config.js
@@ -35,6 +35,7 @@ const validConfigProperties = new Set([
   'babelPresets',
   'babelExclude',
   'postcssPlugins',
+  'pageSpecificCss',
   'fileLoaderExtensions',
   'jsxtremeMarkdownOptions',
   'includePromisePolyfill',
@@ -97,6 +98,7 @@ function validateConfig(
     ],
     jsxtremeMarkdownOptions: {},
     includePromisePolyfill: true,
+    pageSpecificCss: true,
     developmentDevtool: 'source-map',
     productionDevtool: false,
     clearOutputDirectory: true
@@ -259,6 +261,8 @@ function validateConfig(
     'function or array of functions'
   );
 
+  validatePropertyType('pageSpecificCss', _.isBoolean, 'boolean');
+
   validatePropertyType(
     'fileLoaderExtensions',
     x => {


Test patch:
diff --git a/test/__snapshots__/validate-config.test.js.snap b/test/__snapshots__/validate-config.test.js.snap
index 74d77d0..ae6fe15 100644
--- a/test/__snapshots__/validate-config.test.js.snap
+++ b/test/__snapshots__/validate-config.test.js.snap
@@ -25,6 +25,7 @@ Object {
   "includePromisePolyfill": true,
   "jsxtremeMarkdownOptions": Object {},
   "outputDirectory": "/my-project/_batfish_site",
+  "pageSpecificCss": true,
   "pagesDirectory": "/my-project/src/pages",
   "port": 8080,
   "postcssPlugins": Array [