From 417ec6dca8dc53808e9d8b91925e4cc31d9262ae Mon Sep 17 00:00:00 2001 From: Cedric van Putten Date: Thu, 23 Nov 2023 11:07:05 +0100 Subject: [PATCH] refactor(json-file): move to `expo/expo` (#25405) # Why This package is used in various places [within this monorepo](https://github.com/search?q=repo%3Aexpo%2Fexpo%20%40expo%2Fjson-file%20path%3A**%2Fpackage.json&type=code). The clean-up PR is here: expo/expo-cli#4782 # How - Moved `@expo/json-file` from `expo/expo-cli`. - Added missing `devDependencies` relation to `expo-module-scripts` - Updated `jest.config.js`, and `package.json` - Building with TSC, not with babel, so dropped `babel.config.js` # Test Plan See if CI passes. # Checklist - [x] Documentation is up to date to reflect these changes (eg: https://docs.expo.dev and README.md). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). --- .github/CODEOWNERS | 1 + .github/workflows/cli.yml | 2 + packages/@expo/json-file/.eslintignore | 2 + packages/@expo/json-file/.eslintrc.js | 2 + packages/@expo/json-file/CHANGELOG.md | 13 + packages/@expo/json-file/LICENSE | 22 ++ packages/@expo/json-file/README.md | 29 ++ packages/@expo/json-file/build/JsonFile.d.ts | 61 +++ packages/@expo/json-file/build/JsonFile.js | 283 ++++++++++++++ .../@expo/json-file/build/JsonFile.js.map | 1 + .../@expo/json-file/build/JsonFileError.d.ts | 13 + .../@expo/json-file/build/JsonFileError.js | 35 ++ .../json-file/build/JsonFileError.js.map | 1 + packages/@expo/json-file/jest.config.js | 9 + packages/@expo/json-file/package.json | 46 +++ packages/@expo/json-file/src/JsonFile.ts | 352 ++++++++++++++++++ packages/@expo/json-file/src/JsonFileError.ts | 31 ++ .../json-file/src/__tests__/JsonFile-test.ts | 138 +++++++ .../src/__tests__/JsonFileError-test.ts | 21 ++ .../src/__tests__/files/syntax-error.json | 28 ++ .../src/__tests__/files/syntax-error.json5 | 6 + .../src/__tests__/files/test-json5.json | 15 + .../json-file/src/__tests__/files/test.json | 12 + packages/@expo/json-file/tsconfig.json | 9 + yarn.lock | 38 +- 25 files changed, 1156 insertions(+), 14 deletions(-) create mode 100644 packages/@expo/json-file/.eslintignore create mode 100644 packages/@expo/json-file/.eslintrc.js create mode 100644 packages/@expo/json-file/CHANGELOG.md create mode 100644 packages/@expo/json-file/LICENSE create mode 100644 packages/@expo/json-file/README.md create mode 100644 packages/@expo/json-file/build/JsonFile.d.ts create mode 100644 packages/@expo/json-file/build/JsonFile.js create mode 100644 packages/@expo/json-file/build/JsonFile.js.map create mode 100644 packages/@expo/json-file/build/JsonFileError.d.ts create mode 100644 packages/@expo/json-file/build/JsonFileError.js create mode 100644 packages/@expo/json-file/build/JsonFileError.js.map create mode 100644 packages/@expo/json-file/jest.config.js create mode 100644 packages/@expo/json-file/package.json create mode 100644 packages/@expo/json-file/src/JsonFile.ts create mode 100644 packages/@expo/json-file/src/JsonFileError.ts create mode 100644 packages/@expo/json-file/src/__tests__/JsonFile-test.ts create mode 100644 packages/@expo/json-file/src/__tests__/JsonFileError-test.ts create mode 100644 packages/@expo/json-file/src/__tests__/files/syntax-error.json create mode 100644 packages/@expo/json-file/src/__tests__/files/syntax-error.json5 create mode 100644 packages/@expo/json-file/src/__tests__/files/test-json5.json create mode 100644 packages/@expo/json-file/src/__tests__/files/test.json create mode 100644 packages/@expo/json-file/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2818a9b2cf3c0..1bb180cc9bb30 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,6 +4,7 @@ /packages/@expo/prebuild-config @EvanBacon /packages/@expo/config-plugins @EvanBacon @brentvatne /packages/@expo/config-types @EvanBacon @douglowder +/packages/@expo/json-file @EvanBacon @bycedric /packages/@expo/metro-config @EvanBacon @bycedric @marklawlor /packages/@expo/metro-runtime @EvanBacon @bycedric @marklawlor /packages/@expo/package-manager @EvanBacon @bycedric diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 54cf4d62ec51d..efdd9ea821346 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -11,6 +11,7 @@ on: - packages/@expo/config/src/** - packages/@expo/config-plugins/src/** - packages/@expo/env/src/** + - packages/@expo/json-file/src/** - packages/@expo/package-manager/src/** - packages/@expo/prebuild-config/src/** - packages/@expo/server/src/** @@ -26,6 +27,7 @@ on: - packages/@expo/config/src/** - packages/@expo/config-plugins/src/** - packages/@expo/env/src/** + - packages/@expo/json-file/src/** - packages/@expo/package-manager/src/** - packages/@expo/prebuild-config/src/** - packages/@expo/server/src/** diff --git a/packages/@expo/json-file/.eslintignore b/packages/@expo/json-file/.eslintignore new file mode 100644 index 0000000000000..dc84959d1dcee --- /dev/null +++ b/packages/@expo/json-file/.eslintignore @@ -0,0 +1,2 @@ +build/ + diff --git a/packages/@expo/json-file/.eslintrc.js b/packages/@expo/json-file/.eslintrc.js new file mode 100644 index 0000000000000..2720197860feb --- /dev/null +++ b/packages/@expo/json-file/.eslintrc.js @@ -0,0 +1,2 @@ +// @generated by expo-module-scripts +module.exports = require('expo-module-scripts/eslintrc.base.js'); diff --git a/packages/@expo/json-file/CHANGELOG.md b/packages/@expo/json-file/CHANGELOG.md new file mode 100644 index 0000000000000..368093dd53eb3 --- /dev/null +++ b/packages/@expo/json-file/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## Unpublished + +### šŸ›  Breaking changes + +### šŸŽ‰ New features + +### šŸ› Bug fixes + +### šŸ’” Others + +- Move package from `expo/expo-cli` to `expo/expo`. ([#25405](https://github.com/expo/expo/pull/25405) by [@byCedric](https://github.com/byCedric)) diff --git a/packages/@expo/json-file/LICENSE b/packages/@expo/json-file/LICENSE new file mode 100644 index 0000000000000..6b3ac8fa181d5 --- /dev/null +++ b/packages/@expo/json-file/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015-present 650 Industries, Inc. (aka Expo) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/packages/@expo/json-file/README.md b/packages/@expo/json-file/README.md new file mode 100644 index 0000000000000..f5b2275a0c5a2 --- /dev/null +++ b/packages/@expo/json-file/README.md @@ -0,0 +1,29 @@ + +

+šŸ‘‹ Welcome to
@expo/json-file +

+ +

A library for reading and writing JSON files.

+ + + +## šŸ Setup + +Install `@expo/json-file` in your project. + +```sh +yarn add @expo/json-file +``` + +## āš½ļø Usage + +```ts +import JsonFile, { JSONObject } from '@expo/json-file'; + +// Create a file instance +const jsonFile = new JsonFile(filePath); + +// Interact with the file +await jsonFile.readAsync(); +await jsonFile.writeAsync({ some: 'data' }); +``` diff --git a/packages/@expo/json-file/build/JsonFile.d.ts b/packages/@expo/json-file/build/JsonFile.d.ts new file mode 100644 index 0000000000000..28e0b23015265 --- /dev/null +++ b/packages/@expo/json-file/build/JsonFile.d.ts @@ -0,0 +1,61 @@ +export type JSONValue = boolean | number | string | null | JSONArray | JSONObject; +export interface JSONArray extends Array { +} +export interface JSONObject { + [key: string]: JSONValue | undefined; +} +type Defined = T extends undefined ? never : T; +type Options = { + badJsonDefault?: TJSONObject; + jsonParseErrorDefault?: TJSONObject; + cantReadFileDefault?: TJSONObject; + ensureDir?: boolean; + default?: TJSONObject; + json5?: boolean; + space?: number; + addNewLineAtEOF?: boolean; +}; +/** + * The JsonFile class represents the contents of json file. + * + * It's polymorphic on "JSONObject", which is a simple type representing + * and object with string keys and either objects or primitive types as values. + * @type {[type]} + */ +export default class JsonFile { + file: string; + options: Options; + static read: typeof read; + static readAsync: typeof readAsync; + static parseJsonString: typeof parseJsonString; + static writeAsync: typeof writeAsync; + static getAsync: typeof getAsync; + static setAsync: typeof setAsync; + static mergeAsync: typeof mergeAsync; + static deleteKeyAsync: typeof deleteKeyAsync; + static deleteKeysAsync: typeof deleteKeysAsync; + static rewriteAsync: typeof rewriteAsync; + constructor(file: string, options?: Options); + read(options?: Options): TJSONObject; + readAsync(options?: Options): Promise; + writeAsync(object: TJSONObject, options?: Options): Promise; + parseJsonString(json: string, options?: Options): TJSONObject; + getAsync(key: K, defaultValue: TDefault, options?: Options): Promise | TDefault>; + setAsync(key: string, value: unknown, options?: Options): Promise; + mergeAsync(sources: Partial | Partial[], options?: Options): Promise; + deleteKeyAsync(key: string, options?: Options): Promise; + deleteKeysAsync(keys: string[], options?: Options): Promise; + rewriteAsync(options?: Options): Promise; + _getOptions(options?: Options): Options; +} +declare function read(file: string, options?: Options): TJSONObject; +declare function readAsync(file: string, options?: Options): Promise; +declare function parseJsonString(json: string, options?: Options, fileName?: string): TJSONObject; +declare function getAsync(file: string, key: K, defaultValue: DefaultValue, options?: Options): Promise; +declare function writeAsync(file: string, object: TJSONObject, options?: Options): Promise; +declare function setAsync(file: string, key: string, value: unknown, options?: Options): Promise; +declare function mergeAsync(file: string, sources: Partial | Partial[], options?: Options): Promise; +declare function deleteKeyAsync(file: string, key: string, options?: Options): Promise; +declare function deleteKeysAsync(file: string, keys: string[], options?: Options): Promise; +declare function rewriteAsync(file: string, options?: Options): Promise; +export {}; diff --git a/packages/@expo/json-file/build/JsonFile.js b/packages/@expo/json-file/build/JsonFile.js new file mode 100644 index 0000000000000..c7816571bde05 --- /dev/null +++ b/packages/@expo/json-file/build/JsonFile.js @@ -0,0 +1,283 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const code_frame_1 = require("@babel/code-frame"); +const fs_1 = __importDefault(require("fs")); +const json5_1 = __importDefault(require("json5")); +const path_1 = __importDefault(require("path")); +const util_1 = require("util"); +const write_file_atomic_1 = __importDefault(require("write-file-atomic")); +const JsonFileError_1 = __importStar(require("./JsonFileError")); +const writeFileAtomicAsync = (0, util_1.promisify)(write_file_atomic_1.default); +const DEFAULT_OPTIONS = { + badJsonDefault: undefined, + jsonParseErrorDefault: undefined, + cantReadFileDefault: undefined, + ensureDir: false, + default: undefined, + json5: false, + space: 2, + addNewLineAtEOF: true, +}; +/** + * The JsonFile class represents the contents of json file. + * + * It's polymorphic on "JSONObject", which is a simple type representing + * and object with string keys and either objects or primitive types as values. + * @type {[type]} + */ +class JsonFile { + file; + options; + static read = read; + static readAsync = readAsync; + static parseJsonString = parseJsonString; + static writeAsync = writeAsync; + static getAsync = getAsync; + static setAsync = setAsync; + static mergeAsync = mergeAsync; + static deleteKeyAsync = deleteKeyAsync; + static deleteKeysAsync = deleteKeysAsync; + static rewriteAsync = rewriteAsync; + constructor(file, options = {}) { + this.file = file; + this.options = options; + } + read(options) { + return read(this.file, this._getOptions(options)); + } + async readAsync(options) { + return readAsync(this.file, this._getOptions(options)); + } + async writeAsync(object, options) { + return writeAsync(this.file, object, this._getOptions(options)); + } + parseJsonString(json, options) { + return parseJsonString(json, options); + } + async getAsync(key, defaultValue, options) { + return getAsync(this.file, key, defaultValue, this._getOptions(options)); + } + async setAsync(key, value, options) { + return setAsync(this.file, key, value, this._getOptions(options)); + } + async mergeAsync(sources, options) { + return mergeAsync(this.file, sources, this._getOptions(options)); + } + async deleteKeyAsync(key, options) { + return deleteKeyAsync(this.file, key, this._getOptions(options)); + } + async deleteKeysAsync(keys, options) { + return deleteKeysAsync(this.file, keys, this._getOptions(options)); + } + async rewriteAsync(options) { + return rewriteAsync(this.file, this._getOptions(options)); + } + _getOptions(options) { + return { + ...this.options, + ...options, + }; + } +} +exports.default = JsonFile; +function read(file, options) { + let json; + try { + json = fs_1.default.readFileSync(file, 'utf8'); + } + catch (error) { + assertEmptyJsonString(json, file); + const defaultValue = cantReadFileDefault(options); + if (defaultValue === undefined) { + throw new JsonFileError_1.default(`Can't read JSON file: ${file}`, error, error.code, file); + } + else { + return defaultValue; + } + } + return parseJsonString(json, options, file); +} +async function readAsync(file, options) { + let json; + try { + json = await fs_1.default.promises.readFile(file, 'utf8'); + } + catch (error) { + assertEmptyJsonString(json, file); + const defaultValue = cantReadFileDefault(options); + if (defaultValue === undefined) { + throw new JsonFileError_1.default(`Can't read JSON file: ${file}`, error, error.code); + } + else { + return defaultValue; + } + } + return parseJsonString(json, options); +} +function parseJsonString(json, options, fileName) { + assertEmptyJsonString(json, fileName); + try { + if (_getOption(options, 'json5')) { + return json5_1.default.parse(json); + } + else { + return JSON.parse(json); + } + } + catch (e) { + const defaultValue = jsonParseErrorDefault(options); + if (defaultValue === undefined) { + const location = locationFromSyntaxError(e, json); + if (location) { + const codeFrame = (0, code_frame_1.codeFrameColumns)(json, { start: location }); + e.codeFrame = codeFrame; + e.message += `\n${codeFrame}`; + } + throw new JsonFileError_1.default(`Error parsing JSON: ${json}`, e, 'EJSONPARSE', fileName); + } + else { + return defaultValue; + } + } +} +async function getAsync(file, key, defaultValue, options) { + const object = await readAsync(file, options); + if (key in object) { + return object[key]; + } + if (defaultValue === undefined) { + throw new JsonFileError_1.default(`No value at key path "${String(key)}" in JSON object from: ${file}`); + } + return defaultValue; +} +async function writeAsync(file, object, options) { + if (options?.ensureDir) { + await fs_1.default.promises.mkdir(path_1.default.dirname(file), { recursive: true }); + } + const space = _getOption(options, 'space'); + const json5 = _getOption(options, 'json5'); + const addNewLineAtEOF = _getOption(options, 'addNewLineAtEOF'); + let json; + try { + if (json5) { + json = json5_1.default.stringify(object, null, space); + } + else { + json = JSON.stringify(object, null, space); + } + } + catch (e) { + throw new JsonFileError_1.default(`Couldn't JSON.stringify object for file: ${file}`, e); + } + const data = addNewLineAtEOF ? `${json}\n` : json; + await writeFileAtomicAsync(file, data, {}); + return object; +} +async function setAsync(file, key, value, options) { + // TODO: Consider implementing some kind of locking mechanism, but + // it's not critical for our use case, so we'll leave it out for now + const object = await readAsync(file, options); + return writeAsync(file, { ...object, [key]: value }, options); +} +async function mergeAsync(file, sources, options) { + const object = await readAsync(file, options); + if (Array.isArray(sources)) { + Object.assign(object, ...sources); + } + else { + Object.assign(object, sources); + } + return writeAsync(file, object, options); +} +async function deleteKeyAsync(file, key, options) { + return deleteKeysAsync(file, [key], options); +} +async function deleteKeysAsync(file, keys, options) { + const object = await readAsync(file, options); + let didDelete = false; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (object.hasOwnProperty(key)) { + delete object[key]; + didDelete = true; + } + } + if (didDelete) { + return writeAsync(file, object, options); + } + return object; +} +async function rewriteAsync(file, options) { + const object = await readAsync(file, options); + return writeAsync(file, object, options); +} +function jsonParseErrorDefault(options = {}) { + if (options.jsonParseErrorDefault === undefined) { + return options.default; + } + else { + return options.jsonParseErrorDefault; + } +} +function cantReadFileDefault(options = {}) { + if (options.cantReadFileDefault === undefined) { + return options.default; + } + else { + return options.cantReadFileDefault; + } +} +function _getOption(options, field) { + if (options) { + if (options[field] !== undefined) { + return options[field]; + } + } + return DEFAULT_OPTIONS[field]; +} +function locationFromSyntaxError(error, sourceString) { + // JSON5 SyntaxError has lineNumber and columnNumber. + if ('lineNumber' in error && 'columnNumber' in error) { + return { line: error.lineNumber, column: error.columnNumber }; + } + // JSON SyntaxError only includes the index in the message. + const match = /at position (\d+)/.exec(error.message); + if (match) { + const index = parseInt(match[1], 10); + const lines = sourceString.slice(0, index + 1).split('\n'); + return { line: lines.length, column: lines[lines.length - 1].length }; + } + return null; +} +function assertEmptyJsonString(json, file) { + if (json?.trim() === '') { + throw new JsonFileError_1.EmptyJsonFileError(file); + } +} +//# sourceMappingURL=JsonFile.js.map \ No newline at end of file diff --git a/packages/@expo/json-file/build/JsonFile.js.map b/packages/@expo/json-file/build/JsonFile.js.map new file mode 100644 index 0000000000000..1b7c15a4dc615 --- /dev/null +++ b/packages/@expo/json-file/build/JsonFile.js.map @@ -0,0 +1 @@ +{"version":3,"file":"JsonFile.js","sourceRoot":"","sources":["../src/JsonFile.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/@expo/json-file/build/JsonFileError.d.ts b/packages/@expo/json-file/build/JsonFileError.d.ts new file mode 100644 index 0000000000000..31bc8e64c1a06 --- /dev/null +++ b/packages/@expo/json-file/build/JsonFileError.d.ts @@ -0,0 +1,13 @@ +/** + * Note that instances of this class do NOT pass `instanceof JsonFileError`. + */ +export default class JsonFileError extends Error { + cause: Error | undefined; + code: string | undefined; + fileName: string | undefined; + isJsonFileError: true; + constructor(message: string, cause?: Error, code?: string, fileName?: string); +} +export declare class EmptyJsonFileError extends JsonFileError { + constructor(fileName?: string); +} diff --git a/packages/@expo/json-file/build/JsonFileError.js b/packages/@expo/json-file/build/JsonFileError.js new file mode 100644 index 0000000000000..dd458b0e3fe56 --- /dev/null +++ b/packages/@expo/json-file/build/JsonFileError.js @@ -0,0 +1,35 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.EmptyJsonFileError = void 0; +/** + * Note that instances of this class do NOT pass `instanceof JsonFileError`. + */ +class JsonFileError extends Error { + cause; + code; + fileName; + isJsonFileError; + constructor(message, cause, code, fileName) { + let fullMessage = message; + if (fileName) { + fullMessage += `\n${cause ? 'ā”œ' : 'ā””'}ā”€ File: ${fileName}`; + } + if (cause) { + fullMessage += `\nā””ā”€ Cause: ${cause.name}: ${cause.message}`; + } + super(fullMessage); + this.name = this.constructor.name; + this.cause = cause; + this.code = code; + this.fileName = fileName; + this.isJsonFileError = true; + } +} +exports.default = JsonFileError; +class EmptyJsonFileError extends JsonFileError { + constructor(fileName) { + super(`Cannot parse an empty JSON string`, undefined, 'EJSONEMPTY', fileName); + } +} +exports.EmptyJsonFileError = EmptyJsonFileError; +//# sourceMappingURL=JsonFileError.js.map \ No newline at end of file diff --git a/packages/@expo/json-file/build/JsonFileError.js.map b/packages/@expo/json-file/build/JsonFileError.js.map new file mode 100644 index 0000000000000..70ed702189d61 --- /dev/null +++ b/packages/@expo/json-file/build/JsonFileError.js.map @@ -0,0 +1 @@ +{"version":3,"file":"JsonFileError.js","sourceRoot":"","sources":["../src/JsonFileError.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACH,MAAqB,aAAc,SAAQ,KAAK;IAC9C,KAAK,CAAoB;IACzB,IAAI,CAAqB;IACzB,QAAQ,CAAqB;IAC7B,eAAe,CAAO;IAEtB,YAAY,OAAe,EAAE,KAAa,EAAE,IAAa,EAAE,QAAiB;QAC1E,IAAI,WAAW,GAAG,OAAO,CAAC;QAC1B,IAAI,QAAQ,EAAE;YACZ,WAAW,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,WAAW,QAAQ,EAAE,CAAC;SAC5D;QACD,IAAI,KAAK,EAAE;YACT,WAAW,IAAI,eAAe,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,OAAO,EAAE,CAAC;SAC9D;QACD,KAAK,CAAC,WAAW,CAAC,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC;QAClC,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,eAAe,GAAG,IAAI,CAAC;IAC9B,CAAC;CACF;AArBD,gCAqBC;AAED,MAAa,kBAAmB,SAAQ,aAAa;IACnD,YAAY,QAAiB;QAC3B,KAAK,CAAC,mCAAmC,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,CAAC,CAAC;IAChF,CAAC;CACF;AAJD,gDAIC"} \ No newline at end of file diff --git a/packages/@expo/json-file/jest.config.js b/packages/@expo/json-file/jest.config.js new file mode 100644 index 0000000000000..88ac58c2cae99 --- /dev/null +++ b/packages/@expo/json-file/jest.config.js @@ -0,0 +1,9 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require('expo-module-scripts/jest-preset-cli'), + preset: 'ts-jest', + clearMocks: true, + displayName: require('./package').name, + rootDir: __dirname, + roots: ['src'], +}; diff --git a/packages/@expo/json-file/package.json b/packages/@expo/json-file/package.json new file mode 100644 index 0000000000000..8095ca85b64cf --- /dev/null +++ b/packages/@expo/json-file/package.json @@ -0,0 +1,46 @@ +{ + "name": "@expo/json-file", + "version": "8.2.37", + "description": "A module for reading, writing, and manipulating JSON files", + "main": "build/JsonFile.js", + "scripts": { + "build": "expo-module tsc", + "clean": "expo-module clean", + "lint": "expo-module lint", + "prepare": "expo-module clean && expo-module tsc", + "prepublishOnly": "expo-module prepublishOnly", + "test": "expo-module test", + "typecheck": "expo-module typecheck", + "watch": "expo-module tsc --watch --preserveWatchOutput" + }, + "repository": { + "type": "git", + "url": "https://github.com/expo/expo.git", + "directory": "packages/@expo/json-file" + }, + "keywords": [ + "json" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/expo/expo/issues" + }, + "homepage": "https://github.com/expo/expo/tree/main/packages/@expo/json-file#readme", + "files": [ + "build" + ], + "dependencies": { + "@babel/code-frame": "~7.10.4", + "json5": "^2.2.2", + "write-file-atomic": "^2.3.0" + }, + "devDependencies": { + "@types/babel__code-frame": "^7.0.1", + "@types/json5": "^2.2.0", + "@types/write-file-atomic": "^2.1.1", + "expo-module-scripts": "^3.3.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@expo/json-file/src/JsonFile.ts b/packages/@expo/json-file/src/JsonFile.ts new file mode 100644 index 0000000000000..c8c2c44fd72e8 --- /dev/null +++ b/packages/@expo/json-file/src/JsonFile.ts @@ -0,0 +1,352 @@ +import { codeFrameColumns } from '@babel/code-frame'; +import fs from 'fs'; +import JSON5 from 'json5'; +import path from 'path'; +import { promisify } from 'util'; +import writeFileAtomic from 'write-file-atomic'; + +import JsonFileError, { EmptyJsonFileError } from './JsonFileError'; + +const writeFileAtomicAsync: ( + filename: string, + data: string | Buffer, + options: writeFileAtomic.Options +) => void = promisify(writeFileAtomic); + +export type JSONValue = boolean | number | string | null | JSONArray | JSONObject; +export interface JSONArray extends Array {} +export interface JSONObject { + [key: string]: JSONValue | undefined; +} + +type Defined = T extends undefined ? never : T; + +type Options = { + badJsonDefault?: TJSONObject; + jsonParseErrorDefault?: TJSONObject; + cantReadFileDefault?: TJSONObject; + ensureDir?: boolean; + default?: TJSONObject; + json5?: boolean; + space?: number; + addNewLineAtEOF?: boolean; +}; + +const DEFAULT_OPTIONS = { + badJsonDefault: undefined, + jsonParseErrorDefault: undefined, + cantReadFileDefault: undefined, + ensureDir: false, + default: undefined, + json5: false, + space: 2, + addNewLineAtEOF: true, +}; + +/** + * The JsonFile class represents the contents of json file. + * + * It's polymorphic on "JSONObject", which is a simple type representing + * and object with string keys and either objects or primitive types as values. + * @type {[type]} + */ +export default class JsonFile { + file: string; + options: Options; + + static read = read; + static readAsync = readAsync; + static parseJsonString = parseJsonString; + static writeAsync = writeAsync; + static getAsync = getAsync; + static setAsync = setAsync; + static mergeAsync = mergeAsync; + static deleteKeyAsync = deleteKeyAsync; + static deleteKeysAsync = deleteKeysAsync; + static rewriteAsync = rewriteAsync; + + constructor(file: string, options: Options = {}) { + this.file = file; + this.options = options; + } + + read(options?: Options): TJSONObject { + return read(this.file, this._getOptions(options)); + } + + async readAsync(options?: Options): Promise { + return readAsync(this.file, this._getOptions(options)); + } + + async writeAsync(object: TJSONObject, options?: Options) { + return writeAsync(this.file, object, this._getOptions(options)); + } + + parseJsonString(json: string, options?: Options): TJSONObject { + return parseJsonString(json, options); + } + + async getAsync( + key: K, + defaultValue: TDefault, + options?: Options + ): Promise | TDefault> { + return getAsync(this.file, key, defaultValue, this._getOptions(options)); + } + + async setAsync(key: string, value: unknown, options?: Options) { + return setAsync(this.file, key, value, this._getOptions(options)); + } + + async mergeAsync( + sources: Partial | Partial[], + options?: Options + ): Promise { + return mergeAsync(this.file, sources, this._getOptions(options)); + } + + async deleteKeyAsync(key: string, options?: Options) { + return deleteKeyAsync(this.file, key, this._getOptions(options)); + } + + async deleteKeysAsync(keys: string[], options?: Options) { + return deleteKeysAsync(this.file, keys, this._getOptions(options)); + } + + async rewriteAsync(options?: Options) { + return rewriteAsync(this.file, this._getOptions(options)); + } + + _getOptions(options?: Options): Options { + return { + ...this.options, + ...options, + }; + } +} + +function read( + file: string, + options?: Options +): TJSONObject { + let json; + try { + json = fs.readFileSync(file, 'utf8'); + } catch (error: any) { + assertEmptyJsonString(json, file); + const defaultValue = cantReadFileDefault(options); + if (defaultValue === undefined) { + throw new JsonFileError(`Can't read JSON file: ${file}`, error, error.code, file); + } else { + return defaultValue; + } + } + return parseJsonString(json, options, file); +} + +async function readAsync( + file: string, + options?: Options +): Promise { + let json; + try { + json = await fs.promises.readFile(file, 'utf8'); + } catch (error: any) { + assertEmptyJsonString(json, file); + const defaultValue = cantReadFileDefault(options); + if (defaultValue === undefined) { + throw new JsonFileError(`Can't read JSON file: ${file}`, error, error.code); + } else { + return defaultValue; + } + } + return parseJsonString(json, options); +} + +function parseJsonString( + json: string, + options?: Options, + fileName?: string +): TJSONObject { + assertEmptyJsonString(json, fileName); + try { + if (_getOption(options, 'json5')) { + return JSON5.parse(json); + } else { + return JSON.parse(json); + } + } catch (e: any) { + const defaultValue = jsonParseErrorDefault(options); + if (defaultValue === undefined) { + const location = locationFromSyntaxError(e, json); + if (location) { + const codeFrame = codeFrameColumns(json, { start: location }); + e.codeFrame = codeFrame; + e.message += `\n${codeFrame}`; + } + throw new JsonFileError(`Error parsing JSON: ${json}`, e, 'EJSONPARSE', fileName); + } else { + return defaultValue; + } + } +} + +async function getAsync( + file: string, + key: K, + defaultValue: DefaultValue, + options?: Options +): Promise { + const object = await readAsync(file, options); + if (key in object) { + return object[key]; + } + if (defaultValue === undefined) { + throw new JsonFileError(`No value at key path "${String(key)}" in JSON object from: ${file}`); + } + return defaultValue; +} + +async function writeAsync( + file: string, + object: TJSONObject, + options?: Options +): Promise { + if (options?.ensureDir) { + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + } + const space = _getOption(options, 'space'); + const json5 = _getOption(options, 'json5'); + const addNewLineAtEOF = _getOption(options, 'addNewLineAtEOF'); + let json; + try { + if (json5) { + json = JSON5.stringify(object, null, space); + } else { + json = JSON.stringify(object, null, space); + } + } catch (e: any) { + throw new JsonFileError(`Couldn't JSON.stringify object for file: ${file}`, e); + } + const data = addNewLineAtEOF ? `${json}\n` : json; + await writeFileAtomicAsync(file, data, {}); + return object; +} + +async function setAsync( + file: string, + key: string, + value: unknown, + options?: Options +): Promise { + // TODO: Consider implementing some kind of locking mechanism, but + // it's not critical for our use case, so we'll leave it out for now + const object = await readAsync(file, options); + return writeAsync(file, { ...object, [key]: value }, options); +} + +async function mergeAsync( + file: string, + sources: Partial | Partial[], + options?: Options +): Promise { + const object = await readAsync(file, options); + if (Array.isArray(sources)) { + Object.assign(object, ...sources); + } else { + Object.assign(object, sources); + } + return writeAsync(file, object, options); +} + +async function deleteKeyAsync( + file: string, + key: string, + options?: Options +): Promise { + return deleteKeysAsync(file, [key], options); +} + +async function deleteKeysAsync( + file: string, + keys: string[], + options?: Options +): Promise { + const object = await readAsync(file, options); + let didDelete = false; + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (object.hasOwnProperty(key)) { + delete object[key]; + didDelete = true; + } + } + + if (didDelete) { + return writeAsync(file, object, options); + } + return object; +} + +async function rewriteAsync( + file: string, + options?: Options +): Promise { + const object = await readAsync(file, options); + return writeAsync(file, object, options); +} + +function jsonParseErrorDefault( + options: Options = {} +): TJSONObject | void { + if (options.jsonParseErrorDefault === undefined) { + return options.default; + } else { + return options.jsonParseErrorDefault; + } +} + +function cantReadFileDefault( + options: Options = {} +): TJSONObject | void { + if (options.cantReadFileDefault === undefined) { + return options.default; + } else { + return options.cantReadFileDefault; + } +} + +function _getOption>( + options: Options | undefined, + field: K +): Options[K] { + if (options) { + if (options[field] !== undefined) { + return options[field]; + } + } + return DEFAULT_OPTIONS[field]; +} + +function locationFromSyntaxError(error: any, sourceString: string) { + // JSON5 SyntaxError has lineNumber and columnNumber. + if ('lineNumber' in error && 'columnNumber' in error) { + return { line: error.lineNumber, column: error.columnNumber }; + } + // JSON SyntaxError only includes the index in the message. + const match = /at position (\d+)/.exec(error.message); + if (match) { + const index = parseInt(match[1], 10); + const lines = sourceString.slice(0, index + 1).split('\n'); + return { line: lines.length, column: lines[lines.length - 1].length }; + } + + return null; +} + +function assertEmptyJsonString(json?: string, file?: string) { + if (json?.trim() === '') { + throw new EmptyJsonFileError(file); + } +} diff --git a/packages/@expo/json-file/src/JsonFileError.ts b/packages/@expo/json-file/src/JsonFileError.ts new file mode 100644 index 0000000000000..1cc956bff3b50 --- /dev/null +++ b/packages/@expo/json-file/src/JsonFileError.ts @@ -0,0 +1,31 @@ +/** + * Note that instances of this class do NOT pass `instanceof JsonFileError`. + */ +export default class JsonFileError extends Error { + cause: Error | undefined; + code: string | undefined; + fileName: string | undefined; + isJsonFileError: true; + + constructor(message: string, cause?: Error, code?: string, fileName?: string) { + let fullMessage = message; + if (fileName) { + fullMessage += `\n${cause ? 'ā”œ' : 'ā””'}ā”€ File: ${fileName}`; + } + if (cause) { + fullMessage += `\nā””ā”€ Cause: ${cause.name}: ${cause.message}`; + } + super(fullMessage); + this.name = this.constructor.name; + this.cause = cause; + this.code = code; + this.fileName = fileName; + this.isJsonFileError = true; + } +} + +export class EmptyJsonFileError extends JsonFileError { + constructor(fileName?: string) { + super(`Cannot parse an empty JSON string`, undefined, 'EJSONEMPTY', fileName); + } +} diff --git a/packages/@expo/json-file/src/__tests__/JsonFile-test.ts b/packages/@expo/json-file/src/__tests__/JsonFile-test.ts new file mode 100644 index 0000000000000..e92720d7e766d --- /dev/null +++ b/packages/@expo/json-file/src/__tests__/JsonFile-test.ts @@ -0,0 +1,138 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import JsonFile from '../JsonFile'; + +jest.setTimeout(20 * 1000); + +const FIXTURES = path.join(os.tmpdir(), 'json-file-fixtures'); + +beforeAll(() => fs.promises.mkdir(FIXTURES, { recursive: true })); +afterAll(() => fs.promises.rmdir(FIXTURES, { recursive: true })); + +it(`is a class`, () => { + const file = new JsonFile(path.join(__dirname, '../../package.json')); + expect(file instanceof JsonFile).toBe(true); +}); + +it(`has static functions`, () => { + expect(JsonFile.readAsync).toBeDefined(); + expect(JsonFile.writeAsync).toBeDefined(); +}); + +it(`reads JSON from a file`, async () => { + const file = new JsonFile(path.join(__dirname, '../../package.json')); + const object = await file.readAsync(); + expect(object.version).toBeDefined(); +}); + +it(`reads JSON statically from a file`, async () => { + const object = await JsonFile.readAsync(path.join(__dirname, '../../package.json')); + expect(object.version).toBeDefined(); +}); + +it(`reads JSON5 from a file`, async () => { + const file = new JsonFile(path.join(__dirname, 'files/test-json5.json'), { json5: true }); + const object = await file.readAsync(); + expect(object.itParsedProperly).toBe(42); +}); + +it(`has useful error messages for JSON parsing errors`, async () => { + await expect( + JsonFile.readAsync(path.join(__dirname, 'files/syntax-error.json')) + ).rejects.toThrowError(/Cause: SyntaxError: Unexpected string in JSON at position 602/); +}); + +it(`has useful error messages for JSON5 parsing errors`, async () => { + await expect( + JsonFile.readAsync(path.join(__dirname, 'files/syntax-error.json5'), { json5: true }) + ).rejects.toThrowError(/Cause: SyntaxError: JSON5: invalid character ',' at 4:15/); +}); + +const obj1 = { x: 1 }; + +it(`writes JSON to a file`, async () => { + const filename = path.join(FIXTURES, 'test.json'); + const file = new JsonFile(filename, { json5: true }); + await file.writeAsync(obj1); + expect(fs.existsSync(filename)).toBe(true); + await expect(file.readAsync()).resolves.toEqual(obj1); +}); + +it(`rewrite async`, async () => { + const filename = path.join(FIXTURES, 'test.json'); + const file = new JsonFile(filename, { json5: true }); + await file.writeAsync(obj1); + expect(fs.existsSync(filename)).toBe(true); + await expect(file.readAsync()).resolves.toEqual(obj1); + await expect(file.rewriteAsync()).resolves.toBeDefined(); + expect(fs.existsSync(filename)).toBe(true); + await expect(file.readAsync()).resolves.toEqual(obj1); +}); + +it(`changes an existing key in that file`, async () => { + const file = new JsonFile(path.join(FIXTURES, 'test.json'), { json5: true }); + await expect(file.setAsync('x', 2)).resolves.toBeDefined(); + await expect(file.readAsync()).resolves.toEqual({ x: 2 }); +}); + +it(`adds a new key to the file`, async () => { + const file = new JsonFile(path.join(FIXTURES, 'test.json'), { json5: true }); + await expect(file.setAsync('x', 2)).resolves.toBeDefined(); + await expect(file.readAsync()).resolves.toEqual({ x: 2 }); + await expect(file.setAsync('y', 3)).resolves.toBeDefined(); + await expect(file.readAsync()).resolves.toEqual({ x: 2, y: 3 }); +}); + +it(`deletes that same new key from the file`, async () => { + const file = new JsonFile(path.join(FIXTURES, 'test.json'), { json5: true }); + await expect(file.setAsync('x', 2)).resolves.toBeDefined(); + await expect(file.setAsync('y', 3)).resolves.toBeDefined(); + await expect(file.deleteKeyAsync('y')).resolves.toBeDefined(); + await expect(file.readAsync()).resolves.toEqual({ x: 2 }); +}); + +it(`deletes another key from the file`, async () => { + const file = new JsonFile(path.join(FIXTURES, 'test.json'), { json5: true }); + await expect(file.setAsync('x', 2)).resolves.toBeDefined(); + await expect(file.setAsync('y', 3)).resolves.toBeDefined(); + await expect(file.deleteKeyAsync('x')).resolves.toBeDefined(); + await expect(file.deleteKeyAsync('y')).resolves.toBeDefined(); + await expect(file.readAsync()).resolves.toEqual({}); +}); + +// This fails when i is high, around 200. However, no realistic use case would have the user +// constantly update a file that often +it('Multiple updates to the same file have no race conditions', async () => { + const file = new JsonFile(path.join(FIXTURES, 'atomic-test.json'), { json5: true }); + for (let i = 0; i < 50; i++) { + await file.writeAsync({}); + let baseObj = {}; + for (let j = 0; j < 20; j++) { + baseObj = { ...baseObj, [j]: j }; + await file.setAsync(String(j), j); + } + const json = await file.readAsync(); + expect(json).toEqual(baseObj); + } +}); + +it('Continuous updating!', async () => { + const file = new JsonFile(path.join(FIXTURES, 'test.json'), { json5: true }); + await file.writeAsync({ i: 0 }); + for (let i = 0; i < 20; i++) { + await file.writeAsync({ i }); + await expect(file.readAsync()).resolves.toEqual({ i }); + } +}); + +it('adds a new line at the eof', async () => { + const filename = path.join(FIXTURES, 'test.json'); + const file = new JsonFile(filename, { json5: true }); + await file.writeAsync(obj1); + expect(fs.existsSync(filename)).toBe(true); + const data = await fs.promises.readFile(filename, 'utf-8'); + const lastChar = data[data.length - 1]; + expect(lastChar).toEqual('\n'); +}); diff --git a/packages/@expo/json-file/src/__tests__/JsonFileError-test.ts b/packages/@expo/json-file/src/__tests__/JsonFileError-test.ts new file mode 100644 index 0000000000000..4fa22110ed490 --- /dev/null +++ b/packages/@expo/json-file/src/__tests__/JsonFileError-test.ts @@ -0,0 +1,21 @@ +import JsonFileError from '../JsonFileError'; + +describe('JsonFileError', () => { + it(`is an error`, () => { + const error = new JsonFileError('Example'); + expect(error instanceof Error).toBe(true); + expect(error instanceof JsonFileError).toBe(true); + }); + + it(`has a flag that says it's a JsonFileError`, () => { + const error = new JsonFileError('Example'); + expect(error.isJsonFileError).toBe(true); + }); + + it(`includes its cause`, () => { + const cause = new Error('Root cause'); + const error = new JsonFileError('Example', cause); + expect(error.cause).toBe(cause); + expect(error.message).toMatch(cause.message); + }); +}); diff --git a/packages/@expo/json-file/src/__tests__/files/syntax-error.json b/packages/@expo/json-file/src/__tests__/files/syntax-error.json new file mode 100644 index 0000000000000..cbb816e844a62 --- /dev/null +++ b/packages/@expo/json-file/src/__tests__/files/syntax-error.json @@ -0,0 +1,28 @@ +{ + "expo": { + "name": "test-project", + "description": "This project is really great.", + "slug": "test-project", + "privacy": "public", + "sdkVersion": "26.0.0", + "platforms": ["ios", "android"], + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "updates": { + "fallbackToCacheTimeout": 0 + }, + "assetBundlePatterns": [ + "**/*" + ], + "ios": { + "supportsTablet": true, + "bundleIdentifier" "com.wow.testapp" + } + } +} diff --git a/packages/@expo/json-file/src/__tests__/files/syntax-error.json5 b/packages/@expo/json-file/src/__tests__/files/syntax-error.json5 new file mode 100644 index 0000000000000..11a4faf21dc8a --- /dev/null +++ b/packages/@expo/json-file/src/__tests__/files/syntax-error.json5 @@ -0,0 +1,6 @@ +{ + expo: { + // The following line has an error + sdkVersion, "26.0.0", + }, +} diff --git a/packages/@expo/json-file/src/__tests__/files/test-json5.json b/packages/@expo/json-file/src/__tests__/files/test-json5.json new file mode 100644 index 0000000000000..0abee6caef82f --- /dev/null +++ b/packages/@expo/json-file/src/__tests__/files/test-json5.json @@ -0,0 +1,15 @@ +{ + '3': 4, + // Another comment! + '5': { + '6': { + /* Comment comment comment! */ + '7': 8 + }, + '9': 10 + }, + '11': 12, + itParsedProperly: 42, + score: 5, + x: 'z' +} diff --git a/packages/@expo/json-file/src/__tests__/files/test.json b/packages/@expo/json-file/src/__tests__/files/test.json new file mode 100644 index 0000000000000..c38dbfc1731b7 --- /dev/null +++ b/packages/@expo/json-file/src/__tests__/files/test.json @@ -0,0 +1,12 @@ +{ + "3": 4, + "5": { + "6": { + "7": 8 + }, + "9": 10 + }, + "11": 12, + "score": 5, + "x": "z" +} \ No newline at end of file diff --git a/packages/@expo/json-file/tsconfig.json b/packages/@expo/json-file/tsconfig.json new file mode 100644 index 0000000000000..105d23fe0d270 --- /dev/null +++ b/packages/@expo/json-file/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.node", + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"], + "compilerOptions": { + "outDir": "./build", + "sourceMap": true + } +} diff --git a/yarn.lock b/yarn.lock index 7e8eef715304b..5bb9a63004bbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1403,15 +1403,6 @@ semver "7.3.2" tempy "0.3.0" -"@expo/json-file@8.2.37", "@expo/json-file@^8.2.37", "@expo/json-file@~8.2.37": - version "8.2.37" - resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.2.37.tgz#9c02d3b42134907c69cc0a027b18671b69344049" - integrity sha512-YaH6rVg11JoTS2P6LsW7ybS2CULjf40AbnAHw2F1eDPuheprNjARZMnyHFPkKv7GuxCy+B9GPcbOKgc4cgA80Q== - dependencies: - "@babel/code-frame" "~7.10.4" - json5 "^2.2.2" - write-file-atomic "^2.3.0" - "@expo/multipart-body-parser@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@expo/multipart-body-parser/-/multipart-body-parser-1.0.0.tgz#7227bab9cfe9d4baea925b748a3212e0239ba55d" @@ -4115,6 +4106,11 @@ resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.2.tgz#6f1225829d89794fd9f891989c9ce667422d7f64" integrity sha512-PHKZuMN+K5qgKIWhBodXzQslTo5P+K/6LqeKXS6O/4liIDdZqaX5RXrCK++LAw+y/nptN48YmUMFiQHRSWYwtQ== +"@types/babel__code-frame@^7.0.1": + version "7.0.6" + resolved "https://registry.yarnpkg.com/@types/babel__code-frame/-/babel__code-frame-7.0.6.tgz#20a899c0d29fba1ddf5c2156a10a2bda75ee6f29" + integrity sha512-Anitqkl3+KrzcW2k77lRlg/GfLZLWXBuNgbEcIOU6M92yw42vsd3xV/Z/yAHEj8m+KUjL6bWOVOFqX8PFPJ4LA== + "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" @@ -4447,6 +4443,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/json5@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-2.2.0.tgz#afff29abf9182a7d4a7e39105ca051f11c603d13" + integrity sha512-NrVug5woqbvNZ0WX+Gv4R+L4TGddtmFek2u8RtccAgFZWtS9QXF2xCXY22/M4nzkaKF0q9Fc6M/5rxLDhfwc/A== + dependencies: + json5 "*" + "@types/keyv@*": version "3.1.4" resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" @@ -4842,6 +4845,13 @@ resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-8.0.1.tgz#59dc91b83a2e949971da8617592d9eaaf6592774" integrity sha512-cjwgM6WWy9YakrQ36Pq0vg5XoNblVEaNq+/pHngKl4GyyDIxTeskPoG+tp4LsRk0lHrA4LaLJqlvYridi7mzlw== +"@types/write-file-atomic@^2.1.1": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@types/write-file-atomic/-/write-file-atomic-2.1.2.tgz#1657761692b70cf9ad2593e4eb4ac498adb9463f" + integrity sha512-/P4wq72qka9+JNqDgHe7Kr2Gpu1kmhA0H8tLlKi9G0eXmePiuT9k0izZAdkXXNA6Nb27xnzdgjRv2jZWO3+2oQ== + dependencies: + "@types/node" "*" + "@types/ws@^8.0.0", "@types/ws@^8.5.4": version "8.5.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5" @@ -13068,6 +13078,11 @@ json3@^3.3.2: resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" integrity sha512-c7/8mbUsKigAbLkD5B010BK4D9LZm7A1pNItkEwiUZRpIN66exu/e7YQWysGun+TRKaJp8MhemM+VkfWv42aCA== +json5@*, json5@^2.1.1, json5@^2.1.2, json5@^2.2.2, json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + json5@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" @@ -13075,11 +13090,6 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.1, json5@^2.1.2, json5@^2.2.2, json5@^2.2.3: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"