generate-examples-index-webpack-plugin
Version:
Generate an examples index page from your examples folder
373 lines (331 loc) • 12.6 kB
JavaScript
const path = require('path');
const fs = require('fs');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const validate = require('@webpack-contrib/schema-utils');
const schema = require('../schema/plugin.json');
const getPackageJson = require('./utils/getPackageJson');
const getAssociatedFile = require('./utils/getAssociatedFile');
const getExcludeAssets = require('./utils/getExcludeAssets');
const getFolders = require('./utils/getFolders');
const getHtmlTitle = require('./utils/getHtmlTitle');
const stripExtension = require('./utils/stripExtension');
const isWebpack4Compiler = require('./utils/isWebpack4Compiler');
const getIndexListHtml = require('./html/getIndexListHtml');
const getExamplesFilterHtml = require('./html/getExamplesFilterHtml');
const getBreadCrumbsHtml = require('./html/breadcrumb');
const pluginName = require('../package.json').name;
class ExamplesGenerator {
constructor(options = {}) {
validate({
name: 'generate-examples-index-webpack-plugin',
schema,
target: options,
});
this.options = {
addJsExtension: undefined,
analyzer: 'assets/analyzer.html',
app: 'assets/app',
assets: 'assets',
breadcrumbsHtmlGenerator: getBreadCrumbsHtml,
buildTimeLocale: 'ja',
examples: 'examples',
examplesFilterHtmlGenerator: getExamplesFilterHtml,
examplesIndexHtmlGenerator: getIndexListHtml,
excludeAssets: false,
extensions: ["js", "jsx", "ts", "tsx"],
noEntries: false,
outputPath: 'examplesBuild',
packageJson: undefined,
static: 'assets',
templateIndex: path.join(__dirname, 'html', 'index.html'),
vendorContent: [],
vendorFolder: '',
vendorName: 'vendor',
...options,
};
this.options.buildTimeOptions = {
timeZone: 'Asia/Tokyo',
hour12: false,
hourCycle: 'h23',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
...options.buildTimeOptions,
};
this.options.examples = path.resolve(this.options.examples);
this.options.outputPath = path.resolve(this.options.outputPath);
if (this.options.vendorFolder) {
this.options.vendorFolder = path.resolve(this.options.vendorFolder);
}
}
apply(compiler) {
// only works with webpack-4 (T_T)
if (!isWebpack4Compiler(compiler)) {
// eslint-disable-next-line no-console
console.warn(`${pluginName} requires webpack 4. No examples index will be generated`);
return;
}
// if `addJsExtension` is not specified, try to detect the correct value
if (this.options.addJsExtension === undefined) {
this.options.addJsExtension = compiler.options.output.filename.indexOf('.js') === -1;
}
this.entry = compiler.options.entry;
this.plugins = compiler.options.plugins;
this.outputPath = compiler.options.output.path;
const relativeOutput = path.relative(compiler.options.output.path, this.options.outputPath);
const info = this.getExamplesInfo(this.options.examples);
const entries = this.getExamplesEntries(info, relativeOutput);
const plugins = this.getExamplesPlugins(info, relativeOutput);
if (this.options.noEntries) {
compiler.options.entry = {};
}
// transform the `entry` list to an object so we can add the examples entries
if (!compiler.options.entry) {
compiler.options.entry = {};
} else if (typeof compiler.options.entry !== 'object' || Array.isArray(compiler.options.entry)) {
compiler.options.entry = { main: compiler.options.entry };
}
// duplicate the original files to the app folder
// (would be better to just generate the files once and copy them, but can't with copy-webpack-plugin)
Object.keys(compiler.options.entry).forEach((key) => {
const value = compiler.options.entry[key];
compiler.options.entry[path.join(relativeOutput, this.options.app, key)] = value;
});
// webpack-merge doesn't work here (?), so copy entries and plugins manually
const exampleEntries = Object.keys(entries);
exampleEntries.forEach((chunk) => {
const chunkName = this.options.addJsExtension ? `${chunk}.js` : chunk;
compiler.options.entry[chunkName] = entries[chunk];
});
plugins.forEach((plugin) => {
plugin.apply(compiler);
});
if (this.options.excludeAssets) {
if (!compiler.options.stats) {
compiler.options.stats = {};
}
if (!compiler.options.devServer) {
compiler.options.devServer = { stats: {} };
}
if (!compiler.options.devServer.stats) {
compiler.options.devServer.stats = {};
}
compiler.options.stats.excludeAssets = getExcludeAssets(compiler.options.stats.excludeAssets, exampleEntries);
compiler.options.devServer.stats.excludeAssets = getExcludeAssets(compiler.options.devServer.stats.excludeAssets,
exampleEntries);
}
}
/**
* Analyze the examples folder and generate information to use for generating
* html files, indexes and webpack entries objects
*
* It returns an object like: {
* entries: { 'chunkName': ['tjsFiles'] ],
* examples: [{
* folderName: 'folderName':
* cases: [{
* name: 'case name',
* title: 'case title',
* htmlPath: 'htmlAbsolutePath',
* route: 'exampleRelativePath',
* chunks: ['required chunk names'],
* }],
* }],
* }
*
* If an example folder is named like `this.options.assets`, will be ignored because
* it's the one used for placing the app static files
*/
getExamplesInfo(examplesPath) {
const folders = getFolders(examplesPath);
const res = {
entries: {},
examples: [],
app: this.getAppFilesInfo(),
};
// get the needed files per each folder
folders.forEach((folder) => {
const folderName = path.basename(folder);
if (folderName === this.options.assets || folderName === this.options.app) {
// eslint-disable-next-line no-console
console.warn(`Examples in ${folderName} folder will be ignored.`);
return;
}
const folderObject = {
folderName,
cases: [],
};
const files = fs.readdirSync(folder);
files.forEach((htmlFile) => {
if (path.extname(htmlFile) === '.html') {
const fullPath = path.join(folder, htmlFile);
const tjsFile = getAssociatedFile(fullPath, this.options.extensions);
const caseObject = {
name: htmlFile,
title: getHtmlTitle(fullPath),
route: path.relative(examplesPath, fullPath),
htmlPath: fullPath,
};
if (tjsFile) {
const chunkFile = path.relative(examplesPath, tjsFile);
caseObject.chunks = [res.app.vendor, chunkFile];
res.entries[chunkFile] = tjsFile;
} else {
caseObject.chunks = [];
}
folderObject.cases.push(caseObject);
}
});
if (folderObject.cases.length > 0) {
res.examples.push(folderObject);
}
});
return res;
}
/**
* Get the list of chunks and associated files for webpack to build
*
* @param info Object from getExamplesInfo
*/
getExamplesEntries(info, relativeOutput) {
const res = { ...info.entries, ...info.app.entries };
Object.keys(res).forEach((key) => {
const fixedPath = path.normalize(path.join(relativeOutput, key));
const value = res[key];
delete(res[key]);
res[fixedPath] = value;
});
return res;
}
/**
* Get a list of plugins to add to webpack (HtmlWebpackPlugin)
* Basically, for each example it has an html with this injected junks:
* - vendor + associated js/ts file if any
* - vendor + app files if there's no associated js/ts file
* Plus, a HtmlWebpackPlugin entry for the index page,
* which has no JS but a list of available examples
*
* @param info Object from getExamplesInfo
*/
getExamplesPlugins(info, relativeOutput) {
const plugins = [];
const packageJson = getPackageJson(this.options.packageJson);
const projectName = packageJson && packageJson.name || '';
const projectVersion = packageJson && packageJson.version || '';
const examplesFolder = path.basename(this.options.examples);
const buildDate = new Date().toLocaleString(this.options.buildTimeLocale, this.options.buildTimeOptions);
const reportFilename = this.options.analyzer && path.join(relativeOutput, this.options.analyzer);
// one plugin for the index page
plugins.push(new HtmlWebpackPlugin({
filename: path.normalize(path.join(relativeOutput, 'index.html')),
template: this.options.templateIndex,
chunks: [],
minify: false,
// available values in the template
buildDate,
projectName,
projectVersion,
examplesFolder,
examplesIndex: this.options.examplesIndexHtmlGenerator(info),
examplesFilter: this.options.examplesFilterHtmlGenerator(info),
assets: this.options.assets,
reportLink: reportFilename || '',
}));
// copy the internal assets to the examples assets folder
plugins.push(new CopyWebpackPlugin([{
from: path.join(__dirname, 'assets'),
to: path.join(this.options.outputPath, this.options.assets),
ignore: '.DS_Store',
}]));
// copy the static folder to the examples assets folder
if (fs.existsSync(this.options.static)) {
plugins.push(new CopyWebpackPlugin([{
from: this.options.static,
to: path.join(this.options.outputPath, this.options.assets),
}]));
}
if (reportFilename) {
plugins.push(new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename,
}));
}
// one plugin per example case
info.examples.forEach((example) => {
example.cases.forEach((entry) => {
const relPath = path.relative(entry.htmlPath, this.options.examples).replace('../', '');
const backLink = path.join(relPath, 'index.html');
const assets = path.join(relPath, this.options.assets);
const app = path.join(relPath, this.options.app);
const projectCss = projectName && path.join(relPath, `${assets}/${projectName}.css`) || '';
const breadcrumbs = this.options.breadcrumbsHtmlGenerator(entry.route);
plugins.push(new HtmlWebpackPlugin({
filename: path.normalize(path.join(relativeOutput, entry.route)),
template: entry.htmlPath,
chunks: entry.chunks.map((chunk) => {
const chunkName = path.normalize(path.join(relativeOutput, chunk));
return this.options.addJsExtension ? `${chunkName}.js` : chunkName;
}),
inject: true,
minify: false,
// available values in the template
breadcrumbs,
buildDate,
projectName,
projectVersion,
examplesFolder,
app,
projectCss,
assets,
backLink,
}));
});
});
return plugins;
}
/**
* Return an object with information about the app files to use in the example.
* The object is like this: {
* vendor: `vendorChunkName`,
* entries: { chunkName: [files] },
* chunks: [chunkNames],
* }
*/
getAppFilesInfo() {
const res = { entries: {} };
res.vendor = path.join(this.options.app, stripExtension(this.options.vendorName));
Object.keys(this.entry, (key) => {
const chunk = path.join(this.options.app, key);
res.entries[chunk] = this.entry[key];
});
res.chunks = Object.keys(res.entries);
return res;
}
/**
*
* @param {*} list
*/
getVendor(list) {
list = list || {};
if (this.options.vendorName) {
const vendorName = stripExtension(this.options.vendor);
const vendorFolder = this.options.vendorFolder;
list[vendorName] = this.options.vendorContent;
if (fs.existsSync(vendorFolder)) {
fs.readdirSync(vendorFolder).forEach((file) => {
if (/\.[tj]sx?$/.test(file)) {
list[vendorName].push(path.join(vendorFolder, file));
}
});
}
}
return list;
}
}
module.exports = ExamplesGenerator;