UNPKG

htmlgaga

Version:

Manage non-SPA pages with webpack and React.js

1,483 lines (1,259 loc) 47.5 kB
'use strict'; 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;