import fs from "fs-extra" ; import os from "os" ; import path from "path" ; import globs from "globs" ; import { matchAll } from "./helpers" ; import { BundleResult , FileRegistry , ImportData } from "./contracts" ; const IMPORT_PATTERN = /@import\s+['"](.+)['"];/g ; const COMMENT_PATTERN = /\/\/.*$/gm ; const MULTILINE_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g ; const DEFAULT_FILE_EXTENSION = ".scss" ; const ALLOWED_FILE_EXTENSIONS = [ ".scss" , ".css" ] ; const NODE_MODULES = "node_modules" ; const TILDE = "~" ; export class Bundler { private usedImports : { [ key ] : number ; } = { } ; private importsByFile : { [ key ] : BundleResult [ ] ; } = { } ; constructor ( private fileRegistry = { } , private readonly projectDirectory ? ) { } public async bundle ( file , dedupeGlobs = [ ] , includePaths = [ ] , ignoredImports = [ ] ) { try { if ( this . projectDirectory != null ) { file = path . resolve ( this . projectDirectory , file ) ; } await fs . access ( file ) ; const contentPromise = fs . readFile ( file , "utf-8" ) ; const dedupeFilesPromise = this . globFilesOrEmpty ( dedupeGlobs ) ; const [ content , dedupeFiles ] = await Promise . all ( [ contentPromise , dedupeFilesPromise ] ) ; const ignoredImportsRegEx = ignoredImports . map ( ignoredImport => new RegExp ( ignoredImport ) ) ; return this . _bundle ( file , content , dedupeFiles , includePaths , ignoredImportsRegEx ) ; } catch { return { filePath : file , found : false } ; } } private isExtensionExists ( importName ) { return ALLOWED_FILE_EXTENSIONS . some ( extension => importName . indexOf ( extension ) !== - 1 ) ; } private async _bundle ( filePath , content , dedupeFiles , includePaths , ignoredImports ) { content = this . removeImportsFromComments ( content ) ; filePath = path . resolve ( filePath ) ; const dirname = path . dirname ( filePath ) ; if ( this . fileRegistry [ filePath ] == null ) { this . fileRegistry [ filePath ] = content ; } const importsPromises = matchAll ( content , IMPORT_PATTERN ) . map ( async ( match ) => { let importName = match [ 1 ] ; if ( ! this . isExtensionExists ( importName ) ) { importName += DEFAULT_FILE_EXTENSION ; } const ignored = ignoredImports . findIndex ( ignoredImportRegex => ignoredImportRegex . test ( importName ) ) !== - 1 ; let fullPath ; const tilde = importName . startsWith ( TILDE ) ; if ( tilde && this . projectDirectory != null ) { importName = `./${ NODE_MODULES }/${ importName . substr ( TILDE . length , importName . length ) }` ; fullPath = path . resolve ( this . projectDirectory , importName ) ; } else { fullPath = path . resolve ( dirname , importName ) ; } const importData = { importString : match [ 0 ] , tilde : tilde , path : importName , fullPath : fullPath , found : false , ignored : ignored } ; await this . resolveImport ( importData , includePaths ) ; return importData ; } ) ; const imports = await Promise . all ( importsPromises ) ; const bundleResult = { filePath : filePath , found : true } ; const shouldCheckForDedupes = dedupeFiles != null && dedupeFiles . length > 0 ; const currentImports = [ ] ; for ( const imp of imports ) { let contentToReplace ; let currentImport ; if ( ! imp . found ) { currentImport = { filePath : imp . fullPath , tilde : imp . tilde , found : false , ignored : imp . ignored } ; } else if ( this . usedImports [ imp . fullPath ] == null ) { this . usedImports [ imp . fullPath ] = 1 ; const impContent = this . fileRegistry [ imp . fullPath ] == null ? await fs . readFile ( imp . fullPath , "utf-8" ) : this . fileRegistry [ imp . fullPath ] as string ; const bundledImport = await this . _bundle ( imp . fullPath , impContent , dedupeFiles , includePaths , ignoredImports ) ; this . fileRegistry [ imp . fullPath ] = bundledImport . bundledContent ; currentImport = bundledImport ; } else { if ( this . usedImports != null ) { this . usedImports [ imp . fullPath ] ++ ; } let childImports = [ ] ; if ( this . importsByFile != null ) { childImports = this . importsByFile [ imp . fullPath ] ; } currentImport = { filePath : imp . fullPath , tilde : imp . tilde , found : true , imports : childImports } ; } if ( imp . ignored ) { if ( this . usedImports [ imp . fullPath ] > 1 ) { contentToReplace = "" ; } else { contentToReplace = imp . importString ; } } else { contentToReplace = this . fileRegistry [ imp . fullPath ] ; if ( contentToReplace == null ) { contentToReplace = `/*** IMPORTED FILE NOT FOUND ***/${ os . EOL }${ imp . importString }/*** --- ***/` ; } if ( shouldCheckForDedupes && this . usedImports != null ) { const timesUsed = this . usedImports [ imp . fullPath ] ; if ( dedupeFiles . indexOf ( imp . fullPath ) !== - 1 && timesUsed != null && timesUsed > 1 ) { contentToReplace = "" ; currentImport . deduped = true ; } } } content = this . replaceLastOccurance ( content , imp . importString , contentToReplace ) ; currentImports . push ( currentImport ) ; } bundleResult . bundledContent = content ; bundleResult . imports = currentImports ; if ( this . importsByFile != null ) { this . importsByFile [ filePath ] = currentImports ; } return bundleResult ; } private replaceLastOccurance ( content , importString , contentToReplace ) { const index = content . lastIndexOf ( importString ) ; return content . slice ( 0 , index ) + content . slice ( index ) . replace ( importString , contentToReplace ) ; } private removeImportsFromComments ( text ) { const patterns = [ COMMENT_PATTERN , MULTILINE_COMMENT_PATTERN ] ; for ( const pattern of patterns ) { text = text . replace ( pattern , x => x . replace ( IMPORT_PATTERN , "" ) ) ; } return text ; } private async resolveImport ( importData , includePaths ) { if ( this . fileRegistry [ importData . fullPath ] ) { importData . found = true ; return importData ; } try { await fs . access ( importData . fullPath ) ; importData . found = true ; } catch ( error ) { const underscoredDirname = path . dirname ( importData . fullPath ) ; const underscoredBasename = path . basename ( importData . fullPath ) ; const underscoredFilePath = path . join ( underscoredDirname , `_${ underscoredBasename }` ) ; try { await fs . access ( underscoredFilePath ) ; importData . fullPath = underscoredFilePath ; importData . found = true ; } catch ( underscoreErr ) { if ( includePaths . length ) { importData . fullPath = path . resolve ( includePaths [ 0 ] , importData . path ) ; const remainingIncludePaths = includePaths . slice ( 1 ) ; return this . resolveImport ( importData , remainingIncludePaths ) ; } } } return importData ; } private async globFilesOrEmpty ( globsList ) { return new Promise < string [ ] > ( ( resolve , reject ) => { if ( globsList == null || globsList . length === 0 ) { resolve ( [ ] ) ; return ; } globs ( globsList , ( error , files ) => { if ( error != null ) { reject ( error ) ; } const fullPaths = files . map ( file => path . resolve ( file ) ) ; resolve ( fullPaths ) ; } ) ; } ) ; } }