diff --git a/package.json b/package.json index b2c9fae..5682e9f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "execa": "^5.0.0" }, "devDependencies": { - "@angular/compiler": "^11.0.5", "@angular/compiler-cli": "^11.0.5", "snowpack": "^2.18.5", "typescript": "4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9a05a..14675c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,15 +1,13 @@ dependencies: execa: 5.0.0 devDependencies: - '@angular/compiler': 11.0.5 - '@angular/compiler-cli': 11.0.5_ae04eea250591f41b901d33a8893b81f + '@angular/compiler-cli': 11.0.5_typescript@4.0.5 snowpack: 2.18.5 typescript: 4.0.5 lockfileVersion: 5.2 packages: - /@angular/compiler-cli/11.0.5_ae04eea250591f41b901d33a8893b81f: + /@angular/compiler-cli/11.0.5_typescript@4.0.5: dependencies: - '@angular/compiler': 11.0.5 '@babel/core': 7.12.10 '@babel/types': 7.12.12 canonical-path: 1.0.0 @@ -35,12 +33,6 @@ packages: typescript: '>=4.0 <4.1' resolution: integrity: sha512-1EbnDdK2Em9xpnbLCjw+9w2F0I6gl5AS6QAn03ztYX9ZooNzCeC6sT8qghzrNTFTV89nyIoAqyMtgcLS6udVkg== - /@angular/compiler/11.0.5: - dependencies: - tslib: 2.0.3 - dev: true - resolution: - integrity: sha512-japxEn07P9z9FnW8ii+M5DIfgRAGNxl6QNQWKBkNo5ytN6iCAB7pVbJI0vn1AUT9TByV3+xDW/FNuoSuzsnX3w== /@babel/code-frame/7.12.11: dependencies: '@babel/highlight': 7.10.4 @@ -2265,7 +2257,6 @@ packages: resolution: integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== specifiers: - '@angular/compiler': ^11.0.5 '@angular/compiler-cli': ^11.0.5 execa: ^5.0.0 snowpack: ^2.18.5 diff --git a/src/compile.ts b/src/compile.ts index ba0d0cf..a7a26d1 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -16,149 +16,14 @@ export interface CacheEntry { content?: string; } -export interface WatchCompilationResult extends ng.PerformCompilationResult { +export interface RecompileResult extends ng.PerformCompilationResult { recompiledFiles: string[]; } -export type RecompileFunction = ( - fileName: string, - src: string -) => WatchCompilationResult | null; - export type RecompileFunctionAsync = ( fileName: string, src: string -) => Promise; - -export const compile = ({ - rootNames, - compilerHost, - compilerOptions, -}: CompileArgs): ng.PerformCompilationResult => { - const compilationResult = ng.performCompilation({ - rootNames, - host: compilerHost, - options: compilerOptions, - }); - return compilationResult; -}; - -export const watchCompile = ({ - rootNames, - compilerHost, - compilerOptions, -}: CompileArgs) => { - const compiledFiles = new Set(); - const fileCache = new Map(); - const modifiedFile = new Set(); - let cachedProgram: ng.Program | undefined; - - const getCacheEntry = (fileName: string) => { - fileName = path.normalize(fileName); - let entry = fileCache.get(fileName); - if (!entry) { - entry = {}; - fileCache.set(fileName, entry); - } - return entry; - }; - - // Setup compilerHost to use cache - const oriWriteFile = compilerHost.writeFile; - compilerHost.writeFile = ( - fileName, - data, - writeByteOrderMark, - onError, - sourceFiles - ) => { - const srcRelativePath = path.relative( - path.resolve(compilerOptions.outDir!), - path.resolve(fileName) - ); - compiledFiles.add(srcRelativePath); - return oriWriteFile( - fileName, - data, - writeByteOrderMark, - onError, - sourceFiles - ); - }; - const oriFileExists = compilerHost.fileExists; - compilerHost.fileExists = (fileName) => { - const cache = getCacheEntry(fileName); - if (cache.exists === null || cache.exists === undefined) - cache.exists = oriFileExists(fileName); - return cache.exists; - }; - const oriGetSourceFile = compilerHost.getSourceFile; - compilerHost.getSourceFile = (fileName, languageVersion) => { - const cache = getCacheEntry(fileName); - if (!cache.sf) cache.sf = oriGetSourceFile(fileName, languageVersion); - return cache.sf; - }; - const oriReadFile = compilerHost.readFile; - compilerHost.readFile = (fileName) => { - const cache = getCacheEntry(fileName); - if (!cache.content) cache.content = oriReadFile(fileName); - return cache.content; - }; - // Read resource is a optional function, - // it has priority over readFile when loading resources (html/css), - // async file processing will require a custom performCompilation to run `program.loadNgStuctureAsync()` - const oriReadResource = compilerHost.readResource; - if (oriReadResource) - compilerHost.readResource = (fileName) => { - const cache = getCacheEntry(fileName); - if (!cache.content) cache.content = oriReadResource(fileName) as string; - return cache.content; - }; - - compilerHost.getModifiedResourceFiles = () => { - return modifiedFile; - }; - - // Do first compile - const firstCompilation = compile({ - rootNames, - compilerHost, - compilerOptions, - }); - cachedProgram = firstCompilation.program; - - const recompile: RecompileFunction = (fileName: string, src: string) => { - // perhaps this function need debouncing like in perform_watch.ts - fileName = path.normalize(fileName); - fileCache.delete(fileName); - const compiledFilePath = path.relative( - path.resolve(src), - path.resolve(fileName) - ); - if (!compiledFiles.has(compiledFilePath)) { - modifiedFile.add(fileName); - compiledFiles.clear(); - const oldProgram = cachedProgram; - cachedProgram = undefined; - const recompileResult = ng.performCompilation({ - rootNames, - host: compilerHost, - options: compilerOptions, - oldProgram, - }); - cachedProgram = recompileResult.program; - modifiedFile.clear(); - return { - program: recompileResult.program, - emitResult: recompileResult.emitResult, - recompiledFiles: [...compiledFiles], - diagnostics: recompileResult.diagnostics, - }; - } - return null; - }; - return { firstCompilation, recompile }; -}; +) => Promise; /** * Based on `@angular/compiler-cli.performCompilation()` @@ -289,34 +154,27 @@ export const watchCompileAsync = async ({ // perhaps this function need debouncing like in perform_watch.ts fileName = path.normalize(fileName); fileCache.delete(fileName); - const compiledFilePath = path.relative( - path.resolve(src), - path.resolve(fileName) + modifiedFile.add(fileName); + compiledFiles.clear(); + const oldProgram = cachedProgram; + cachedProgram = undefined; + const recompileResult = await performCompilationAsync( + { + rootNames, + compilerHost, + compilerOptions, + oldProgram, + }, + false ); - if (!compiledFiles.has(compiledFilePath)) { - modifiedFile.add(fileName); - compiledFiles.clear(); - const oldProgram = cachedProgram; - cachedProgram = undefined; - const recompileResult = await performCompilationAsync( - { - rootNames, - compilerHost, - compilerOptions, - oldProgram, - }, - false - ); - cachedProgram = recompileResult.program; - modifiedFile.clear(); - return { - program: recompileResult.program, - emitResult: recompileResult.emitResult, - recompiledFiles: [...compiledFiles], - diagnostics: recompileResult.diagnostics, - }; - } - return null; + cachedProgram = recompileResult.program; + modifiedFile.clear(); + return { + program: recompileResult.program, + emitResult: recompileResult.emitResult, + recompiledFiles: [...compiledFiles], + diagnostics: recompileResult.diagnostics, + }; }; return { firstCompilation, recompile }; }; diff --git a/src/compilerService.ts b/src/compilerService.ts new file mode 100644 index 0000000..6043725 --- /dev/null +++ b/src/compilerService.ts @@ -0,0 +1,266 @@ +import { AngularJsonWrapper, readAngularJson } from './configParser'; +import * as ng from '@angular/compiler-cli'; +import fs, { promises as fsp } from 'fs'; +import path from 'path'; +import execa from 'execa'; +import { + compileAsync, + RecompileFunctionAsync, + watchCompileAsync, +} from './compile'; +import { + TypeCheckWorker, + createTypeCheckWorker, + TypeCheckArgs, +} from './typeCheckWorker'; +import { + getTSDiagnosticErrorFile, + getTSDiagnosticErrorInFile, + tsFormatDiagnosticHost, +} from './typeCheck'; + +interface BuildStatus { + built: boolean; + building: boolean; + buildReadyCallback: VoidFunction[]; +} + +interface BuiltJSFile { + code: string; + map?: string; +} + +type TypeCheckErrorListener = (diagnostic: ng.Diagnostics) => void; + +export class AngularCompilerService { + private _angularConfig: AngularJsonWrapper; + private _ngCompilerHost: ng.CompilerHost; + private _ngConfiguration: ng.ParsedConfiguration; + private get _ngCompilerOptions(): ng.CompilerOptions { + return this._ngConfiguration.options; + } + private _builtFiles = new Map(); + private _buildStatus: BuildStatus = { + built: false, + building: false, + buildReadyCallback: [], + }; + private _recompileFunction?: RecompileFunctionAsync; + private _lastCompilationResult?: ng.PerformCompilationResult; + private _typeCheckErrorListeners = new Map(); + private _typeCheckErrorListenerId = 0; + private _typeCheckWorker?: TypeCheckWorker; + private _lastTypeCheckResult: ng.Diagnostics = []; + + constructor( + angularJson: string, + private sourceDirectory: string, + private ngccTargets: string[], + private angularProject?: string + ) { + this._angularConfig = readAngularJson(angularJson); + const { tsConfig } = this._angularConfig.getResolvedFilePaths( + angularProject + ); + this._ngConfiguration = ng.readConfiguration(tsConfig); + this._ngCompilerHost = this.configureCompilerHost( + ng.createCompilerHost({ options: this._ngCompilerOptions }) + ); + } + private async runNgcc(targets: string[]) { + for (const target of targets) { + const ngcc = execa('ngcc', ['-t', target]); + const { stdout } = await ngcc; + stdout.split('\n').forEach((line) => { + if (line !== '') console.log(`[angular] ${line}`); + }); + } + console.log( + '[angular] Try clearing snowpack development cache with "snowpack --reload" if facing errors during dev mode' + ); + } + + private configureCompilerHost(host: ng.CompilerHost): ng.CompilerHost { + host.writeFile = (fileName, contents) => { + fileName = path.relative( + path.resolve(this._ngCompilerOptions.outDir || this.sourceDirectory), + path.resolve(fileName) + ); + // Prevent multiple sourceMappingUrl as snowpack will append it if sourceMaps is enabled + contents = contents.replace(/\/\/# sourceMappingURL.*/, ''); + this._builtFiles.set(fileName, contents); + }; + host.readResource = async (fileName) => { + const contents = await fsp.readFile(fileName, 'utf-8'); + return contents; + }; + return host; + } + + private registerTypeCheckWorker() { + this._typeCheckWorker = createTypeCheckWorker(); + this._typeCheckWorker.on('message', (msg) => { + this._lastTypeCheckResult = msg; + this._typeCheckErrorListeners.forEach((listener) => listener(msg)); + }); + } + + async buildSource(watch: boolean) { + if (this._buildStatus.built) return; + else if (!this._buildStatus.built && !this._buildStatus.building) { + this._buildStatus.building = true; + const compileArgs = { + rootNames: this._ngConfiguration.rootNames, + compilerHost: this._ngCompilerHost, + compilerOptions: this._ngCompilerOptions, + }; + await this.runNgcc(this.ngccTargets); + if (watch) { + this.registerTypeCheckWorker(); + const result = await watchCompileAsync(compileArgs); + this._recompileFunction = result.recompile; + this._lastCompilationResult = result.firstCompilation; + } else { + this._lastCompilationResult = await compileAsync(compileArgs); + } + this._buildStatus.built = true; + this._buildStatus.building = false; + this._buildStatus.buildReadyCallback.forEach((cb) => cb()); + this._buildStatus.buildReadyCallback = []; + } else { + return new Promise((resolve) => { + this._buildStatus.buildReadyCallback.push(resolve); + }); + } + } + + async recompile(modifiedFile: string) { + if (!this._recompileFunction) + throw new Error( + 'Cannot recompile as angular was not build with watch mode enabled' + ); + else { + const recompiledResult = await this._recompileFunction( + modifiedFile, + this.sourceDirectory + ); + this._lastCompilationResult = { + diagnostics: recompiledResult.diagnostics, + emitResult: recompiledResult.emitResult, + program: recompiledResult.program, + }; + this._lastTypeCheckResult = []; + const workerMessage: TypeCheckArgs = { + action: 'run_check', + data: { + options: this._ngCompilerOptions, + rootNames: this._ngConfiguration.rootNames, + }, + }; + this._typeCheckWorker!.postMessage(workerMessage); + const recompiledFiles = recompiledResult.recompiledFiles + .filter((file) => path.extname(file) === '.js') + .map((file) => + path + .resolve(path.join(this.sourceDirectory, file)) + .replace(path.extname(file), '.ts') + ); + return { + recompiledFiles, + recompiledResult, + }; + } + } + + getBuiltFile(filePath: string): BuiltJSFile | null { + filePath = path.relative( + path.resolve(this.sourceDirectory), + path.resolve(filePath) + ); + let result: BuiltJSFile | null = null; + const codeFile = filePath.replace(path.extname(filePath), '.js'); + const mapFile = filePath.replace(path.extname(filePath), '.js.map'); + if (this._builtFiles.has(codeFile)) { + result = { + code: this._builtFiles.get(codeFile)!, + }; + if (this._builtFiles.has(mapFile)) + result.map = this._builtFiles.get(mapFile); + } + return result; + } + + onTypeCheckError(listener: TypeCheckErrorListener) { + const id = this._typeCheckErrorListenerId++; + this._typeCheckErrorListeners.set(id, listener); + return id; + } + + removeTypeCheckErrorListener(id: number) { + this._typeCheckErrorListeners.delete(id); + } + + getIndexInjects() { + const { + index, + styles, + scripts, + main, + polyfills, + } = this._angularConfig.getResolvedFilePaths(this.angularProject); + const indexDir = path.dirname(index); + const injectStyles = styles.map((style) => { + const relativeUrl = path + .relative(indexDir, style) + .replace(path.extname(style), '.css'); + return ``; + }); + const injectScripts = scripts.map((script) => { + const relativeUrl = path + .relative(indexDir, script) + .replace(path.extname(script), '.js'); + return ``; + }); + const relativePolyfillsUrl = path + .relative(indexDir, polyfills) + .replace(path.extname(polyfills), '.js'); + const injectPolyfills = ``; + const relativeMainUrl = path + .relative(indexDir, main) + .replace(path.extname(main), '.js'); + const injectMain = ``; + return { + injectPolyfills, + injectMain, + injectStyles, + injectScripts, + }; + } + + getErrorInFile(filePath: string): ng.Diagnostics { + return getTSDiagnosticErrorInFile(filePath, [ + ...this._lastTypeCheckResult, + ...this._lastCompilationResult!.diagnostics, + ]); + } + + getErroredFiles(diagnostics: ng.Diagnostics): string[] { + return getTSDiagnosticErrorFile(diagnostics); + } + + formatDiagnostics(diagnostics: ng.Diagnostics) { + return ng.formatDiagnostics(diagnostics, tsFormatDiagnosticHost); + } + + get ngConfiguration() { + return this._ngConfiguration; + } + + get angularConfig() { + return this._angularConfig; + } + + getAngularCriticalFiles() { + return this._angularConfig.getResolvedFilePaths(this.angularProject); + } +} diff --git a/src/index.ts b/src/index.ts index cb3a464..7202c35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,310 +1,112 @@ -import type { - PluginLoadOptions, - SnowpackBuiltFile, - SnowpackConfig, - SnowpackPluginFactory, -} from 'snowpack'; -import * as ng from '@angular/compiler-cli'; -import ts from 'typescript'; +import { SnowpackPlugin, SnowpackPluginFactory } from 'snowpack'; +import { AngularCompilerService } from './compilerService'; import path from 'path'; -import { promises as fs } from 'fs'; -import { - compileAsync, - RecompileFunctionAsync, - watchCompileAsync, -} from './compile'; -import execa from 'execa'; -import { - AngularCriticalFiles, - AngularJsonWrapper, - readAngularJson, -} from './configParser'; -import { createWorker, TypeCheckArgs } from './typeCheckWorker'; -import { getTSDiagnosticErrorFile } from './typeCheck'; -import worker from 'worker_threads'; -export interface AngularSnowpackPluginOptions { +export interface pluginOptions { /** @default 'src' */ src?: string; - /** @default 'normal' */ - logLevel?: 'normal' | 'debug'; /** @default 'angular.json' */ angularJson?: string; /** @default defaultProject in angular.json */ angularProject?: string; /** @default [] */ ngccTargets?: string[]; + /** @default true */ + errorToBrowser?: boolean; } -/** - * Build Logic: Based on https://github.com/aelbore/rollup-plugin-ngc - * Watch Logic: Based on packages/compiler-cli/src/perform_watch.ts https://github.com/angular/angular - */ -const plugin: SnowpackPluginFactory = ( - options: SnowpackConfig, - pluginOptions?: AngularSnowpackPluginOptions +const pluginFactory: SnowpackPluginFactory = ( + snowpackConfig, + pluginOptions ) => { - const srcDir = pluginOptions?.src || 'src'; - const logLevel = pluginOptions?.logLevel || 'normal'; - const angularJsonPath = pluginOptions?.angularJson || 'angular.json'; - const ngccTargets = pluginOptions?.ngccTargets || []; + const angularJson = pluginOptions?.angularJson || 'angular.json'; const angularProject = pluginOptions?.angularProject; - const buildSourceMap = options.buildOptions.sourceMaps; - let compilerHost: ng.CompilerHost; - let parsedTSConfig: ng.CompilerOptions; - let parsedAngularJson: AngularJsonWrapper; - let angularCriticalFiles: AngularCriticalFiles; - const builtSourceFiles = new Map(); - const cwd = path.resolve(process.cwd()); - - let rootNamesCompiled: boolean = false; - let rootNamesCompiling: boolean = false; - let rootNames: string[] = []; - let recompile: RecompileFunctionAsync; - let recompiledFiles: string[] = []; - let compilationResult: ng.PerformCompilationResult; - let typeChecker: worker.Worker; - - let compilationReadyCb: (() => void)[] = []; - const isCompilationReady = () => { - return new Promise((resolve) => { - compilationReadyCb.push(resolve); - }); - }; - - const compileRootNames = async (isDev = false) => { - if (!rootNamesCompiled && !rootNamesCompiling) { - rootNamesCompiling = true; - pluginLog('Building source...'); - console.time('[angular] Source built in'); - if (isDev) { - const watchCompileResult = await watchCompileAsync({ - rootNames, - compilerHost, - compilerOptions: parsedTSConfig, - }); - recompile = watchCompileResult.recompile; - compilationResult = watchCompileResult.firstCompilation; - } else { - compilationResult = await compileAsync({ - rootNames, - compilerHost, - compilerOptions: parsedTSConfig, - }); - } - console.timeEnd('[angular] Source built in'); - for (const cb of compilationReadyCb) { - cb(); - } - compilationReadyCb = []; - rootNamesCompiling = false; - rootNamesCompiled = true; - } else if (rootNamesCompiling) { - return await isCompilationReady(); - } - }; - - const pluginLog = (contents: string): void => { - console.log(`[angular] ${contents}`); - }; - - const pluginDebug = (contents: string): void => { - if (logLevel === 'debug') pluginLog(contents); - }; - - const readAndParseTSConfig = (configFile: string): ng.ParsedConfiguration => { - configFile = path.resolve(configFile); - const parsedConfig = ng.readConfiguration(configFile); - return parsedConfig; - }; - - const tsFormatDiagnosticHost: ts.FormatDiagnosticsHost = { - getCanonicalFileName: (fileName) => fileName, - getCurrentDirectory: () => cwd, - getNewLine: () => '\n', - }; - - const runNgcc = async () => { - ngccTargets.unshift('@angular/platform-browser'); - for (const target of ngccTargets) { - const ngcc = execa('ngcc', ['-t', target]); - ngcc.stdout?.pipe(process.stdout); - await ngcc; - } - pluginLog('***************'); - pluginLog( - 'NGCC finished. Run "snowpack --reload" if strange errors regarding ivy appears during dev mode' - ); - pluginLog('***************'); - }; + const sourceDirectory = pluginOptions?.src || 'src'; + const errorToBrowser = pluginOptions?.errorToBrowser ?? true; + const ngccTargets = pluginOptions?.ngccTargets || []; + ngccTargets.unshift( + '@angular/core', + '@angular/common', + '@angular/platform-browser-dynamic' + ); + const compiler = new AngularCompilerService( + angularJson, + sourceDirectory, + ngccTargets, + angularProject + ); + const skipRecompileFiles: string[] = []; + const index = compiler.getAngularCriticalFiles().index; - return { - name: 'angular', + const plugin: SnowpackPlugin = { + name: 'angular-snowpack-plugin', knownEntrypoints: ['@angular/common'], resolve: { input: ['.ts'], - output: ['.js', '.ts'], - }, - async run() { - await runNgcc(); + output: ['.js'], }, config() { - const angularJsonReadResult = readAngularJson(angularJsonPath); - parsedAngularJson = angularJsonReadResult; - angularCriticalFiles = angularJsonReadResult.getResolvedFilePaths( - angularProject - ); - const parsedConfig = readAndParseTSConfig(angularCriticalFiles.tsConfig); - parsedTSConfig = parsedConfig.options; - rootNames = parsedConfig.rootNames.map((file) => path.resolve(file)); - compilerHost = ng.createCompilerHost({ options: parsedTSConfig }); - compilerHost.writeFile = (fileName, contents) => { - fileName = path.relative( - path.resolve(parsedTSConfig.outDir!), - path.resolve(fileName) - ); - pluginDebug(`File Compiled: ${fileName}`); - builtSourceFiles.set( - fileName, - contents.replace(/\/\/# sourceMappingURL.*/, '') // required, to prevent multiple sourceMappingUrl as snowpack will append it if sourceMaps is enabled - ); - if (!typeChecker) { - typeChecker = createWorker(); - typeChecker.on('message', (msg: ng.Diagnostics) => { - if (msg.length > 0) { - compilationResult.diagnostics = [ - ...compilationResult.diagnostics, - ...msg, - ]; - const errorFiles = getTSDiagnosticErrorFile(msg); - for (const file of errorFiles) { - recompiledFiles.push(file); - this.markChanged!(file); - } - } - }); + compiler.onTypeCheckError((diagnostic) => { + if (diagnostic.length > 0) { + if (!errorToBrowser) { + console.error( + `[angular] ${compiler.formatDiagnostics(diagnostic)}` + ); + } else { + const erroredFiles = compiler.getErroredFiles(diagnostic); + skipRecompileFiles.push(...erroredFiles); + erroredFiles.forEach((file) => this.markChanged!(file)); + } } - }; - compilerHost.readResource = async (fileName) => { - pluginDebug(`Resource Read: ${fileName}`); - const contents = await fs.readFile(fileName, 'utf-8'); - // TODO: find a way to integrate with snowpack style plugins - return contents; - }; + }); }, - async load(options: PluginLoadOptions) { - const sourceMap = options.isDev || buildSourceMap; - pluginDebug(`Loading: ${options.filePath}`); - await compileRootNames(options.isDev); - const relativeFilePathFromSrc = path.relative( - path.resolve(srcDir), - path.resolve(options.filePath) - ); - const fileBaseName = relativeFilePathFromSrc.replace('.ts', ''); - const result: SnowpackBuiltFile = {} as any; - const sourceFile: SnowpackBuiltFile = {} as any; - // Throw pretty diagnostics when error happened during compilation - if (compilationResult.diagnostics.length > 0) { - const formatted = ng.formatDiagnostics( - compilationResult.diagnostics, - tsFormatDiagnosticHost - ); - throw new Error(`[angular] ${formatted}`); - } - // Load the file from builtSourceFiles - if (builtSourceFiles.has(`${fileBaseName}.js`)) - result.code = builtSourceFiles.get(`${fileBaseName}.js`)!; - if (sourceMap && builtSourceFiles.has(`${fileBaseName}.js.map`)) - result.map = builtSourceFiles.get(`${fileBaseName}.js.map`); - // Not desirable, copy the original source file as well if sourceMaps is enabled. - sourceFile.code = sourceMap - ? await fs.readFile(options.filePath, 'utf-8') - : undefined!; + async load({ filePath, isDev }) { + const useSourceMaps = isDev || snowpackConfig.buildOptions.sourceMaps; + await compiler.buildSource(isDev); + const error = compiler.getErrorInFile(filePath); + if (error.length > 0) + throw new Error(`[angular] ${compiler.formatDiagnostics(error)}`); + const result = compiler.getBuiltFile(filePath); return { - '.js': result, - '.ts': sourceFile, + '.js': { + code: result?.code!, + map: useSourceMaps ? result?.map : undefined, + }, }; }, async onChange({ filePath }) { - // doRecompile is needed to avoid infinite loops where a component affects its module to be recompiled, or vice versa - // if false, nothing will be done, the recompiled file will be reloaded by snowpack - // changes to any resource files (.html/.ts/.css) will be sent to @angular/compiler-cli to recompile - const doRecompile = !recompiledFiles.includes(filePath); - if (!doRecompile) - recompiledFiles.splice(recompiledFiles.indexOf(filePath), 1); - else { - console.time('[angular] Incremental Build Finished, Took'); - const recompiledResult = await recompile(filePath, srcDir); - console.timeEnd('[angular] Incremental Build Finished, Took'); - if (recompiledResult) compilationResult = recompiledResult; - const workerMessage: TypeCheckArgs = { - action: 'run_check', - data: { - options: parsedTSConfig, - rootNames, - }, - }; - typeChecker.postMessage(workerMessage); - // map the compiled files path back to its source - const files = recompiledResult!.recompiledFiles - .filter((file) => file !== filePath && path.extname(file) === '.js') // Filter self / sourcemaps from recompiled - .map((file) => - path - .resolve(path.join(srcDir, file)) - .replace(path.extname(file), '.ts') - ); // Map the compiled js file back to its source ts - if (files.length === 0) - // Not the best solution, but work for now, used when error happens during recompilation and no files were recompiled, forcing a reload to throw error to snowpack - // rootNames[0] is presumably src/main.ts (anything would work though) - files.push(rootNames[0]); - for (const file of files) { - recompiledFiles.push(file); - this.markChanged!(file); - } + filePath = path.resolve(filePath); + if (skipRecompileFiles.includes(filePath)) { + skipRecompileFiles.splice(skipRecompileFiles.indexOf(filePath), 1); + return; } + const recompile = await compiler.recompile(filePath); + recompile.recompiledFiles = recompile.recompiledFiles.filter( + (file) => file !== filePath + ); + skipRecompileFiles.push(...recompile.recompiledFiles); + recompile.recompiledFiles.forEach((file) => this.markChanged!(file)); }, async transform({ id, contents }) { - if (path.resolve(id) === angularCriticalFiles.index) { - // responsible for adding global styles, scripts as defined in angular.json - const { index } = angularCriticalFiles; - const styles = angularCriticalFiles.styles.map( - (style) => - `` - ); - const scripts = angularCriticalFiles.scripts.map( - (script) => - `` - ); - const polyfills = ``; - const main = ``; + id = path.resolve(id); + if (id === index) { + const { + injectMain, + injectPolyfills, + injectScripts, + injectStyles, + } = compiler.getIndexInjects(); return contents .toString('utf-8') + .replace('', `${injectStyles.join('')}\n`) .replace( - '', - `${styles.join('\n')} - ` - ) - .replace( - '', - ` - ${polyfills} - ${main} - ${scripts.join('\n')}` + '', + `${injectPolyfills}${injectMain}${injectScripts.join('')}\n` ); } }, }; + return plugin; }; -export default plugin; +export default pluginFactory; diff --git a/src/typeCheck.ts b/src/typeCheck.ts index bbce585..4a2401d 100644 --- a/src/typeCheck.ts +++ b/src/typeCheck.ts @@ -56,3 +56,26 @@ export const getTSDiagnosticErrorFile = (diagnostics: ng.Diagnostics) => { ) ); }; + +export const getTSDiagnosticErrorInFile = ( + filePath: string, + diagnostics: ng.Diagnostics +) => { + const tsErrors = diagnostics.filter((diagnostic) => + ng.isTsDiagnostic(diagnostic) + ) as ts.Diagnostic[]; + return tsErrors.filter( + (error) => + path.resolve( + isTemplateDiagnostic(error) + ? error.componentFile.fileName + : error.file!.fileName + ) === path.resolve(filePath) + ); +}; + +export const tsFormatDiagnosticHost: ts.FormatDiagnosticsHost = { + getCanonicalFileName: (fileName) => fileName, + getCurrentDirectory: () => path.resolve(process.cwd()), + getNewLine: () => '\n', +}; diff --git a/src/typeCheckWorker.ts b/src/typeCheckWorker.ts index f73053f..c5acecc 100644 --- a/src/typeCheckWorker.ts +++ b/src/typeCheckWorker.ts @@ -3,6 +3,7 @@ import * as ng from '@angular/compiler-cli'; import { runTypeCheck } from './typeCheck'; export type TypeCheckWorkerAction = 'run_check'; +export type TypeCheckWorker = worker.Worker; export interface TypeCheckArgs { data: { @@ -12,13 +13,12 @@ export interface TypeCheckArgs { action: TypeCheckWorkerAction; } -export const createWorker = () => { +export const createTypeCheckWorker = () => { const typeCheckWorker = new worker.Worker(__filename); return typeCheckWorker; }; const runWorker = () => { - console.log('Worker now running'); worker.parentPort!.on('message', ({ action, data }: TypeCheckArgs) => { switch (action) { case 'run_check':