diff --git a/src/constants.ts b/src/constants.ts index 811c4b70..4d8e32a6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,7 +14,10 @@ export const DEFAULT_OPTIONS: Omit = { OtherAssetType.JSON, OtherAssetType.TS ], - formatOptions: { json: { indent: 4 } }, + formatOptions: { + json: { indent: 4 }, + ts: { types: ['stringLiteral', 'enum'] } + }, pathOptions: {}, templates: {}, codepoints: {}, diff --git a/src/generators/asset-types/__tests__/__snapshots__/ts.ts.snap b/src/generators/asset-types/__tests__/__snapshots__/ts.ts.snap index 3f501f1e..d41c8b21 100644 --- a/src/generators/asset-types/__tests__/__snapshots__/ts.ts.snap +++ b/src/generators/asset-types/__tests__/__snapshots__/ts.ts.snap @@ -1,134 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`\`TS\` asset generator generates constant only 1`] = ` -"export const MY_ICONS_SET_CODEPOINTS: Record = { - \\"foo\\": \\"4265\\", - \\"bar\\": \\"1231\\", -}; -" -`; - -exports[`\`TS\` asset generator generates constant with literalId if no enum generated 1`] = ` -"export type MyIconsSetId = - | \\"foo\\" - | \\"bar\\"; - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSetId]: string } = { - \\"foo\\": \\"4265\\", - \\"bar\\": \\"1231\\", -}; -" -`; - -exports[`\`TS\` asset generator generates constant with literalKey if no enum generated 1`] = ` -"export type MyIconsSetKey = - | \\"Foo\\" - | \\"Bar\\"; - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSetKey]: string } = { - \\"foo\\": \\"4265\\", - \\"bar\\": \\"1231\\", -}; -" -`; - -exports[`\`TS\` asset generator generates no key string literal type if option passed like that 1`] = ` -"export enum MyIconsSet { - Foo = \\"foo\\", - Bar = \\"bar\\", -} - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSet]: string } = { - [MyIconsSet.Foo]: \\"4265\\", - [MyIconsSet.Bar]: \\"1231\\", -}; -" -`; - -exports[`\`TS\` asset generator generates single quotes if format option passed 1`] = ` -"export type MyIconsSetId = - | 'foo' - | 'bar'; - -export type MyIconsSetKey = - | 'Foo' - | 'Bar'; - -export enum MyIconsSet { - Foo = 'foo', - Bar = 'bar', -} - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSet]: string } = { - [MyIconsSet.Foo]: '4265', - [MyIconsSet.Bar]: '1231', -}; -" -`; - -exports[`\`TS\` asset generator prevents enum keys that start with digits 1`] = ` -"export type MyIconsSetId = - | \\"1234\\" - | \\"5678\\"; - -export type MyIconsSetKey = - | \\"i1234\\" - | \\"i5678\\"; - -export enum MyIconsSet { - i1234 = \\"1234\\", - i5678 = \\"5678\\", -} - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSet]: string } = { - [MyIconsSet.i1234]: \\"undefined\\", - [MyIconsSet.i5678]: \\"undefined\\", -}; -" -`; - -exports[`\`TS\` asset generator prevents enum keys that start with digits when digits and chars 1`] = ` -"export type MyIconsSetId = - | \\"1234asdf\\" - | \\"5678ab\\" - | \\"foo\\"; - -export type MyIconsSetKey = - | \\"i1234asdf\\" - | \\"i5678ab\\" - | \\"Foo\\"; - -export enum MyIconsSet { - i1234asdf = \\"1234asdf\\", - i5678ab = \\"5678ab\\", - Foo = \\"foo\\", -} - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSet]: string } = { - [MyIconsSet.i1234asdf]: \\"undefined\\", - [MyIconsSet.i5678ab]: \\"undefined\\", - [MyIconsSet.Foo]: \\"4265\\", -}; -" -`; - -exports[`\`TS\` asset generator renders expected TypeScript module content 1`] = ` -"export type MyIconsSetId = - | \\"foo\\" - | \\"bar\\"; - -export type MyIconsSetKey = - | \\"Foo\\" - | \\"Bar\\"; - -export enum MyIconsSet { - Foo = \\"foo\\", - Bar = \\"bar\\", -} - -export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSet]: string } = { - [MyIconsSet.Foo]: \\"4265\\", - [MyIconsSet.Bar]: \\"1231\\", -}; -" -`; +exports[`\`TS\` asset generator renders expected TypeScript module content 1`] = `"export enum MyIconsSet { Foo = \\"foo\\", Bar = \\"bar\\", } export type MyIconsSetId = \`MyIconsSet\`; export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSetId]: string } = { \\"foo\\": \\"4265\\", \\"bar\\": \\"1231\\", }; "`; diff --git a/src/generators/asset-types/__tests__/ts.ts b/src/generators/asset-types/__tests__/ts.ts index 9ed4198f..fbadc425 100644 --- a/src/generators/asset-types/__tests__/ts.ts +++ b/src/generators/asset-types/__tests__/ts.ts @@ -1,5 +1,8 @@ import tsGen from '../ts'; import { join, dirname } from 'path'; +import { FontGeneratorOptions } from '../../../types/generator'; +import { DEFAULT_OPTIONS } from '../../../constants'; +import { FormatOptions } from '../../../types/format'; const mockAssets = { foo: { @@ -27,174 +30,133 @@ const mockOptions = { const cleanWhiteSpace = (subject: string): string => subject.replace(/\n+/, '').replace(/\s+/g, ' '); -const getCleanGen = async (options = {}) => - cleanWhiteSpace( - (await tsGen.generate({ ...mockOptions, ...options }, null)) as string - ); +const getCleanGen = async ({ + formatOptions = DEFAULT_OPTIONS.formatOptions as FormatOptions, + rmWhiteSpace = false +}: { + formatOptions?: FormatOptions; + rmWhiteSpace?: boolean; +} = {}) => { + const result = await tsGen.generate({ ...mockOptions, formatOptions }, null); + if (rmWhiteSpace) { + return cleanWhiteSpace(String(result)); + } + return String(result); +}; describe('`TS` asset generator', () => { test('renders expected TypeScript module content', async () => { - expect(await tsGen.generate(mockOptions, null)).toMatchSnapshot(); + expect(await getCleanGen({ rmWhiteSpace: true })).toMatchSnapshot(); }); - test('correctly renders type declaration', async () => { + test('extracts string literal type from enum when given', async () => { expect(await getCleanGen()).toContain( - 'export type MyIconsSetId = | "foo" | "bar";' + 'export type MyIconsSetId = `${MyIconsSet}`;' ); }); - test('correctly enum declaration', async () => { - expect(await getCleanGen()).toContain( - 'export enum MyIconsSet { Foo = "foo", Bar = "bar", }' + test('defines string literal as typeof extraction of the constant', async () => { + const result = await getCleanGen({ + formatOptions: { ts: { types: ['stringLiteral'], asConst: true } } + }); + expect(result).toContain( + 'export type MyIconsSetKey = typeof MY_ICONS_SET_CODEPOINTS[number];' ); + expect(result).toContain('export const MY_ICONS_SET_CODEPOINTS = {'); }); - test('correctly codepoints declaration', async () => { - expect(await getCleanGen()).toContain( - 'export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSet]: string }' + - ' = { [MyIconsSet.Foo]: "4265", [MyIconsSet.Bar]: "1231", };' + test('defines constant with "as const;" when asConst given and string literal not extracted from constant', async () => { + expect( + await getCleanGen({ + formatOptions: { + ts: { types: ['enum', 'stringLiteral'], asConst: true } + } + }) + ).toContain( + 'export const MY_ICONS_SET_CODEPOINTS: readonly { [key in MyIconsSetId]: string } = {' ); }); - test('generates single quotes if format option passed', async () => { + test('defines plain string literal type when asConst is false', async () => { expect( - await tsGen.generate( - { - ...mockOptions, - formatOptions: { ts: { singleQuotes: true } } - }, - null - ) - ).toMatchSnapshot(); + await getCleanGen({ + rmWhiteSpace: true, + formatOptions: { ts: { types: ['stringLiteral'], asConst: false } } + }) + ).toContain('export type MyIconsSetId = | "foo" | "bar";'); }); - test('generates no key string literal type if option passed like that', async () => { - const result = await tsGen.generate( - { - ...mockOptions, - formatOptions: { ts: { types: ['constant', 'enum'] } } - }, - null - ); - const cleanResult = cleanWhiteSpace(result as string); - expect(result).toMatchSnapshot(); - - expect(cleanResult).not.toContain( - 'export type MyIconsSetKey = | "Foo" | "Bar";' - ); - expect(cleanResult).not.toContain( - 'export type MyIconsSetId = | "foo" | "bar";' + test('sets readonly and const when only enum and asConst', async () => { + const result = await getCleanGen({ + rmWhiteSpace: false, + formatOptions: { ts: { types: ['enum'], asConst: true } } + }); + expect(result).toContain( + 'export const MY_ICONS_SET_CODEPOINTS: readonly { [key in MyIconsSet]: string } = {' ); + expect(result).toContain('} as const;'); }); - test('generates constant with literalId if no enum generated', async () => { - const result = await tsGen.generate( - { - ...mockOptions, - formatOptions: { ts: { types: ['constant', 'literalId'] } } - }, - null - ); - const cleanResult = cleanWhiteSpace(result as string); - - expect(result).toMatchSnapshot(); - expect(cleanResult).toContain( - 'export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSetId]: string }' - ); - - expect(cleanResult).not.toContain( - 'export type MyIconsSetKey = | "Foo" | "Bar";' - ); - expect(cleanResult).not.toContain('export enum MyIconsSet'); + test('uses single quotes when singleQuotes on string literal and const', async () => { + const result = await getCleanGen({ + rmWhiteSpace: false, + formatOptions: { + ts: { types: ['stringLiteral'], asConst: true, singleQuotes: true } + } + }); + expect(result).toContain("'"); + expect(result).not.toContain('"'); }); - test('generates constant with literalKey if no enum generated', async () => { - const result = await tsGen.generate( - { - ...mockOptions, - formatOptions: { ts: { types: ['constant', 'literalKey'] } } - }, - null - ); - const cleanResult = cleanWhiteSpace(result as string); - - expect(result).toMatchSnapshot(); - expect(cleanResult).toContain( - 'export const MY_ICONS_SET_CODEPOINTS: { [key in MyIconsSetKey]: string }' - ); - - expect(cleanResult).not.toContain( - 'export type MyIconsSetId = | "foo" | "bar";' - ); - expect(cleanResult).not.toContain('export enum MyIconsSet'); + test('uses single quotes when singleQuotes on enum and const', async () => { + const result = await getCleanGen({ + rmWhiteSpace: false, + formatOptions: { + ts: { types: ['enum'], asConst: true, singleQuotes: true } + } + }); + expect(result).toContain("'"); + expect(result).not.toContain('"'); }); - test('generates constant only', async () => { - const result = await tsGen.generate( - { - ...mockOptions, - formatOptions: { ts: { types: ['constant'] } } - }, - null - ); - const cleanResult = cleanWhiteSpace(result as string); - - expect(result).toMatchSnapshot(); - expect(cleanResult).toContain( - 'export const MY_ICONS_SET_CODEPOINTS: Record' - ); - - expect(cleanResult).not.toContain( - 'export type MyIconsSetId = | "foo" | "bar";' - ); - expect(cleanResult).not.toContain('export enum MyIconsSet'); + test('uses single quotes when singleQuotes on enum and stringLiteral and const', async () => { + const result = await getCleanGen({ + rmWhiteSpace: false, + formatOptions: { + ts: { + types: ['enum', 'stringLiteral'], + asConst: true, + singleQuotes: true + } + } + }); + expect(result).toContain("'"); + expect(result).not.toContain('"'); }); - test('prevents enum keys that start with digits', async () => { - const result = await tsGen.generate( - { - ...mockOptions, - assets: { 1234: mockAssets.foo, 5678: mockAssets.bar } - }, - null - ); - const cleanResult = cleanWhiteSpace(result as string); - - expect(result).toMatchSnapshot(); - expect(cleanResult).toContain( - 'export type MyIconsSetId = | "1234" | "5678";' - ); - expect(cleanResult).toContain( - 'export type MyIconsSetKey = | "i1234" | "i5678";' - ); - expect(cleanResult).toContain( - 'export enum MyIconsSet { i1234 = "1234", i5678 = "5678", }' - ); + test('no string and no enum when no as const', async () => { + const result = await getCleanGen({ + rmWhiteSpace: false, + formatOptions: { + ts: { types: [], asConst: false } + } + }); + expect(result).toContain('export const MY_ICONS_SET_CODEPOINTS = {'); + expect(result).not.toContain('} as const;'); + expect(result).not.toContain('export type MyIconsSetId ='); + expect(result).not.toContain('export enum MyIconsSet {'); }); - test('prevents enum keys that start with digits when digits and chars', async () => { - const result = await tsGen.generate( - { - ...mockOptions, - assets: { - '1234asdf': mockAssets.foo, - '5678ab': mockAssets.bar, - foo: mockAssets.foo - } - }, - null - ); - const cleanResult = cleanWhiteSpace(result as string); - - expect(result).toMatchSnapshot(); - expect(cleanResult).toContain( - 'export type MyIconsSetId = | "1234asdf" | "5678ab" | "foo";' - ); - expect(cleanResult).toContain( - 'export type MyIconsSetKey = | "i1234asdf" | "i5678ab" | "Foo";' - ); - expect(cleanResult).toContain( - 'export enum MyIconsSet { i1234asdf = "1234asdf", i5678ab = "5678ab", Foo = "foo", }' - ); + test('no string and no enum when with as const', async () => { + const result = await getCleanGen({ + rmWhiteSpace: false, + formatOptions: { + ts: { types: [], asConst: true } + } + }); + expect(result).toContain('export const MY_ICONS_SET_CODEPOINTS = {'); + expect(result).toContain('} as const;'); + expect(result).not.toContain('export type MyIconsSetId ='); + expect(result).not.toContain('export enum MyIconsSet {'); }); }); diff --git a/src/generators/asset-types/ts.ts b/src/generators/asset-types/ts.ts index 48a05afb..a5c23256 100644 --- a/src/generators/asset-types/ts.ts +++ b/src/generators/asset-types/ts.ts @@ -1,131 +1,150 @@ import { pascalCase, constantCase } from 'change-case'; -import { FontGenerator } from '../../types/generator'; +import { FormatOptions } from '../../types/format'; +import { FontGenerator, FontGeneratorOptions } from '../../types/generator'; -const generateEnumKeys = (assetKeys: string[]): Record => - assetKeys - .map(name => { - const enumName = pascalCase(name); - const prefix = enumName.match(/^\d/) ? 'i' : ''; +class TsGenerator { + isStringLiteral = false; + isEnum = false; + isAsConst = false; + isEnumExtractStringLiteral = false; + isConstExtractStringLiteral = false; - return { - [name]: `${prefix}${enumName}` - }; - }) - .reduce((prev, curr) => Object.assign(prev, curr), {}); - -const generateEnums = ( - enumName: string, - enumKeys: { [eKey: string]: string }, - quote = '"' -): string => - [ - `export enum ${enumName} {`, - ...Object.entries(enumKeys).map( - ([enumValue, enumKey]) => ` ${enumKey} = ${quote}${enumValue}${quote},` - ), - '}\n' - ].join('\n'); - -const generateConstant = ({ - codepointsName, - enumName, - literalIdName, - literalKeyName, - enumKeys, - codepoints, - quote = '"', - kind = {} -}: { - codepointsName: string; - enumName: string; - literalIdName: string; - literalKeyName: string; - enumKeys: { [eKey: string]: string }; - codepoints: Record; - quote?: '"' | "'"; - kind: Record; -}): string => { - let varType = ': Record'; - - if (kind.enum) { - varType = `: { [key in ${enumName}]: string }`; - } else if (kind.literalId) { - varType = `: { [key in ${literalIdName}]: string }`; - } else if (kind.literalKey) { - varType = `: { [key in ${literalKeyName}]: string }`; + name: string; + get enumName(): string { + return pascalCase(this.name); + } + get codepointsName(): string { + return `${constantCase(this.name)}_CODEPOINTS`; + } + get literalIdName(): string { + return `${pascalCase(this.name)}Id`; + } + get literalKeyName(): string { + return `${pascalCase(this.name)}Key`; + } + get keyTypeSig(): string { + const readonlyStr = this.isAsConst ? 'readonly ' : ''; + if (!this.isStringLiteral && this.isEnum) { + return `: ${readonlyStr}{ [key in ${this.enumName}]: string }`; + } + if (this.isStringLiteral && !this.isConstExtractStringLiteral) { + return `: ${readonlyStr}{ [key in ${this.literalIdName}]: string }`; + } + return ''; } - return [ - `export const ${codepointsName}${varType} = {`, - Object.entries(enumKeys) - .map(([enumValue, enumKey]) => { - const key = kind.enum - ? `[${enumName}.${enumKey}]` - : `${quote}${enumValue}${quote}`; - return ` ${key}: ${quote}${codepoints[enumValue]}${quote},`; - }) - .join('\n'), - '};\n' - ].join('\n'); -}; - -const generateStringLiterals = ( - typeName: string, - literals: string[], - quote = '"' -): string => - [ - `export type ${typeName} =`, - `${literals.map(key => ` | ${quote}${key}${quote}`).join('\n')};\n` - ].join('\n'); + quote = '"'; + codePointDict: Record; + codepoints: FontGeneratorOptions['codepoints']; -const generator: FontGenerator = { - generate: async ({ + constructor({ name, codepoints, assets, formatOptions: { ts } = {} - }) => { - const quote = Boolean(ts?.singleQuotes) ? "'" : '"'; - const generateKind: Record = ( - Boolean(ts?.types?.length) - ? ts.types - : ['enum', 'constant', 'literalId', 'literalKey'] - ) - .map(kind => ({ [kind]: true })) - .reduce((prev, curr) => Object.assign(prev, curr), {}); + }: FontGeneratorOptions) { + this.name = name; + this.codepoints = codepoints; + this.createCodePointDict(Object.keys(assets)); + this.isAsConst = Boolean(ts?.asConst); + this.isEnum = ts?.types?.includes('enum'); + this.isStringLiteral = ts?.types?.includes('stringLiteral'); + this.isEnumExtractStringLiteral = this.isEnum && this.isStringLiteral; + this.isConstExtractStringLiteral = + !this.isEnumExtractStringLiteral && + this.isAsConst && + this.isStringLiteral; + if (ts?.singleQuotes) { + this.quote = "'"; + } + } - const enumName = pascalCase(name); - const codepointsName = `${constantCase(name)}_CODEPOINTS`; - const literalIdName = `${pascalCase(name)}Id`; - const literalKeyName = `${pascalCase(name)}Key`; - const names = { enumName, codepointsName, literalIdName, literalKeyName }; + generate(): string { + return [ + this.generateEnum(), + this.generateBeforeConstLiteral(), + this.generateConst(), + this.generateAfterConstLiteral() + ] + .filter(Boolean) + .join('\n'); + } - const enumKeys = generateEnumKeys(Object.keys(assets)); + private generateConst(): string { + const keyDef = this.getConstRecKeyFn(); + const records = Object.entries(this.codePointDict).map( + ([eVal, eKey]) => + `${keyDef([eVal, eKey])}${this.inQuote(this.codepoints[eVal])},` + ); + return [ + `export const ${this.codepointsName}${this.keyTypeSig} = {`, + ...records, + `}${this.isAsConst ? ' as const' : ''};\n` + ].join('\n'); + } - const stringLiteralId = generateKind.literalId - ? generateStringLiterals(literalIdName, Object.keys(enumKeys), quote) - : null; - const stringLiteralKey = generateKind.literalKey - ? generateStringLiterals(literalKeyName, Object.values(enumKeys), quote) - : null; + private getConstRecKeyFn(): (kv: [string, string]) => string { + if (this.isEnum && !this.isStringLiteral) { + return ([, eKey]) => ` [${this.enumName}.${eKey}]: `; + } + return ([eVal]) => ` ${this.inQuote(eVal)}: `; + } - const enums = generateKind.enum - ? generateEnums(enumName, enumKeys, quote) - : null; - const constant = generateKind.constant - ? generateConstant({ - ...names, - enumKeys, - codepoints, - quote, - kind: generateKind - }) - : null; + private generateBeforeConstLiteral(): string { + if (!this.isStringLiteral || this.isConstExtractStringLiteral) { + return; + } + const exportStatement = `export type ${this.literalIdName} =`; + if (this.isEnumExtractStringLiteral) { + return `${exportStatement} \`\${${this.enumName}}\`;\n\n`; + } + return ( + [ + exportStatement, + ...Object.keys(this.codePointDict).map( + eVal => ` | ${this.inQuote(eVal)}` + ) + ].join('\n') + ';\n' + ); + } - return [stringLiteralId, stringLiteralKey, enums, constant] - .filter(Boolean) - .join('\n'); + private generateAfterConstLiteral(): string { + if (!this.isConstExtractStringLiteral) { + return; + } + return `export type ${this.literalKeyName} = typeof ${this.codepointsName}[number];\n`; + } + + private generateEnum(): string { + if (!this.isEnum) { + return; + } + return [ + `export enum ${this.enumName} {`, + ...Object.entries(this.codePointDict).map( + ([eVal, eKey]) => ` ${eKey} = ${this.inQuote(eVal)},` + ), + '}\n\n' + ].join('\n'); + } + + private inQuote = (val: string | number): string => + `${this.quote}${val}${this.quote}`; + + private createCodePointDict(assetKeys: string[]): void { + this.codePointDict = Object.fromEntries( + assetKeys.map(name => { + const enumName = pascalCase(name); + return [name, `${enumName.match(/^\d/) ? 'i' : ''}${enumName}`]; + }) + ); + } +} + +const generator: FontGenerator = { + generate: async options => { + const tsGenerator = new TsGenerator(options); + return tsGenerator.generate(); } }; diff --git a/src/types/format.ts b/src/types/format.ts index 6438e766..32b76265 100644 --- a/src/types/format.ts +++ b/src/types/format.ts @@ -14,9 +14,30 @@ interface JsonOptions { indent?: number; } +type TsType = 'enum' | 'stringLiteral'; + interface TsOptions { - types?: ('enum' | 'constant' | 'literalId' | 'literalKey')[]; + /** + * Choose if the codepoints constant should be typed + * with an enum and/or a string literal type. + * @defaultValue `['stringLiteral', 'enum']` + */ + types?: TsType[]; + /** + * .ts file has `"` by default - `true` will produce `'`. + * @defaultValue `false` + */ singleQuotes?: boolean; + /** + * the codepoints constant is exported as readonly + * ```ts + * export const ICONS_CODEPOINTS = { + * example: '1234', + * } as const; + * ``` + * @defaultValue `true` + */ + asConst?: boolean; } export interface FormatOptions {