From 7538fdc2fdfefbed8304010175850ece6ba87e53 Mon Sep 17 00:00:00 2001 From: Lexus Drumgold Date: Sat, 4 Feb 2023 19:11:30 -0500 Subject: [PATCH] feat(utils): `loadTsconfig` Signed-off-by: Lexus Drumgold --- .eslintignore | 2 + .prettierignore | 1 + .vscode/settings.json | 8 +- __fixtures__/tsconfig.build.json | 12 ++ .../{.gitkeep => tsconfig.empty.json} | 0 __fixtures__/tsconfig.invalid-json.json | 3 + __fixtures__/tsconfig.invalid-schema.json | 1 + package.json | 5 +- src/index.ts | 1 + src/utils/__snapshots__/load-tsconfig.snap | 77 +++++++ src/utils/__tests__/load-tsconfig.spec.ts | 64 ++++++ src/utils/index.ts | 6 + src/utils/load-tsconfig.ts | 188 ++++++++++++++++++ tsconfig.json | 2 +- yarn.lock | 95 ++++++++- 15 files changed, 460 insertions(+), 5 deletions(-) create mode 100644 __fixtures__/tsconfig.build.json rename __fixtures__/{.gitkeep => tsconfig.empty.json} (100%) create mode 100644 __fixtures__/tsconfig.invalid-json.json create mode 100644 __fixtures__/tsconfig.invalid-schema.json create mode 100644 src/utils/__snapshots__/load-tsconfig.snap create mode 100644 src/utils/__tests__/load-tsconfig.spec.ts create mode 100644 src/utils/index.ts create mode 100644 src/utils/load-tsconfig.ts diff --git a/.eslintignore b/.eslintignore index 7024a7b8..f44b07d3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,6 +4,8 @@ # DIRECTORIES & FILES **/*.snap **/.DS_Store +__fixtures__/tsconfig.empty.json +__fixtures__/tsconfig.invalid-json.json __tests__/report.json coverage/ dist/ diff --git a/.prettierignore b/.prettierignore index a21c3a49..607a1252 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,6 +17,7 @@ .prettierignore .yarn/ Brewfile +__fixtures__/tsconfig.invalid-json.json __tests__/report.json coverage/ dist/ diff --git a/.vscode/settings.json b/.vscode/settings.json index b54557d1..a2bd6e28 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -301,7 +301,13 @@ "icon": "graphql" }, { - "extensions": ["build.json", "typecheck.json"], + "extensions": [ + "build.json", + "empty.json", + "invalid-json.json", + "invalid-schema.json", + "typecheck.json" + ], "format": "svg", "icon": "tsconfig" }, diff --git a/__fixtures__/tsconfig.build.json b/__fixtures__/tsconfig.build.json new file mode 100644 index 00000000..eba6e3e1 --- /dev/null +++ b/__fixtures__/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "lib": ["es2020"], + "noEmitOnError": true, + "skipLibCheck": false, + "target": "es2020" + }, + "exclude": ["**/__mocks__/**", "**/__tests__/**"], + "extends": "../tsconfig", + "include": ["../dist", "../src"], + "references": [] +} diff --git a/__fixtures__/.gitkeep b/__fixtures__/tsconfig.empty.json similarity index 100% rename from __fixtures__/.gitkeep rename to __fixtures__/tsconfig.empty.json diff --git a/__fixtures__/tsconfig.invalid-json.json b/__fixtures__/tsconfig.invalid-json.json new file mode 100644 index 00000000..d50caf73 --- /dev/null +++ b/__fixtures__/tsconfig.invalid-json.json @@ -0,0 +1,3 @@ +{ + 'compilerOptions': {} +} diff --git a/__fixtures__/tsconfig.invalid-schema.json b/__fixtures__/tsconfig.invalid-schema.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/__fixtures__/tsconfig.invalid-schema.json @@ -0,0 +1 @@ +[] diff --git a/package.json b/package.json index d39323d7..16f230a0 100644 --- a/package.json +++ b/package.json @@ -67,11 +67,13 @@ "typecheck:watch": "vitest typecheck" }, "dependencies": { - "@flex-development/errnode": "1.4.0", + "@flex-development/errnode": "1.5.0", "@flex-development/mlly": "1.0.0-alpha.9", "@flex-development/pathe": "1.0.3", "@flex-development/tsconfig-types": "2.0.3", "@flex-development/tutils": "6.0.0-alpha.10", + "merge-anything": "5.1.4", + "sort-keys": "5.0.0", "strip-bom": "5.0.0", "strip-json-comments": "5.0.0" }, @@ -133,6 +135,7 @@ "jsonc-eslint-parser": "2.1.0", "lint-staged": "13.1.0", "mri": "1.2.0", + "node-fetch": "3.3.0", "node-notifier": "10.0.1", "prettier": "2.8.3", "prettier-plugin-sh": "0.12.8", diff --git a/src/index.ts b/src/index.ts index 5d89cab0..85a9ec4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,3 +4,4 @@ */ export * from './interfaces' +export * from './utils' diff --git a/src/utils/__snapshots__/load-tsconfig.snap b/src/utils/__snapshots__/load-tsconfig.snap new file mode 100644 index 00000000..9ae04db3 --- /dev/null +++ b/src/utils/__snapshots__/load-tsconfig.snap @@ -0,0 +1,77 @@ +// Vitest Snapshot v1 + +exports[`unit:utils/loadTsconfig > should return TSConfig object if tsconfig file is found 1`] = ` +{ + "compilerOptions": { + "allowJs": true, + "allowUnreachableCode": false, + "alwaysStrict": false, + "baseUrl": "..", + "checkJs": false, + "declaration": true, + "declarationMap": true, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "exactOptionalPropertyTypes": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "importsNotUsedAsValues": "error", + "isolatedModules": true, + "lib": [ + "es2020", + ], + "module": "esnext", + "moduleDetection": "force", + "moduleResolution": "node", + "newLine": "lf", + "noEmit": true, + "noEmitOnError": true, + "noErrorTruncation": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "outDir": "../dist", + "paths": { + "#fixtures/*": [ + "__fixtures__/*", + ], + "#src": [ + "src/index", + ], + "#src/*": [ + "src/*", + ], + "#tests/*": [ + "__tests__/*", + ], + }, + "preserveConstEnums": true, + "preserveSymlinks": true, + "pretty": true, + "resolveJsonModule": true, + "rootDir": "..", + "skipLibCheck": false, + "sourceMap": true, + "strict": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "target": "es2020", + "useDefineForClassFields": true, + "useUnknownInCatchVariables": true, + }, + "exclude": [ + "**/__mocks__/**", + "**/__tests__/**", + ], + "extends": "../tsconfig", + "include": [ + "../dist", + "../src", + ], + "references": [], +} +`; diff --git a/src/utils/__tests__/load-tsconfig.spec.ts b/src/utils/__tests__/load-tsconfig.spec.ts new file mode 100644 index 00000000..930f745c --- /dev/null +++ b/src/utils/__tests__/load-tsconfig.spec.ts @@ -0,0 +1,64 @@ +/** + * @file Unit Tests - loadTsconfig + * @module tsconfig-utils/utils/tests/unit/loadTsconfig + */ + +import { ErrorCode, type NodeError } from '@flex-development/errnode' +import { pathToFileURL, type URL } from 'node:url' +import testSubject from '../load-tsconfig' + +describe('unit:utils/loadTsconfig', () => { + it('should return TSConfig object if tsconfig file is found', () => { + // Arrange + const id: URL = pathToFileURL('__fixtures__/tsconfig.build.json') + + // Act + Expect + expect(testSubject(id)).toMatchSnapshot() + }) + + it('should return empty object if tsconfig file is empty', () => { + expect(testSubject('__fixtures__/tsconfig.empty.json')).to.deep.equal({}) + }) + + it('should return null if tsconfig file does not exist', () => { + expect(testSubject('__fixtures__/tsconfig.dev.json')).to.be.null + }) + + describe('throws', () => { + it('should throw if tsconfig file does not contain valid JSON', () => { + // Arrange + const code: ErrorCode = ErrorCode.ERR_OPERATION_FAILED + let error: NodeError + + // Act + try { + testSubject('__fixtures__/tsconfig.invalid-json') + } catch (e: unknown) { + error = e as typeof error + } + + // Expect + expect(error!).to.not.be.undefined + expect(error!).to.have.property('code').equal(code) + }) + + it('should throw if tsconfig file does not convert to plain object', () => { + // Arrange + const code: ErrorCode = ErrorCode.ERR_INVALID_RETURN_VALUE + const message_regex: RegExp = /plain object .+ 'parseJSON'/ + let error: NodeError + + // Act + try { + testSubject('__fixtures__/tsconfig.invalid-schema') + } catch (e: unknown) { + error = e as typeof error + } + + // Expect + expect(error!).to.not.be.undefined + expect(error!).to.have.property('code').equal(code) + expect(error!).to.have.property('message').match(message_regex) + }) + }) +}) diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..11ad9eff --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,6 @@ +/** + * @file Entry Point - Utilities + * @module tsconfig-utils/utils + */ + +export { default as loadTsconfig } from './load-tsconfig' diff --git a/src/utils/load-tsconfig.ts b/src/utils/load-tsconfig.ts new file mode 100644 index 00000000..19fe50a4 --- /dev/null +++ b/src/utils/load-tsconfig.ts @@ -0,0 +1,188 @@ +/** + * @file Utilities - loadTsconfig + * @module tsconfig-utils/utils/loadTsconfig + */ + +import type { LoadTsconfigOptions } from '#src/interfaces' +import * as internal from '#src/internal' +import { + ERR_INVALID_RETURN_VALUE, + ERR_OPERATION_FAILED, + type NodeError +} from '@flex-development/errnode' +import * as mlly from '@flex-development/mlly' +import pathe from '@flex-development/pathe' +import type { + CompilerOptions, + CompilerOptionsValue, + TSConfig +} from '@flex-development/tsconfig-types' +import { + isEmptyString, + isObjectPlain, + isString, + type Nilable, + type Nullable +} from '@flex-development/tutils' +import { mergeAndCompare } from 'merge-anything' +import type { URL } from 'node:url' +import sortKeys from 'sort-keys' + +/** + * Reads and parses the [tsconfig][1] file at the given module `id`. + * + * If the tsconfig file is found, comments and [byte order marks (BOMs)][2] will + * be removed before parsing. If successfully parsed, an object representation + * of the tsconfig file will be returned. + * + * [Extending tsconfig files][3] is also supported. If not overwritten, the + * [`baseUrl`][4], [`outDir`][5], and [`rootDir`][6] properties from the base + * tsconfig file will be made relative to the tsconfig file being loaded. + * + * [1]: https://www.typescriptlang.org/tsconfig + * [2]: https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8 + * [3]: https://www.typescriptlang.org/tsconfig#extends + * [4]: https://www.typescriptlang.org/tsconfig#baseUrl + * [5]: https://www.typescriptlang.org/tsconfig#outDir + * [6]: https://www.typescriptlang.org/tsconfig#rootDir + * + * @param {URL | string} id - Module id of tsconfig file + * @param {LoadTsconfigOptions} [options={}] - Load options + * @return {Nullable} `TSConfig` object or `null` + * @throws {NodeError} + */ +const loadTsconfig = ( + id: URL | string, + options: LoadTsconfigOptions = {} +): Nullable => { + const { file = internal.isFile, read = internal.readFile } = options + + // ensure id is an instance of URL or a string + internal.validateURLString(id, 'id') + + // ensure option schemas + internal.validateFunction(file, 'options.file') + internal.validateFunction(read, 'options.read') + + // ensure id is an instance of URL + id = mlly.toURL(id) + + // ensure module id includes '.json' extension + if (!id.href.endsWith('.json')) id = mlly.toURL(id.href + '.json') + + // exit early if tsconfig file is not a file + if (!file(id)) return null + + /** + * Tsconfig file content. + * + * @const {string} content + */ + const content: string = read(id) + + // exit early if tsconfig file is empty + if (!content.trim()) return {} + + /** + * Tsconfig object. + * + * @var {Nullable} tsconfig + */ + let tsconfig: Nullable = {} + + // parse tsconfig file + try { + tsconfig = internal.parseJSON(content) + } catch (e: unknown) { + throw new ERR_OPERATION_FAILED((e as Error).message) + } + + // throw if tsconfig is not plain object + if (!isObjectPlain(tsconfig)) { + throw new ERR_INVALID_RETURN_VALUE('a plain object', 'parseJSON', tsconfig) + } + + // try merging tsconfig and base tsconfig + if (isString(tsconfig.extends) && !isEmptyString(tsconfig.extends.trim())) { + /** + * Absolute path to base tsconfig file. + * + * @const {string} basepath + */ + const basepath: string = pathe.join( + pathe.dirname(id.pathname), + (tsconfig.extends + '.json').replace(/(\.json\.json)$/, '.json') + ) + + /** + * Base tsconfig object. + * + * @const {Nullable} base + */ + const base: Nullable = loadTsconfig(basepath, { file, read }) + + // merge tsconfig objects if base tsconfig object was found + if (base) { + /** + * Compares values from a base tsconfig file and inheriting tsconfig file + * to determine how the values should be merged. + * + * @param {Nilable} b - Value from + * base tsconfig object, {@linkcode base} + * @param {Nilable} t - Value from + * inheriting tsconfig object, {@linkcode tsconfig} + * @param {string | symbol} key - Object key being evaluated + * @return {Nilable} Merge value + */ + const compare = ( + b: Nilable, + t: Nilable, + key: string | symbol + ): Nilable => { + /** + * Merge value. + * + * @var {Nilable} merged + */ + let merged: Nilable = t + + // determine how to merge values + switch (true) { + // relative paths should be interpreted as relative to base tsconfig, + // but they need also need to be relative to inheriting tsconfig + case key === 'baseUrl' && isString(b): + case key === 'outDir' && isString(b): + case key === 'rootDir' && isString(b): + const { extends: extend = '' } = tsconfig! + if (b === t) merged = pathe.join(pathe.dirname(extend), b as string) + break + // recursively merge compilerOptions + case key === 'compilerOptions': + merged = sortKeys(mergeAndCompare(compare, b, t) as CompilerOptions) + break + // exclude, files, and include properties from inheriting tsconfig + // file should overwrite those from base tsconfig file. + case key === 'exclude': + case key === 'files': + case key === 'include': + merged = t ?? /* c8 ignore next */ b + break + // references is the only top-level property excluded from inheritance + case key === 'references': + break + default: + break + } + + return merged + } + + // merge tsconfig objects + tsconfig = mergeAndCompare(compare, base, tsconfig) + } + } + + return sortKeys(tsconfig) +} + +export default loadTsconfig diff --git a/tsconfig.json b/tsconfig.json index 7c1a47a0..5a199d8d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "forceConsistentCasingInFileNames": true, "importsNotUsedAsValues": "error", "isolatedModules": true, - "lib": ["es2020"], + "lib": ["dom", "dom.iterable", "es2020"], "module": "esnext", "moduleDetection": "force", "moduleResolution": "node", diff --git a/yarn.lock b/yarn.lock index 90911db4..f5fd064e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1175,6 +1175,18 @@ __metadata: languageName: node linkType: hard +"@flex-development/errnode@npm:1.5.0": + version: 1.5.0 + resolution: "@flex-development/errnode@npm:1.5.0::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Ferrnode%2F1.5.0%2F33dedbfaeb3139375e41d4382a8de53f9415a261" + dependencies: + "@flex-development/tutils": "npm:6.0.0-alpha.10" + node-inspect-extracted: "npm:2.0.0" + peerDependencies: + "@types/node": ">=14" + checksum: 884d5339afb245fdb3e635ba599e373ba1dd9f68e789c796817fe4fbe15317fb0192f49684b524c3baba1d4b70934799a5b353439543f29ee36b513d63e53bfa + languageName: node + linkType: hard + "@flex-development/export-regex@npm:1.0.0": version: 1.0.0 resolution: "@flex-development/export-regex@npm:1.0.0::__archiveUrl=https%3A%2F%2Fnpm.pkg.github.com%2Fdownload%2F%40flex-development%2Fexport-regex%2F1.0.0%2Fee7486c94c3ecbb392ae3beff0c9ecc4f1ce7282" @@ -1303,7 +1315,7 @@ __metadata: "@commitlint/cli": "npm:17.4.2" "@commitlint/config-conventional": "npm:17.4.2" "@faker-js/faker": "npm:8.0.0-alpha.0" - "@flex-development/errnode": "npm:1.4.0" + "@flex-development/errnode": "npm:1.5.0" "@flex-development/mkbuild": "npm:1.0.0-alpha.9" "@flex-development/mlly": "npm:1.0.0-alpha.9" "@flex-development/pathe": "npm:1.0.3" @@ -1361,13 +1373,16 @@ __metadata: is-ci: "npm:3.0.1" jsonc-eslint-parser: "npm:2.1.0" lint-staged: "npm:13.1.0" + merge-anything: "npm:5.1.4" mri: "npm:1.2.0" + node-fetch: "npm:3.3.0" node-notifier: "npm:10.0.1" prettier: "npm:2.8.3" prettier-plugin-sh: "npm:0.12.8" sade: "npm:1.8.1" semver: "npm:7.3.8" serve: "npm:14.2.0" + sort-keys: "npm:5.0.0" strip-bom: "npm:5.0.0" strip-json-comments: "npm:5.0.0" tempfile: "npm:4.0.0" @@ -3805,6 +3820,13 @@ __metadata: languageName: node linkType: hard +"data-uri-to-buffer@npm:^4.0.0": + version: 4.0.1 + resolution: "data-uri-to-buffer@npm:4.0.1" + checksum: 4398e0c9ca2073b89c0c6f90ffe5044e9193966f3f734b8492237d8dcd1305c77e08d964922da6e5bde9e380eddbde1c110340d7fbb34dcbdfeea35c45383211 + languageName: node + linkType: hard + "dataloader@npm:2.1.0": version: 2.1.0 resolution: "dataloader@npm:2.1.0" @@ -4715,6 +4737,16 @@ __metadata: languageName: node linkType: hard +"fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": + version: 3.2.0 + resolution: "fetch-blob@npm:3.2.0" + dependencies: + node-domexception: "npm:^1.0.0" + web-streams-polyfill: "npm:^3.0.3" + checksum: 114f3d29d46bf029fdc4753b3688295e9a917f37c81c124b3fcad7388ecffe234c29cd48259bed2319ca25aaf105ffd96a3e369c3ad1bcca5f94f410876f5b0d + languageName: node + linkType: hard + "figures@npm:^3.0.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -4817,6 +4849,15 @@ __metadata: languageName: node linkType: hard +"formdata-polyfill@npm:^4.0.10": + version: 4.0.10 + resolution: "formdata-polyfill@npm:4.0.10" + dependencies: + fetch-blob: "npm:^3.1.2" + checksum: 8954f9e756728f96239da0b07b2651193ebad3be58c7c9b114c3982982861d8bbd820497926b1d5018e5a57281af86693471672ed7c6c26860910c5597d5fc9d + languageName: node + linkType: hard + "fs-extra@npm:10.1.0": version: 10.1.0 resolution: "fs-extra@npm:10.1.0" @@ -5637,6 +5678,13 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^4.0.0": + version: 4.1.0 + resolution: "is-plain-obj@npm:4.1.0" + checksum: 9d6bfe46ad30eda62cc2f0caec2ee980257a84a0a003523588c8c0e5eb33b6e42e73910f42c323490bfdfdd1bf7fd7854e8f156c275da7c12bebebb1be11c73a + languageName: node + linkType: hard + "is-port-reachable@npm:4.0.0": version: 4.0.0 resolution: "is-port-reachable@npm:4.0.0" @@ -5681,6 +5729,13 @@ __metadata: languageName: node linkType: hard +"is-what@npm:^4.1.8": + version: 4.1.8 + resolution: "is-what@npm:4.1.8" + checksum: c2e9f829658980ab7bc0d21be714e9683bef7a48b76e412792135f333105f940098a77258704d352d794f7766def0da36549ce6080d1cc3c8e1f3c45a728c8db + languageName: node + linkType: hard + "is-wsl@npm:^2.2.0": version: 2.2.0 resolution: "is-wsl@npm:2.2.0" @@ -6312,6 +6367,15 @@ __metadata: languageName: node linkType: hard +"merge-anything@npm:5.1.4": + version: 5.1.4 + resolution: "merge-anything@npm:5.1.4" + dependencies: + is-what: "npm:^4.1.8" + checksum: 489fbf11bb7455e397a59bb1ed46e577308d6822b26c81ca204974f251c1e30b0f8586910195a59769c3732b33d6026f254031c05f77d54c99dcc160b35cb9a4 + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -6679,6 +6743,24 @@ __metadata: languageName: node linkType: hard +"node-domexception@npm:^1.0.0": + version: 1.0.0 + resolution: "node-domexception@npm:1.0.0" + checksum: 7b65cf4b5e9545fbf17d8fd969952f71074048ff6f5c94d4ba9b98f1aee84ca9c5ec12e0eb7d5db0b6ad199c8c8c100056ef36c1145eabb542d910159c034bb7 + languageName: node + linkType: hard + +"node-fetch@npm:3.3.0": + version: 3.3.0 + resolution: "node-fetch@npm:3.3.0" + dependencies: + data-uri-to-buffer: "npm:^4.0.0" + fetch-blob: "npm:^3.1.4" + formdata-polyfill: "npm:^4.0.10" + checksum: 1a833a97b36f8646171409ca426a31b08a360a30f90f282c722cf7dce2f9c754ce687706cd6d76d110b7da7d1fe3d8530ba70640f0c23fa1a7e0b98fe59ecfbe + languageName: node + linkType: hard + "node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.9": version: 2.6.9 resolution: "node-fetch@npm:2.6.9" @@ -8051,6 +8133,15 @@ __metadata: languageName: node linkType: hard +"sort-keys@npm:5.0.0": + version: 5.0.0 + resolution: "sort-keys@npm:5.0.0" + dependencies: + is-plain-obj: "npm:^4.0.0" + checksum: f8a0a3e63f2c1a30921b8693ac785f974dd496e9e849f39685c4ce9f5901fc0f597226130668f4d1b95cf4c5a1aab4744802713428ef4057fd5b9257f0c6dee8 + languageName: node + linkType: hard + "source-map-js@npm:^1.0.2": version: 1.0.2 resolution: "source-map-js@npm:1.0.2" @@ -9095,7 +9186,7 @@ __metadata: languageName: node linkType: hard -"web-streams-polyfill@npm:^3.2.1": +"web-streams-polyfill@npm:^3.0.3, web-streams-polyfill@npm:^3.2.1": version: 3.2.1 resolution: "web-streams-polyfill@npm:3.2.1" checksum: d0b6246240d181d6e2d8de6ded04938581bc5807da33ccd6f6b4a431c1f3fa3c04ffe0dfb739c7172d1208141717b4c80e8df7b300998fa9287ddb69bbaa0c68