From cca8951e7ebf0432acb19286f7d59af61f9c5dc2 Mon Sep 17 00:00:00 2001 From: Alice Date: Thu, 12 May 2022 09:17:47 -0400 Subject: [PATCH] refactor(config): remove strictNullChecks errors from compiler/config (#3335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this is another PR for removing _some_ strictNullChecks errors, in particular in the code related to loading and validating the `Config`. this PR proposes a change to how the `Config` loading / validation pipeline works. instead of dealing the whole way through with a single `Config` type, this puts a boundary in place which is crossed in the `validateConfig` function between an `UnvalidatedConfig` and a `Config`. how does this work? basically `Config` is the type that we expect to be able to pass around everywhere else in the code base without issue, while `UnvalidatedConfig` is a type which is around to specifically denote the fact that the object we're dealing with is sort of a 'config in progress' and it cannot yet be used freely throughout the compiler. `UnvalidatedConfig` is implemented w/ a new type called `Loose`, which looks like this: ```ts type Loose = Record & Partial ``` `UnvalidatedConfig` looks like this: ```ts type UnvalidatedConfig = Loose ``` this amounts to 1) making all properties on `Config` optional (via `Partial`) and 2) allowing access to properties which are _not_ defined on `Config`. we need the former because this opens the door to later changing the typing of properties on `Config` to _not_ all be optional (as they currently are). this will be a big help towards turning `strictNullChecks` on, since doing so right now results in a literal ton of errors around property access on `Config` objects—consider, for instance, that currently `Config.sys` is an optional property, so _every time_ the `.sys` property is accessed we'll get an error with `strictNullChecks`. we need the latter change because with `strictNullChecks` if you do something like the following you'll get a type error: ```ts interface Foo { bar: string } let obj: Foo = { bar: "hey!" } let otherPropValue = obj["asdfasdf"] ``` TypeScript here will (and should!) throw an error for `otherPropValue` because the index type on `Foo` is `"bar"` and not `string`. the `Record &` bit in the definition of `Loose` lets us access properties not defined on `Config` in our validation code (for an example of this see the `setBoolean` function in `/src/compiler/config/config-utils.ts`) without giving up on types in `Config` entirely. What do I mean by that? Basically just that if you do this: ```ts let config: UnvalidatedConfig = someFunctionThatProducesConfig(); ``` then the type of `config["somePropertyWeDontDefine"]` will be `any`, and we can do things we need to with such properties within the validation code, _but_ if I access `config.sys` the type will still be `CompilerSystem`. So for this reason I think typing something as `Loose` is superior to just doing `Record` or `Object` or (shudders) `any`. This commit just gets us started with this sort of 'lifecycle' for Config objects (they start out as `UnvalidatedConfig`s, get passed through `validateConfig` and come out as full `Config` objects) and more work will be needed to get all of the config validation and loading code on board. Once we do that we can safely start removing optional properties on `Config` itself, since we'll have assurance from the compiler that anything marked `Config` has already been fully validated and should always have, e.g., a `.sys` property, a `buildDir` property, etc. This will make it much easier for us to turn `strictNullChecks` on without having to sprinkle the codebase with (literally hundreds of) optional property accesses (`?.`). --- src/compiler/config/config-utils.ts | 88 ++++++++----------- src/compiler/config/load-config.ts | 18 +++- src/compiler/config/outputs/index.ts | 2 +- .../config/test/validate-dev-server.spec.ts | 63 +++++-------- src/compiler/config/validate-config.ts | 19 +++- src/compiler/config/validate-dev-server.ts | 45 ++++++---- src/compiler/config/validate-hydrated.ts | 21 ++++- src/compiler/config/validate-namespace.ts | 6 +- src/compiler/config/validate-paths.ts | 2 +- src/compiler/config/validate-plugins.ts | 2 +- src/compiler/config/validate-rollup-config.ts | 2 +- src/compiler/config/validate-testing.ts | 24 ++--- src/compiler/config/validate-workers.ts | 2 +- src/declarations/stencil-public-compiler.ts | 43 ++++++++- src/utils/helpers.ts | 11 +-- 15 files changed, 201 insertions(+), 147 deletions(-) diff --git a/src/compiler/config/config-utils.ts b/src/compiler/config/config-utils.ts index 7456f5da9b3..688890a24a0 100644 --- a/src/compiler/config/config-utils.ts +++ b/src/compiler/config/config-utils.ts @@ -1,17 +1,38 @@ import type * as d from '../../declarations'; import { isAbsolute, join } from 'path'; +import { isBoolean } from '@utils'; -export const getAbsolutePath = (config: d.Config, dir: string) => { +export const getAbsolutePath = (config: d.Config | d.UnvalidatedConfig, dir: string) => { if (!isAbsolute(dir)) { dir = join(config.rootDir, dir); } return dir; }; -export const setBooleanConfig = (config: any, configName: string, flagName: string, defaultValue: boolean) => { +/** + * This function does two things: + * + * 1. If you pass a `flagName`, it will hoist that `flagName` out of the + * `ConfigFlags` object and onto the 'root' level (if you will) of the + * `config` under the `configName` (`keyof d.Config`) that you pass. + * 2. If you _don't_ pass a `flagName` it will just set the value you supply + * on the config. + * + * @param config the config that we want to update + * @param configName the key we're setting on the config + * @param flagName either the name of a ConfigFlag prop we want to hoist up or null + * @param defaultValue the default value we should set! + */ +export const setBooleanConfig = ( + config: d.UnvalidatedConfig, + configName: (K & keyof d.ConfigFlags) | K, + flagName: keyof d.ConfigFlags | null, + defaultValue: d.Config[K] +) => { if (flagName) { - if (typeof config.flags[flagName] === 'boolean') { - config[configName] = config.flags[flagName]; + let flagValue = config.flags?.[flagName]; + if (isBoolean(flagValue)) { + config[configName] = flagValue; } } @@ -21,64 +42,29 @@ export const setBooleanConfig = (config: any, configName: string, flagName: stri config[userConfigName] = !!config[userConfigName](); } - if (typeof config[userConfigName] === 'boolean') { + if (isBoolean(config[userConfigName])) { config[configName] = config[userConfigName]; } else { config[configName] = defaultValue; } }; -export const setNumberConfig = (config: any, configName: string, _flagName: string, defaultValue: number) => { - const userConfigName = getUserConfigName(config, configName); - - if (typeof config[userConfigName] === 'function') { - config[userConfigName] = config[userConfigName](); - } - - if (typeof config[userConfigName] === 'number') { - config[configName] = config[userConfigName]; - } else { - config[configName] = defaultValue; - } -}; - -export const setStringConfig = (config: any, configName: string, defaultValue: string) => { - const userConfigName = getUserConfigName(config, configName); - - if (typeof config[userConfigName] === 'function') { - config[userConfigName] = config[userConfigName](); - } - - if (typeof config[userConfigName] === 'string') { - config[configName] = config[userConfigName]; - } else { - config[configName] = defaultValue; - } -}; - -export const setArrayConfig = (config: any, configName: string, defaultValue?: any[]) => { - const userConfigName = getUserConfigName(config, configName); - - if (typeof config[userConfigName] === 'function') { - config[userConfigName] = config[userConfigName](); - } - - if (!Array.isArray(config[configName])) { - if (Array.isArray(defaultValue)) { - config[configName] = defaultValue.slice(); - } else { - config[configName] = []; - } - } -}; - -const getUserConfigName = (config: d.Config, correctConfigName: string) => { +/** + * Find any possibly mis-capitalized configuration names on the config, logging + * and warning if one is found. + * + * @param config the user-supplied config that we're dealing with + * @param correctConfigName the configuration name that we're checking for right now + * @returns a string container a mis-capitalized config name found on the + * config object, if any. + */ +const getUserConfigName = (config: d.UnvalidatedConfig, correctConfigName: keyof d.Config): string => { const userConfigNames = Object.keys(config); for (const userConfigName of userConfigNames) { if (userConfigName.toLowerCase() === correctConfigName.toLowerCase()) { if (userConfigName !== correctConfigName) { - config.logger.warn(`config "${userConfigName}" should be "${correctConfigName}"`); + config.logger?.warn(`config "${userConfigName}" should be "${correctConfigName}"`); return userConfigName; } break; diff --git a/src/compiler/config/load-config.ts b/src/compiler/config/load-config.ts index e2eef18551d..0a4fafffa75 100644 --- a/src/compiler/config/load-config.ts +++ b/src/compiler/config/load-config.ts @@ -1,4 +1,10 @@ -import type { CompilerSystem, Config, Diagnostic, LoadConfigInit, LoadConfigResults } from '../../declarations'; +import type { + CompilerSystem, + Diagnostic, + LoadConfigInit, + LoadConfigResults, + UnvalidatedConfig, +} from '../../declarations'; import { buildError, catchError, hasError, isString, normalizePath } from '@utils'; import { createLogger } from '../sys/logger/console-logger'; import { createSystem } from '../sys/stencil-sys'; @@ -89,8 +95,12 @@ export const loadConfig = async (init: LoadConfigInit = {}) => { return results; }; -const loadConfigFile = async (sys: CompilerSystem, diagnostics: Diagnostic[], configPath: string) => { - let config: Config = null; +const loadConfigFile = async ( + sys: CompilerSystem, + diagnostics: Diagnostic[], + configPath: string +): Promise => { + let config: UnvalidatedConfig = null; if (isString(configPath)) { // the passed in config was a string, so it's probably a path to the config we need to load @@ -113,7 +123,7 @@ const loadConfigFile = async (sys: CompilerSystem, diagnostics: Diagnostic[], co }; const evaluateConfigFile = async (sys: CompilerSystem, diagnostics: Diagnostic[], configFilePath: string) => { - let configFileData: { config?: Config } = null; + let configFileData: { config?: UnvalidatedConfig } = null; try { if (IS_NODE_ENV) { diff --git a/src/compiler/config/outputs/index.ts b/src/compiler/config/outputs/index.ts index f7e4406cc4a..36dfa9a0bfc 100644 --- a/src/compiler/config/outputs/index.ts +++ b/src/compiler/config/outputs/index.ts @@ -13,7 +13,7 @@ import { validateStats } from './validate-stats'; import { validateWww } from './validate-www'; import { validateCustomElementBundle } from './validate-custom-element-bundle'; -export const validateOutputTargets = (config: d.Config, diagnostics: d.Diagnostic[]) => { +export const validateOutputTargets = (config: d.UnvalidatedConfig, diagnostics: d.Diagnostic[]) => { const userOutputs = (config.outputTargets || []).slice(); userOutputs.forEach((outputTarget) => { diff --git a/src/compiler/config/test/validate-dev-server.spec.ts b/src/compiler/config/test/validate-dev-server.spec.ts index 86c08a31ffd..7cf07d61f67 100644 --- a/src/compiler/config/test/validate-dev-server.spec.ts +++ b/src/compiler/config/test/validate-dev-server.spec.ts @@ -24,23 +24,14 @@ describe('validateDevServer', () => { expect(config.devServer.address).toBe('0.0.0.0'); }); - it('should remove http from address', () => { - inputConfig.devServer.address = 'http://localhost'; - const { config } = validateConfig(inputConfig); - expect(config.devServer.address).toBe('localhost'); - }); - - it('should remove https from address', () => { - inputConfig.devServer.address = 'https://localhost'; - const { config } = validateConfig(inputConfig); - expect(config.devServer.address).toBe('localhost'); - }); - - it('should remove trailing / from address', () => { - inputConfig.devServer.address = 'localhost/'; - const { config } = validateConfig(inputConfig); - expect(config.devServer.address).toBe('localhost'); - }); + it.each(['https://localhost', 'http://localhost', 'https://localhost/', 'http://localhost/', 'localhost/'])( + 'should remove extraneious stuff from addres %p', + (address) => { + inputConfig.devServer.address = address; + const { config } = validateConfig(inputConfig); + expect(config.devServer.address).toBe('localhost'); + } + ); it('should set address', () => { inputConfig.devServer.address = '123.123.123.123'; @@ -109,22 +100,20 @@ describe('validateDevServer', () => { expect(config.devServer.gzip).toBe(false); }); - it('should default port', () => { - const { config } = validateConfig(inputConfig); - expect(config.devServer.port).toBe(3333); - }); - - it('should default port with ip address', () => { - inputConfig.devServer.address = '192.12.12.10'; + it.each(['localhost', '192.12.12.10', '127.0.0.1'])('should default port with address %p', () => { const { config } = validateConfig(inputConfig); expect(config.devServer.port).toBe(3333); }); - it('should default port with localhost', () => { - inputConfig.devServer.address = 'localhost'; - const { config } = validateConfig(inputConfig); - expect(config.devServer.port).toBe(3333); - }); + it.each(['https://subdomain.stenciljs.com:3000', 'localhost:3000/', 'localhost:3000'])( + 'should override port in address with port property', + (address) => { + inputConfig.devServer.port = 1234; + inputConfig.devServer.address = address; + const { config } = validateConfig(inputConfig); + expect(config.devServer.port).toBe(1234); + } + ); it('should not set default port if null', () => { inputConfig.devServer.port = null; @@ -132,25 +121,17 @@ describe('validateDevServer', () => { expect(config.devServer.port).toBe(null); }); - it('should set port if in address', () => { - inputConfig.devServer.address = 'localhost:88'; + it.each(['localhost:20/', 'localhost:20'])('should set port from address %p if no port prop', (address) => { + inputConfig.devServer.address = address; const { config } = validateConfig(inputConfig); - expect(config.devServer.port).toBe(88); + expect(config.devServer.port).toBe(20); expect(config.devServer.address).toBe('localhost'); }); - it('should set port if in address and has trailing slash', () => { - inputConfig.devServer.address = 'https://localhost:88/'; - const { config } = validateConfig(inputConfig); - expect(config.devServer.port).toBe(88); - expect(config.devServer.address).toBe('localhost'); - expect(config.devServer.protocol).toBe('https'); - }); - it('should set address, port null, protocol', () => { inputConfig.devServer.address = 'https://subdomain.stenciljs.com/'; const { config } = validateConfig(inputConfig); - expect(config.devServer.port).toBe(null); + expect(config.devServer.port).toBe(undefined); expect(config.devServer.address).toBe('subdomain.stenciljs.com'); expect(config.devServer.protocol).toBe('https'); }); diff --git a/src/compiler/config/validate-config.ts b/src/compiler/config/validate-config.ts index 419b8626a07..65920ace748 100644 --- a/src/compiler/config/validate-config.ts +++ b/src/compiler/config/validate-config.ts @@ -1,4 +1,4 @@ -import { Config, ConfigBundle, Diagnostic } from '../../declarations'; +import { Config, ConfigBundle, Diagnostic, UnvalidatedConfig } from '../../declarations'; import { buildError, isBoolean, isNumber, isString, sortBy } from '@utils'; import { setBooleanConfig } from './config-utils'; import { validateDevServer } from './validate-dev-server'; @@ -12,7 +12,20 @@ import { validateRollupConfig } from './validate-rollup-config'; import { validateTesting } from './validate-testing'; import { validateWorkers } from './validate-workers'; -export const validateConfig = (userConfig?: Config) => { +/** + * Validate a Config object, ensuring that all its field are present and + * consistent with our expectations. This function transforms an + * `UnvalidatedConfig` to a `Config`. + * + * @param userConfig an unvalidated config that we've gotten from a user + * @returns an object with config and diagnostics props + */ +export const validateConfig = ( + userConfig: UnvalidatedConfig = {} +): { + config: Config; + diagnostics: Diagnostic[]; +} => { const config = Object.assign({}, userConfig || {}); // not positive it's json safe const diagnostics: Diagnostic[] = []; @@ -48,7 +61,7 @@ export const validateConfig = (userConfig?: Config) => { setBooleanConfig(config, 'sourceMap', null, typeof config.sourceMap === 'undefined' ? false : config.sourceMap); setBooleanConfig(config, 'watch', 'watch', false); setBooleanConfig(config, 'buildDocs', 'docs', !config.devMode); - setBooleanConfig(config, 'buildDist', 'esm', !config.devMode || config.buildEs5); + setBooleanConfig(config, 'buildDist', null, !config.devMode || config.buildEs5); setBooleanConfig(config, 'profile', 'profile', config.devMode); setBooleanConfig(config, 'writeLog', 'log', false); setBooleanConfig(config, 'buildAppCore', null, true); diff --git a/src/compiler/config/validate-dev-server.ts b/src/compiler/config/validate-dev-server.ts index bae82173714..dd0bc8d626b 100644 --- a/src/compiler/config/validate-dev-server.ts +++ b/src/compiler/config/validate-dev-server.ts @@ -3,21 +3,25 @@ import { buildError, isBoolean, isNumber, isString, normalizePath } from '@utils import { isAbsolute, join } from 'path'; import { isOutputTargetWww } from '../output-targets/output-utils'; -export const validateDevServer = (config: d.Config, diagnostics: d.Diagnostic[]) => { +export const validateDevServer = ( + config: d.UnvalidatedConfig, + diagnostics: d.Diagnostic[] +): d.DevServerConfig | undefined => { if ((config.devServer === null || (config.devServer as any)) === false) { - return null; + return undefined; } - const flags = config.flags; + const flags = config.flags ?? {}; const devServer = { ...config.devServer }; - if (isString(flags.address)) { + if (flags.address && isString(flags.address)) { devServer.address = flags.address; } else if (!isString(devServer.address)) { devServer.address = '0.0.0.0'; } - let addressProtocol: 'http' | 'https'; + // default to http for localdev + let addressProtocol: 'http' | 'https' = 'http'; if (devServer.address.toLowerCase().startsWith('http://')) { devServer.address = devServer.address.substring(7); addressProtocol = 'http'; @@ -28,8 +32,16 @@ export const validateDevServer = (config: d.Config, diagnostics: d.Diagnostic[]) devServer.address = devServer.address.split('/')[0]; - let addressPort: number; + // split on `:` to get the domain and the (possibly present) port + // separately. we've already sliced off the protocol (if present) above + // so we can safely split on `:` here. const addressSplit = devServer.address.split(':'); + + const isLocalhost = addressSplit[0] === 'localhost' || !isNaN(addressSplit[0].split('.')[0] as any); + + // if localhost we use 3333 as a default port + let addressPort: number | undefined = isLocalhost ? 3333 : undefined; + if (addressSplit.length > 1) { if (!isNaN(addressSplit[1] as any)) { devServer.address = addressSplit[0]; @@ -42,10 +54,6 @@ export const validateDevServer = (config: d.Config, diagnostics: d.Diagnostic[]) } else if (devServer.port !== null && !isNumber(devServer.port)) { if (isNumber(addressPort)) { devServer.port = addressPort; - } else if (devServer.address === 'localhost' || !isNaN(devServer.address.split('.')[0] as any)) { - devServer.port = 3333; - } else { - devServer.port = null; } } @@ -79,7 +87,7 @@ export const validateDevServer = (config: d.Config, diagnostics: d.Diagnostic[]) } if (devServer.ssr) { - const wwwOutput = config.outputTargets.find(isOutputTargetWww); + const wwwOutput = (config.outputTargets ?? []).find(isOutputTargetWww); devServer.prerenderConfig = wwwOutput?.prerenderConfig; } @@ -109,16 +117,17 @@ export const validateDevServer = (config: d.Config, diagnostics: d.Diagnostic[]) devServer.openBrowser = false; } - let serveDir: string = null; - let basePath: string = null; - const wwwOutputTarget = config.outputTargets.find(isOutputTargetWww); + let serveDir: string; + let basePath: string; + const wwwOutputTarget = (config.outputTargets ?? []).find(isOutputTargetWww); if (wwwOutputTarget) { - const baseUrl = new URL(wwwOutputTarget.baseUrl, 'http://config.stenciljs.com'); + const baseUrl = new URL(wwwOutputTarget.baseUrl ?? '', 'http://config.stenciljs.com'); basePath = baseUrl.pathname; - serveDir = wwwOutputTarget.appDir; + serveDir = wwwOutputTarget.appDir ?? ''; } else { - serveDir = config.rootDir; + basePath = ''; + serveDir = config.rootDir ?? ''; } if (!isString(basePath) || basePath.trim() === '') { @@ -153,7 +162,7 @@ export const validateDevServer = (config: d.Config, diagnostics: d.Diagnostic[]) } if (!isAbsolute(devServer.root)) { - devServer.root = join(config.rootDir, devServer.root); + devServer.root = join(config.rootDir as string, devServer.root); } devServer.root = normalizePath(devServer.root); diff --git a/src/compiler/config/validate-hydrated.ts b/src/compiler/config/validate-hydrated.ts index 9775f7cc51f..0335567a8a1 100644 --- a/src/compiler/config/validate-hydrated.ts +++ b/src/compiler/config/validate-hydrated.ts @@ -1,11 +1,26 @@ -import { Config, HydratedFlag } from '../../declarations'; +import { HydratedFlag, UnvalidatedConfig } from '../../declarations'; import { isString } from '@utils'; -export const validateHydrated = (config: Config) => { +/** + * Check the provided `.hydratedFlag` prop and return a properly-validated value. + * + * @param config the configuration we're examining + * @returns a suitable value for the hydratedFlag property + */ +export const validateHydrated = (config: UnvalidatedConfig): HydratedFlag | undefined => { + /** + * If `config.hydratedFlag` is set to `null` that is an explicit signal that we + * should _not_ create a default configuration when validating and should instead + * just return `undefined`. + * + * See {@link HydratedFlag} for more details. + */ if (config.hydratedFlag === null || config.hydratedFlag === false) { - return null; + return undefined; } + // Here we start building up a default config since `.hydratedFlag` wasn't set to + // `null` on the provided config. const hydratedFlag: HydratedFlag = { ...config.hydratedFlag }; if (!isString(hydratedFlag.name) || hydratedFlag.property === '') { diff --git a/src/compiler/config/validate-namespace.ts b/src/compiler/config/validate-namespace.ts index cfcd70f2f78..d6f98de2a5f 100644 --- a/src/compiler/config/validate-namespace.ts +++ b/src/compiler/config/validate-namespace.ts @@ -2,7 +2,7 @@ import type * as d from '../../declarations'; import { buildError, dashToPascalCase, isString } from '@utils'; import { isOutputTargetDist } from '../output-targets/output-utils'; -export const validateNamespace = (c: d.Config, diagnostics: d.Diagnostic[]) => { +export const validateNamespace = (c: d.UnvalidatedConfig, diagnostics: d.Diagnostic[]) => { c.namespace = isString(c.namespace) ? c.namespace : DEFAULT_NAMESPACE; c.namespace = c.namespace.trim(); @@ -40,8 +40,8 @@ export const validateNamespace = (c: d.Config, diagnostics: d.Diagnostic[]) => { } }; -export const validateDistNamespace = (config: d.Config, diagnostics: d.Diagnostic[]) => { - const hasDist = config.outputTargets.some(isOutputTargetDist); +export const validateDistNamespace = (config: d.UnvalidatedConfig, diagnostics: d.Diagnostic[]) => { + const hasDist = (config.outputTargets ?? []).some(isOutputTargetDist); if (hasDist) { if (!isString(config.namespace) || config.namespace.toLowerCase() === 'app') { const err = buildError(diagnostics); diff --git a/src/compiler/config/validate-paths.ts b/src/compiler/config/validate-paths.ts index 12a2c126c32..465a053ae61 100644 --- a/src/compiler/config/validate-paths.ts +++ b/src/compiler/config/validate-paths.ts @@ -1,7 +1,7 @@ import type * as d from '../../declarations'; import { isAbsolute, join } from 'path'; -export const validatePaths = (config: d.Config) => { +export const validatePaths = (config: d.UnvalidatedConfig) => { if (typeof config.rootDir !== 'string') { config.rootDir = '/'; } diff --git a/src/compiler/config/validate-plugins.ts b/src/compiler/config/validate-plugins.ts index 8ce17087393..d8822aebe27 100644 --- a/src/compiler/config/validate-plugins.ts +++ b/src/compiler/config/validate-plugins.ts @@ -1,7 +1,7 @@ import type * as d from '../../declarations'; import { buildWarn } from '@utils'; -export const validatePlugins = (config: d.Config, diagnostics: d.Diagnostic[]) => { +export const validatePlugins = (config: d.UnvalidatedConfig, diagnostics: d.Diagnostic[]) => { const userPlugins = config.plugins; if (!config.rollupPlugins) { diff --git a/src/compiler/config/validate-rollup-config.ts b/src/compiler/config/validate-rollup-config.ts index 6a872fbc80b..4fb8ff701f0 100644 --- a/src/compiler/config/validate-rollup-config.ts +++ b/src/compiler/config/validate-rollup-config.ts @@ -1,7 +1,7 @@ import type * as d from '../../declarations'; import { isObject, pluck } from '@utils'; -export const validateRollupConfig = (config: d.Config) => { +export const validateRollupConfig = (config: d.UnvalidatedConfig): void => { const cleanRollupConfig = getCleanRollupConfig(config.rollupConfig); config.rollupConfig = cleanRollupConfig; }; diff --git a/src/compiler/config/validate-testing.ts b/src/compiler/config/validate-testing.ts index 209671ff75a..1f97378a3cd 100644 --- a/src/compiler/config/validate-testing.ts +++ b/src/compiler/config/validate-testing.ts @@ -4,20 +4,20 @@ import { isAbsolute, join, basename, dirname } from 'path'; import { isLocalModule } from '../sys/resolve/resolve-utils'; import { isOutputTargetDist, isOutputTargetWww } from '../output-targets/output-utils'; -export const validateTesting = (config: d.Config, diagnostics: d.Diagnostic[]) => { +export const validateTesting = (config: d.UnvalidatedConfig, diagnostics: d.Diagnostic[]) => { const testing = (config.testing = Object.assign({}, config.testing || {})); if (!config.flags || (!config.flags.e2e && !config.flags.spec)) { return; } - let configPathDir = config.configPath; + let configPathDir = config.configPath!; if (isString(configPathDir)) { if (basename(configPathDir).includes('.')) { configPathDir = dirname(configPathDir); } } else { - configPathDir = config.rootDir; + configPathDir = config.rootDir!; } if (typeof config.flags.headless === 'boolean') { @@ -43,7 +43,7 @@ export const validateTesting = (config: d.Config, diagnostics: d.Diagnostic[]) = if (typeof testing.rootDir === 'string') { if (!isAbsolute(testing.rootDir)) { - testing.rootDir = join(config.rootDir, testing.rootDir); + testing.rootDir = join(config.rootDir!, testing.rootDir); } } else { testing.rootDir = config.rootDir; @@ -55,11 +55,11 @@ export const validateTesting = (config: d.Config, diagnostics: d.Diagnostic[]) = if (typeof testing.screenshotConnector === 'string') { if (!isAbsolute(testing.screenshotConnector)) { - testing.screenshotConnector = join(config.rootDir, testing.screenshotConnector); + testing.screenshotConnector = join(config.rootDir!, testing.screenshotConnector); } } else { testing.screenshotConnector = join( - config.sys.getCompilerExecutingPath(), + config.sys!.getCompilerExecutingPath(), '..', '..', 'screenshot', @@ -69,18 +69,18 @@ export const validateTesting = (config: d.Config, diagnostics: d.Diagnostic[]) = if (!Array.isArray(testing.testPathIgnorePatterns)) { testing.testPathIgnorePatterns = DEFAULT_IGNORE_PATTERNS.map((ignorePattern) => { - return join(testing.rootDir, ignorePattern); + return join(testing.rootDir!, ignorePattern); }); - config.outputTargets + (config.outputTargets ?? []) .filter((o) => (isOutputTargetDist(o) || isOutputTargetWww(o)) && o.dir) .forEach((outputTarget: d.OutputTargetWww) => { - testing.testPathIgnorePatterns.push(outputTarget.dir); + testing.testPathIgnorePatterns?.push(outputTarget.dir!); }); } if (typeof testing.preset !== 'string') { - testing.preset = join(config.sys.getCompilerExecutingPath(), '..', '..', 'testing'); + testing.preset = join(config.sys!.getCompilerExecutingPath(), '..', '..', 'testing'); } else if (!isAbsolute(testing.preset)) { testing.preset = join(configPathDir, testing.preset); } @@ -90,7 +90,7 @@ export const validateTesting = (config: d.Config, diagnostics: d.Diagnostic[]) = } testing.setupFilesAfterEnv.unshift( - join(config.sys.getCompilerExecutingPath(), '..', '..', 'testing', 'jest-setuptestframework.js') + join(config.sys!.getCompilerExecutingPath(), '..', '..', 'testing', 'jest-setuptestframework.js') ); if (isString(testing.testEnvironment)) { @@ -146,7 +146,7 @@ export const validateTesting = (config: d.Config, diagnostics: d.Diagnostic[]) = } if (typeof testing.runner !== 'string') { - testing.runner = join(config.sys.getCompilerExecutingPath(), '..', '..', 'testing', 'jest-runner.js'); + testing.runner = join(config.sys!.getCompilerExecutingPath(), '..', '..', 'testing', 'jest-runner.js'); } if (typeof testing.waitBeforeScreenshot === 'number') { diff --git a/src/compiler/config/validate-workers.ts b/src/compiler/config/validate-workers.ts index 799cc7cb7d0..a2c09284d32 100644 --- a/src/compiler/config/validate-workers.ts +++ b/src/compiler/config/validate-workers.ts @@ -1,6 +1,6 @@ import type * as d from '../../declarations'; -export const validateWorkers = (config: d.Config) => { +export const validateWorkers = (config: d.UnvalidatedConfig) => { if (typeof config.maxConcurrentWorkers !== 'number') { config.maxConcurrentWorkers = 8; } diff --git a/src/declarations/stencil-public-compiler.ts b/src/declarations/stencil-public-compiler.ts index e91e1c920b7..8a43be0e50f 100644 --- a/src/declarations/stencil-public-compiler.ts +++ b/src/declarations/stencil-public-compiler.ts @@ -352,6 +352,40 @@ export interface Config extends StencilConfig { _isTesting?: boolean; } +/** + * A 'loose' type useful for wrapping an incomplete / possible malformed + * object as we work on getting it comply with a particular Interface T. + * + * Example: + * + * ```ts + * interface Foo { + * bar: string + * } + * + * function validateFoo(foo: Loose): Foo { + * let validatedFoo = { + * ...foo, + * bar: foo.bar || DEFAULT_BAR + * } + * + * return validatedFoo + * } + * ``` + * + * Use this when you need to take user input or something from some other part + * of the world that we don't control and transform it into something + * conforming to a given interface. For best results, pair with a validation + * function as shown in the example. + */ +type Loose = Record & Partial; + +/** + * A Loose version of the Config interface. This is intended to let us load a partial config + * and have type information carry though as we construct an object which is a valid `Config`. + */ +export type UnvalidatedConfig = Loose; + export interface HydratedFlag { /** * Defaults to `hydrated`. @@ -2124,7 +2158,7 @@ export interface LoadConfigInit { * User config object to merge into default config and * config loaded from a file path. */ - config?: Config; + config?: UnvalidatedConfig; /** * Absolute path to a Stencil config file. This path cannot be * relative and it does not resolve config files within a directory. @@ -2140,8 +2174,13 @@ export interface LoadConfigInit { initTsConfig?: boolean; } +/** + * Results from an attempt to load a config. The values on this interface + * have not yet been validated and are not ready to be used for arbitrary + * operations around the codebase. + */ export interface LoadConfigResults { - config: Config; + config: UnvalidatedConfig; diagnostics: Diagnostic[]; tsconfig: { path: string; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 2570322ea4c..a051897a1d1 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -81,12 +81,13 @@ export const pluck = (obj: { [key: string]: any }, keys: string[]) => { }; export const isBoolean = (v: any): v is boolean => typeof v === 'boolean'; -export const isDefined = (v: any) => v !== null && v !== undefined; -export const isUndefined = (v: any) => v === null || v === undefined; +export const isDefined = (v: any): v is NonNullable => v !== null && v !== undefined; +export const isUndefined = (v: any): v is null | undefined => v === null || v === undefined; export const isFunction = (v: any): v is Function => typeof v === 'function'; -export const isNumber = (v: any): v is boolean => typeof v === 'number'; -export const isObject = (val: Object) => val != null && typeof val === 'object' && Array.isArray(val) === false; +export const isNumber = (v: any): v is number => typeof v === 'number'; +export const isObject = (val: Object): val is Object => + val != null && typeof val === 'object' && Array.isArray(val) === false; export const isString = (v: any): v is string => typeof v === 'string'; -export const isIterable = (v: any): v is Iterable => isDefined(v) && isFunction(v[Symbol.iterator]); +export const isIterable = (v: any): v is Iterable => isDefined(v) && isFunction(v[Symbol.iterator]); export const isPromise = (v: any): v is Promise => !!v && (typeof v === 'object' || typeof v === 'function') && typeof v.then === 'function';