UNPKG

@knapsack/app

Version:

Build Design Systems on top of knapsack, by Basalt

489 lines (430 loc) • 14.4 kB
/* 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 } }