diff --git a/README.md b/README.md index 43440a17..5467bcd8 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,17 @@ npx ember-native-class-codemod http://localhost:4200/path/to/server [OPTIONS] pa The codemod accepts the following options, passed as CLI arguments, set in a `.codemods.{json,js,cjs,yml}` file, or set in a `"codemods"` object in `package.json`. -| Option | Type | Default | Details | -| ----------------------------------------------- | ----------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--class-fields` / `classFields` | `boolean` | `true` | Enable/disable transformation using class fields | -| `--decorators` / `decorators` | `boolean \| DecoratorsConfig` | `true` | Set to `false` to disable transformation using decorators. Set to `DecoratorsConfig` object (see below) to pass additional decorator options. | -| `--classic-decorator` / `classicDecorator` | `boolean` | `true` | Enable/disable adding the [`@classic` decorator](https://github.com/pzuraq/ember-classic-decorator), which helps with transitioning Ember Octane | -| `--type` / `type` | `'services' \| 'routes' \| 'components' \| 'controllers' \| undefined`' | `undefined` | Apply transformation to only passed type. If `undefined, will match all types in path. | -| `--quote` / `quote` | `'single' \| 'double'` | `'single'` | Whether to use double or single quotes by default for new statements that are added during the codemod. | -| `--partial-transforms` / `partialTransforms` | `boolean` | `true` | If `false`, the entire file will fail validation if any EmberObject within it fails validation. | -| `--ignore-leaking-state` / `ignoreLeakingState` | `string[]` | `['queryParams']` | Allow-list for `ObjectExpression` or `ArrayExpression` properties to ignore issues detailed in [eslint-plugin-ember/avoid-leaking-state-in-ember-objects](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/avoid-leaking-state-in-ember-objects.md). In the classic class syntax, using arrays and objects as default properties causes their state to "leak" between instances. If you have custom properties where you know that the shared state won't be a problem (for example, read-only configuration values), you can use this config to ignore them. NOTE: Passing this option will override the defaults, so ensure you include `'queryParams'` in the list unless you explicitly wish to disallow it. | -| `DecoratorsConfig` | An object with the following properties. | See below. | Allow-list for decorators currently applied to object literal properties that can be safely applied to class properties. Pass as a comma-separated string if using as a CLI-option. Otherwise pass as an array of strings. | -| `DecoratorsConfig.inObjectLiterals` | `string \| string[]` | `[]` | Allow-list for decorators currently applied to object literal properties that can be safely applied to class properties. Pass as a comma-separated string if using as a CLI-option. Otherwise pass as an array of strings. NOTE: Decorators on object methods will be allowed by default. | +| Option | Type | Default | Details | +| ----------------------------------------------- | ----------------------------------------------------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--class-fields` / `classFields` | `boolean` | `true` | Enable/disable transformation using class fields | +| `--decorators` / `decorators` | `boolean \| DecoratorsConfig` | `true` | Set to `false` to disable transformation using decorators. Set to `DecoratorsConfig` object (see below) to pass additional decorator options. | +| `--classic-decorator` / `classicDecorator` | `boolean` | `true` | Enable/disable adding the [`@classic` decorator](https://github.com/pzuraq/ember-classic-decorator), which helps with transitioning Ember Octane | +| `--type` / `type` | `'services' \| 'routes' \| 'components' \| 'controllers' \| undefined`' | `undefined` | Apply transformation to only passed type. If `undefined, will match all types in path. | +| `--quote` / `quote` | `'single' \| 'double'` | `'single'` | Whether to use double or single quotes by default for new statements that are added during the codemod. | +| `--partial-transforms` / `partialTransforms` | `boolean` | `true` | If `false`, the entire file will fail validation if any EmberObject within it fails validation. | +| `--ignore-leaking-state` / `ignoreLeakingState` | `string \| string[] | `['queryParams']` | Allow-list for `ObjectExpression` or `ArrayExpression` properties to ignore issues detailed in [eslint-plugin-ember/avoid-leaking-state-in-ember-objects](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/avoid-leaking-state-in-ember-objects.md). In the classic class syntax, using arrays and objects as default properties causes their state to "leak" between instances. If you have custom properties where you know that the shared state won't be a problem (for example, read-only configuration values), you can use this config to ignore them. NOTE: Passing this option will override the defaults, so ensure you include `'queryParams'` in the list unless you explicitly wish to disallow it. Pass as a comma-separated string if using as a CLI-option. Otherwise pass as an array of strings. | +| `DecoratorsConfig` | An object with the following properties. | See below. | A list of decorators that are allowed on object literal properties. (Method decorators will always be allowed.) When the codemod finds a field with one of these decorators, it will be translated directly into a class field with the same decorator. Including a decorator in this list means that you believe that the decorator will work correctly on a class field. Pass as a comma-separated string if using as a CLI-option. Otherwise pass as an array of strings. | +| `DecoratorsConfig.inObjectLiterals` | `string \| string[]` | `[]` | Allow-list for decorators currently applied to object literal properties that can be safely applied to class properties. Pass as a comma-separated string if using as a CLI-option. Otherwise pass as an array of strings. NOTE: Decorators on object methods will be allowed by default. | ### Gathering Runtime Data diff --git a/test/helpers/expect-logs.ts b/test/helpers/expect-logs.ts new file mode 100644 index 00000000..312e4710 --- /dev/null +++ b/test/helpers/expect-logs.ts @@ -0,0 +1,118 @@ +import { expect, jest } from '@jest/globals'; +import logger from '../../transforms/helpers/log-helper'; + +/** + * Spies on all logger log levels for messages matching those passed in the + * config. + * + * @param callback The callback expected to trigger (or not) the logs. + * @param config An optional object with an array of expected messages for each + * log level. If no array is passed, no messages will be expected for that + * level. If no object is passed, the function will expect that there are no + * logs. + */ +export function expectLogs( + callback: () => void, + { + info = [], + warn = [], + error = [], + }: { + info?: string[]; + warn?: string[]; + error?: string[]; + } = {} +): void { + const infoConfig = { + level: 'info' as const, + expectedMessages: info, + restoreAllMocks: false, + }; + const warnConfig = { + level: 'warn' as const, + expectedMessages: warn, + restoreAllMocks: false, + }; + const errorConfig = { + level: 'error' as const, + expectedMessages: error, + restoreAllMocks: true, + }; + + expectLogLevel(() => { + expectLogLevel(() => { + expectLogLevel(callback, infoConfig); + }, warnConfig); + }, errorConfig); + + jest.restoreAllMocks(); +} + +/** + * Spies on the logger for messages matching those passed in the config. + * + * @param callback The callback expected to trigger (or not) the logs. + * @param config An optional object with an specified log `level`, an array of + * `expectedMessages` for that log level, and an option to run + * `jest.restoreAllMocks()` after the callback and expectations are complete. + * If no object is passed, will default to spying on the `'error'` log level, + * expect that no messages are sent, and will restore all mocks after the test. + */ +function expectLogLevel( + callback: () => void, + { + level = 'error', + expectedMessages = [], + restoreAllMocks = true, + }: { + level?: 'info' | 'warn' | 'error'; + expectedMessages?: string[]; + restoreAllMocks?: boolean; + } = {} +): void { + const spy = jest.spyOn(logger, level); + + callback(); + + if (expectedMessages.length > 0) { + expect(spy).toHaveBeenCalledTimes(expectedMessages.length); + for (const [index, expectedError] of expectedMessages.entries()) { + expect(spy).toHaveBeenNthCalledWith( + index + 1, + expect.stringMatching(expectedError) + ); + } + } else { + expect(spy).not.toHaveBeenCalled(); + } + + if (restoreAllMocks) { + jest.restoreAllMocks(); + } +} + +/** + * Makes a regexp pattern to match logs. String arguments passed to + * `makeLogMatcher` will be escaped then merged together into a regexp that will + * match partial lines of multi-line logs when paired with Jest + * `expect.stringMatching`. + * + * @example + * ``` + * const expected = makeLogMatcher('Line 1', 'Line 2', '3') + * //=> 'Line 1[\S\s]*Line 2[\S\s]*3' + * + * expect('Line 1\nLine 2\nLine 3').toEqual(expect.stringMatching(expected)); + * //=> passes + * ``` + */ +export function makeLogMatcher(...parts: string[]): string { + return parts.map(escapeRegExp).join('[\\S\\s]*'); +} + +/** + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + */ +function escapeRegExp(string: string): string { + return string.replace(/[$()*+.?[\\\]^{|}]/g, '\\$&'); // $& means the whole matched string +} diff --git a/test/options.test.ts b/test/options.test.ts new file mode 100644 index 00000000..a5e76266 --- /dev/null +++ b/test/options.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, test } from '@jest/globals'; +import { DEFAULT_OPTIONS, parseConfig } from '../transforms/helpers/options'; +import { expectLogs, makeLogMatcher } from './helpers/expect-logs'; + +describe('options', () => { + describe('parseConfig', () => { + test('it parses an empty config', () => { + expectLogs(() => { + const config = parseConfig('test', {}); + expect(config).toStrictEqual({}); + }); + }); + + test('it parses the DEFAULT_OPTIONS', () => { + expectLogs(() => { + const config = parseConfig('test', DEFAULT_OPTIONS); + expect(config).toStrictEqual(DEFAULT_OPTIONS); + }); + }); + + describe('decorators', () => { + test('it parses `{ decorators: true }`', () => { + expectLogs(() => { + const config = parseConfig('test', { decorators: true }); + expect(config).toStrictEqual({ + decorators: { inObjectLiterals: [] }, + }); + }); + }); + + test('it parses `{ decorators: "true" }`', () => { + expectLogs(() => { + const config = parseConfig('test', { decorators: 'true' }); + expect(config).toStrictEqual({ + decorators: { inObjectLiterals: [] }, + }); + }); + }); + + test('it parses `{ decorators: false }`', () => { + expectLogs(() => { + const config = parseConfig('test', { decorators: false }); + expect(config).toStrictEqual({ decorators: false }); + }); + }); + + test('it parses `{ decorators: "false" }`', () => { + expectLogs(() => { + const config = parseConfig('test', { decorators: 'false' }); + expect(config).toStrictEqual({ decorators: false }); + }); + }); + + test('it parses DecoratorOptions.inObjectLiterals with array of strings', () => { + expectLogs(() => { + const config = parseConfig('test', { + decorators: { inObjectLiterals: ['one', 'two', 'three'] }, + }); + expect(config).toStrictEqual({ + decorators: { inObjectLiterals: ['one', 'two', 'three'] }, + }); + }); + }); + + test('it parses DecoratorOptions.inObjectLiterals with string of strings', () => { + expectLogs(() => { + const config = parseConfig('test', { + decorators: { inObjectLiterals: 'one,two , three' }, + }); + expect(config).toStrictEqual({ + decorators: { inObjectLiterals: ['one', 'two', 'three'] }, + }); + }); + }); + + test('it logs an error for invalid `decorators` config', () => { + expectLogs( + () => { + const config = parseConfig('test', { decorators: 'oops' }); + expect(config).toStrictEqual({}); + }, + { + error: [ + makeLogMatcher( + '[test]: CONFIG ERROR:', + "[decorators] Expected DecoratorOptions object or boolean, received 'oops'" + ), + ], + } + ); + }); + }); + + describe.each(['classFields', 'classicDecorator', 'partialTransforms'])( + '%s (StringBooleanSchema)', + (fieldName) => { + test(`it parses \`{ ${fieldName}: true }\``, () => { + expectLogs(() => { + const config = parseConfig('test', { [fieldName]: true }); + expect(config).toStrictEqual({ [fieldName]: true }); + }); + }); + + test(`it parses \`{ ${fieldName}: "true" }\``, () => { + expectLogs(() => { + const config = parseConfig('test', { [fieldName]: 'true' }); + expect(config).toStrictEqual({ [fieldName]: true }); + }); + }); + + test(`it parses \`{ ${fieldName}: false }\``, () => { + expectLogs(() => { + const config = parseConfig('test', { [fieldName]: false }); + expect(config).toStrictEqual({ [fieldName]: false }); + }); + }); + + test(`it parses \`{ ${fieldName}: "false" }\``, () => { + expectLogs(() => { + const config = parseConfig('test', { [fieldName]: 'false' }); + expect(config).toStrictEqual({ [fieldName]: false }); + }); + }); + + test(`it logs an error for invalid \`${fieldName}\` config`, () => { + expectLogs( + () => { + const config = parseConfig('test', { [fieldName]: 'oops' }); + expect(config).toStrictEqual({}); + }, + { + error: [ + makeLogMatcher( + '[test]: CONFIG ERROR:', + `[${fieldName}] Expected boolean, received string` + ), + ], + } + ); + }); + } + ); + + describe('quote', () => { + test('it parses `{ quote: "single" }`', () => { + expectLogs(() => { + const config = parseConfig('test', { quote: 'single' }); + expect(config).toStrictEqual({ quote: 'single' }); + }); + }); + + test('it parses `{ quote: "double" }`', () => { + expectLogs(() => { + const config = parseConfig('test', { quote: 'double' }); + expect(config).toStrictEqual({ quote: 'double' }); + }); + }); + + test('it logs an error for invalid `quote` config', () => { + expectLogs( + () => { + const config = parseConfig('test', { quote: 'oops' }); + expect(config).toStrictEqual({}); + }, + { + error: [ + makeLogMatcher( + '[test]: CONFIG ERROR:', + "[quote] Expected 'single' or 'double', received 'oops" + ), + ], + } + ); + }); + }); + + describe('ignoreLeakingState', () => { + test('it parses `ignoreLeakingState` with an empty array', () => { + expectLogs(() => { + const config = parseConfig('test', { ignoreLeakingState: [] }); + expect(config).toStrictEqual({ ignoreLeakingState: [] }); + }); + }); + + test('it parses `ignoreLeakingState` with array of strings', () => { + expectLogs(() => { + const config = parseConfig('test', { + ignoreLeakingState: ['one', 'two', 'three'], + }); + expect(config).toStrictEqual({ + ignoreLeakingState: ['one', 'two', 'three'], + }); + }); + }); + + test('it parses `ignoreLeakingState` with string of strings', () => { + expectLogs(() => { + const config = parseConfig('test', { + ignoreLeakingState: 'one,two , three', + }); + expect(config).toStrictEqual({ + ignoreLeakingState: ['one', 'two', 'three'], + }); + }); + }); + + test('it logs an error for invalid `ignoreLeakingState` config', () => { + expectLogs( + () => { + const config = parseConfig('test', { ignoreLeakingState: false }); + expect(config).toStrictEqual({}); + }, + { + error: [ + makeLogMatcher( + '[test]: CONFIG ERROR:', + '[ignoreLeakingState] Expected array of strings or comma-separated string, received false' + ), + ], + } + ); + }); + }); + + describe('type', () => { + test.each(['services', 'routes', 'components', 'controllers'])( + 'it parses `{ type: "%s" }`', + (type) => { + expectLogs(() => { + const config = parseConfig('test', { type }); + expect(config).toStrictEqual({ type }); + }); + } + ); + + test('it logs an error for invalid `type` config', () => { + expectLogs( + () => { + const config = parseConfig('test', { type: 'oops' }); + expect(config).toStrictEqual({}); + }, + { + error: [ + makeLogMatcher( + '[test]: CONFIG ERROR:', + "[type] Expected 'services', 'routes', 'components', or 'controllers', received 'oops" + ), + ], + } + ); + }); + }); + }); +}); diff --git a/transforms/ember-object/__testfixtures__/options/string-boolean.input.js b/transforms/ember-object/__testfixtures__/options/string-boolean.input.js new file mode 100644 index 00000000..6e545f14 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/options/string-boolean.input.js @@ -0,0 +1,35 @@ +const Foo1 = EmberObject.extend({}); + +/** + * Program comments + */ +const Foo2 = Test.extend({ + /** + * Property comments + */ + prop: "defaultValue", + boolProp: true, + numProp: 123, + [MY_VAL]: "val", + + /** + * Method comments + */ + method() { + // do things + }, + + otherMethod: function() {}, + + get accessor() { + return this._value; + }, + + set accessor(value) { + this._value = value; + }, + + anotherMethod() { + this._super(...arguments); + } +}); diff --git a/transforms/ember-object/__testfixtures__/options/string-boolean.options.json b/transforms/ember-object/__testfixtures__/options/string-boolean.options.json new file mode 100644 index 00000000..f3aabcad --- /dev/null +++ b/transforms/ember-object/__testfixtures__/options/string-boolean.options.json @@ -0,0 +1,5 @@ +{ + "classicDecorator": "false", + "classFields": "false", + "decorators": "true" +} diff --git a/transforms/ember-object/__testfixtures__/options/string-boolean.output.js b/transforms/ember-object/__testfixtures__/options/string-boolean.output.js new file mode 100644 index 00000000..1fb1d3a8 --- /dev/null +++ b/transforms/ember-object/__testfixtures__/options/string-boolean.output.js @@ -0,0 +1,35 @@ +class Foo1 extends EmberObject {} + +/** + * Program comments + */ +const Foo2 = Test.extend({ + /** + * Property comments + */ + prop: "defaultValue", + boolProp: true, + numProp: 123, + [MY_VAL]: "val", + + /** + * Method comments + */ + method() { + // do things + }, + + otherMethod: function() {}, + + get accessor() { + return this._value; + }, + + set accessor(value) { + this._value = value; + }, + + anotherMethod() { + this._super(...arguments); + } +}); diff --git a/transforms/ember-object/index.ts b/transforms/ember-object/index.ts index bd646902..8bbe7232 100644 --- a/transforms/ember-object/index.ts +++ b/transforms/ember-object/index.ts @@ -20,7 +20,7 @@ const transformer: Transform = function ( if (replaced) { source = root.toSource({ - quote: userOptions.quotes ?? userOptions.quote, + quote: userOptions.quote, }); } diff --git a/transforms/helpers/options.ts b/transforms/helpers/options.ts index d16541a0..2f08ef23 100644 --- a/transforms/helpers/options.ts +++ b/transforms/helpers/options.ts @@ -1,7 +1,13 @@ +import { inspect } from 'node:util'; import type { ZodError } from 'zod'; import { z } from 'zod'; import logger from './log-helper'; import type { RuntimeData } from './runtime-data'; +import { + StringArraySchema, + StringBooleanSchema, + StringFalseSchema, +} from './schema-helper'; const DEFAULT_DECORATOR_CONFIG = { inObjectLiterals: [], @@ -9,41 +15,84 @@ const DEFAULT_DECORATOR_CONFIG = { const DecoratorOptionsSchema = z .object({ - /** Allow-list for decorators currently applied to object literal properties that can be safely applied to class properties. */ - inObjectLiterals: z.preprocess((arg: unknown) => { - return typeof arg === 'string' ? arg.split(/\s*,\s*/) : arg; - }, z.array(z.string())), + inObjectLiterals: StringArraySchema.describe( + 'A list of decorators that are allowed on object literal properties. (Method decorators will always be allowed.) When the codemod finds a field with one of these decorators, it will be translated directly into a class field with the same decorator. Including a decorator in this list means that you believe that the decorator will work correctly on a class field.' + ), }) .partial(); +const DecoratorsSchema = z.preprocess( + (arg: unknown) => { + return arg === true || arg === 'true' ? DEFAULT_DECORATOR_CONFIG : arg; + }, + z.union([DecoratorOptionsSchema, StringFalseSchema], { + errorMap: (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_union) { + return { + message: `Expected DecoratorOptions object or boolean, received ${inspect( + ctx.data + )}`, + }; + } + return { message: ctx.defaultError }; + }, + }) +); + +const QuoteSchema = z.union([z.literal('single'), z.literal('double')], { + errorMap: (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_union) { + return { + message: `Expected 'single' or 'double', received ${inspect(ctx.data)}`, + }; + } + return { message: ctx.defaultError }; + }, +}); + +const TypeSchema = z.union( + [ + z.literal('services'), + z.literal('routes'), + z.literal('components'), + z.literal('controllers'), + ], + { + errorMap: (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_union) { + return { + message: `Expected 'services', 'routes', 'components', or 'controllers', received ${inspect( + ctx.data + )}`, + }; + } + return { message: ctx.defaultError }; + }, + } +); + export const UserOptionsSchema = z.object({ - /** Enable/disable transformation using decorators, or pass in DecoratorOptions */ - decorators: z.preprocess((arg: unknown) => { - return arg === true ? DEFAULT_DECORATOR_CONFIG : arg; - }, z.union([DecoratorOptionsSchema, z.literal(false)])), - /** Enable/disable transformation using class fields */ - classFields: z.boolean(), - /** Enable/disable adding the [`@classic` decorator](https://github.com/pzuraq/ember-classic-decorator), which helps with transitioning Ember Octane */ - classicDecorator: z.boolean(), - /** If `false`, the entire file will fail validation if any EmberObject within it fails validation. */ - partialTransforms: z.boolean(), - /** Whether to use double or single quotes by default for new statements that are added during the codemod. */ - quote: z.union([z.literal('single'), z.literal('double')]), - quotes: z.union([z.literal('single'), z.literal('double')]).optional(), - /** - * Allow-list for ObjectExpression or ArrayExpression properties to ignore - * issues detailed in eslint-plugin-ember/avoid-leaking-state-in-ember-objects. - */ - ignoreLeakingState: z.array(z.string()), - /** Apply transformation to only passed type. */ - type: z - .union([ - z.literal('services'), - z.literal('routes'), - z.literal('components'), - z.literal('controllers'), - ]) - .optional(), + decorators: DecoratorsSchema.describe( + 'Enable/disable transformation using decorators, or pass in DecoratorOptions' + ), + classFields: StringBooleanSchema.describe( + 'Enable/disable transformation using class fields' + ), + classicDecorator: StringBooleanSchema.describe( + 'Enable/disable adding the [`@classic` decorator](https://github.com/pzuraq/ember-classic-decorator), which helps with transitioning Ember Octane' + ), + partialTransforms: StringBooleanSchema.describe( + 'If `false`, the entire file will fail validation if any EmberObject within it fails validation.' + ), + quote: QuoteSchema.describe( + 'Whether to use double or single quotes by default for new statements that are added during the codemod.' + ), + ignoreLeakingState: StringArraySchema.describe( + 'Allow-list for ObjectExpression or ArrayExpression properties to ignore issues detailed in eslint-plugin-ember/avoid-leaking-state-in-ember-objects.' + ), + type: TypeSchema.describe( + 'Apply transformation to only passed type.' + ).optional(), }); export type UserOptions = z.infer; @@ -76,9 +125,14 @@ export function parseConfig(source: string, raw: unknown): PartialUserOptions { function logConfigError( source: string, - { message }: ZodError + error: ZodError ): void { - logger.error(`[${source}]: CONFIG ERROR: \n\t${message}`); + const flattened = error.flatten(); + const errors = flattened.formErrors; + for (const [key, value] of Object.entries(flattened.fieldErrors)) { + errors.push(`[${key}] ${value.join('; ')}`); + } + logger.error(`[${source}]: CONFIG ERROR: \n\t${errors.join('\n\t')}`); } interface PrivateOptions { diff --git a/transforms/helpers/schema-helper.ts b/transforms/helpers/schema-helper.ts new file mode 100644 index 00000000..ee0681db --- /dev/null +++ b/transforms/helpers/schema-helper.ts @@ -0,0 +1,40 @@ +import { inspect } from 'node:util'; +import type { ZodEffects, ZodTypeAny } from 'zod'; +import { z } from 'zod'; + +const preprocessStringToBoolean = ( + schema: I +): ZodEffects => { + return z.preprocess(strictCoerceStringToBoolean, schema); +}; + +/** Allows true | false | 'true' | 'false' */ +export const StringBooleanSchema = preprocessStringToBoolean(z.boolean()); + +/** Allows false | 'false' */ +export const StringFalseSchema = preprocessStringToBoolean(z.literal(false)); + +function strictCoerceStringToBoolean(arg: unknown): unknown { + return typeof arg === 'string' + ? { true: true, false: false }[arg] ?? arg // strictly coerce 'true' and 'false' to true and false + : arg; +} + +/** Allows an array of strings or a comma-separated string */ +export const StringArraySchema = z.preprocess( + (arg: unknown) => { + return typeof arg === 'string' ? arg.split(/\s*,\s*/) : arg; + }, + z.array(z.string(), { + errorMap: (issue, ctx) => { + if (issue.code === z.ZodIssueCode.invalid_type) { + return { + message: `Expected array of strings or comma-separated string, received ${inspect( + ctx.data + )}`, + }; + } + return { message: ctx.defaultError }; + }, + }) +); diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 0984d351..26e9ff00 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -1,5 +1,5 @@ { "extends": "./tsconfig.json", - "include": ["transforms/**/*", "test/**/*.test.ts"], + "include": ["transforms/**/*", "test/**/*.ts"], "exclude": ["transforms/ember-object/__testfixtures__/**/*"] }