/* eslint-disable */ import { Node } from 'acorn' import { readdir, readFile, statSync } from 'fs' import glob from 'glob' import { dirname, join, relative } from 'path' import { promisify } from 'util' import { Compiler as WebpackCompiler } from 'webpack' type WebpackModule = any module.exports = getDemoPlugin // import ConstDependency from 'webpack/lib/dependencies/ConstDependency' // import ModuleDependency from 'webpack/lib/dependencies/ModuleDependency' // import NormalModule from 'webpack/lib/NormalModule' // 为什么不直接在这里import而是让用户传进来? // 因为node_modules中可能存在多份webpack,这样import到的webpack可能不是实际正在运行的 function getDemoPlugin({ ConstDependency, ModuleDependency, NormalModule }) { const readFileAsync = promisify(readFile) const readDirAsync = promisify(readdir) // breadth first search // return [modules used by this demo] async function collectDemoModules( entryModule: WebpackModule, shouldBeCollected: NonNullable ) { // modules that need to be visited const BFSQueue: WebpackModule[] = [entryModule] // modules that have been visited const BFSSet = new Set() while (BFSQueue.length > 0) { const workingModule = BFSQueue.shift()! BFSSet.add(workingModule) const deps = workingModule.dependencies .filter(dependency => dependency instanceof ModuleDependency) .map(dependency => dependency.module) // async filter for `deps` const shouldCollect = await Promise.all( deps.map( async module => module instanceof NormalModule && (await shouldBeCollected(module, entryModule)) ) ) const newModules = deps.filter( (module, index) => shouldCollect[index] && !BFSSet.has(module) ) BFSQueue.push(...newModules) } return Array.from(BFSSet) } function removeDuplicateModules(modules: WebpackModule[]) { const modulePaths = new Set() const result: WebpackModule[] = [] modules.forEach(m => { if (modulePaths.has(m.resource)) { return } result.push(m) modulePaths.add(m.resource) }) return result } async function findProjectTemplatePath(startDir: string) { let current = startDir while (true) { const list = await readDirAsync(current) const index = list.indexOf('.demoProjectTemplate') if (index !== -1) { const foundPath = join(current, '.demoProjectTemplate') if (!statSync(foundPath).isDirectory()) { throw new Error(`${foundPath} is not a directory`) } return foundPath } if (current === '/') { return null } current = join(current, '../') } } async function findPackageRoot(startDir: string) { let current = startDir while (true) { const list = await readDirAsync(current) const index = list.indexOf('package.json') if (index !== -1) { return current } if (current === '/') { return null } current = join(current, '../') } } async function loadProjectTemplate(entryPath: string) { const projectTemplatePath = await findProjectTemplatePath( dirname(entryPath) ) if (!projectTemplatePath) { throw new Error( `can not found .demoProjectTemplate upwards. demo entry: ${entryPath}` ) } const files = await new Promise((res, rej) => { glob( '**/*', { cwd: projectTemplatePath, nodir: true, dot: true }, (err, files2) => { if (err) { rej(err) } res(files2) } ) }) const projectFiles = files.filter(f => f !== 'projectTemplateConfig.js') if (files.length - 1 !== projectFiles.length) { // There is no "projectTemplateConfig.js" in 'files' throw new Error( `there should be a "projectTemplateConfig.js" in the project template: ${projectTemplatePath}` ) } const projectTemplateConfig = require(join( projectTemplatePath, 'projectTemplateConfig.js' )) const loadedFiles: { [key: string]: string } = {} await Promise.all( projectFiles.map(async projectFile => { const fileContent = await readFileAsync( join(projectTemplatePath, projectFile), 'utf8' ) loadedFiles[projectFile] = fileContent }) ) return { projectTemplateFiles: loadedFiles, projectTemplateConfig } } /** * merge source tree with projectTemplate. * * example: * * demo files in the source: * - folder * - b.js(entryModule) * - c.js * - a.js * * projectTemplate: * - index.js * - folder(baseDir) * * mergeDemoModulesIntoProjectTemplate will generate this file tree: * - index.js * - folder * - b.js * - c.js * - a.js * * Algorithm: * first put entryModule(b.js) into baseDir. * then put other files(c.js, a.js) into projectTemplate, **keeping the same relative path with b.js** */ async function mergeDemoModulesIntoProjectTemplate( demoModules: WebpackModule[], entryModule: WebpackModule ) { const demoBaseDir = dirname(entryModule.resource) const projectTemplate = await loadProjectTemplate(entryModule.resource) const mergedProjectFiles = { ...projectTemplate.projectTemplateFiles, } let entryModulePathInMergedProject await Promise.all( demoModules.map(async demoModule => { // demoModule.resource could be `path/to/module?query=value` const modulePath = demoModule.resource.split('?')[0] const relativePath = relative(demoBaseDir, modulePath) const pathInMergedProject = join( projectTemplate.projectTemplateConfig.baseDir, relativePath ) if (demoModule === entryModule) { entryModulePathInMergedProject = pathInMergedProject } mergedProjectFiles[pathInMergedProject] = await readFileAsync( modulePath, 'utf8' ) }) ) mergedProjectFiles['demoMeta.json'] = JSON.stringify({ entryPath: entryModulePathInMergedProject, }) if ( typeof projectTemplate.projectTemplateConfig.afterMerge === 'function' ) { projectTemplate.projectTemplateConfig.afterMerge( mergedProjectFiles, entryModulePathInMergedProject ) } return mergedProjectFiles } interface IDemoPluginParam { shouldBeCollected?: ( module: WebpackModule, entryModule: WebpackModule ) => boolean | Promise } class DemoPlugin { private shouldBeCollected: NonNullable< IDemoPluginParam['shouldBeCollected'] > constructor({ shouldBeCollected: shouldBeCollectedArg = defaultShouldBeCollected, }: IDemoPluginParam = {}) { this.shouldBeCollected = shouldBeCollectedArg } public apply(compiler: WebpackCompiler) { compiler.hooks.normalModuleFactory.tap('testPlugin', factory => { factory.hooks.parser .for('javascript/auto') .tap('testPlugin', parser => { parser.hooks.expression .for('__demo_loader_placeholder__') .tap('testPlugin', (expr: Node) => { // we will replace this content with correct value later const dep = new ConstDependency( '"placeholder for demoPlugin to inject value"', expr.range, false ) dep.loc = expr.loc Object.defineProperty(dep, '__inject_demo_info__', { enumerable: false, value: true, }) parser.state.current.addDependency(dep) return true }) }) }) compiler.hooks.compilation.tap('testPlugin', compilation => { compilation.hooks.finishModules.tapPromise( 'testPlugin', async (modules: any) => { await Promise.all( modules.map(async module => { const { loaders } = module if ( loaders && loaders.find(({ loader }) => /\/demoLoader\.js$/.test(loader)) ) { // this module is an entry of demo const entryPath = module.resource const injectToDependency = module.dependencies.find( dep => (dep as any).__inject_demo_info__ ) if (!injectToDependency) { // this may be caused by using const enum with @babel/plugin-transform-typescript // https://babeljs.io/docs/en/babel-plugin-transform-typescript.html // tslint:disable-next-line: no-console console.error( `demo-plugin: can't find injectToDependency in ${entryPath}` ) return } const demoModules = removeDuplicateModules( await collectDemoModules(module, this.shouldBeCollected) ) const mergedProject = await mergeDemoModulesIntoProjectTemplate( demoModules, module ) ;(injectToDependency as any).expression = JSON.stringify( mergedProject ) } }) ) } ) }) } } // map from module path => module package path const packageRootCache = new Map() async function defaultShouldBeCollected( module: WebpackModule, entryModule: WebpackModule ) { const entryPath = entryModule.resource let packageRoot: string | null if (packageRootCache.has(entryPath)) { packageRoot = packageRootCache.get(entryPath) || null } else { packageRoot = await findPackageRoot(dirname(entryModule.resource)) packageRootCache.set(entryPath, packageRoot) } const relativePath = relative(packageRoot || '', module.resource) if ( // outside of current package root relativePath.startsWith('../') || // in build directories relativePath.startsWith('lib/') || relativePath.startsWith('es/') || relativePath.startsWith('node_modules/') ) { return false } // console.log('collect module', relativePath); // debugger; return true } return DemoPlugin }