After configuring the development setup with Storybook for JS, CSS and other assets, it's time to finally create a build pipeline to publish our HTML, JS and CSS with Webpack.
yarn add webpack webpack-cli lodash --dev
The key idea is to traverse through the component tree by invoking React's render function and trigger side-effect operations to gather all scripts.
Also, we can tap into the import statements to gather CSS imports as well.
// useScripts.js
import { useEffect } from "react";
// The utils we'll be building
import { addScripts } from "../scripts/buildUtils";
export function useScripts(scriptLoaders) {
// Accumulate imported scripts during builds
if (process.env.BUILD) {
const scriptPaths = scriptLoaders.map((load) => {
const stringified = load.toString();
// We extract the import path from the stringified load function
const match = stringified.match(/"([^"]*)"/);
if (!match) {
throw new Error(
`Error finding matching imported module under \`useScripts\` hooks\nstringified:\n${stringified}`
);
}
return match[1].replace(/\?.*$/, "");
});
// The parent's id is required to resolve the absolute path for the script
addScripts(scriptPaths, module.parent.id);
// Node caches only the first parent but we need the intermediate ones
// so we have to clear it.
delete require.cache[__filename];
}
useEffect(() => {
loadScripts(scriptLoaders);
});
}
// Each dynamic import is asynchronous
async function loadScripts(scriptLoaders) {
for (const load of scriptLoaders) {
await load();
}
}
// generateEntryPoints.js
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import path from "path";
import fs from "fs";
import uniq from "lodash/uniq";
import glob from "glob";
// The utils are also used here
import {
setCurrentChunkName,
getScriptGroup,
addScripts,
getChunkNameFromPagePath,
} from "../scripts/buildUtils";
// Side-effects tapped into import statements for CSS files
require.extensions[".css"] = (cssModule) => {
addScripts([cssModule.id], cssModule.parent.id);
};
const cwd = path.resolve(__dirname, "..");
const pages = glob.sync("pages/**/*.jsx", { cwd });
function main() {
pages.forEach((pagePath) => {
// We'll use each page's path as its own chunk name
setCurrentChunkName(getChunkNameFromPagePath(pagePath));
const PageComponent = require(path.resolve(cwd, pagePath)).default;
// This is where the magic happens. It invokes React's rendering and will trigger
// the side-effects to gather the scripts.
renderToStaticMarkup(<PageComponent />);
});
const assetsImportContent = Object.entries(getScriptGroup())
.map(([chunkName, scriptPaths]) => {
return ` '${chunkName}': [${uniq(scriptPaths)
.map((scriptPath) => `'${scriptPath}'`)
.join(",\n")}]`;
})
.join(",\n");
const assetsMapContent = `module.exports = {\n${assetsImportContent}\n}\n`;
// The generated entry points are in object notation written in to a file.
fs.writeFileSync(
path.resolve(__dirname, "../.tmp/entries.js"),
assetsMapContent
);
}
main();
// buildUtils.js
import path from "path";
let currentChunkName;
const scriptGroup = {};
export function getScriptGroup() {
return scriptGroup;
}
export function addScripts(scripts, parentId) {
if (!scriptGroup[currentChunkName]) {
scriptGroup[currentChunkName] = [];
}
const resolvedScripts = scripts.map((s) =>
path.resolve(path.basename(parentId), s)
);
scriptGroup[currentChunkName].push(...resolvedScripts);
}
export function setCurrentChunkName(chunkName) {
currentChunkName = chunkName;
scriptGroup[currentChunkName] = [];
}
export function getChunkNameFromPagePath(pagePath) {
return pagePath.replace(/\//g, "-");
}
├── pages │ ├── AboutUs.jsx │ ├── Home.css │ └── Home.jsx ├── scripts │ ├── build.js │ ├── buildUtils.js // added │ └── generateEntryPoints.js // added ├── src │ ├── about-us.js │ ├── home.js │ └── useScripts.js // modified ├── stories │ └── pages.stories.js ├── babel.config.js └── package.json
// package.json
"scripts": {
"build:entries": "mkdir -p .tmp && BUILD=1 babel-node scripts/generateEntryPoints.js",
...
}
yarn build:entries
// entries.js
module.exports = {
"pages-AboutUs.jsx": [
"/abs/path/to/pages/Home.css",
"/abs/path/to/src/about-us",
],
"pages-Home.jsx": [
"/abs/path/to/pages/Home.css",
"/abs/path/to/src/home",
],
};
yarn add mini-css-extract-plugin css-loader stats-webpack-plugin --dev
// webpack.config.js
const path = require("path");
const glob = require("glob");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const StatsPlugin = require("stats-webpack-plugin");
const entries = require("./.tmp/entries");
module.exports = {
entry: {
...entries,
},
resolve: {
extensions: [".js", ".jsx"],
},
output: {
path: path.resolve(__dirname, "dist/assets"),
publicPath: "/assets/",
filename: "[name]-[contenthash].js",
},
mode: "production",
target: "web",
module: {
rules: [
{
test: /\.css$/i,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
plugins: [
new MiniCssExtractPlugin(),
new StatsPlugin("../../.tmp/webpack-stats.json"),
],
};
"scripts": {
...
"build:webpack": "webpack",
...
},
yarn build:webpack
import React from "react";
import path from "path";
import fs from "fs";
import glob from "glob";
import { renderToStaticMarkup } from "react-dom/server";
import { spawnSync } from "child_process";
import webpackStats from "../.tmp/webpack-stats.json";
import { getChunkNameFromPagePath } from "./buildUtils";
require.extensions[".css"] = () => {};
const cwd = path.resolve(__dirname, "..");
// We glob all the files under "pages" directory
const reactFiles = glob.sync("pages/**/*.jsx", { cwd });
// Generate <link rel="stylesheet" href="..."> tags from Webpack's stats file
function generateStyleTags(assets) {
function getCSSAssets() {
if (!assets) {
return [];
}
if (Array.isArray(assets)) {
return assets.filter((asset) => asset.endsWith(".css"));
}
return assets.endsWith(".css") ? [assets] : [];
}
const cssAssets = getCSSAssets();
return cssAssets.length > 0
? cssAssets
.map((asset) => `<link rel="stylesheet" href="/assets/${asset}">`)
.join("\n")
: "";
}
// Generate <script type="text/javascript" src="..."> tags from Webpack's stats file
function generateScriptTags(assets) {
function getJSAssets() {
if (!assets) {
return [];
}
if (Array.isArray(assets)) {
return assets.filter((asset) => asset.endsWith(".js"));
}
return assets.endsWith(".js") ? [assets] : [];
}
const jsAssets = getJSAssets();
return jsAssets.length > 0
? jsAssets
.map(
(asset) =>
`<script type="text/javascript" src="/assets/${asset}"></script>`
)
.join("\n")
: "";
}
reactFiles.forEach((reactFile) => {
const fileName = path.basename(reactFile).replace(".jsx", "");
// Page components are from "default" export
const Component = require(path.resolve(__dirname, "../", reactFile)).default;
const chunkName = getChunkNameFromPagePath(reactFile);
const assets = webpackStats.assetsByChunkName[chunkName];
const staticMarkup = renderToStaticMarkup(<Component />);
// Generate HTML file name from ".jsx" files by replacing the extension with ".html"
const htmlFile = path.resolve(
__dirname,
"../dist/",
reactFile.replace(".jsx", ".html")
);
const dirName = path.dirname(htmlFile);
// In case we have nested page structure, we need to create directory recursively.
fs.mkdirSync(dirName, { recursive: true });
fs.writeFileSync(
htmlFile,
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>${fileName}</title>
${generateStyleTags(assets) /* CSS files are linked here */}
</head>
<body>
${staticMarkup}
${generateScriptTags(assets) /* JS files are linked here */}
</body>
</html>`,
"utf8"
);
});
"scripts": {
...
"build": "yarn build:entries && yarn build:webpack && babel-node scripts/build.js",
...
},
yarn build
npm install --global http-server
http-server dist
From us to your inbox weekly.