Implement a first-pass static site generation

Summary:
Some of the code here is adapted from my site (source available on
GitHub at wchargin/wchargin.github.io). It has been improved when
possible and made worse when necessary to fit into our existing build
system with minimal churn.

As of this commit, there remain the following outstanding tasks:
  - Use a non-hardcoded list of paths in static site generation router.
    This is not trivial. We have the paths nicely available in
    `routes.js`, but this module is written in ES6, and transitively
    depends on many files written in ES6 (i.e., the whole app). Yet
    naïvely it would be required from a Webpack config file, which is
    interpreted as vanilla JavaScript.
  - Add `csso-loader` to minify our CSS. This is easy.
  - Add unit tests for `dedent`. (As is, it comes from my site
    verbatim. I wrote it. dmnd’s `dedent` package on npm is insufficient
    because it dedents arguments as well as the format string, which is
    incorrect at least for our purposes.)
  - Link in canonical static data for the site.
  - Rip out the whole build system and replace it with my build config,
    which is orders of magnitude saner and less bad. (By “the whole
    build system” I mostly mean `webpack.config.{dev,prod}.js`.)

Test Plan:

```shell
$ yarn backend
$ yarn build
$ node ./bin/sourcecred.js start
```

wchargin-branch: static-v0
This commit is contained in:
William Chargin 2018-07-20 23:19:40 -07:00
parent df76975fae
commit b41009b1f7
4 changed files with 104 additions and 102 deletions

View File

@ -39,6 +39,7 @@ module.exports = {
appPublic: resolveApp("src/app/public"),
appHtml: resolveApp("src/app/public/index.html"),
appIndexJs: resolveApp("src/app/index.js"),
appServerSideRenderingIndexJs: resolveApp("src/app/server.js"),
appPackageJson: resolveApp("package.json"),
appSrc: resolveApp("src"),
yarnLockFile: resolveApp("yarn.lock"),

View File

@ -1,12 +1,9 @@
// @no-flow
const autoprefixer = require("autoprefixer");
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ExtractTextPlugin = require("extract-text-webpack-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const SWPrecacheWebpackPlugin = require("sw-precache-webpack-plugin");
const StaticSiteGeneratorPlugin = require("static-site-generator-webpack-plugin");
const eslintFormatter = require("react-dev-utils/eslintFormatter");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const paths = require("./paths");
@ -15,9 +12,6 @@ const getClientEnvironment = require("./env");
// Webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
const publicPath = paths.servedPath;
// Some apps do not use client-side routing with pushState.
// For these, "homepage" can be set to "." to enable relative asset paths.
const shouldUseRelativeAssetPaths = publicPath === "./";
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
// `publicUrl` is just like `publicPath`, but we will provide it to our app
@ -33,18 +27,6 @@ if (env.stringified["process.env"].NODE_ENV !== '"production"') {
throw new Error("Production builds must have NODE_ENV=production.");
}
// Note: defined here because it will be used more than once.
const cssFilename = "static/css/[name].[contenthash:8].css";
// ExtractTextPlugin expects the build output to be flat.
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
// However, our output is structured with css, js and media folders.
// To have this structure working with relative paths, we have to use custom options.
const extractTextPluginOptions = shouldUseRelativeAssetPaths
? // Making sure that the publicPath goes back to to build folder.
{publicPath: Array(cssFilename.split("/").length).join("../")}
: {};
// This is the production configuration.
// It compiles slowly and is focused on producing a fast and minimal bundle.
// The development configuration is different and lives in a separate file.
@ -55,7 +37,10 @@ module.exports = {
// You can exclude the *.map files from the build during deployment.
devtool: shouldUseSourceMap ? "source-map" : false,
// In production, we only want to load the polyfills and the app code.
entry: [require.resolve("./polyfills"), paths.appIndexJs],
entry: {
main: [require.resolve("./polyfills"), paths.appIndexJs],
ssr: [require.resolve("./polyfills"), paths.appServerSideRenderingIndexJs],
},
output: {
// The build folder.
path: paths.appBuild,
@ -71,6 +56,8 @@ module.exports = {
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/"),
// We need to use a UMD module to build the static site.
libraryTarget: "umd",
},
resolve: {
// This allows you to set a fallback for where Webpack should look for modules.
@ -149,64 +136,9 @@ module.exports = {
compact: true,
},
},
// The notation here is somewhat confusing.
// "postcss" loader applies autoprefixer to our CSS.
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader normally turns CSS into JS modules injecting <style>,
// but unlike in development configuration, we do something different.
// `ExtractTextPlugin` first applies the "postcss" and "css" loaders
// (second argument), then grabs the result CSS and puts it into a
// separate file in our build process. This way we actually ship
// a single CSS file in production instead of JS code injecting <style>
// tags. If you use code splitting, however, any async bundles will still
// use the "style" loader inside the async code so CSS from them won't be
// in the main CSS file.
{
test: /\.css$/,
loader: ExtractTextPlugin.extract(
Object.assign(
{
fallback: {
loader: require.resolve("style-loader"),
options: {
hmr: false,
},
},
use: [
{
loader: require.resolve("css-loader"),
options: {
importLoaders: 1,
minimize: true,
sourceMap: shouldUseSourceMap,
},
},
{
loader: require.resolve("postcss-loader"),
options: {
// Necessary for external CSS imports to work
// https://github.com/facebookincubator/create-react-app/issues/2677
ident: "postcss",
plugins: () => [
require("postcss-flexbugs-fixes"),
autoprefixer({
browsers: [
">1%",
"last 4 versions",
"Firefox ESR",
"not ie < 9", // React doesn't support IE8 anyway
],
flexbox: "no-2009",
}),
],
},
},
],
},
extractTextPluginOptions
)
),
// Note: this won't work without `new ExtractTextPlugin()` in `plugins`.
loader: "css-loader", // TODO(@wchargin): add csso-loader
},
// "file" loader makes sure assets end up in the `build` folder.
// When you `import` an asset, you get its filename.
@ -230,28 +162,10 @@ module.exports = {
],
},
plugins: [
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico">
// In production, it will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(env.raw),
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
inject: true,
template: paths.appHtml,
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
new StaticSiteGeneratorPlugin({
entry: "ssr",
paths: /* TODO(@wchargin): Non-hard-coded routes */ ["/", "/explorer"],
locals: {},
}),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
@ -279,10 +193,6 @@ module.exports = {
},
sourceMap: shouldUseSourceMap,
}),
// Note: this won't work without ExtractTextPlugin.extract(..) in `loaders`.
new ExtractTextPlugin({
filename: cssFilename,
}),
// Generate a manifest file which contains a mapping of all asset filenames
// to their corresponding output file so that tools can pick it up without
// having to parse `index.html`.

41
src/app/dedent.js Normal file
View File

@ -0,0 +1,41 @@
// @flow
/*
* A template tag function that performs dedenting on the template, but
* not its arguments.
*
* For instance, given the template
*
* |dedent`\
* | one ${one}
* | two ${two}
* | done`,
*
* where `one === "1"` and `two === "\n 2"`, the template string
* would expand to "one 1\n two\n 2\ndone". Note that four spaces
* of indentation were stripped off of each of "one" and "two", but not
* from "2".
*
* Lines that contain only whitespace are not used for measuring.
*/
export default function dedent(strings: string[], ...values: string[]) {
const lineLengths = strings
.join("")
.split("\n")
.filter((line) => line.trim().length !== 0)
.map((line) => line.length - line.trimLeft().length);
const trimAmount = Math.min.apply(null, lineLengths);
const parts = [];
for (let i = 0; i < strings.length; i++) {
const trimmed = strings[i]
.split("\n")
.map((line, j) => (i === 0 || j > 0 ? line.substr(trimAmount) : line))
.join("\n");
parts.push(trimmed);
if (i < values.length) {
parts.push(values[i]);
}
}
return parts.join("");
}

50
src/app/server.js Normal file
View File

@ -0,0 +1,50 @@
// @flow
import {StyleSheetServer} from "aphrodite/no-important";
import React from "react";
import ReactDOMServer from "react-dom/server";
import {match, RouterContext} from "react-router";
import {createRoutes, resolveTitleFromPath} from "./routes";
import dedent from "./dedent";
export default function render(
locals: {+path: string, +assets: {[string]: string}},
callback: (error: ?mixed, result?: string) => void
): void {
const bundlePath = locals.assets["main"];
const url = locals.path;
const routes = createRoutes();
match({routes, location: url}, (error, redirectLocation, renderProps) => {
if (error) {
callback(error);
} else if (renderProps) {
const component = <RouterContext {...renderProps} />;
const {html, css} = StyleSheetServer.renderStatic(() =>
ReactDOMServer.renderToString(component)
);
const page = dedent`\
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="shortcut icon" href="/favicon.ico" />
<title>${resolveTitleFromPath(url)}</title>
<style>${require("./index.css")}</style>
<style data-aphrodite>${css.content}</style>
</head>
<body style="overflow-y:scroll">
<div id="root">${html}</div>
<script src="${bundlePath}"></script>
</body>
</html>
`;
callback(null, page);
} else {
// This shouldn't happen because we should only be visiting
// the right routes.
throw new Error(`unexpected 404 from ${url}`);
}
});
}