zudell.io.
jon@zudell.io > static_site_generation v0.4.2
Using a slightly modified version of this repositories webpack pluginmarkdalgleish/static-site-generator-webpack-pluginand my own custom webpack plugin / configuration I was able to write a Static Site Generator with webpack, react(-router), and TypeScript. Routes are generated from the src/pages directory.
My Template Wrapper Plugin, it reads the output assets from the webpack build and rewrites the index.html files with the content from the statically generated routes.
1class TemplateWrapperPlugin {
2 apply(compiler) {
3 compiler.hooks.thisCompilation.tap(
4 'TemplateWrapperPlugin',
5 (compilation) => {
6 compilation.hooks.processAssets.tapAsync(
7 {
8 name: 'TemplateWrapperPlugin',
9 stage: webpack.Compilation.PROCESS_ASSETS_STAGE_SUMMARIZE,
10 },
11 (assets, callback) => {
12 data.routes.forEach((route) => {
13 const assetKey = `${route.replace(/^\//, '')}index.html`;
14
15 const asset = assets[assetKey];
16 if (asset) {
17 const content = asset.source();
18 const template = fs.readFileSync(
19 path.join(__dirname, '/src/index.html'),
20 'utf8',
21 );
22
23 const htmlOutput = template.replace(
24 '<!-- inject:body -->',
25 content,
26 ); // Example modification
27 assets[assetKey] = {
28 source: () => htmlOutput,
29 size: () => htmlOutput.length,
30 };
31 } else {
32 console.warn(
33 `TemplateWrapperPlugin: Asset not found for route ${route}`,
34 );
35 }
36 });
37 callback();
38 },
39 );
40 },
41 );
42 }
43}
The template wrapper plugin references the index.html file. It is provided below
1<!DOCTYPE html>
2<html lang="en">
3 <head>
4 <meta charset="utf-8" />
5 <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
6 <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
7 <meta name="viewport" content="width=device-width, initial-scale=1" />
8 <meta
9 name="description"
10 content="Web site created using create-react-app"
11 />
12 <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
13 <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
14 </head>
15 <body>
16 <noscript>You need to enable JavaScript to run this app.</noscript>
17 <!-- inject:body -->
18 <script defer src="/main.js"></script>
19 <link rel="stylesheet" href="/main.css">
20 </body>
21</html>
The two plugins work together to generate the static site. Inside the webpack configuration static-site-generator plugin is fed routes generated by a data.js file.
1// eslint-disable-next-line @typescript-eslint/no-var-requires
2const globSync = require('glob').sync;
3// eslint-disable-next-line @typescript-eslint/no-var-requires
4const relative = require('path').relative;
5
6const pages = globSync('./src/pages/**/*.tsx')
7 .filter((file) => !file.includes('/_'))
8 .map((file) => {
9 const relativePath = relative('./src/pages', file);
10 let route = '/' + relativePath.replace(/\.tsx$/, '');
11 if (route === '/index') {
12 route = '/';
13 } else {
14 route += '/';
15 }
16 return route;
17 });
18
19module.exports = {
20 title: 'zudell.io',
21 routes: pages,
22};
23
1/* eslint-disable @typescript-eslint/no-var-requires */
2const path = require('path');
3const fs = require('fs');
4const StaticSiteGeneratorPlugin = require('./ssgwp');
5const HtmlWebpackPlugin = require('html-webpack-plugin');
6const webpack = require('webpack');
7const data = require('./src/data');
8const MiniCssExtractPlugin = require('mini-css-extract-plugin');
9const TemplateWrapperPlugin = require('./TemplateWrapperPlugin');
10
11module.exports = {
12 mode: 'production',
13 entry: {
14 main: './src/csr.tsx',
15 ssg: './src/ssg.tsx',
16 },
17 output: {
18 filename: '[name].js',
19 path: path.resolve(__dirname, 'dist'),
20 libraryTarget: 'umd',
21 globalObject: 'this',
22 clean: true,
23 },
24 devtool: 'source-map',
25 resolve: {
26 fallback: {
27 fs: false,
28 path: require.resolve('path-browserify'),
29 crypto: require.resolve('crypto-browserify'),
30 stream: require.resolve('stream-browserify'),
31 buffer: require.resolve('buffer/'),
32 },
33 extensions: ['.tsx', '.ts', '.js'],
34 },
35 module: {
36 rules: [
37 {
38 test: /\.tsx?$/,
39 exclude: /node_modules/,
40 use: 'ts-loader',
41 },
42 {
43 test: /\.css$/,
44 use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
45 },
46 {
47 test: /\.scss$/,
48 use: [MiniCssExtractPlugin.loader, 'css-loader', 'scss-loader'],
49 },
50 ],
51 },
52 plugins: [
53 new StaticSiteGeneratorPlugin({
54 entry: 'ssg',
55 paths: data.routes,
56 locals: {
57 data: data,
58 },
59 globals: {
60 window: {},
61 },
62 }),
63 new TemplateWrapperPlugin(),
64 new MiniCssExtractPlugin({
65 filename: '[name].css',
66 }),
67 ],
68};
69
All of these files working together with the following directory structure:
src/pages
├── _posts.stories.tsx
├── _posts.tsx
├── contact.tsx
├── index.tsx
└── posts
├── a11y.tsx
├── authn.tsx
├── functional.tsx
├── hire_me.tsx
├── init.tsx
├── js_modules.tsx
└── ssg.tsx
2 directories, 11 files
Will produce this dist folder
dist
├── contact
│ └── index.html
├── index.html
├── main.css
├── main.css.map
├── main.js
├── main.js.map
├── posts
│ ├── a11y
│ │ └── index.html
│ ├── authn
│ │ └── index.html
│ ├── functional
│ │ └── index.html
│ ├── hire_me
│ │ └── index.html
│ ├── init
│ │ └── index.html
│ ├── js_modules
│ │ └── index.html
│ └── ssg
│ └── index.html
├── ssg.css
├── ssg.css.map
├── ssg.js
└── ssg.js.map
10 directories, 17 files
# Client Side Hydration
The static site generator uses the entry point ssg.tsx to generate the markup. The client side hydration is handled by the entry point csr.tsx. In order for the hydration to go off without a hitch ssg.tsx and csr.tsx need to generate the exact same string of valid html.
1import React from 'react';
2import { renderToStaticMarkup } from 'react-dom/server';
3import { StaticRouter } from 'react-router-dom/server';
4import Root from './components/core/Root';
5import './main.css';
6
7interface Locals {
8 path: string;
9 [key: string]: any;
10}
11
12export default function (locals: Locals) {
13 const html = renderToStaticMarkup(
14 <StaticRouter location={locals.path}>
15 <Root />
16 </StaticRouter>,
17 );
18
19 return `${html}`; // Ensure it returns a valid HTML string
20}
21
1import React from 'react';
2import { hydrateRoot } from 'react-dom/client';
3import { BrowserRouter } from 'react-router-dom';
4import Root from './components/core/Root';
5import './main.css';
6
7const container = document.getElementById('root');
8
9export function hydrateApp(container: HTMLElement | null) {
10 if (container) {
11 hydrateRoot(
12 container,
13 <BrowserRouter>
14 <Root />
15 </BrowserRouter>,
16 );
17 }
18}
19
20hydrateApp(container);
21