@knapsack/app
Version:
Build Design Systems on top of knapsack, by Basalt
489 lines (430 loc) • 14.4 kB
text/typescript
/* eslint-disable class-methods-use-this */
/* eslint-disable max-classes-per-file */
import fs from 'fs-extra';
import path from 'path';
import { Compiler } from 'webpack';
import ManifestPlugin from 'webpack-manifest-plugin';
import { camelCase } from 'change-case';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import VirtualModulePlugin from 'webpack-virtual-modules';
import { compile } from 'ejs';
import { knapsackEvents, EVENTS, KnapsackEventsData } from './events';
import * as log from '../cli/log';
import { KnapsackRendererBase } from './renderer-base';
import {
KnapsackTemplateRendererBase,
KnapsackConfig,
KsTemplateRendererWrapHtmlParams,
} from '../schemas/knapsack-config';
import {
KnapsackPatternTemplate,
KnapsackTemplateDemo,
} from '../schemas/patterns';
// should root be `dataDir` or CWD?
const entryPath = path.join(process.cwd(), 'ks-entry.js');
const ksBootstrapEntryPath = path.join(process.cwd(), 'ks-boostrap.js');
function upperCamelCase(str: string): string {
const cased = camelCase(str);
return cased.charAt(0).toUpperCase() + cased.slice(1);
}
const renderEntryTemplate = compile(
fs.readFileSync(
path.join(__dirname, './templates/renderer-webpack-base-entry.ejs'),
'utf-8',
),
{
filename: 'renderer-webpack-base-entry.ejs',
async: false,
},
);
interface KsEntryItem {
id: string;
path: string;
name: string;
alias?: string;
}
interface KsEntryTemplate extends KsEntryItem {
demos?: KsEntryItem[];
}
type KsEntryData = {
patterns: {
id: string;
templates: KsEntryTemplate[];
}[];
extras?: KsEntryItem[];
};
function getEntryString({
entryData: { patterns, extras = [] },
format,
}: {
entryData: KsEntryData;
format?: boolean;
}): string {
let entryString = renderEntryTemplate({ patterns, extras });
if (format) {
entryString = KnapsackRendererBase.formatCode({
code: entryString,
language: 'ts',
});
}
return entryString;
}
type KsWebpackEntriesManifest = {
entrypoints: {
[entryId: string]: string[];
};
};
export class KnapsackRendererWebpackBase extends KnapsackRendererBase
implements KnapsackTemplateRendererBase {
webpack: typeof import('webpack');
webpackConfig: import('webpack').Configuration;
entryData: KsEntryData;
publicPath: string;
language: string;
restartWebpackWatch: () => void;
webpackCompiler: import('webpack').Compiler;
entriesManifest: KsWebpackEntriesManifest;
webpackWatcher: import('webpack').Compiler.Watching;
patterns: import('@knapsack/app/src/server/patterns').Patterns;
virtualModules: VirtualModulePlugin;
extraScripts: string[];
private webpackEntryPathsManifest: string;
constructor({
id,
extension,
language,
webpackConfig,
webpack,
extraScripts = [],
}: {
id: string;
extension: string;
language: string;
webpackConfig: import('webpack').Configuration;
webpack: typeof import('webpack');
extraScripts?: string[];
}) {
super({
id,
extension,
language,
});
this.webpack = webpack;
this.webpackConfig = webpackConfig;
this.extraScripts = extraScripts;
}
createWebpackCompiler(entryData: KsEntryData) {
const { plugins = [] } = this.webpackConfig;
const { patterns, extras } = entryData;
const entryString = getEntryString({
entryData: { patterns, extras },
format: true,
});
// for debug, upcomment:
// fs.writeFileSync(path.join(process.cwd(), 'ks-entry--fyi.js'), entryString);
const virtualWebpackEntries = {
[entryPath]: entryString,
[ksBootstrapEntryPath]: `
import knapsack from '${entryPath}';
//console.log('Multi Entry Knapsack!', { knapsack });
window.knapsack = knapsack;
// create and dispatch the event
const ksReadyEvent = new CustomEvent('KsRendererClientManifestReady', {
detail: knapsack,
});
document.dispatchEvent(ksReadyEvent);
`,
};
this.virtualModules = new VirtualModulePlugin(virtualWebpackEntries);
const newWebpackConfig: import('webpack').Configuration = {
optimization: {
minimize: process.env.NODE_ENV === 'production',
runtimeChunk: 'single',
splitChunks: {
name: true,
chunks: 'all',
maxInitialRequests: 8,
maxAsyncRequests: 20,
maxSize: 300000,
},
},
...this.webpackConfig,
entry: {
main: [...this.extraScripts, ...Object.keys(virtualWebpackEntries)],
},
mode:
process.env.NODE_ENV === 'production' ? 'production' : 'development',
externals: {
react: 'React',
'react-dom': 'ReactDOM',
},
output: {
filename: '[name].bundle.[hash].js',
path: this.outputDir,
publicPath: this.publicPath,
chunkFilename: '[name].chunk.[hash].js',
},
plugins: [
...plugins,
this.virtualModules,
new ManifestPlugin({
writeToFileEmit: true,
generate: (seed, files, entrypoints) => {
// Tapping into this so we can get the actual entrypoints: if `entry.main` is the key, then a `string[]` of all the JS/CSS needed for it is desired. The original `manifest.json` made didn't work as it contained a single `string` & only described the output, not all the CSS/JS needed to make that entrypoint work. Originally, we had an entrypoint per React component to render, but now we have a single entrypoint that has a bunch of async functions to fetch any React Component needed.
const data: KsWebpackEntriesManifest = { entrypoints: {} };
Object.keys(entrypoints).forEach(id => {
const assets = entrypoints[id];
data.entrypoints[id] = assets.map(asset =>
encodeURI(path.join(this.publicPath, asset)),
);
});
fs.writeFileSync(
this.webpackEntryPathsManifest,
JSON.stringify(data),
);
// the original default "generate the manfiest" function
return files.reduce(
(manifest, { name, path: filePath }) => ({
...manifest,
[name]: filePath,
}),
seed,
);
},
}),
],
};
this.webpackCompiler = this.webpack(newWebpackConfig);
log.verbose(
'New Webpack Config and Compiler created',
null,
this.logPrefix,
);
}
createWebpackEntryDataFromPatterns(
patterns: import('@knapsack/app/src/server/patterns').Patterns,
): KsEntryData {
const entryData: KsEntryData = { patterns: [], extras: [] };
patterns.getPatterns().forEach(pattern => {
const patternTemplates: KsEntryTemplate[] = [];
pattern.templates
.filter(t => t.templateLanguageId === this.id)
.forEach(template => {
const templateDemos: KsEntryItem[] = [];
const absPath = patterns.getTemplateAbsolutePath({
patternId: pattern.id,
templateId: template.id,
});
const demos = Object.values(template?.demosById ?? {});
if (demos) {
demos
.filter(KnapsackRendererWebpackBase.isTemplateDemo)
.forEach(demo => {
if (demo?.templateInfo?.path) {
const demoAbsPath = patterns.getTemplateDemoAbsolutePath({
patternId: pattern.id,
templateId: template.id,
demoId: demo.id,
});
const entryItem: KsEntryItem = {
id: demo.id,
path: demoAbsPath,
alias: demo.templateInfo.alias,
name: this.getReactName({
pattern,
template,
demo,
}),
};
templateDemos.push(entryItem);
}
});
}
const entryItem: KsEntryItem = {
id: template.id,
path: absPath,
alias: template.alias,
name: this.getReactName({ pattern, template }),
};
patternTemplates.push({ ...entryItem, demos: templateDemos });
});
entryData.patterns.push({
id: pattern.id,
templates: patternTemplates,
});
});
return {
patterns: entryData.patterns,
extras: entryData.extras,
};
}
getReactName({
pattern,
template,
demo,
}: {
pattern: KnapsackPattern;
template: KnapsackPatternTemplate;
demo?: KnapsackTemplateDemo;
}): string {
const pId = pattern.id;
const tId = template.id;
if (demo) {
if (!KnapsackRendererWebpackBase.isTemplateDemo(demo)) {
log.inspect(demo, 'demo');
throw new Error(`Can't run getReactName on non-template demos`);
}
const { alias } = demo.templateInfo;
const isNamedImport = alias && alias !== 'default';
return upperCamelCase(
`${pId} ${tId} ${isNamedImport ? alias : ''} Demo ${demo.id}`,
);
}
const { alias, templateLanguageId } = template;
const isNamedImport = alias && alias !== 'default';
const isOnlyLanguage =
pattern.templates.filter(t => t.templateLanguageId === templateLanguageId)
?.length === 1;
if (isNamedImport) {
const isOnlyWithThisNamedImport =
pattern.templates.filter(t => t.alias === alias)?.length === 1;
return isOnlyWithThisNamedImport
? alias
: upperCamelCase(`${alias} ${tId}`);
}
return upperCamelCase(isOnlyLanguage ? pId : `${pId} ${tId}`);
}
async init(opt: {
config: KnapsackConfig;
patterns: import('@knapsack/app/src/server/patterns').Patterns;
cacheDir: string;
}): Promise<void> {
await super.init(opt);
this.publicPath = `/${path.relative(this.cacheDir, this.outputDir)}/`;
this.patterns = opt.patterns;
this.webpackEntryPathsManifest = path.join(
this.outputDir,
'manifest--entries.json',
);
}
setManifest() {
return fs
.readFile(this.webpackEntryPathsManifest, 'utf8')
.then(manifestString => JSON.parse(manifestString))
.then(manifest => {
this.entriesManifest = manifest;
})
.catch(error => {
log.error('setManifest()', error);
throw new Error(
`Error getting WebPack manifest--entries.json file. ${error.message}`,
);
});
}
setManifestSync() {
try {
const manifestString = fs.readFileSync(
this.webpackEntryPathsManifest,
'utf8',
);
const manifest = JSON.parse(manifestString);
this.entriesManifest = manifest;
} catch (error) {
log.error('setManifest()', error);
throw new Error(
`Error getting WebPack manifest--entries.json file. ${error.message}`,
);
}
}
getWebPackEntryPath(id: string): string[] {
if (!this.entriesManifest) this.setManifestSync();
if (!this.entriesManifest) {
throw new Error(
`Webpack has not been built yet, cannot access id "${id}"`,
);
}
const result = this.entriesManifest?.entrypoints[id];
if (!result) {
const msg = `Could not find webpack entry "${id}".`;
console.error(
`Possible ids: "${Object.keys(
this.entriesManifest?.entrypoints ?? {},
)}"`,
);
throw new Error(msg);
}
return result;
}
build(): Promise<void> {
return new Promise((resolve, reject) => {
this.entryData = this.createWebpackEntryDataFromPatterns(this.patterns);
this.createWebpackCompiler(this.entryData);
this.webpackCompiler.run(async (err, stats) => {
if (err || stats.hasErrors()) {
log.error(stats.toString(), err, this.logPrefix);
reject();
return;
}
await this.setManifest();
resolve();
});
});
}
webpackWatch(): import('webpack').Compiler.Watching {
log.verbose('Starting Webpack watch...', null, this.logPrefix);
const watchOptions: import('webpack').Compiler.WatchOptions = {};
return this.webpackCompiler.watch(
watchOptions,
async (err: Error, stats: import('webpack').Stats) => {
if (err || stats.hasErrors()) {
log.error(stats.toString(), err, this.logPrefix);
return;
}
await this.setManifest();
log.info('Webpack recompiled', null, this.logPrefix);
super.onChange({
path: '',
}); // @todo get path of file changed from `stats` and pass it in here
},
);
}
async watch({ templatePaths }: { templatePaths: string[] }) {
await super.watch({ templatePaths });
this.entryData = this.createWebpackEntryDataFromPatterns(this.patterns);
this.createWebpackCompiler(this.entryData);
knapsackEvents.on(
EVENTS.PATTERNS_DATA_READY,
(allPatterns: KnapsackEventsData['PATTERNS_DATA_READY']) => {
const entryData = this.createWebpackEntryDataFromPatterns(
this.patterns,
);
if (JSON.stringify(this.entryData) !== JSON.stringify(entryData)) {
// @todo enure the new data from `entryData` does trigger the proper re-render w/o restarting WebPack. This event is usually fired when a new pattern template or template demo is added
this.entryData = entryData;
const entryString = getEntryString({
entryData: this.entryData,
format: true,
});
this.virtualModules.writeModule(entryPath, entryString);
// Old "restart WebPack watcher" code below:
// this.createWebpackCompiler(entryData);
// if (this.restartWebpackWatch) {
// this.restartWebpackWatch();
// }
}
},
);
this.restartWebpackWatch = () => {
log.verbose('Restarting Webpack Watch', null, this.logPrefix);
this.webpackWatcher.close(() => {
log.verbose('Restarted Webpack Watch', null, this.logPrefix);
this.webpackWatcher = this.webpackWatch();
});
};
this.webpackWatcher = this.webpackWatch();
}
// eslint-disable-next-line class-methods-use-this
onChange() {
// overwriting so we can call event after webpack compiles
}
}