htmlgaga
Version:
Manage non-SPA pages with webpack and React.js
1,483 lines (1,259 loc) • 47.5 kB
JavaScript
;
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
function _interopNamespace(e) {
if (e && e.__esModule) { return e; } else {
var n = {};
if (e) {
Object.keys(e).forEach(function (k) {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () {
return e[k];
}
});
});
}
n['default'] = e;
return n;
}
}
var yargs = _interopDefault(require('yargs'));
var rimraf = _interopDefault(require('rimraf'));
var fs = require('fs');
var path = require('path');
var path__default = _interopDefault(path);
var pino = _interopDefault(require('pino'));
var MiniCssExtractPlugin = _interopDefault(require('mini-css-extract-plugin'));
var rehypePrism = _interopDefault(require('@mapbox/rehype-prism'));
var webpack = _interopDefault(require('webpack'));
var express = _interopDefault(require('express'));
var devMiddleware = _interopDefault(require('webpack-dev-middleware'));
var http = _interopDefault(require('http'));
var fs$1 = _interopDefault(require('fs-extra'));
var HtmlWebpackPlugin = _interopDefault(require('html-webpack-plugin'));
var CssoWebpackPlugin = _interopDefault(require('csso-webpack-plugin'));
var WebpackAssetsManifest = _interopDefault(require('webpack-manifest-plugin'));
var TerserJSPlugin = _interopDefault(require('terser-webpack-plugin'));
var prettier = _interopDefault(require('prettier'));
var react = require('react');
var server = require('react-dom/server');
var HtmlTags = _interopDefault(require('html-webpack-plugin/lib/html-tags'));
var tapable = require('tapable');
var merge = _interopDefault(require('lodash.merge'));
var MessageType = require('./MessageType-312bbe19.cjs.dev.js');
var WebSocket = _interopDefault(require('ws'));
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
// it would resolve to htmlgaga\node_modules\@htmlgaga\doc
// when I run `yarn dev` under htmlgaga\packages\doc
const cwd = process.cwd();
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
prettyPrint: {
translateTime: 'HH:MM:ss',
ignore: 'pid,hostname'
},
serializers: {
err: pino.stdSerializers.err,
error: pino.stdSerializers.err
}
});
const assetsRoot = 'static';
const alias = {
img: path.resolve(cwd, `${assetsRoot}/img`),
css: path.resolve(cwd, `${assetsRoot}/css`),
js: path.resolve(cwd, `${assetsRoot}/js`)
};
const publicFolder = 'public';
const extensions = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.json'];
const performance = require('perf_hooks').performance;
const PerformanceObserver = require('perf_hooks').PerformanceObserver;
const cacheRoot = path.join(cwd, '.htmlgaga', 'cache');
const babelPresets = ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript']; // rules for webpack production mode
const rules = [{
test: /\.(js|jsx|ts|tsx|mjs)$/i,
exclude: /node_modules/,
use: [{
loader: 'babel-loader',
options: {
presets: [...babelPresets],
plugins: ['react-require'],
cacheDirectory: true,
cacheCompression: false
}
}]
}, {
test: /\.(md|mdx)$/i,
use: [{
loader: 'babel-loader',
options: {
presets: [...babelPresets],
plugins: ['react-require'],
cacheDirectory: true,
cacheCompression: false
}
}, {
loader: '@mdx-js/loader',
options: {
rehypePlugins: [rehypePrism]
}
}]
}, {
test: /\.(png|svg|jpg|jpeg|gif)$/i,
loader: 'file-loader',
options: {
name: '[name].[contenthash].[ext]'
}
}, {
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
use: [{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/'
}
}]
}, {
test: /\.(sa|sc|c)ss$/i,
use: [{
loader: MiniCssExtractPlugin.loader
}, 'css-loader', {
loader: 'postcss-loader',
options: {
ident: 'postcss',
plugins: [require('tailwindcss'), require('autoprefixer')]
}
}, 'sass-loader']
}];
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function isHtmlRequest(url) {
if (url.endsWith('/')) return true;
if (/\.html$/.test(url)) return true;
return false;
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function deriveFilenameFromRelativePath(from, to) {
const relativePath = path.relative(from, to);
const {
ext
} = path.parse(relativePath);
return relativePath.replace(ext, '.html');
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
const doNotFilter = () => true; // returns an array of all pages' absolute path
async function collect(root = cwd, filter = doNotFilter, acc = []) {
const files = await fs.promises.readdir(root);
for (const file of files) {
const filePath = path.resolve(root, file);
const stats = await fs.promises.stat(filePath);
if (stats.isFile()) {
const f = filePath;
if (filter(f)) {
acc.push(f);
}
} else if (stats.isDirectory()) {
await collect(filePath, filter, acc);
}
}
return acc;
}
function _classPrivateFieldGet(receiver, privateMap) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to get private field on non-instance"); } if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; }
function _classPrivateFieldSet(receiver, privateMap, value) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to set private field on non-instance"); } if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } return value; }
var _options = new WeakMap();
class PrettyPlugin {
constructor(options) {
_options.set(this, {
writable: true,
value: void 0
});
_classPrivateFieldSet(this, _options, options);
}
apply(compiler) {
// TODO compiler.hooks.compilation.tap
compiler.hooks.emit.tap('PrettyPlugin', compilation => {
var _classPrivateFieldGet2;
if (!((_classPrivateFieldGet2 = _classPrivateFieldGet(this, _options).html) === null || _classPrivateFieldGet2 === void 0 ? void 0 : _classPrivateFieldGet2.pretty)) return; // TODO compilation.hooks.processAssets
Object.keys(compilation.assets).forEach(asset => {
if (asset.endsWith('.html')) {
const html = compilation.assets[asset];
const source = html.source();
const prettyHtml = prettier.format(Buffer.isBuffer(source) ? source.toString() : source, {
parser: 'html'
});
compilation.assets[asset] = {
source: () => prettyHtml,
size: () => prettyHtml.length
};
}
});
});
}
}
var _pagesDir = new WeakMap();
var _outputPath = new WeakMap();
var _clients = new WeakMap();
var _config = new WeakMap();
class ClientsCompiler {
constructor(pagesDir, outputPath, config) {
_pagesDir.set(this, {
writable: true,
value: void 0
});
_outputPath.set(this, {
writable: true,
value: void 0
});
_clients.set(this, {
writable: true,
value: void 0
});
_config.set(this, {
writable: true,
value: void 0
});
_classPrivateFieldSet(this, _pagesDir, pagesDir);
_classPrivateFieldSet(this, _outputPath, outputPath);
_classPrivateFieldSet(this, _config, config);
}
createWebpackConfig(entry) {
var _ref, _classPrivateFieldGet3;
const relative = path__default.relative(_classPrivateFieldGet(this, _pagesDir), entry);
const outputHtml = relative.replace(/\.client\.(js|ts)$/, '.html');
return {
experiments: {
asset: true
},
mode: 'production',
optimization: {
minimize: true,
minimizer: [new TerserJSPlugin({
terserOptions: {},
extractComments: false
})],
splitChunks: {
cacheGroups: {
vendors: path__default.resolve(cwd, 'node_modules')
}
}
},
entry: {
[relative.replace(/\.client.*/, '').split(path__default.sep).join('-')]: entry
},
output: {
ecmaVersion: 5,
// I need ie 11 support :(
path: path__default.resolve(_classPrivateFieldGet(this, _outputPath)),
filename: '[name].[contenthash].js',
chunkFilename: '[name]-[id].[contenthash].js',
publicPath: ASSET_PATH !== null && ASSET_PATH !== void 0 ? ASSET_PATH : _classPrivateFieldGet(this, _config).assetPath
},
module: {
rules
},
resolve: {
extensions,
alias
},
externals: _classPrivateFieldGet(this, _config).globalScripts ? _classPrivateFieldGet(this, _config).globalScripts.map(script => ({
[script[0]]: script[1].global
})) : [],
plugins: [new HtmlWebpackPlugin({
template: path__default.resolve(_classPrivateFieldGet(this, _outputPath), outputHtml),
filename: outputHtml,
minify: (_ref = (_classPrivateFieldGet3 = _classPrivateFieldGet(this, _config).html) === null || _classPrivateFieldGet3 === void 0 ? void 0 : _classPrivateFieldGet3.pretty) !== null && _ref !== void 0 ? _ref : true
}), new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
}), new CssoWebpackPlugin({
restructure: false
}), new MiniCssExtractPlugin({
filename: '[name].[contenthash].css'
}), new WebpackAssetsManifest({
fileName: 'client-assets.json',
generate: generateManifest
}), new PrettyPlugin(_classPrivateFieldGet(this, _config))]
};
}
async run(callback) {
_classPrivateFieldSet(this, _clients, (await collect(_classPrivateFieldGet(this, _pagesDir), filename => filename.endsWith('.client.js') || filename.endsWith('.client.ts'))));
const configs = _classPrivateFieldGet(this, _clients).map(client => this.createWebpackConfig(client)); // return
webpack(configs).run(callback);
}
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function Render(App) {
return server.renderToStaticMarkup(react.createElement(App));
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function hasClientEntry(pageEntry, exts = 'js,ts'.split(',')) {
const {
name,
dir
} = path__default.parse(pageEntry);
for (let i = 0; i < exts.length; i++) {
const ext = exts[i]; // find clientEntry beside pageEntry
const clientEntry = path__default.join(dir, `${name}.client.${ext}`);
if (fs$1.existsSync(clientEntry)) {
return {
exists: true,
clientEntry: clientEntry
};
}
}
return {
exists: false
};
}
function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
const {
htmlTagObjectToString
} = HtmlTags;
async function loadHtmlTags(root, filename) {
try {
const {
headTags,
bodyTags
} = await new Promise(function (resolve) { resolve(_interopNamespace(require(path__default.resolve(root, filename)))); });
return {
headTags,
bodyTags
};
} catch (err) {
return {
headTags: [],
bodyTags: []
};
}
}
async function loadAllHtmlTags(root, files) {
return Promise.all(files.map(async file => await loadHtmlTags(root, file))).then(values => values.reduce((acc, cur) => {
acc['headTags'] = acc.headTags.concat(cur.headTags);
acc['bodyTags'] = acc.bodyTags.concat(cur.bodyTags);
return acc;
}, {
headTags: [],
bodyTags: []
}));
}
class Ssr {
constructor() {
_defineProperty(this, "hooks", void 0);
_defineProperty(this, "helmet", void 0);
this.hooks = {
helmet: new tapable.SyncHook()
};
}
async run(pagesDir, templateName, cacheRoot, outputPath, htmlgagaConfig) {
const htmlTags = await loadAllHtmlTags(cacheRoot, [`${templateName}.json`]);
let {
headTags
} = htmlTags;
let {
bodyTags
} = htmlTags;
if (htmlgagaConfig.globalScripts) {
headTags = htmlgagaConfig.globalScripts.map(script => {
const {
global,
...others
} = script[1];
return {
tagName: 'script',
voidTag: false,
attributes: { ...others
}
};
}).concat(headTags);
} // only include page entrypoint, so no need
bodyTags = [];
let preloadStyles = '';
if (htmlgagaConfig.html.preload.style) {
preloadStyles = headTags.filter(tag => tag.tagName === 'link').map(tag => {
return `<link rel="preload" href="${tag.attributes.href}" as="${tag.attributes.rel === 'stylesheet' ? 'style' : ''}" />`;
}).join('');
}
let preloadScripts = '';
if (htmlgagaConfig.html.preload.script) {
preloadScripts = bodyTags.filter(tag => tag.tagName === 'script').concat(headTags.filter(tag => tag.tagName === 'script')).map(tag => {
return `<link rel="preload" href="${tag.attributes.src}" as="script" />`;
}).join('');
}
const hd = headTags.map(tag => htmlTagObjectToString(tag, true)).join('');
const bd = bodyTags.map(tag => htmlTagObjectToString(tag, true)).join('');
const appPath = `${path__default.resolve(outputPath, templateName + '.js')}`;
const {
default: App
} = await new Promise(function (resolve) { resolve(_interopNamespace(require(appPath))); });
const html = Render(App);
this.hooks.helmet.call();
let body;
if (this.helmet) {
body = `<!DOCTYPE html><html ${this.helmet.htmlAttributes.toString()}><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /><meta name="generator" content="htmlgaga" />${this.helmet.title.toString()}${this.helmet.meta.toString()}${this.helmet.link.toString()}${preloadStyles}${preloadScripts}${hd}</head><body>${html}${bd}</body></html>`;
} else {
body = `<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /><meta name="generator" content="htmlgaga" />${preloadStyles}${preloadScripts}${hd}</head><body>${html}${bd}</body></html>`;
}
const hasClientJs = hasClientEntry(path__default.join(pagesDir, templateName));
if (hasClientJs.exists === false) {
var _htmlgagaConfig$html;
if ((htmlgagaConfig === null || htmlgagaConfig === void 0 ? void 0 : (_htmlgagaConfig$html = htmlgagaConfig.html) === null || _htmlgagaConfig$html === void 0 ? void 0 : _htmlgagaConfig$html.pretty) === true) {
body = prettier.format(body, {
parser: 'html'
});
}
}
fs$1.outputFileSync(path__default.join(outputPath, templateName + '.html'), body);
fs$1.removeSync(appPath);
}
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function normalizeAssetPath() {
const ASSET_PATH = process.env.ASSET_PATH;
if (typeof ASSET_PATH === 'undefined') return undefined;
return ASSET_PATH.endsWith('/') ? ASSET_PATH : ASSET_PATH + '/';
}
function _defineProperty$1(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
// default htmlgaga.config.js
const defaultConfiguration = {
html: {
pretty: true,
preload: {
style: true,
script: true
}
},
plugins: [],
assetPath: ''
};
const configuration = 'htmlgaga.config.js';
class Builder {
constructor(pagesDir) {
_defineProperty$1(this, "pagesDir", void 0);
_defineProperty$1(this, "config", void 0);
this.pagesDir = pagesDir;
}
applyOptionsDefaults() {
var _this$config$html, _this$config$plugins;
this.config = { ...defaultConfiguration,
...this.config,
html: merge({}, defaultConfiguration.html, (_this$config$html = this.config.html) !== null && _this$config$html !== void 0 ? _this$config$html : {}),
plugins: merge([], defaultConfiguration.plugins, (_this$config$plugins = this.config.plugins) !== null && _this$config$plugins !== void 0 ? _this$config$plugins : [])
};
}
async resolveConfig() {
const configName = path__default.resolve(this.pagesDir, '..', configuration);
let config;
try {
// how can I mock this in test?
config = await new Promise(function (resolve) { resolve(_interopNamespace(require(configName))); });
} catch (err) {
// config file does not exist
config = {};
}
this.config = config;
this.applyOptionsDefaults();
logger.debug(configuration, this.config);
}
}
function _defineProperty$2(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
class PersistDataPlugin {
apply(compiler) {
compiler.hooks.compilation.tap(PersistDataPlugin.PluginName, compilation => {
// we need to persist some data in htmlPluginData for next usage
// @ts-ignore
HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync(PersistDataPlugin.PluginName, (htmlPluginData, callback) => {
fs$1.outputJSON(path__default.join(cacheRoot, `${htmlPluginData.outputName.replace(/\.html$/, '')}.json`), {
headTags: htmlPluginData.headTags,
bodyTags: htmlPluginData.bodyTags
}, () => {
callback(null, htmlPluginData);
});
});
});
}
}
_defineProperty$2(PersistDataPlugin, "PluginName", 'PersistDataPlugin');
function _defineProperty$3(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Warn: should be put after htmlwebpackplugin when removing html
*/
const NAME = 'RemoveAssetsPlugin';
class RemoveAssetsPlugin {
constructor(filter, callback) {
_defineProperty$3(this, "filter", void 0);
_defineProperty$3(this, "callback", void 0);
this.filter = filter;
this.callback = callback;
}
apply(compiler) {
compiler.hooks.compilation.tap(NAME, compilation => {
compilation.hooks.processAssets.tap(NAME, assets => {
Object.keys(assets).forEach(filename => {
if (this.filter(filename)) {
delete assets[filename];
if (this.callback) this.callback(filename);
}
});
});
});
}
}
function _defineProperty$4(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
function _classPrivateFieldGet$1(receiver, privateMap) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to get private field on non-instance"); } if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; }
function _classPrivateFieldSet$1(receiver, privateMap, value) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to set private field on non-instance"); } if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } return value; }
function generateManifest(seed, files, entrypoints) {
return {
files: files.reduce((manifest, {
name,
path
}) => ({ ...manifest,
[name]: path
}), seed),
entrypoints
};
}
const BEGIN = 'begin';
const END = 'end';
const ASSET_PATH = normalizeAssetPath();
var _pages = new WeakMap();
var _outputPath$1 = new WeakMap();
var _pageEntries = new WeakMap();
class ProdBuilder extends Builder {
constructor(pagesDir, outputPath) {
super(pagesDir);
_pages.set(this, {
writable: true,
value: void 0
});
_outputPath$1.set(this, {
writable: true,
value: void 0
});
_defineProperty$4(this, "config", void 0);
_pageEntries.set(this, {
writable: true,
value: void 0
});
_classPrivateFieldSet$1(this, _outputPath$1, outputPath);
_classPrivateFieldSet$1(this, _pageEntries, []);
}
normalizedPageEntry(pagePath) {
return path.relative(this.pagesDir, pagePath) // calculate relative path
.replace(new RegExp(`\\${path.extname(pagePath)}$`), ''); // remove extname
}
createWebpackConfig(pages) {
const entries = pages.reduce((acc, page) => {
const pageEntryKey = this.normalizedPageEntry(page);
_classPrivateFieldGet$1(this, _pageEntries).push(pageEntryKey);
acc[pageEntryKey] = page;
return acc;
}, {});
const htmlPlugins = pages.map(page => {
const filename = deriveFilenameFromRelativePath(this.pagesDir, page);
return new HtmlWebpackPlugin({
chunks: [this.normalizedPageEntry(page)],
filename,
minify: false,
inject: false,
cache: false,
showErrors: false,
meta: false
});
});
return {
experiments: {
asset: true
},
externals: ['react-helmet', 'react', 'react-dom'],
mode: 'production',
entry: { ...entries
},
optimization: {
minimize: false
},
output: {
ecmaVersion: 5,
// I need ie 11 support :(
path: path.resolve(_classPrivateFieldGet$1(this, _outputPath$1)),
libraryTarget: 'commonjs2',
filename: pathData => {
var _pathData$chunk;
if (pathData === null || pathData === void 0 ? void 0 : (_pathData$chunk = pathData.chunk) === null || _pathData$chunk === void 0 ? void 0 : _pathData$chunk.name) {
var _pathData$chunk2;
if (entries[pathData === null || pathData === void 0 ? void 0 : (_pathData$chunk2 = pathData.chunk) === null || _pathData$chunk2 === void 0 ? void 0 : _pathData$chunk2.name]) {
// do not include contenthash for those entry pages
// since we only use it for server side render
return '[name].js';
}
}
return '[name].[contenthash].js';
},
chunkFilename: '[name]-[id].[contenthash].js',
publicPath: ASSET_PATH !== null && ASSET_PATH !== void 0 ? ASSET_PATH : this.config.assetPath // ASSET_PATH takes precedence over assetPath in htmlgaga.config.js
},
module: {
rules
},
resolve: {
extensions,
alias
},
plugins: [new PersistDataPlugin(), new WebpackAssetsManifest({
fileName: 'assets.json',
generate: generateManifest
}), new webpack.DefinePlugin({
'process.env.NODE_ENV': '"production"'
}), new CssoWebpackPlugin({
restructure: false
}), ...htmlPlugins, new RemoveAssetsPlugin(filename => _classPrivateFieldGet$1(this, _pageEntries).indexOf(filename.replace('.html', '')) !== -1, filename => logger.debug(`${filename} removed by RemoveAssetsPlugin`)), new MiniCssExtractPlugin({
filename: '[name].[contenthash].css'
})]
};
}
runCallback(err, stats) {
if (err) {
if (err.stack) {
logger.error(err.stack);
} else {
logger.error(err);
}
if (err.details) {
logger.error(err.details);
}
return;
}
if (!stats) return;
const info = stats.toJson();
if (stats.hasErrors()) {
info.errors.forEach(err => logger.error(err.message));
}
if (stats.hasWarnings()) {
info.warnings.forEach(warning => logger.warn(warning.message));
}
} // measure end
markEnd() {
performance.mark(END);
performance.measure(`${BEGIN} to ${END}`, BEGIN, END);
const observerCallback = (list, observer) => {
logger.info(`All ${this.pageOrPages(_classPrivateFieldGet$1(this, _pages).length)} built in ${(list.getEntries()[0].duration / 1000).toFixed(2)}s!`);
observer.disconnect();
};
const obs = new PerformanceObserver(observerCallback);
obs.observe({
entryTypes: ['measure']
});
performance.measure('Build time', BEGIN, END);
} // measure begin
markBegin() {
performance.mark(BEGIN);
}
pageOrPages(len) {
return len < 2 ? len + ' page' : len + ' pages';
}
async ssr() {
for (const templateName of _classPrivateFieldGet$1(this, _pageEntries)) {
const ssr = new Ssr();
if (Array.isArray(this.config.plugins)) {
for (const plugin of this.config.plugins) {
plugin.apply(ssr);
}
}
ssr.run(this.pagesDir, templateName, cacheRoot, _classPrivateFieldGet$1(this, _outputPath$1), this.config);
}
}
async run() {
this.markBegin();
logger.info('Collecting pages...');
_classPrivateFieldSet$1(this, _pages, (await collect(this.pagesDir, searchPageEntry)));
logger.info(`${this.pageOrPages(_classPrivateFieldGet$1(this, _pages).length)} collected`); // resolve htmlgaga config
await this.resolveConfig();
const compiler = webpack(this.createWebpackConfig(_classPrivateFieldGet$1(this, _pages)));
compiler.run(async (err, stats) => {
this.runCallback(err, stats);
await this.ssr();
const clientJsCompiler = new ClientsCompiler(this.pagesDir, _classPrivateFieldGet$1(this, _outputPath$1), this.config);
await clientJsCompiler.run((err, stats) => {
this.runCallback(err, stats);
this.cleanCache(); // copy public
fs$1.copySync(path.join(this.pagesDir, '..', publicFolder), _classPrivateFieldGet$1(this, _outputPath$1));
this.markEnd();
});
});
}
cleanCache() {
fs$1.removeSync(cacheRoot);
}
}
const exts = 'mjs,js,jsx,ts,tsx,md,mdx';
function searchPageEntry(pagePath, extList = exts) {
const entryPattern = new RegExp(`.(${extList.split(',').join('|')})$`);
return entryPattern.test(pagePath) && extList.split(',').every(ext => pagePath.includes(`.client.${ext}`) === false);
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function findRawFile(sourceDir, url, extList = exts.split(',')) {
// normalize url
if (url.endsWith('/')) url = url + '/index.html'; // return the first one matched
// so orders in exts matter
for (let i = 0; i < extList.length; i++) {
const searchExt = extList[i];
const rawFilePath = path.join(sourceDir, url.replace(/\.html$/, `.${searchExt}`));
if (fs$1.existsSync(rawFilePath)) {
return {
src: rawFilePath,
exists: true
};
}
}
return {
exists: false
};
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
function deriveEntryKeyFromRelativePath(from, to) {
const relativePath = path.relative(from, to);
const {
base,
name
} = path.parse(relativePath);
return path.join(relativePath.replace(base, '') + name + '/index');
}
// share vendors between multiple pages
const vendors = {
'react-vendors': ['react', 'react-dom']
};
const socketClient = `${require.resolve('../Client')}`;
function createEntries(pagesDir, pages, vendors$1 = vendors) {
const entrypoints = pages.reduce((acc, page) => {
const entryKey = deriveEntryKeyFromRelativePath(pagesDir, page);
const hasClientJs = hasClientEntry(page); // page entry depends on vendors
const entry = {
[entryKey]: {
import: [socketClient, page],
dependOn: Object.keys(vendors$1)
}
};
if (hasClientJs.exists === true) {
// client entry depends on page entry
entry[deriveEntryKeyFromRelativePath(pagesDir, hasClientJs.clientEntry)] = {
import: [hasClientJs.clientEntry],
dependOn: [entryKey]
};
}
acc = { ...acc,
...entry
};
return acc;
}, {});
return { ...entrypoints,
...vendors$1
};
}
const createDOMRenderRule = pagesDir => ({
include: filename => {
return filename.startsWith(pagesDir) && filename.includes('.client.') === false // entries under pagesDir // exclude client entry
;
},
plugins: [['react-dom-render', // render page entry in dom
{
hydrate: false,
root: 'htmlgaga-app'
}]]
});
function createWebpackConfig(pages, pagesDir, socketUrl, options) {
return {
experiments: {
asset: true
},
mode: 'development',
entry: () => createEntries(pagesDir, pages),
output: {
ecmaVersion: 5,
// I need ie 11 support :(
publicPath: '/'
},
stats: 'minimal',
module: {
rules: [{
test: /\.(mjs|js|jsx|ts|tsx)$/i,
exclude: [/node_modules/],
use: [{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: ['react-require'],
// inject React automatically when jsx presented
overrides: [createDOMRenderRule(pagesDir)]
}
}]
}, {
test: /\.(mdx|md)$/,
use: [{
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: ['react-require'],
overrides: [createDOMRenderRule(pagesDir)]
}
}, {
loader: '@mdx-js/loader',
options: {
rehypePlugins: [rehypePrism]
}
}]
}, {
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset'
}, {
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
use: [{
loader: 'file-loader',
// TODO replace file-loader with asset module
options: {
name: '[name].[ext]',
outputPath: 'fonts/'
}
}]
}, {
test: /\.(sa|sc|c)ss$/i,
use: ['style-loader', 'css-loader', {
loader: 'postcss-loader',
options: {
ident: 'postcss',
plugins: [require('tailwindcss'), require('autoprefixer')]
}
}, 'sass-loader']
}]
},
resolve: {
extensions,
alias
},
plugins: [new webpack.DefinePlugin({
'process.env.NODE_ENV': '"development"',
__WEBSOCKET__: JSON.stringify(socketUrl)
}), new webpack.NoEmitOnErrorsPlugin()],
...options
};
}
function newHtmlWebpackPlugin(pagesDir, page, vendors$1 = vendors) {
const htmlFilename = deriveFilenameFromRelativePath(pagesDir, page);
const entryKey = deriveEntryKeyFromRelativePath(pagesDir, page);
const hasClientJs = hasClientEntry(page);
return new HtmlWebpackPlugin({
template: require.resolve('../devTemplate'),
chunks: hasClientJs.exists === true ? [...Object.keys(vendors$1), entryKey, deriveEntryKeyFromRelativePath(pagesDir, hasClientJs.clientEntry)] : [...Object.keys(vendors$1), entryKey],
chunksSortMode: 'manual',
// order matters, client entry must come after page entry
filename: htmlFilename
});
}
function watchCompilation(compiler, wsServer) {
// make sure wsServer is ready
if (!wsServer) return;
const reloadPluginName = 'htmlgaga-reload';
compiler.hooks.done.tap(reloadPluginName, stats => {
const statsJson = stats.toJson({
all: false,
hash: true,
assets: true,
warnings: true,
errors: true,
errorDetails: false
});
const hasErrors = stats.hasErrors();
const hasWarnings = stats.hasWarnings();
wsServer.clients.forEach(client => {
if (client.readyState !== WebSocket.OPEN) return;
client.send(JSON.stringify({
type: MessageType.MessageType.HASH,
data: {
hash: statsJson.hash,
startTime: stats.startTime,
endTime: stats.endTime
}
}));
if (hasErrors) {
console.log(statsJson.errors);
return client.send(JSON.stringify({
type: MessageType.MessageType.ERRORS,
data: statsJson.errors
}));
}
if (hasWarnings) {
return client.send(JSON.stringify({
type: MessageType.MessageType.WARNINGS,
data: statsJson.warnings
}));
}
client.send(JSON.stringify({
type: MessageType.MessageType.RELOAD
}));
});
});
compiler.hooks.invalid.tap(reloadPluginName, () => {
wsServer.clients.forEach(client => {
if (client.readyState !== WebSocket.OPEN) return;
client.send(JSON.stringify({
type: MessageType.MessageType.INVALID
}));
});
});
}
function createWebSocketServer(httpServer, socketPath) {
const wsServer = new WebSocket.Server({
server: httpServer,
path: socketPath
});
wsServer.on('connection', socket => {
socket.on('message', data => {
// received data from client
// TODO we might sync browsers in future
console.log(`${data} from client`);
});
});
wsServer.on('close', () => {
console.log('closed');
});
function cleanup() {
wsServer.close(() => {
process.exit(1);
});
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
return wsServer;
}
function _defineProperty$5(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
const PLUGIN_NAME = 'InjectGlobalScripts';
class InjectGlobalScriptsPlugin {
constructor(scripts) {
_defineProperty$5(this, "scripts", void 0);
this.scripts = scripts;
}
apply(compiler) {
compiler.hooks.compilation.tap(PLUGIN_NAME, compilation => {
// @ts-ignore
HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync(PLUGIN_NAME, (htmlPluginData, callback) => {
const globalScripts = this.scripts.map(script => ({
tagName: 'script',
voidTag: false,
attributes: {
src: script
}
}));
callback(null, { ...htmlPluginData,
headTags: htmlPluginData.headTags.concat(globalScripts)
});
});
});
}
}
function _classPrivateFieldGet$2(receiver, privateMap) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to get private field on non-instance"); } if (descriptor.get) { return descriptor.get.call(receiver); } return descriptor.value; }
function _classPrivateFieldSet$2(receiver, privateMap, value) { var descriptor = privateMap.get(receiver); if (!descriptor) { throw new TypeError("attempted to set private field on non-instance"); } if (descriptor.set) { descriptor.set.call(receiver, value); } else { if (!descriptor.writable) { throw new TypeError("attempted to set read only private field"); } descriptor.value = value; } return value; }
var _host = new WeakMap();
var _port = new WeakMap();
var _pages$1 = new WeakMap();
var _httpServer = new WeakMap();
class DevServer extends Builder {
constructor(pagesDir, {
host,
port
}) {
super(pagesDir);
_host.set(this, {
writable: true,
value: void 0
});
_port.set(this, {
writable: true,
value: void 0
});
_pages$1.set(this, {
writable: true,
value: void 0
});
_httpServer.set(this, {
writable: true,
value: void 0
});
_classPrivateFieldSet$2(this, _host, host);
_classPrivateFieldSet$2(this, _port, port);
_classPrivateFieldSet$2(this, _pages$1, []);
}
listen() {
_classPrivateFieldGet$2(this, _httpServer).listen(_classPrivateFieldGet$2(this, _port), _classPrivateFieldGet$2(this, _host), () => {
const server = `http://${_classPrivateFieldGet$2(this, _host)}:${_classPrivateFieldGet$2(this, _port)}`;
console.log(`Listening on ${server}`);
}).on('error', err => {
logger.info(`You might run server on another port with option like --port 9999`);
throw err;
});
}
async start() {
await this.resolveConfig();
const socketPath = '/__websocket';
const webpackConfig = createWebpackConfig(_classPrivateFieldGet$2(this, _pages$1), this.pagesDir, `${_classPrivateFieldGet$2(this, _host)}:${_classPrivateFieldGet$2(this, _port)}${socketPath}`, {
externals: this.config.globalScripts ? this.config.globalScripts.reduce((acc, cur) => {
acc[cur[0]] = cur[1].global;
return acc;
}, {}) : []
});
const compiler = webpack(webpackConfig);
const devMiddlewareInstance = devMiddleware(compiler);
const app = express();
const htmlgagaMiddleware = pagesDir => (req, res, next) => {
if (isHtmlRequest(req.url)) {
// check if page does exit on disk
const page = findRawFile(pagesDir, req.url);
if (page.exists) {
const src = page.src;
if (!_classPrivateFieldGet$2(this, _pages$1).includes(src)) {
// update pages' table
_classPrivateFieldGet$2(this, _pages$1).push(src); // @ts-ignore
// ts reports error because html-webpack-plugin uses types from @types/webpack
// while we have types from webpack 5
newHtmlWebpackPlugin(pagesDir, src).apply(compiler);
new InjectGlobalScriptsPlugin(this.config.globalScripts ? this.config.globalScripts.map(script => script[1].src) : []).apply(compiler);
devMiddlewareInstance.invalidate();
}
}
}
next();
};
app.use(htmlgagaMiddleware(this.pagesDir));
app.use(devMiddlewareInstance);
const cwd = path.resolve(this.pagesDir, '..');
app.use(express.static(path.join(cwd, publicFolder))); // serve statics from public folder.
app.use(function (req, res, next) {
if (req.is('html')) {
return res.status(404).end('Page Not Found'); // TODO list all pages
}
next();
});
_classPrivateFieldSet$2(this, _httpServer, http.createServer(app));
const wsServer = createWebSocketServer(_classPrivateFieldGet$2(this, _httpServer), socketPath);
watchCompilation(compiler, wsServer);
this.listen();
return app;
}
}
/**
* Copyright 2020-present, Sam Chen.
*
* Licensed under GPL-3.0-or-later
*
* This file is part of htmlgaga.
htmlgaga is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
htmlgaga is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with htmlgaga. If not, see <https://www.gnu.org/licenses/>.
*/
yargs.scriptName('htmlgaga').usage(`$0 <cmd> [args]`).command('dev', 'Run development server', {
host: {
default: 'localhost',
description: 'Host to run server'
},
port: {
default: 8080,
description: 'Port to run server'
}
}, async function (argv) {
const {
host,
port
} = argv;
const pagesDir = path.resolve(cwd, 'pages');
const server = new DevServer(pagesDir, {
host,
port
});
server.start();
}).command('build', 'Build static html & assets', {
dest: {
default: 'out',
description: 'The output directory'
}
}, function (argv) {
const pagesDir = path.resolve(cwd, 'pages');
if (!fs.existsSync(pagesDir)) {
throw new Error("Couldn't find a `pages` directory. Make sure you have it under the project root");
}
const {
dest
} = argv;
const outDir = path.resolve(cwd, dest); // Clean outDir first
rimraf(outDir, async err => {
if (err) return logger.error(err); // remove .htmlgaga folder
rimraf(path.resolve(cwd, '.htmlgaga'), async err => {
if (err) return logger.error(err);
const builder = new ProdBuilder(pagesDir, outDir);
process.env['NODE_ENV'] = 'production';
await builder.run();
});
});
}).help().argv;