gemstone-config-webpack
Version:
Webpack Bundling Configuration for Gemstone JavaScript Technology Stack
591 lines (559 loc) • 23.2 kB
JavaScript
/*
** GemstoneJS -- Gemstone JavaScript Technology Stack
** Copyright (c) 2016-2019 Gemstone Project <http://gemstonejs.com>
** Licensed under Apache License 2.0 <https://spdx.org/licenses/Apache-2.0>
*/
const path = require("path")
const fs = require("mz/fs")
const rimraf = require("rimraf")
const gemstoneConfig = require("gemstone-config")
const webpack = require("webpack")
const BundleAnalyzer = require("webpack-bundle-analyzer").BundleAnalyzerPlugin
const ExtractTextPlugin = require("extract-text-webpack-plugin")
const HTMLWebpackPlugin = require("html-webpack-plugin")
const FaviconsPlugin = require("favicons-webpack-plugin")
const CompressionPlugin = require("compression-webpack-plugin")
const BrotliPlugin = require("brotli-webpack-plugin")
const UglifyPlugin = require("uglifyjs-webpack-plugin")
const Chalk = require("chalk")
const Progress = require("progress")
const stripIndent = require("strip-indent")
const jsBeautify = require("js-beautify")
const hashFiles = require("hash-files")
const Moment = require("moment")
module.exports = function (opts) {
/* determine Gemstone configuration */
const cfg = gemstoneConfig()
/* get a chalk instance */
const chalk = new Chalk.constructor({ enabled: true })
/* instanciate progress bar */
let progressCur = 0.0
const progressBar = new Progress(` compiling: [${chalk.green(":bar")}] ${chalk.bold(":percent")} (elapsed: :elapseds) :msg `, {
complete: "#",
incomplete: "=",
width: 20,
total: 1.0,
stream: process.stderr
})
/* generate HTML index page skeleton */
const index = stripIndent(`
<!DOCTYPE html>
<!--
%header%
-->
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="robots" content="noindex, nofollow, noarchive">
<meta name="generator" content="Gemstone">
<title>${cfg.meta.title}</title>
<meta name="description" content="${cfg.meta.description}">
<meta name="author" content="${cfg.meta.author}">
<meta name="keywords" content="${cfg.meta.keywords}">
</head>
<body>
</body>
</html>
` ).replace(/%header%\n/, cfg.header !== "" ? cfg.header : " Gemstone Application\n")
.replace(/^\n+/, "")
.replace(/([ \t]*\n)+[ \t]*$/, "\n")
/* determine resolved path to source files */
const sourceResolved = path.resolve(cfg.path.source)
const resourceResolved = path.resolve(cfg.path.resource)
/* the SVG image/font checking cache */
const svgIsFont = (() => {
const isFontCache = {}
return (path) => {
let isFont = isFontCache[path]
if (isFont === undefined) {
const svg = fs.readFileSync(path, "utf8")
isFont = (svg.match(/<font[^>]*>(.|\r?\n)*<\/font>/) !== null)
isFontCache[path] = true
}
return isFont
}
})()
/* start assembling Webpack configuration object */
const pathSepRe = path.sep.replace(/\\/, "\\\\")
const config = {
mode: (opts.env === "production" ? "production" : "development"),
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
new ExtractTextPlugin({
filename: "[name].css",
allChunks: true
}),
new HTMLWebpackPlugin({
templateContent: index,
filename: "index.html",
inject: "head",
title: cfg.meta.title,
chunksSortMode: "dependency",
cache: true
}),
new FaviconsPlugin({
logo: (cfg.path.icon !== "" ? cfg.path.icon : path.resolve(path.join(__dirname, "gemstone-icon.png"))),
prefix: "index-",
emitStats: false,
persistentCache: opts.env === "development",
inject: true,
background: "#ffffff",
title: cfg.meta.title,
icons: {
android: true,
appleIcon: true,
appleStartup: false,
favicons: true,
firefox: false,
coast: false,
opengraph: false,
twitter: false,
yandex: false,
windows: false
}
}),
new webpack.BannerPlugin({
banner: cfg.header,
raw: false,
entryOnly: true
}),
new webpack.ProgressPlugin((percentage, msg) => {
if (msg.length > 40)
msg = msg.substr(0, 40) + "..."
const delta = percentage - progressCur
progressBar.tick(delta, { msg })
if (progressBar.complete)
process.stderr.write("\n")
progressCur += delta
})
],
context: process.cwd(),
entry: {
"app": cfg.path.main
},
resolve: {
modules: cfg.modules.source.concat([
cfg.path.source,
"node_modules",
"bower_components"
]),
descriptionFiles: [
"package.json",
"bower.json"
],
mainFields: [
"browser",
"main"
],
alias: {
"gemstone.css$": "gemstone-framework-frontend/dst/gemstone.css",
"gemstone$": `gemstone-framework-frontend/dst/gemstone${opts.env === "production" ? "" : ".dev"}.js`,
"jquery$": `gemstone-framework-frontend/lib/jquery${opts.env === "production" ? "" : ".dev"}.js`,
"vue$": `gemstone-framework-frontend/lib/vue${opts.env === "production" ? "" : ".dev"}.js`,
"componentjs$": `gemstone-framework-frontend/lib/componentjs${opts.env === "production" ? "" : ".dev"}.js`
}
},
externals: [{
"navigator": "navigator",
"window": "window",
"document": "document",
"websocket": "WebSocket"
}],
module: {
noParse: new RegExp(`${pathSepRe}gemstone-framework-frontend${pathSepRe}$`),
rules: [
/* ==== LIB ==== */
{
test: (path) => {
return path.match(new RegExp(`${pathSepRe}(?:node_modules|bower_components)${pathSepRe}`))
|| (path.indexOf(resourceResolved) === 0)
},
rules: [
/* JavaScript */
{ test: /\.js$/,
rules: [
{
parser: {
amd: false,
commonjs: true
}
}
]
},
/* post-loader: remove strictness indicators */
{ test: /\.js$/,
enforce: "post",
use: {
loader: require.resolve("gemstone-loader-nostrict")
}
},
/* CSS/LESS */
{ test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: require.resolve("style-loader"),
use: require.resolve("css-loader")
})
},
/* JPEG/PNG/GIF images */
{ test: /\.(?:jpg|png|gif)$/,
use: {
loader: require.resolve("file-loader"),
options: "name=lib-img-[md5:hash:base62:32].[ext]"
}
},
/* SVG images/fonts */
{ test: /\.svg$/,
rules: [ {
test: (path) => !svgIsFont(path),
use: {
loader: require.resolve("file-loader"),
options: "name=lib-img-[md5:hash:base62:32].[ext]"
}
}, {
test: (path) => svgIsFont(path),
use: {
loader: require.resolve("file-loader"),
options: "name=lib-font-[md5:hash:base62:32].[ext]"
}
} ]
},
/* EOT/WOFF/TTF fonts */
{ test: /\.(?:eot|woff2?|ttf)$/,
use: {
loader: require.resolve("file-loader"),
options: "name=lib-font-[md5:hash:base62:32].[ext]"
}
},
/* TXT/BIN files */
{ test: /\.(?:txt|bin)$/,
use: require.resolve("raw-loader")
}
]
},
/* ==== APP ==== */
{
test: (path) => {
return (path.indexOf(sourceResolved) === 0)
},
rules: [
/* pre-loader: Unique Component Identifier (UCID) */
{ test: /\.(?:js|tsx?|html|yaml|css|svg)$/,
enforce: "pre",
use: {
loader: require.resolve("gemstone-loader-ucid"),
options: {
sourceDir: sourceResolved,
idMatch: "__ucid",
idReplace: "ucid<ucid>"
}
}
},
/* JavaScript */
{ test: /\.js$/,
exclude: (path) => {
return (path.match(/(?:node_modules|bower_components)/))
},
use: require.resolve("gemstone-loader-js")
},
/* TypeScript */
{ test: /\.tsx?$/,
exclude: (path) => {
return (path.match(/(?:node_modules|bower_components)/))
},
use: {
loader: require.resolve("gemstone-loader-ts"),
options: {
transpileOnly: true,
silent: true
}
}
},
/* HTML */
{ test: /\.html$/,
use: require.resolve("gemstone-loader-html")
},
/* YAML */
{ test: /\.yaml$/,
use: require.resolve("gemstone-loader-yaml")
},
/* CSS/LESS */
{ test: /\.css$/,
use: ExtractTextPlugin.extract({
fallback: require.resolve("style-loader"),
use: require.resolve("gemstone-loader-css")
})
},
/* JPEG/PNG/SVG images
(should not be used due to inline-assets in gemstone-loader-{css,html} */
{ test: /\.(?:jpg|png|gif)$/,
use: {
loader: require.resolve("file-loader"),
options: "name=app-img-[md5:hash:base62:32].[ext]"
}
},
/* SVG images/fonts
(should not be used due to inline-assets in gemstone-loader-{css,html} */
{ test: /\.svg$/,
rules: [ {
test: (path) => !svgIsFont(path),
use: {
loader: require.resolve("file-loader"),
options: "name=app-img-[md5:hash:base62:32].[ext]"
}
}, {
test: (path) => svgIsFont(path),
use: {
loader: require.resolve("file-loader"),
options: "name=app-font-[md5:hash:base62:32].[ext]"
}
} ]
},
/* EOT/WOFF/TTF fonts
(should not be used due to inline-assets in gemstone-loader-{css,html} */
{ test: /\.(?:eot|woff2?|ttf)$/,
use: {
loader: require.resolve("file-loader"),
options: "name=app-font-[md5:hash:base62:32].[ext]"
}
},
/* TXT/BIN files */
{ test: /\.(?:txt|bin)$/,
use: require.resolve("raw-loader")
}
]
}
]
},
target: "web",
output: {
path: path.resolve(cfg.path.output),
filename: "[name].js",
libraryTarget: "var",
library: "App",
publicPath: ""
},
stats: {
colors: chalk.supportsColor,
hash: false,
version: false,
timings: false,
warnings: false,
errors: true,
errorDetails: true,
assets: false,
assetsSort: "chunks",
children: false,
cached: false,
cachedAssets: false,
entrypoints: true,
chunks: true,
chunkModules: true,
chunkOrigins: false,
chunksSort: "",
modules: true,
modulesSort: "",
maxModules: Infinity,
exclude: [ "node_modules", "bower_components" ],
usedExports: false,
providedExports: false,
performance: true,
publicPath: false,
reasons: false,
source: false
},
performance: {
hints: false
}
}
/* pre-process operation */
config.plugins.push({
apply (compiler) {
compiler.hooks.beforeRun.tapPromise("Gemstone", async () => {
/* remove destination directory (recursively) for production builds */
if (opts.env === "production") {
if (await fs.exists(cfg.path.output)) {
await new Promise((resolve, reject) => {
rimraf(cfg.path.output, {}, (err) => {
if (err) reject(err)
else resolve()
})
})
}
}
})
}
})
/* post-process operation */
config.plugins.push({
apply (compiler) {
compiler.hooks.afterEmit.tapPromise("Gemstone", async () => {
/* remove unwanted generated file */
const manifest = path.join(cfg.path.output, "index-manifest.json")
if (await fs.exists(manifest))
await fs.unlink(manifest)
/* reformat index.html file */
const filename = path.join(cfg.path.output, "index.html")
if (await fs.exists(filename)) {
let html = await fs.readFile(filename, "utf8")
html = jsBeautify.html(html, {
indent_size: 4,
indent_char: " ",
indent_inner_html: true,
extra_liners: []
})
await fs.writeFile(filename, html, "utf8")
}
})
}
})
/* provide library chunk splitting */
config.optimization = {
splitChunks: {
chunks: "all",
name: false,
cacheGroups: {
commons: {
test: /[\\/](?:node_modules|bower_components|gemstone-framework-frontend)[\\/]/,
name: "lib",
chunks: "all"
}
}
}
}
/* provide Webpack module aliasing */
cfg.modules.alias.forEach((alias) => {
config.resolve.alias[alias.from] = alias.to
})
/* provide Webpack module loaders */
cfg.modules.rules.forEach((rule) => {
const uses = []
Object.keys(rule.use).forEach((use) => {
uses.push({
loader: require.resolve(`${use}-loader`),
options: rule.use[use]
})
})
config.module.rules[0].rules.unshift({
test: new RegExp(rule.test),
use: uses
})
})
/* provide Webpack module provides */
const provides = {
"jQuery": "jquery",
"Vue": "vue",
"ComponentJS": "componentjs"
}
cfg.modules.provide.forEach((provide) => {
provides[provide.name] = provide.require
})
config.plugins.push(new webpack.ProvidePlugin(provides))
/* provide Webpack module replacements */
cfg.modules.replace.forEach((replace) => {
const from = new RegExp(replace.match)
let to = replace.replace
if (typeof to === "object" && to instanceof Array) {
const substs = to
to = function (resource) {
substs.forEach((subst) => {
resource.request = resource.request.replace(new RegExp(subst[0]), subst[1])
})
}
}
config.plugins.push(new webpack.NormalModuleReplacementPlugin(from, to))
})
/* determine build hash ("HHHH.HHHH.HHHH.HHHH") */
let hash = hashFiles.sync({
files: [ `${sourceResolved}/**/*` ],
algorithm: "md5"
})
hash = hash.toUpperCase()
.split("").filter((x, i) => i % 2 === 0).join("")
.replace(/([0-9A-F]{4})(?=.)/g, "$1.")
/* determine build time ("YYYY.MMDD.hhmm.ssSS") */
const time = Moment(new Date()).format("YYYY.MMDD.hhmm.ssSS")
/* provide environment information */
config.plugins.push(new webpack.DefinePlugin({
"process.env": {
"NODE_ENV": `"${opts.env}"`
},
"process.config": {
"env": `"${opts.env}"`,
"tag": `"${opts.tag}"`,
"hash": `"${hash}"`,
"time": `"${time}"`
}
}))
/* final environment-specific treatments */
if (opts.env === "production") {
/* minimize JS files */
config.optimization.minimize = true
config.optimization.minimizer = [
new UglifyPlugin({
parallel: true,
cache: false,
sourceMap: false,
uglifyOptions: {
beautify: false,
comments: false,
mangle: false,
parse: {
ecma: 8
},
compress: {
warnings: false,
ecma: 5
},
output: {
ecma: 5
}
}
})
]
/* minimize any other files (in general) */
config.plugins.push(new webpack.LoaderOptionsPlugin({
minimize: true
}))
/* compress JS/CSS/HTML files */
config.plugins.push(new CompressionPlugin({
asset: "[path].gz[query]",
algorithm: "gzip",
test: /\.(?:js|css|html)$/,
threshold: 10 * 1024,
minRatio: 0.8,
deleteOriginalAssets: false
}))
config.plugins.push(new BrotliPlugin({
asset: "[path].br[query]",
test: /\.(?:js|css|html)$/,
threshold: 10 * 1024,
minRatio: 0.8
}))
}
else {
/* do NOT minimize any files */
config.plugins.push(new webpack.LoaderOptionsPlugin({
minimize: false
}))
/* provide bundle analyzer information */
config.plugins.push(new BundleAnalyzer({
analyzerMode: "static",
reportFilename: "index-report.html",
defaultSizes: "parsed",
openAnalyzer: false,
generateStatsFile: false,
logLevel: "error"
}))
/* provide source-maps for debugging */
config.plugins.push(new webpack.SourceMapDevToolPlugin({
test: /app\.(?:css|js)$/,
filename: "[file].map"
}))
}
return config
}