diff --git a/.gitignore b/.gitignore index df5f1e83..c0b8ab88 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,11 @@ jspm_packages # Optional npm cache directory .npm +package-lock.json # Optional REPL history .node_repl_history -# IDEA directory +# Editor directories and files .idea +.vscode \ No newline at end of file diff --git a/README.md b/README.md index e7aaf852..18dfc1c3 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,10 @@ should keep free 1 core for *build* and 1 core for a *system* *(for example syst node doesn't share memory between workers - keep in mind that memory usage will increase. Be aware that in some scenarios increasing workers number **can increase checking time**. Default: `ForkTsCheckerWebpackPlugin.ONE_CPU`. +* **vue** `boolean`: +If `true`, the linter and compiler will process VueJs single-file-component (.vue) files. See the +[Vue section](https://github.com/Realytics/fork-ts-checker-webpack-plugin#vue) further down for information on how to correctly setup your project. + ### Pre-computed consts: * `ForkTsCheckerWebpackPlugin.ONE_CPU` - always use one CPU * `ForkTsCheckerWebpackPlugin.ALL_CPUS` - always use all CPUs (will increase build time) @@ -141,5 +145,94 @@ This plugin provides some custom webpack hooks (all are sync): |`fork-ts-checker-emit`| Service will add errors and warnings to webpack compilation ('build' mode) | `diagnostics`, `lints`, `elapsed` | |`fork-ts-checker-done`| Service finished type checking and webpack finished compilation ('watch' mode) | `diagnostics`, `lints`, `elapsed` | +## Vue +1. Turn on the vue option in the plugin in your webpack config: + +``` + new ForkTsCheckerWebpackPlugin({ + tslint: true, + vue: true + }) +``` + +2. To activate TypeScript in your `.vue` files, you need to ensure your script tag's language attribute is set +to `ts` or `tsx` (also make sure you include the `.vue` extension in all your import statements as shown below): + +```html + +``` + +3. Ideally you are also using `ts-loader` (in transpileOnly mode). Your Webpack config rules may look something like this: + +``` +{ + test: /\.ts$/, + loader: 'ts-loader', + include: [resolve('src'), resolve('test')], + options: { + appendTsSuffixTo: [/\.vue$/], + transpileOnly: true + } +}, +{ + test: /\.vue$/, + loader: 'vue-loader', + options: vueLoaderConfig +}, +``` +4. Add rules to your `tslint.json` and they will be applied to Vue files. For example, you could apply the Standard JS rules [tslint-config-standard](https://github.com/blakeembrey/tslint-config-standard) like this: + +```json +{ + "defaultSeverity": "error", + "extends": [ + "tslint-config-standard" + ] +} +``` +5. Ensure your `tsconfig.json` includes .vue files: + +``` +// tsconfig.json +{ + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ], + "exclude": [ + "node_modules" + ] +} +``` + +6. The commonly used `@` path wildcard will work if you set up a `baseUrl` and `paths` (in `compilerOptions`) to include `@/*`. If you don't set this, then +the fallback for the `@` wildcard will be `[tsconfig directory]/src` (we hope to make this more flexible on future releases): +``` +// tsconfig.json +{ + "compilerOptions": { + + // ... + + "baseUrl": ".", + "paths": { + "@/*": [ + "src/*" + ] + } + } +} + +// In a .ts or .vue file... +import Hello from '@/components/hello.vue' +``` + +7. If you are working in **VSCode**, you can get extensions [Vetur](https://marketplace.visualstudio.com/items?itemName=octref.vetur) and [TSLint Vue](https://marketplace.visualstudio.com/items?itemName=prograhammer.tslint-vue) to complete the developer workflow. + ## License MIT diff --git a/package.json b/package.json index e7d6a853..2465dde2 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ "@types/lodash.startswith": "^4.2.3", "@types/minimatch": "^3.0.1", "@types/node": "^8.0.26", + "@types/resolve": "0.0.4", "@types/webpack": "^3.0.10", "chai": "^3.5.0", + "css-loader": "^0.28.7", "eslint": "^3.19.0", "istanbul": "^0.4.5", "mocha": "^3.4.1", @@ -62,6 +64,10 @@ "ts-loader": "^2.1.0", "tslint": "^5.0.0", "typescript": "^2.1.0", + "vue": "^2.5.9", + "vue-class-component": "^6.1.1", + "vue-loader": "^13.5.0", + "vue-template-compiler": "^2.5.9", "webpack": "^3.0.0" }, "peerDependencies": { @@ -76,6 +82,8 @@ "lodash.isfunction": "^3.0.8", "lodash.isstring": "^4.0.1", "lodash.startswith": "^4.2.1", - "minimatch": "^3.0.4" + "minimatch": "^3.0.4", + "resolve": "^1.5.0", + "vue-parser": "^1.1.5" } } diff --git a/src/IncrementalChecker.ts b/src/IncrementalChecker.ts index a66a67d7..56fdb704 100644 --- a/src/IncrementalChecker.ts +++ b/src/IncrementalChecker.ts @@ -9,6 +9,7 @@ import WorkSet = require('./WorkSet'); import NormalizedMessage = require('./NormalizedMessage'); import CancellationToken = require('./CancellationToken'); import minimatch = require('minimatch'); +import VueProgram = require('./VueProgram'); // Need some augmentation here - linterOptions.exclude is not (yet) part of the official // types for tslint. @@ -36,13 +37,16 @@ class IncrementalChecker { programConfig: ts.ParsedCommandLine; watcher: FilesWatcher; + vue: boolean; + constructor( programConfigFile: string, linterConfigFile: string | false, watchPaths: string[], workNumber: number, workDivision: number, - checkSyntacticErrors: boolean + checkSyntacticErrors: boolean, + vue: boolean ) { this.programConfigFile = programConfigFile; this.linterConfigFile = linterConfigFile; @@ -50,6 +54,7 @@ class IncrementalChecker { this.workNumber = workNumber || 0; this.workDivision = workDivision || 1; this.checkSyntacticErrors = checkSyntacticErrors || false; + this.vue = vue || false; // Use empty array of exclusions in general to avoid having // to check of its existence later on. this.linterExclusions = []; @@ -130,7 +135,8 @@ class IncrementalChecker { nextIteration() { if (!this.watcher) { - this.watcher = new FilesWatcher(this.watchPaths, ['.ts', '.tsx']); + const watchExtensions = this.vue ? ['.ts', '.tsx', '.vue'] : ['.ts', '.tsx']; + this.watcher = new FilesWatcher(this.watchPaths, watchExtensions); // connect watcher with register this.watcher.on('change', (filePath: string, stats: fs.Stats) => { @@ -143,10 +149,6 @@ class IncrementalChecker { this.watcher.watch(); } - if (!this.programConfig) { - this.programConfig = IncrementalChecker.loadProgramConfig(this.programConfigFile); - } - if (!this.linterConfig && this.linterConfigFile) { this.linterConfig = IncrementalChecker.loadLinterConfig(this.linterConfigFile); @@ -158,12 +160,31 @@ class IncrementalChecker { } } - this.program = IncrementalChecker.createProgram(this.programConfig, this.files, this.watcher, this.program); + this.program = this.vue ? this.loadVueProgram() : this.loadDefaultProgram(); + if (this.linterConfig) { this.linter = IncrementalChecker.createLinter(this.program); } } + loadVueProgram() { + this.programConfig = this.programConfig || VueProgram.loadProgramConfig(this.programConfigFile); + + return VueProgram.createProgram( + this.programConfig, + path.dirname(this.programConfigFile), + this.files, + this.watcher, + this.program + ); + } + + loadDefaultProgram() { + this.programConfig = this.programConfig || IncrementalChecker.loadProgramConfig(this.programConfigFile); + + return IncrementalChecker.createProgram(this.programConfig, this.files, this.watcher, this.program); + } + hasLinter() { return this.linter !== undefined; } diff --git a/src/VueProgram.ts b/src/VueProgram.ts new file mode 100644 index 00000000..57a2a47e --- /dev/null +++ b/src/VueProgram.ts @@ -0,0 +1,145 @@ +import fs = require('fs'); +import path = require('path'); +import ts = require('typescript'); +import FilesRegister = require('./FilesRegister'); +import FilesWatcher = require('./FilesWatcher'); +import vueParser = require('vue-parser'); + +class VueProgram { + static loadProgramConfig(configFile: string) { + const extraExtensions = ['vue']; + + const parseConfigHost: ts.ParseConfigHost = { + fileExists: ts.sys.fileExists, + readFile: ts.sys.readFile, + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + readDirectory: (rootDir, extensions, excludes, includes, depth) => { + return ts.sys.readDirectory(rootDir, extensions.concat(extraExtensions), excludes, includes, depth); + } + }; + + const parsed = ts.parseJsonConfigFileContent( + // Regardless of the setting in the tsconfig.json we want isolatedModules to be false + Object.assign(ts.readConfigFile(configFile, ts.sys.readFile).config, { isolatedModules: false }), + parseConfigHost, + path.dirname(configFile) + ); + + parsed.options.allowNonTsExtensions = true; + + return parsed; + } + + /** + * Since 99.9% of Vue projects use the wildcard '@/*', we only search for that in tsconfig CompilerOptions.paths. + * The path is resolved with thie given substitution and includes the CompilerOptions.baseUrl (if given). + * If no paths given in tsconfig, then the default substitution is '[tsconfig directory]/src'. + * (This is a fast, simplified inspiration of what's described here: https://github.com/Microsoft/TypeScript/issues/5039) + */ + public static resolveNonTsModuleName(moduleName: string, containingFile: string, basedir: string, options: ts.CompilerOptions) { + const baseUrl = options.baseUrl ? options.baseUrl : basedir; + const pattern = options.paths ? options.paths['@/*'] : undefined; + const substitution = pattern ? options.paths['@/*'][0].replace('*', '') : 'src'; + const isWildcard = moduleName.substr(0, 2) === '@/'; + const isRelative = !path.isAbsolute(moduleName); + + if (isWildcard) { + moduleName = path.resolve(baseUrl, substitution, moduleName.substr(2)); + } else if (isRelative) { + moduleName = path.resolve(path.dirname(containingFile), moduleName); + } + + return moduleName; + } + + public static isVue(filePath: string) { + return path.extname(filePath) === '.vue'; + } + + static createProgram( + programConfig: ts.ParsedCommandLine, + basedir: string, + files: FilesRegister, + watcher: FilesWatcher, + oldProgram: ts.Program + ) { + const host = ts.createCompilerHost(programConfig.options); + const realGetSourceFile = host.getSourceFile; + + // We need a host that can parse Vue SFCs (single file components). + host.getSourceFile = (filePath, languageVersion, onError) => { + // first check if watcher is watching file - if not - check it's mtime + if (!watcher.isWatchingFile(filePath)) { + try { + const stats = fs.statSync(filePath); + + files.setMtime(filePath, stats.mtime.valueOf()); + } catch (e) { + // probably file does not exists + files.remove(filePath); + } + } + + // get source file only if there is no source in files register + if (!files.has(filePath) || !files.getData(filePath).source) { + files.mutateData(filePath, (data) => { + data.source = realGetSourceFile(filePath, languageVersion, onError); + }); + } + + let source = files.getData(filePath).source; + + // get typescript contents from Vue file + if (source && VueProgram.isVue(filePath)) { + const parsed = vueParser.parse(source.text, 'script', { lang: ['ts', 'tsx', 'js', 'jsx'] }); + source = ts.createSourceFile(filePath, parsed, languageVersion, true); + } + + return source; + }; + + // We need a host with special module resolution for Vue files. + host.resolveModuleNames = (moduleNames, containingFile) => { + const resolvedModules: ts.ResolvedModule[] = []; + + for (const moduleName of moduleNames) { + // Try to use standard resolution. + const result = ts.resolveModuleName(moduleName, containingFile, programConfig.options, { + fileExists: host.fileExists, + readFile: host.readFile + }); + + if (result.resolvedModule) { + resolvedModules.push(result.resolvedModule); + } else { + // For non-ts extensions. + const absolutePath = VueProgram.resolveNonTsModuleName(moduleName, containingFile, basedir, programConfig.options); + + if (VueProgram.isVue(moduleName)) { + resolvedModules.push({ + resolvedFileName: absolutePath, + extension: '.ts' + } as ts.ResolvedModuleFull); + } else { + resolvedModules.push({ + // If the file does exist, return an empty string (because we assume user has provided a ".d.ts" file for it). + resolvedFileName: host.fileExists(absolutePath) ? '' : absolutePath, + extension: '.ts' + } as ts.ResolvedModuleFull); + } + } + } + + return resolvedModules; + }; + + return ts.createProgram( + programConfig.fileNames, + programConfig.options, + host, + oldProgram // re-use old program + ); + } +} + +export = VueProgram; diff --git a/src/index.ts b/src/index.ts index f5c1d79f..dab256f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ interface Options { checkSyntacticErrors: boolean; memoryLimit: number; workers: number; + vue: boolean; } /** @@ -84,6 +85,8 @@ class ForkTsCheckerWebpackPlugin { service: childProcess.ChildProcess; + vue: boolean; + constructor(options: Options) { options = options || {} as Options; this.options = Object.assign({}, options); @@ -126,6 +129,8 @@ class ForkTsCheckerWebpackPlugin { this.typescriptVersion = require('typescript').version; this.tslintVersion = this.tslint ? require('tslint').Linter.VERSION : undefined; + + this.vue = options.vue === true; // default false } static createFormatter(type: 'default' | 'codeframe', options: any) { @@ -313,7 +318,8 @@ class ForkTsCheckerWebpackPlugin { WATCH: this.isWatching ? this.watchPaths.join('|') : '', WORK_DIVISION: Math.max(1, this.workersNumber), MEMORY_LIMIT: this.memoryLimit, - CHECK_SYNTACTIC_ERRORS: this.checkSyntacticErrors + CHECK_SYNTACTIC_ERRORS: this.checkSyntacticErrors, + VUE: this.vue } ), stdio: ['inherit', 'inherit', 'inherit', 'ipc'] diff --git a/src/service.ts b/src/service.ts index 65b37ac9..7d60bd26 100644 --- a/src/service.ts +++ b/src/service.ts @@ -10,7 +10,8 @@ const checker = new IncrementalChecker( process.env.WATCH === '' ? [] : process.env.WATCH.split('|'), parseInt(process.env.WORK_NUMBER, 10), parseInt(process.env.WORK_DIVISION, 10), - process.env.CHECK_SYNTACTIC_ERRORS === 'true' + process.env.CHECK_SYNTACTIC_ERRORS === 'true', + process.env.VUE === 'true' ); function run(cancellationToken: CancellationToken) { diff --git a/test/integration/vue.spec.js b/test/integration/vue.spec.js new file mode 100644 index 00000000..18eeced6 --- /dev/null +++ b/test/integration/vue.spec.js @@ -0,0 +1,150 @@ + +var describe = require('mocha').describe; +var it = require('mocha').it; +var expect = require('chai').expect; +var path = require('path'); +var webpack = require('webpack'); +var process = require('process'); +var ForkTsCheckerWebpackPlugin = require('../../lib/index'); +var IncrementalChecker = require('../../lib/IncrementalChecker'); + +describe('[INTEGRATION] vue', function () { + this.timeout(30000); + process.setMaxListeners(20); + var plugin; + var files; + var compiler; + var checker; + + function createCompiler(options) { + plugin = new ForkTsCheckerWebpackPlugin(Object.assign({}, options, { silent: true })); + + compiler = webpack({ + context: path.resolve(__dirname, './vue'), + entry: './src/index.ts', + output: { + path: path.resolve(__dirname, '../../tmp') + }, + resolve: { + extensions: ['.ts', '.js', '.vue', '.json'], + alias: { + '@': path.resolve(__dirname, './vue/src'), + } + }, + module: { + rules: [ + { + test: /\.vue$/, + loader: 'vue-loader' + }, + { + test: /\.ts$/, + loader: 'ts-loader', + options: { + appendTsSuffixTo: [/\.vue$/], + transpileOnly: true, + silent: true + } + }, + { + test: /\.css$/, + loader: 'css-loader' + } + ] + }, + plugins: [ + plugin + ] + }); + + files = { + 'example.vue': path.resolve(compiler.context, 'src/example.vue'), + 'syntacticError.ts': path.resolve(compiler.context, 'src/syntacticError.ts') + }; + + checker = new IncrementalChecker( + plugin.tsconfigPath, + plugin.tslintPath || false, + [compiler.context], + ForkTsCheckerWebpackPlugin.ONE_CPU, + 1, + plugin.checkSyntacticErrors, + plugin.vue + ); + + checker.nextIteration(); + } + + it('should create a Vue program config if vue=true', function () { + createCompiler({ vue: true }); + + var fileFound; + + fileFound = checker.programConfig.fileNames.indexOf(files['example.vue']) >= 0; + expect(fileFound).to.be.true; + + fileFound = checker.programConfig.fileNames.indexOf(files['syntacticError.ts']) >= 0; + expect(fileFound).to.be.true; + }); + + it('should not create a Vue program config if vue=false', function () { + createCompiler(); + + var fileFound; + + fileFound = checker.programConfig.fileNames.indexOf(files['example.vue']) >= 0; + expect(fileFound).to.be.false; + + fileFound = checker.programConfig.fileNames.indexOf(files['syntacticError.ts']) >= 0; + expect(fileFound).to.be.true; + }); + + it('should create a Vue program if vue=true', function () { + createCompiler({ vue: true }); + + var source; + + source = checker.program.getSourceFile(files['example.vue']); + expect(source).to.not.be.undefined; + + source = checker.program.getSourceFile(files['syntacticError.ts']); + expect(source).to.not.be.undefined; + }); + + it('should not create a Vue program if vue=false', function () { + createCompiler(); + + var source; + + source = checker.program.getSourceFile(files['example.vue']); + expect(source).to.be.undefined; + + source = checker.program.getSourceFile(files['syntacticError.ts']); + expect(source).to.not.be.undefined; + }); + + it('should get syntactic diagnostics from Vue program', function () { + createCompiler({ tslint: true, vue: true }); + + const diagnostics = checker.program.getSyntacticDiagnostics(); + expect(diagnostics.length).to.be.equal(1); + }); + + it('should not find syntactic errors when checkSyntacticErrors is false', function (callback) { + createCompiler({ tslint: true, vue: true }); + + compiler.run(function(error, stats) { + expect(stats.compilation.errors.length).to.be.equal(2); + callback(); + }); + }); + + it('should find syntactic errors when checkSyntacticErrors is true', function (callback) { + createCompiler({ tslint: true, vue: true, checkSyntacticErrors: true }); + + compiler.run(function(error, stats) { + expect(stats.compilation.errors.length).to.be.equal(3); + callback(); + }); + }); +}); \ No newline at end of file diff --git a/test/integration/vue/src/css.d.ts b/test/integration/vue/src/css.d.ts new file mode 100644 index 00000000..ae764235 --- /dev/null +++ b/test/integration/vue/src/css.d.ts @@ -0,0 +1,5 @@ +declare module '*.css' { + const css = ''; + + export default css; +} diff --git a/test/integration/vue/src/example-wild.vue b/test/integration/vue/src/example-wild.vue new file mode 100644 index 00000000..37bf9981 --- /dev/null +++ b/test/integration/vue/src/example-wild.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/test/integration/vue/src/example.css b/test/integration/vue/src/example.css new file mode 100644 index 00000000..da5d2635 --- /dev/null +++ b/test/integration/vue/src/example.css @@ -0,0 +1,3 @@ +.test { + background-color: blue; +} \ No newline at end of file diff --git a/test/integration/vue/src/example.vue b/test/integration/vue/src/example.vue new file mode 100644 index 00000000..4892c15d --- /dev/null +++ b/test/integration/vue/src/example.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/test/integration/vue/src/index.ts b/test/integration/vue/src/index.ts new file mode 100644 index 00000000..ba753b87 --- /dev/null +++ b/test/integration/vue/src/index.ts @@ -0,0 +1,8 @@ +import ExampleWild from "@/example-wild.vue"; +import Example from "./example.vue"; + +const foo = new Example(); +foo.msg = "foo"; + +const bar = new ExampleWild(); +bar.msg = "bar"; diff --git a/test/integration/vue/src/syntacticError.ts b/test/integration/vue/src/syntacticError.ts new file mode 100644 index 00000000..1655a39e --- /dev/null +++ b/test/integration/vue/src/syntacticError.ts @@ -0,0 +1,2 @@ +// Syntactic error +const array = [{} {}]; diff --git a/test/integration/vue/tsconfig.json b/test/integration/vue/tsconfig.json new file mode 100644 index 00000000..6e26e3b4 --- /dev/null +++ b/test/integration/vue/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "experimentalDecorators": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/test/integration/vue/tslint.json b/test/integration/vue/tslint.json new file mode 100644 index 00000000..dfff889c --- /dev/null +++ b/test/integration/vue/tslint.json @@ -0,0 +1,9 @@ +{ + "defaultSeverity": "warning", + "extends": [ + "tslint:recommended" + ], + "jsRules": {}, + "rules": {}, + "rulesDirectory": [] +} \ No newline at end of file diff --git a/test/unit/VueProgram.spec.js b/test/unit/VueProgram.spec.js new file mode 100644 index 00000000..6a2944cd --- /dev/null +++ b/test/unit/VueProgram.spec.js @@ -0,0 +1,66 @@ +var describe = require('mocha').describe; +var it = require('mocha').it; +var expect = require('chai').expect; +var VueProgram = require('../../lib/VueProgram'); + +describe('[UNIT] VueProgram', function () { + it('should determine if file is a Vue file', function() { + expect(VueProgram.isVue('./test.vue')).to.be.true; + expect(VueProgram.isVue('../test.vue')).to.be.true; + expect(VueProgram.isVue('../../test.vue')).to.be.true; + expect(VueProgram.isVue('@/test.vue')).to.be.true; + expect(VueProgram.isVue('../../.vue')).to.be.false; + expect(VueProgram.isVue('./test.css')).to.be.false; + expect(VueProgram.isVue('./')).to.be.false; + }); + + it('should properly resolve relative module names', function() { + var basedir = '/base/dir'; + var containingFile = '/con/tain/ing/main.ts'; + var options = { + baseUrl: '/baseurl', + paths: { + '@/*': [ + 'src/*' + ] + } + } + var moduleNames = [ + './test.vue', + '../test.vue', + '../../test.vue' + ]; + + var resolvedModuleNames = moduleNames.map(function(moduleName) { + return VueProgram.resolveNonTsModuleName(moduleName, containingFile, basedir, options); + }); + + expect(resolvedModuleNames[0]).to.be.equal('/con/tain/ing/test.vue'); + expect(resolvedModuleNames[1]).to.be.equal('/con/tain/test.vue'); + expect(resolvedModuleNames[2]).to.be.equal('/con/test.vue'); + }); + + it('should properly resolve wildcard module names', function() { + var basedir = '/base/dir'; + var containingFile = '/con/tain/ing/main.ts'; + var options = {}; + var moduleName = '@/test.vue'; + + resolvedModuleName = VueProgram.resolveNonTsModuleName(moduleName, containingFile, basedir, options); + expect(resolvedModuleName).to.be.equal('/base/dir/src/test.vue'); + + options.baseUrl = '/baseurl1'; + resolvedModuleName = VueProgram.resolveNonTsModuleName(moduleName, containingFile, basedir, options); + expect(resolvedModuleName).to.be.equal('/baseurl1/src/test.vue'); + + options.baseUrl = '/baseurl2'; + options.paths = { '@/*': ['src1/*'] } + resolvedModuleName = VueProgram.resolveNonTsModuleName(moduleName, containingFile, basedir, options); + expect(resolvedModuleName).to.be.equal('/baseurl2/src1/test.vue'); + + options.baseUrl = '/baseurl3'; + options.paths = { '@/*': ['src1/src2/*'] } + resolvedModuleName = VueProgram.resolveNonTsModuleName(moduleName, containingFile, basedir, options); + expect(resolvedModuleName).to.be.equal('/baseurl3/src1/src2/test.vue'); + }); +}); \ No newline at end of file