UNPKG

18.8 kBPlain TextView Raw
1/**
2 * @license
3 * Copyright (c) 2016 The Polymer Project Authors. All rights reserved.
4 * This code may only be used under the BSD style license found at
5 * http://polymer.github.io/LICENSE.txt
6 * The complete set of authors may be found at
7 * http://polymer.github.io/AUTHORS.txt
8 * The complete set of contributors may be found at
9 * http://polymer.github.io/CONTRIBUTORS.txt
10 * Code distributed by Google as part of the polymer project is also
11 * subject to an additional IP rights grant found at
12 * http://polymer.github.io/PATENTS.txt
13 */
14
15import * as assert from 'assert';
16import * as fs from 'fs';
17import * as jsonschema from 'jsonschema';
18import * as path from 'path';
19import * as logging from 'plylog';
20import {applyBuildPreset, isValidPreset, ProjectBuildOptions} from './builds';
21import minimatchAll = require('minimatch-all');
22import {FsUrlLoader, PackageUrlResolver, WarningFilter, Analyzer, Severity} from 'polymer-analyzer';
23
24export {ProjectBuildOptions, JsCompileTarget, applyBuildPreset} from './builds';
25
26const logger = logging.getLogger('polymer-project-config');
27
28/**
29 * The default globs for matching all user application source files.
30 */
31export const defaultSourceGlobs = ['src/**/*'];
32
33export type ModuleResolutionStrategy = 'none'|'node';
34const moduleResolutionStrategies =
35 new Set<ModuleResolutionStrategy>(['none', 'node']);
36
37/**
38 * Resolve any glob or path from the given path, even if glob
39 * is negative (begins with '!').
40 */
41function globResolve(fromPath: string, glob: string): string {
42 if (glob.startsWith('!')) {
43 const includeGlob = glob.substring(1);
44 return '!' + path.resolve(fromPath, includeGlob);
45 } else {
46 return path.resolve(fromPath, glob);
47 }
48}
49
50/**
51 * Returns a relative path for a glob or path, even if glob
52 * is negative (begins with '!').
53 */
54function globRelative(fromPath: string, glob: string): string {
55 if (glob.startsWith('!')) {
56 return '!' + path.relative(fromPath, glob.substr(1));
57 }
58 return path.relative(fromPath, glob);
59}
60
61/**
62 * Returns a positive glob, even if glob is negative (begins with '!')
63 */
64function getPositiveGlob(glob: string): string {
65 if (glob.startsWith('!')) {
66 return glob.substring(1);
67 } else {
68 return glob;
69 }
70}
71
72/**
73 * Given a user-provided options object, check for deprecated options. When one
74 * is found, warn the user and fix if possible.
75 */
76// tslint:disable-next-line: no-any User input represented as any.
77function fixDeprecatedOptions(options: any): ProjectOptions {
78 if (typeof options.sourceGlobs !== 'undefined') {
79 logger.warn(
80 '"sourceGlobs" config option has been renamed to "sources" and will no longer be supported in future versions');
81 options.sources = options.sources || options.sourceGlobs;
82 }
83 if (typeof options.includeDependencies !== 'undefined') {
84 logger.warn(
85 '"includeDependencies" config option has been renamed to "extraDependencies" and will no longer be supported in future versions');
86 options.extraDependencies =
87 options.extraDependencies || options.includeDependencies;
88 }
89 // TODO(rictic): two releases after v3.5.0, start warning about
90 // options.lint.ignoreWarnings. For now we'll start by just
91 // making them always point to the same object.
92 if (options.lint && options.lint.warningsToIgnore) {
93 options.lint.ignoreWarnings = options.lint.warningsToIgnore;
94 } else if (options.lint && options.lint.ignoreWarnings) {
95 options.lint.warningsToIgnore = options.lint.ignoreWarnings;
96 }
97 return options;
98}
99
100export interface LintOptions {
101 /**
102 * The lint rules to run. Can be the code of a collection of rules like
103 * "polymer-2" or an individual rule like "dom-module-invalid-attrs".
104 */
105 rules: string[];
106
107 /**
108 * Warnings to ignore. After the rules are run, any warning that matches
109 * one of these codes is ignored, project-wide.
110 */
111 warningsToIgnore?: string[];
112
113 /**
114 * Deprecated way of spelling the `warningsToIgnore` lint option.
115 *
116 * Used only if `warningsToIgnore` is not specified.
117 */
118 ignoreWarnings?: string[];
119
120 /**
121 * An array of file globs to never report warnings for.
122 *
123 * The globs follow [minimatch] syntax, and any file that matches any
124 * of the listed globs will never show any linter warnings. This will
125 * typically not have a performance benefit, as the file will usually
126 * still need to be analyzed.
127 *
128 * [minimatch]: https://github.com/isaacs/minimatch
129 */
130 filesToIgnore?: string[];
131}
132
133export interface ProjectOptions {
134 /**
135 * Path to the root of the project on the filesystem. This can be an absolute
136 * path, or a path relative to the current working directory. Defaults to the
137 * current working directory of the process.
138 */
139 root?: string;
140
141 /**
142 * The path relative to `root` of the entrypoint file that will be served for
143 * app-shell style projects. Usually this is index.html.
144 */
145 entrypoint?: string;
146
147 /**
148 * The path relative to `root` of the app shell element.
149 */
150 shell?: string;
151
152 /**
153 * The path relative to `root` of the lazily loaded fragments. Usually the
154 * pages of an app or other bundles of on-demand resources.
155 */
156 fragments?: string[];
157
158 /**
159 * List of glob patterns, relative to root, of this project's sources to read
160 * from the file system.
161 */
162 sources?: string[];
163
164 /**
165 * List of file paths, relative to the project directory, that should be
166 * included as extraDependencies in the build target.
167 */
168 extraDependencies?: string[];
169
170 /**
171 * List of build option configurations.
172 */
173 builds?: ProjectBuildOptions[];
174
175 /**
176 * Set `basePath: true` on all builds. See that option for more details.
177 */
178 autoBasePath?: boolean;
179
180 /**
181 * Options for the Polymer Linter.
182 */
183 lint?: LintOptions;
184
185 /**
186 * Sets other options' defaults to NPM-appropriate values:
187 *
188 * - 'componentDir': 'node_modules/'
189 */
190 npm?: boolean;
191
192 /**
193 * The directory containing this project's dependencies.
194 */
195 componentDir?: string;
196
197 /**
198 * Algorithm to use for resolving module specifiers in import and export
199 * statements when rewriting them to be web-compatible. Valid values are:
200 *
201 * "none": Disable module specifier rewriting. This is the default.
202 * "node": Use Node.js resolution to find modules.
203 */
204 moduleResolution?: ModuleResolutionStrategy;
205}
206
207export class ProjectConfig {
208 readonly root: string;
209 readonly entrypoint: string;
210 readonly shell?: string;
211 readonly fragments: string[];
212 readonly sources: string[];
213 readonly extraDependencies: string[];
214 readonly componentDir?: string;
215 readonly npm?: boolean;
216 readonly moduleResolution: ModuleResolutionStrategy;
217
218 readonly builds: ProjectBuildOptions[];
219 readonly autoBasePath: boolean;
220 readonly allFragments: string[];
221 readonly lint: LintOptions|undefined = undefined;
222
223 /**
224 * Given an absolute file path to a polymer.json-like ProjectOptions object,
225 * read that file. If no file exists, null is returned. If the file exists
226 * but there is a problem reading or parsing it, throw an exception.
227 *
228 * TODO: in the next major version we should make this method and the one
229 * below async.
230 */
231 static loadOptionsFromFile(filepath: string): ProjectOptions|null {
232 try {
233 const configContent = fs.readFileSync(filepath, 'utf-8');
234 const contents = JSON.parse(configContent);
235 return this.validateOptions(contents);
236 } catch (error) {
237 // swallow "not found" errors because they are so common / expected
238 if (error && error.code === 'ENOENT') {
239 logger.debug('no polymer config file found', {file: filepath});
240 return null;
241 }
242 // otherwise, throw an exception
243 throw error;
244 }
245 }
246
247 /**
248 * Given an absolute file path to a polymer.json-like ProjectOptions object,
249 * return a new ProjectConfig instance created with those options.
250 */
251 static loadConfigFromFile(filepath: string): ProjectConfig|null {
252 const configParsed = ProjectConfig.loadOptionsFromFile(filepath);
253 if (!configParsed) {
254 return null;
255 }
256 return new ProjectConfig(configParsed);
257 }
258
259 /**
260 * Returns the given configJsonObject if it is a valid ProjectOptions object,
261 * otherwise throws an informative error message.
262 */
263 static validateOptions(configJsonObject: {}): ProjectOptions {
264 const validator = new jsonschema.Validator();
265 const result = validator.validate(configJsonObject, getSchema());
266 if (result.errors.length > 0) {
267 const error = result.errors[0]!;
268 if (!error.property && !error.message) {
269 throw error;
270 }
271 let propertyName = error.property;
272 if (propertyName.startsWith('instance.')) {
273 propertyName = propertyName.slice(9);
274 }
275 throw new Error(`Property '${propertyName}' ${error.message}`);
276 }
277 return configJsonObject;
278 }
279
280 /**
281 * Returns a new ProjectConfig from the given JSON object if it's valid.
282 *
283 * TODO(rictic): For the next major version we should mark the constructor
284 * private, or perhaps make it validating. Also, we should standardize the
285 * naming scheme across the static methods on this class.
286 *
287 * Throws if the given JSON object is an invalid ProjectOptions.
288 */
289 static validateAndCreate(configJsonObject: {}) {
290 const options = this.validateOptions(configJsonObject);
291 return new this(options);
292 }
293
294 /**
295 * Given a project directory, return an Analyzer (and related objects) with
296 * configuration inferred from polymer.json (and possibly other config files
297 * that we find and interpret).
298 */
299 static async initializeAnalyzerFromDirectory(dirname: string) {
300 const config =
301 this.loadConfigFromFile(path.join(dirname, 'polymer.json')) ||
302 new ProjectConfig({root: dirname});
303 return config.initializeAnalyzer();
304 }
305
306 /**
307 * constructor - given a ProjectOptions object, create the correct project
308 * configuration for those options. This involves setting the correct
309 * defaults, validating options, warning on deprecated options, and
310 * calculating some additional properties.
311 */
312 constructor(options: ProjectOptions) {
313 options = (options) ? fixDeprecatedOptions(options) : {};
314
315 /**
316 * npm
317 */
318 this.npm = options.npm;
319
320 // Set defaults for all NPM related options.
321 if (this.npm) {
322 this.componentDir = 'node_modules/';
323 }
324
325 /**
326 * moduleResolution
327 */
328 this.moduleResolution = options.moduleResolution || 'node';
329
330 /**
331 * root
332 */
333 if (options.root) {
334 this.root = path.resolve(options.root);
335 } else {
336 this.root = process.cwd();
337 }
338
339 /**
340 * entrypoint
341 */
342 if (options.entrypoint) {
343 this.entrypoint = path.resolve(this.root, options.entrypoint);
344 } else {
345 this.entrypoint = path.resolve(this.root, 'index.html');
346 }
347
348 /**
349 * shell
350 */
351 if (options.shell) {
352 this.shell = path.resolve(this.root, options.shell);
353 }
354
355 /**
356 * fragments
357 */
358 if (options.fragments) {
359 this.fragments = options.fragments.map((e) => path.resolve(this.root, e));
360 } else {
361 this.fragments = [];
362 }
363
364 /**
365 * extraDependencies
366 */
367 this.extraDependencies = (options.extraDependencies ||
368 []).map((glob) => globResolve(this.root, glob));
369
370 /**
371 * sources
372 */
373 this.sources = (options.sources || defaultSourceGlobs)
374 .map((glob) => globResolve(this.root, glob));
375 this.sources.push(this.entrypoint);
376 if (this.shell) {
377 this.sources.push(this.shell);
378 }
379 if (this.fragments) {
380 this.sources = this.sources.concat(this.fragments);
381 }
382
383 /**
384 * allFragments
385 */
386 this.allFragments = [];
387 // It's important that shell is first for document-ordering of imports
388 if (this.shell) {
389 this.allFragments.push(this.shell);
390 }
391 if (this.fragments) {
392 this.allFragments = this.allFragments.concat(this.fragments);
393 }
394 if (this.allFragments.length === 0) {
395 this.allFragments.push(this.entrypoint);
396 }
397
398 if (options.lint) {
399 this.lint = options.lint;
400 }
401
402 /**
403 * builds
404 */
405 if (options.builds) {
406 this.builds = options.builds;
407 if (Array.isArray(this.builds)) {
408 this.builds = this.builds.map(applyBuildPreset);
409 }
410 }
411
412 /**
413 * autoBasePath
414 */
415 if (options.autoBasePath) {
416 this.autoBasePath = options.autoBasePath;
417
418 for (const build of this.builds || []) {
419 build.basePath = true;
420 }
421 }
422
423 /**
424 * componentDir
425 */
426 if (options.componentDir) {
427 this.componentDir = options.componentDir;
428 }
429 }
430
431 /**
432 * Get an analyzer (and other related objects) with configuration determined
433 * by this ProjectConfig.
434 */
435 async initializeAnalyzer() {
436 const urlLoader = new FsUrlLoader(this.root);
437 const urlResolver = new PackageUrlResolver(
438 {packageDir: this.root, componentDir: this.componentDir});
439
440 const analyzer = new Analyzer({
441 urlLoader,
442 urlResolver,
443 moduleResolution: convertModuleResolution(this.moduleResolution)
444 });
445 const lintConfig: Partial<LintOptions> = this.lint || {};
446 const warningFilter = new WarningFilter({
447 minimumSeverity: Severity.WARNING,
448 warningCodesToIgnore: new Set(lintConfig.warningsToIgnore || []),
449 filesToIgnore: lintConfig.filesToIgnore || []
450 });
451 return {urlLoader, urlResolver, analyzer, warningFilter};
452 }
453
454 isFragment(filepath: string): boolean {
455 return this.allFragments.indexOf(filepath) !== -1;
456 }
457
458 isShell(filepath: string): boolean {
459 return (!!this.shell && (this.shell === filepath));
460 }
461
462 isSource(filepath: string): boolean {
463 return minimatchAll(filepath, this.sources);
464 }
465
466 /**
467 * Validates that a configuration is accurate, and that all paths are
468 * contained within the project root.
469 */
470 validate(): boolean {
471 const validateErrorPrefix = `Polymer Config Error`;
472 if (this.entrypoint) {
473 assert(
474 this.entrypoint.startsWith(this.root),
475 `${validateErrorPrefix}: entrypoint (${this.entrypoint}) ` +
476 `does not resolve within root (${this.root})`);
477 }
478 if (this.shell) {
479 assert(
480 this.shell.startsWith(this.root),
481 `${validateErrorPrefix}: shell (${this.shell}) ` +
482 `does not resolve within root (${this.root})`);
483 }
484 this.fragments.forEach((f) => {
485 assert(
486 f.startsWith(this.root),
487 `${validateErrorPrefix}: a "fragments" path (${f}) ` +
488 `does not resolve within root (${this.root})`);
489 });
490 this.sources.forEach((s) => {
491 assert(
492 getPositiveGlob(s).startsWith(this.root),
493 `${validateErrorPrefix}: a "sources" path (${s}) ` +
494 `does not resolve within root (${this.root})`);
495 });
496 this.extraDependencies.forEach((d) => {
497 assert(
498 getPositiveGlob(d).startsWith(this.root),
499 `${validateErrorPrefix}: an "extraDependencies" path (${d}) ` +
500 `does not resolve within root (${this.root})`);
501 });
502 assert(
503 moduleResolutionStrategies.has(this.moduleResolution),
504 `${validateErrorPrefix}: "moduleResolution" must be one of: ` +
505 `${[...moduleResolutionStrategies].join(', ')}.`);
506
507 // TODO(fks) 11-14-2016: Validate that files actually exist in the
508 // file system. Potentially become async function for this.
509
510 if (this.builds) {
511 assert(
512 Array.isArray(this.builds),
513 `${validateErrorPrefix}: "builds" (${this.builds}) ` +
514 `expected an array of build configurations.`);
515
516 if (this.builds.length > 1) {
517 const buildNames = new Set<string>();
518 for (const build of this.builds) {
519 const buildName = build.name;
520 const buildPreset = build.preset;
521 assert(
522 !buildPreset || isValidPreset(buildPreset),
523 `${validateErrorPrefix}: "${buildPreset}" is not a valid ` +
524 ` "builds" preset.`);
525 assert(
526 buildName,
527 `${validateErrorPrefix}: all "builds" require ` +
528 `a "name" property when there are multiple builds defined.`);
529 assert(
530 !buildNames.has(buildName!),
531 `${validateErrorPrefix}: "builds" duplicate build name ` +
532 `"${buildName}" found. Build names must be unique.`);
533 buildNames.add(buildName!);
534 }
535 }
536 }
537
538 return true;
539 }
540
541 /**
542 * Generate a JSON string serialization of this configuration. File paths
543 * will be relative to root.
544 */
545 toJSON(): string {
546 let lintObj = undefined;
547 if (this.lint) {
548 lintObj = {...this.lint};
549 delete lintObj.ignoreWarnings;
550 }
551 const isWindows = process.platform === 'win32';
552 const normalizePath = isWindows ?
553 (path: string) => path.replace(/\\/g, '/') :
554 (path: string) => path;
555 const obj = {
556 entrypoint: globRelative(this.root, this.entrypoint),
557 shell: this.shell ? globRelative(this.root, this.shell) : undefined,
558 fragments: this.fragments.map((absolutePath) => {
559 return normalizePath(globRelative(this.root, absolutePath));
560 }),
561 sources: this.sources.map((absolutePath) => {
562 return normalizePath(globRelative(this.root, absolutePath));
563 }),
564 extraDependencies: this.extraDependencies.map((absolutePath) => {
565 return normalizePath(globRelative(this.root, absolutePath));
566 }),
567 builds: this.builds,
568 autoBasePath: this.autoBasePath,
569 lint: lintObj,
570 npm: this.npm,
571 componentDir: this.componentDir,
572 moduleResolution: this.moduleResolution,
573 };
574 return JSON.stringify(obj, null, 2);
575 }
576}
577
578// Gets the json schema for polymer.json, generated from the typescript
579// interface for runtime validation. See the build script in package.json for
580// more info.
581const getSchema: () => jsonschema.Schema = (() => {
582 let schema: jsonschema.Schema;
583
584 return () => {
585 if (schema === undefined) {
586 schema = JSON.parse(
587 fs.readFileSync(path.join(__dirname, 'schema.json'), 'utf-8'));
588 }
589 return schema;
590 };
591})();
592
593
594/**
595 * Module resolution in ProjectConfig is different than the same-named parameter
596 * in the analyzer. So we need to convert between the two.
597 */
598function convertModuleResolution(moduleResolution: 'node'|'none'): 'node'|
599 undefined {
600 switch (moduleResolution) {
601 case 'node':
602 return 'node';
603 case 'none':
604 return undefined;
605 default:
606 const never: never = moduleResolution;
607 throw new Error(`Unknown module resolution parameter: ${never}`);
608 }
609}
610
\No newline at end of file