diff --git a/README.md b/README.md index 5bbb884..38306b5 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ OPTIONS -h, --help show CLI help -o, --out=out output directory -p, --preserve preserve output folder + -X --v10 create contentful.js v10 types -l, --localized add localized types -d, --jsdoc add JSDoc comments -g, --typeguard add type guards @@ -278,10 +279,21 @@ import { CFDefinitionsBuilder, DefaultContentTypeRenderer } from 'cf-content-typ const builder = new CFDefinitionsBuilder([new DefaultContentTypeRenderer()]); ``` +## V10ContentTypeRenderer + +A renderer to render type fields and entry definitions compatible with contentful.js v10. + +```typescript +import { CFDefinitionsBuilder, V10ContentTypeRenderer } from 'cf-content-types-generator'; + +const builder = new CFDefinitionsBuilder([new V10ContentTypeRenderer()]); +``` + ## LocalizedContentTypeRenderer Add additional types for localized fields. It adds utility types to transform fields into localized fields for given locales More details on the utility types can be found here: [Issue 121](https://github.com/contentful-userland/cf-content-types-generator/issues/121) +Note that these types are not needed when using `V10ContentTypeRenderer` as the v10 entry type already supports localization. #### Example Usage @@ -381,7 +393,7 @@ Adds type guard functions for every content type #### Example Usage ```typescript -import { CFDefinitionsBuilder, TypeGuardRenderer } from 'cf-content-types-generator'; +import { CFDefinitionsBuilder, DefaultContentTypeRenderer, TypeGuardRenderer } from 'cf-content-types-generator'; const builder = new CFDefinitionsBuilder([ new DefaultContentTypeRenderer(), @@ -406,6 +418,38 @@ export function isTypeAnimal(entry: WithContentTypeLink): entry is TypeAnimal { } ``` +## V10TypeGuardRenderer + +Adds type guard functions for every content type which are compatible with contentful.js v10. + +#### Example Usage + +```typescript +import { CFDefinitionsBuilder, V10ContentTypeRenderer, V10TypeGuardRenderer } from 'cf-content-types-generator'; + +const builder = new CFDefinitionsBuilder([ + new V10ContentTypeRenderer(), + new V10TypeGuardRenderer(), +]); +``` + +#### Example output + +```typescript +import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from "contentful"; + +export interface TypeAnimalFields { + bread?: EntryFieldTypes.Symbol; +} + +export type TypeAnimalSkeleton = EntrySkeletonType; +export type TypeAnimal = Entry; + +export function isTypeAnimal(entry: Entry): entry is TypeAnimal { + return entry.sys.contentType.sys.id === 'animal' +} +``` + # Direct Usage If you're not a CLI person, or you want to integrate it with your tooling workflow, you can also directly use the `CFDefinitionsBuilder` from `cf-definitions-builder.ts` diff --git a/src/cli.ts b/src/cli.ts index 7453cef..d379d0c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,10 +6,12 @@ import CFDefinitionsBuilder from './cf-definitions-builder'; import { ContentTypeRenderer, DefaultContentTypeRenderer, + V10ContentTypeRenderer, JsDocRenderer, LocalizedContentTypeRenderer, TypeGuardRenderer, } from './renderer'; +import { V10TypeGuardRenderer } from './renderer/type/v10-type-guard-renderer'; // eslint-disable-next-line unicorn/prefer-module const contentfulExport = require('contentful-export'); @@ -21,6 +23,7 @@ class ContentfulMdg extends Command { help: flags.help({ char: 'h' }), out: flags.string({ char: 'o', description: 'output directory' }), preserve: flags.boolean({ char: 'p', description: 'preserve output folder' }), + v10: flags.boolean({ char: 'X', description: 'create contentful.js v10 types' }), localized: flags.boolean({ char: 'l', description: 'add localized types' }), jsdoc: flags.boolean({ char: 'd', description: 'add JSDoc comments' }), typeguard: flags.boolean({ char: 'g', description: 'add type guards' }), @@ -65,8 +68,16 @@ class ContentfulMdg extends Command { }); } - const renderers: ContentTypeRenderer[] = [new DefaultContentTypeRenderer()]; + const renderers: ContentTypeRenderer[] = flags.v10 + ? [new V10ContentTypeRenderer()] + : [new DefaultContentTypeRenderer()]; if (flags.localized) { + if (flags.v10) { + this.error( + '"--localized" option is not needed, contentful.js v10 types have localization built in.', + ); + } + renderers.push(new LocalizedContentTypeRenderer()); } @@ -75,7 +86,7 @@ class ContentfulMdg extends Command { } if (flags.typeguard) { - renderers.push(new TypeGuardRenderer()); + renderers.push(flags.v10 ? new V10TypeGuardRenderer() : new TypeGuardRenderer()); } const builder = new CFDefinitionsBuilder(renderers); diff --git a/src/module-name.ts b/src/module-name.ts index e540948..117f87d 100644 --- a/src/module-name.ts +++ b/src/module-name.ts @@ -9,6 +9,10 @@ export const moduleName = (name: string): string => { return pipe([replaceDash, upperFirst, addPrefix, removeSpace])(name); }; +export const moduleSkeletonName = (name: string): string => { + return moduleName(name) + 'Skeleton'; +}; + export const moduleFieldsName = (name: string): string => { return moduleName(name) + 'Fields'; }; diff --git a/src/renderer/field/index.ts b/src/renderer/field/index.ts index 2875b19..c4d6737 100644 --- a/src/renderer/field/index.ts +++ b/src/renderer/field/index.ts @@ -1,7 +1,8 @@ export * from './render-types'; -export { renderPropAny } from './render-prop-any'; -export { renderPropArray } from './render-prop-array'; -export { renderPropLink } from './render-prop-link'; -export { renderPropResourceLink } from './render-prop-resource-link'; -export { renderRichText } from './render-prop-richtext'; +export { renderPropAny, renderPropAnyV10 } from './render-prop-any'; +export { renderPropArray, renderPropArrayV10 } from './render-prop-array'; +export { renderPropLink, renderPropLinkV10 } from './render-prop-link'; +export { renderPropResourceLink, renderPropResourceLinkV10 } from './render-prop-resource-link'; +export { renderRichText, renderRichTextV10 } from './render-prop-richtext'; export { defaultRenderers } from './default-renderers'; +export { v10Renderers } from './v10-renderers'; diff --git a/src/renderer/field/render-prop-any.ts b/src/renderer/field/render-prop-any.ts index bbf5b9a..30ee1b0 100644 --- a/src/renderer/field/render-prop-any.ts +++ b/src/renderer/field/render-prop-any.ts @@ -1,5 +1,5 @@ import { ContentTypeField } from 'contentful'; -import { renderTypeLiteral, renderTypeUnion } from '../generic'; +import { renderTypeGeneric, renderTypeLiteral, renderTypeUnion } from '../generic'; import { RenderContext } from '../type'; export const renderPropAny = (field: ContentTypeField, context: RenderContext): string => { @@ -26,3 +26,31 @@ export const renderPropAny = (field: ContentTypeField, context: RenderContext): return `EntryFields.${field.type}`; }; + +export const renderPropAnyV10 = (field: ContentTypeField, context: RenderContext): string => { + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['EntryFieldTypes'], + isTypeOnly: true, + }); + + if (field.validations?.length > 0) { + const includesValidation = field.validations.find((validation) => validation.in); + if (includesValidation && includesValidation.in) { + const mapper = (): ((value: string) => string) => { + if (field.type === 'Symbol' || field.type === 'Text' || field.type === 'RichText') { + return renderTypeLiteral; + } + + return (value: string) => value.toString(); + }; + + return renderTypeGeneric( + `EntryFieldTypes.${field.type}`, + renderTypeUnion(includesValidation.in.map((type) => mapper()(type))), + ); + } + } + + return `EntryFieldTypes.${field.type}`; +}; diff --git a/src/renderer/field/render-prop-array.ts b/src/renderer/field/render-prop-array.ts index 44ee29a..206cb24 100644 --- a/src/renderer/field/render-prop-array.ts +++ b/src/renderer/field/render-prop-array.ts @@ -1,6 +1,6 @@ import { ContentTypeField } from 'contentful'; import { inValidations } from '../../extract-validation'; -import { renderTypeArray, renderTypeLiteral, renderTypeUnion } from '../generic'; +import { renderTypeArray, renderTypeGeneric, renderTypeLiteral, renderTypeUnion } from '../generic'; import { RenderContext } from '../type'; export const renderPropArray = (field: ContentTypeField, context: RenderContext): string => { @@ -40,3 +40,46 @@ export const renderPropArray = (field: ContentTypeField, context: RenderContext) throw new Error('unhandled array type "' + field.items.type + '"'); }; + +export const renderPropArrayV10 = (field: ContentTypeField, context: RenderContext): string => { + if (!field.items) { + throw new Error(`missing items for ${field.id}`); + } + + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['EntryFieldTypes'], + isTypeOnly: true, + }); + + if (field.items.type === 'Link') { + return renderTypeGeneric( + 'EntryFieldTypes.Array', + context.getFieldRenderer('Link')(field.items, context), + ); + } + + if (field.items.type === 'ResourceLink') { + return renderTypeGeneric( + 'EntryFieldTypes.Array', + context.getFieldRenderer('ResourceLink')(field, context), + ); + } + + if (field.items.type === 'Symbol') { + const validation = inValidations(field.items); + + if (validation?.length > 0) { + const validationsTypes = validation.map((val: string) => renderTypeLiteral(val)); + + return renderTypeGeneric( + 'EntryFieldTypes.Array', + renderTypeGeneric('EntryFieldTypes.Symbol', renderTypeUnion(validationsTypes)), + ); + } + + return renderTypeGeneric('EntryFieldTypes.Array', 'EntryFieldTypes.Symbol'); + } + + throw new Error('unhandled array type "' + field.items.type + '"'); +}; diff --git a/src/renderer/field/render-prop-link.ts b/src/renderer/field/render-prop-link.ts index e72fd30..79f7fae 100644 --- a/src/renderer/field/render-prop-link.ts +++ b/src/renderer/field/render-prop-link.ts @@ -38,3 +38,35 @@ export const renderPropLink = ( throw new Error(`Unknown linkType "${field.linkType}"`); } }; + +export const renderPropLinkV10 = ( + field: Pick, + context: RenderContext, +): string => { + const linkContentType = ( + field: Pick, + context: RenderContext, + ): string => { + const validations = linkContentTypeValidations(field); + return validations?.length > 0 + ? renderTypeUnion(validations.map((validation) => context.moduleSkeletonName(validation))) + : 'EntrySkeletonType'; + }; + + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['EntryFieldTypes'], + isTypeOnly: true, + }); + + switch (field.linkType) { + case 'Entry': + return renderTypeGeneric('EntryFieldTypes.EntryLink', linkContentType(field, context)); + + case 'Asset': + return 'EntryFieldTypes.AssetLink'; + + default: + throw new Error(`Unknown linkType "${field.linkType}"`); + } +}; diff --git a/src/renderer/field/render-prop-resource-link.ts b/src/renderer/field/render-prop-resource-link.ts index 28f7a03..4bd493c 100644 --- a/src/renderer/field/render-prop-resource-link.ts +++ b/src/renderer/field/render-prop-resource-link.ts @@ -17,3 +17,22 @@ export const renderPropResourceLink = (field: ContentTypeField, context: RenderC return renderTypeGeneric('Entry', 'Record'); }; + +export const renderPropResourceLinkV10 = ( + field: ContentTypeField, + context: RenderContext, +): string => { + for (const resource of field.allowedResources!) { + if (resource.type !== 'Contentful:Entry') { + throw new Error(`Unknown type "${resource.type}"`); + } + } + + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['EntryFieldTypes', 'EntrySkeletonType'], + isTypeOnly: true, + }); + + return renderTypeGeneric('EntryFieldTypes.EntryResourceLink', 'EntrySkeletonType'); +}; diff --git a/src/renderer/field/render-prop-richtext.ts b/src/renderer/field/render-prop-richtext.ts index 548aac8..edefc35 100644 --- a/src/renderer/field/render-prop-richtext.ts +++ b/src/renderer/field/render-prop-richtext.ts @@ -9,3 +9,13 @@ export const renderRichText = (field: ContentTypeField, context: RenderContext): }); return 'EntryFields.RichText'; }; + +export const renderRichTextV10 = (field: ContentTypeField, context: RenderContext): string => { + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['EntryFieldTypes'], + isTypeOnly: true, + }); + + return 'EntryFieldTypes.RichText'; +}; diff --git a/src/renderer/field/v10-renderers.ts b/src/renderer/field/v10-renderers.ts new file mode 100644 index 0000000..5b85a32 --- /dev/null +++ b/src/renderer/field/v10-renderers.ts @@ -0,0 +1,21 @@ +import { renderPropAnyV10 } from './render-prop-any'; +import { renderPropArrayV10 } from './render-prop-array'; +import { renderPropLinkV10 } from './render-prop-link'; +import { renderPropResourceLinkV10 } from './render-prop-resource-link'; +import { renderRichTextV10 } from './render-prop-richtext'; +import { FieldRenderers } from './render-types'; + +export const v10Renderers: FieldRenderers = { + RichText: renderRichTextV10, + Link: renderPropLinkV10, + ResourceLink: renderPropResourceLinkV10, + Array: renderPropArrayV10, + Text: renderPropAnyV10, + Symbol: renderPropAnyV10, + Object: renderPropAnyV10, + Date: renderPropAnyV10, + Number: renderPropAnyV10, + Integer: renderPropAnyV10, + Boolean: renderPropAnyV10, + Location: renderPropAnyV10, +}; diff --git a/src/renderer/generic/render-type-generic.ts b/src/renderer/generic/render-type-generic.ts index 91370b8..9d41cc3 100644 --- a/src/renderer/generic/render-type-generic.ts +++ b/src/renderer/generic/render-type-generic.ts @@ -1,3 +1,3 @@ -export const renderTypeGeneric = (type: string, gen: string): string => { - return `${type}<${gen}>`; +export const renderTypeGeneric = (type: string, ...gen: string[]): string => { + return `${type}<${gen.join(', ')}>`; }; diff --git a/src/renderer/type/create-default-context.ts b/src/renderer/type/create-default-context.ts index 0ca5044..b14fc97 100644 --- a/src/renderer/type/create-default-context.ts +++ b/src/renderer/type/create-default-context.ts @@ -1,12 +1,13 @@ import { ContentTypeFieldType } from 'contentful'; import { ImportDeclarationStructure, OptionalKind } from 'ts-morph'; -import { moduleFieldsName, moduleName } from '../../module-name'; +import { moduleFieldsName, moduleName, moduleSkeletonName } from '../../module-name'; import { defaultRenderers, FieldRenderer } from '../field'; export type RenderContext = { getFieldRenderer: (fieldType: FType) => FieldRenderer; moduleName: (id: string) => string; moduleFieldsName: (id: string) => string; + moduleSkeletonName: (id: string) => string; imports: Set>; }; @@ -14,6 +15,7 @@ export const createDefaultContext = (): RenderContext => { return { moduleName, moduleFieldsName, + moduleSkeletonName, getFieldRenderer: (fieldType: FType) => defaultRenderers[fieldType] as FieldRenderer, imports: new Set(), diff --git a/src/renderer/type/create-v10-context.ts b/src/renderer/type/create-v10-context.ts new file mode 100644 index 0000000..021d3cb --- /dev/null +++ b/src/renderer/type/create-v10-context.ts @@ -0,0 +1,20 @@ +import { ContentTypeFieldType } from 'contentful'; +import { ImportDeclarationStructure, OptionalKind } from 'ts-morph'; +import { FieldRenderer, v10Renderers } from '../field'; +import { createDefaultContext } from './create-default-context'; + +export type RenderContext = { + getFieldRenderer: (fieldType: FType) => FieldRenderer; + moduleName: (id: string) => string; + moduleFieldsName: (id: string) => string; + moduleSkeletonName: (id: string) => string; + imports: Set>; +}; + +export const createV10Context = (): RenderContext => { + return { + ...createDefaultContext(), + getFieldRenderer: (fieldType: FType) => + v10Renderers[fieldType] as FieldRenderer, + }; +}; diff --git a/src/renderer/type/index.ts b/src/renderer/type/index.ts index 4eaaab4..27eeb4e 100644 --- a/src/renderer/type/index.ts +++ b/src/renderer/type/index.ts @@ -1,8 +1,10 @@ export { BaseContentTypeRenderer } from './base-content-type-renderer'; export { ContentTypeRenderer } from './content-type-renderer'; export { DefaultContentTypeRenderer } from './default-content-type-renderer'; +export { V10ContentTypeRenderer } from './v10-content-type-renderer'; export { LocalizedContentTypeRenderer } from './localized-content-type-renderer'; export { JsDocRenderer } from './js-doc-renderer'; export { TypeGuardRenderer } from './type-guard-renderer'; export { createDefaultContext } from './create-default-context'; +export { createV10Context } from './create-v10-context'; export type { RenderContext } from './create-default-context'; diff --git a/src/renderer/type/js-doc-renderer.ts b/src/renderer/type/js-doc-renderer.ts index ddc8088..b51791a 100644 --- a/src/renderer/type/js-doc-renderer.ts +++ b/src/renderer/type/js-doc-renderer.ts @@ -21,10 +21,17 @@ type FieldDocsOptionsProps = { readonly field: ContentTypeField; }; +type SkeletonDocsOptionsProps = { + /* Name of generated Skeleton type */ + readonly name: string; + readonly contentType: CFContentType; +}; + export type JSDocRenderOptions = { renderEntryDocs?: (props: EntryDocsOptionsProps) => OptionalKind | string; renderFieldsDocs?: (props: FieldsDocsOptionsProps) => OptionalKind | string; renderFieldDocs?: (props: FieldDocsOptionsProps) => OptionalKind | string; + renderSkeletonDocs?: (props: SkeletonDocsOptionsProps) => OptionalKind | string; }; export const defaultJsDocRenderOptions: Required = { @@ -106,6 +113,49 @@ export const defaultJsDocRenderOptions: Required = { ], }; }, + + renderSkeletonDocs: ({ contentType, name }) => { + const tags: OptionalKind[] = []; + + tags.push( + { + tagName: 'name', + text: name, + }, + { + tagName: 'type', + text: `{${name}}`, + }, + ); + + const cmaContentType = contentType as ContentTypeProps; + + if (cmaContentType.sys.createdBy?.sys?.id) { + tags.push({ + tagName: 'author', + text: cmaContentType.sys.createdBy.sys.id, + }); + } + + if (cmaContentType.sys.firstPublishedAt) { + tags.push({ + tagName: 'since', + text: cmaContentType.sys.firstPublishedAt, + }); + } + + if (cmaContentType.sys.publishedVersion) { + tags.push({ + tagName: 'version', + text: cmaContentType.sys.publishedVersion.toString(), + }); + } + + return { + description: `Entry skeleton type definition for content type '${contentType.sys.id}' (${contentType.name})`, + tags, + }; + }, }; /* JsDocRenderer only works in conjunction with other Renderers. It relays on previously rendered Interfaces */ @@ -162,5 +212,17 @@ export class JsDocRenderer extends BaseContentTypeRenderer { } } } + + const skeletonInterfaceName = context.moduleSkeletonName(contentType.sys.id); + const skeletonInterface = file.getTypeAlias(skeletonInterfaceName); + + if (skeletonInterface) { + skeletonInterface.addJsDoc( + this.renderOptions.renderSkeletonDocs({ + name: skeletonInterfaceName, + contentType, + }), + ); + } }; } diff --git a/src/renderer/type/v10-content-type-renderer.ts b/src/renderer/type/v10-content-type-renderer.ts new file mode 100644 index 0000000..a2fedc6 --- /dev/null +++ b/src/renderer/type/v10-content-type-renderer.ts @@ -0,0 +1,130 @@ +import { ContentTypeField } from 'contentful'; +import { + OptionalKind, + PropertySignatureStructure, + SourceFile, + TypeAliasDeclarationStructure, +} from 'ts-morph'; +import { propertyImports } from '../../property-imports'; +import { renderTypeGeneric } from '../generic'; +import { CFContentType } from '../../types'; +import { BaseContentTypeRenderer } from './base-content-type-renderer'; +import { RenderContext } from './create-default-context'; +import { createV10Context } from './create-v10-context'; + +export class V10ContentTypeRenderer extends BaseContentTypeRenderer { + public render(contentType: CFContentType, file: SourceFile): void { + const context = this.createContext(); + + this.addDefaultImports(context); + this.renderFieldsInterface(contentType, file, context); + + file.addTypeAlias(this.renderEntrySkeleton(contentType, context)); + + file.addTypeAlias(this.renderEntry(contentType, context)); + + for (const structure of context.imports) { + file.addImportDeclaration(structure); + } + + file.organizeImports({ + ensureNewLineAtEndOfFile: true, + }); + } + + protected renderFieldsInterface( + contentType: CFContentType, + file: SourceFile, + context: RenderContext, + ): void { + const fieldsInterfaceName = context.moduleFieldsName(contentType.sys.id); + const interfaceDeclaration = file.addInterface({ + name: fieldsInterfaceName, + isExported: true, + }); + + for (const field of contentType.fields) { + interfaceDeclaration.addProperty(this.renderField(field, context)); + for (const pImport of propertyImports(field, context, file.getBaseNameWithoutExtension())) { + context.imports.add(pImport); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars,@typescript-eslint/no-empty-function + protected addDefaultImports(context: RenderContext): void {} + + protected renderField( + field: ContentTypeField, + context: RenderContext, + ): OptionalKind { + return { + name: field.id, + hasQuestionToken: field.omitted || !field.required, + type: this.renderFieldType(field, context), + }; + } + + protected renderFieldType(field: ContentTypeField, context: RenderContext): string { + return context.getFieldRenderer(field.type)(field, context); + } + + protected renderEntrySkeleton( + contentType: CFContentType, + context: RenderContext, + ): OptionalKind { + return { + name: context.moduleSkeletonName(contentType.sys.id), + isExported: true, + type: this.renderEntrySkeletonType(contentType, context), + }; + } + + protected renderEntrySkeletonType(contentType: CFContentType, context: RenderContext): string { + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['EntrySkeletonType'], + isTypeOnly: true, + }); + + return renderTypeGeneric( + 'EntrySkeletonType', + context.moduleFieldsName(contentType.sys.id), + `"${contentType.sys.id}"`, + ); + } + + protected renderEntry( + contentType: CFContentType, + context: RenderContext, + ): OptionalKind { + return { + name: renderTypeGeneric( + context.moduleName(contentType.sys.id), + 'Modifiers extends ChainModifiers', + 'Locales extends LocaleCode', + ), + isExported: true, + type: this.renderEntryType(contentType, context), + }; + } + + protected renderEntryType(contentType: CFContentType, context: RenderContext): string { + context.imports.add({ + moduleSpecifier: 'contentful', + namedImports: ['ChainModifiers', 'Entry', 'LocaleCode'], + isTypeOnly: true, + }); + + return renderTypeGeneric( + 'Entry', + context.moduleSkeletonName(contentType.sys.id), + 'Modifiers', + 'Locales', + ); + } + + public createContext(): RenderContext { + return createV10Context(); + } +} diff --git a/src/renderer/type/v10-type-guard-renderer.ts b/src/renderer/type/v10-type-guard-renderer.ts new file mode 100644 index 0000000..769c513 --- /dev/null +++ b/src/renderer/type/v10-type-guard-renderer.ts @@ -0,0 +1,56 @@ +import { Project, SourceFile } from 'ts-morph'; +import { moduleName } from '../../module-name'; +import { CFContentType } from '../../types'; +import { BaseContentTypeRenderer } from './base-content-type-renderer'; +import { renderTypeGeneric } from '../generic'; + +export class V10TypeGuardRenderer extends BaseContentTypeRenderer { + private readonly files: SourceFile[]; + + private static readonly WithContentTypeLink = 'WithContentTypeLink'; + constructor() { + super(); + this.files = []; + } + + public override setup(project: Project): void { + super.setup(project); + } + + public render = (contentType: CFContentType, file: SourceFile): void => { + const entryInterfaceName = moduleName(contentType.sys.id); + + file.addImportDeclaration({ + moduleSpecifier: `contentful`, + namedImports: ['ChainModifiers', 'Entry', 'LocaleCode'], + isTypeOnly: true, + }); + + file.addFunction({ + isExported: true, + name: renderTypeGeneric( + `is${entryInterfaceName}`, + 'Modifiers extends ChainModifiers', + 'Locales extends LocaleCode', + ), + returnType: `entry is ${renderTypeGeneric(entryInterfaceName, 'Modifiers', 'Locales')}`, + parameters: [ + { + name: 'entry', + type: renderTypeGeneric('Entry', 'EntrySkeletonType', 'Modifiers', 'Locales'), + }, + ], + statements: `return entry.sys.contentType.sys.id === '${contentType.sys.id}'`, + }); + + file.organizeImports({ + ensureNewLineAtEndOfFile: true, + }); + + file.formatText(); + }; + + public override additionalFiles(): SourceFile[] { + return this.files; + } +} diff --git a/test/renderer/field/render-prop-any.test.ts b/test/renderer/field/render-prop-any.test.ts index 41a8e3a..2fadc94 100644 --- a/test/renderer/field/render-prop-any.test.ts +++ b/test/renderer/field/render-prop-any.test.ts @@ -1,4 +1,10 @@ -import { createDefaultContext, RenderContext, renderPropAny } from '../../../src'; +import { + createDefaultContext, + createV10Context, + RenderContext, + renderPropAny, + renderPropAnyV10, +} from '../../../src'; describe('A renderPropAny function', () => { let context: RenderContext; @@ -65,3 +71,71 @@ describe('A renderPropAny function', () => { expect(renderPropAny(field, context)).toEqual('EntryFields.Symbol'); }); }); + +describe('A renderPropAnyV10 function', () => { + let context: RenderContext; + + beforeEach(() => { + context = createV10Context(); + }); + + it('can evaluate a "Symbol" type', () => { + const field = JSON.parse(` + { + "id": "internalName", + "name": "Internal name", + "type": "Symbol", + "localized": false, + "required": false, + "validations": [ + ], + "disabled": false, + "omitted": false + } + `); + + expect(renderPropAnyV10(field, context)).toEqual('EntryFieldTypes.Symbol'); + }); + + it('can evaluate a "Symbol" type with "in" validation', () => { + const field = JSON.parse(` + { + "id": "headerAlignment", + "name": "Header alignment", + "type": "Symbol", + "localized": false, + "required": false, + "validations": [ + { + "in": [ + "Left-aligned", + "Center-aligned" + ] + } + ], + "disabled": false, + "omitted": false + } + `); + + expect(renderPropAnyV10(field, context)).toEqual( + 'EntryFieldTypes.Symbol<"Center-aligned" | "Left-aligned">', + ); + }); + + it('can evaluate a "Symbol" type with missing validations', () => { + const field = JSON.parse(` + { + "id": "internalName", + "name": "Internal name", + "type": "Symbol", + "localized": false, + "required": false, + "disabled": false, + "omitted": false + } + `); + + expect(renderPropAnyV10(field, context)).toEqual('EntryFieldTypes.Symbol'); + }); +}); diff --git a/test/renderer/field/render-prop-array.test.ts b/test/renderer/field/render-prop-array.test.ts index a8c70a6..3233ce8 100644 --- a/test/renderer/field/render-prop-array.test.ts +++ b/test/renderer/field/render-prop-array.test.ts @@ -1,4 +1,9 @@ -import { createDefaultContext, renderPropArray } from '../../../src'; +import { + createDefaultContext, + createV10Context, + renderPropArray, + renderPropArrayV10, +} from '../../../src'; describe('A renderPropArray function', () => { it('can evaluate a "Array" of "Link" with no validations', () => { @@ -146,3 +151,156 @@ describe('A renderPropArray function', () => { expect(renderPropArray(field, createDefaultContext())).toEqual('Entry>[]'); }); }); + +describe('A renderPropArrayV10 function', () => { + it('can evaluate a "Array" of "Link" with no validations', () => { + const field = JSON.parse(` + { + "id": "components", + "name": "Components", + "type": "Array", + "localized": false, + "required": true, + "validations": [], + "disabled": false, + "omitted": false, + "items": { + "type": "Link", + "validations": [], + "linkType": "Entry" + } + } + `); + + expect(renderPropArrayV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.Array>', + ); + }); + + it('can evaluate an "Array" of "Symbol"', () => { + const field = JSON.parse(` + { + "id": "tags", + "name": "Tags (optional)", + "type": "Array", + "localized": false, + "required": false, + "validations": [ + ], + "disabled": false, + "omitted": false, + "items": { + "type": "Symbol", + "validations": [ + ] + } + } + `); + + expect(renderPropArrayV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.Array', + ); + }); + + it('can evaluate an "Array" of "Symbol" with "in" validation', () => { + const field = JSON.parse(` + { + "id": "category", + "name": "Category (optional)", + "type": "Array", + "localized": false, + "required": false, + "validations": [ + ], + "disabled": false, + "omitted": false, + "items": { + "type": "Symbol", + "validations": [ + { + "in": [ + "Feature", + "Benefit", + "Tech spec", + "Other" + ] + } + ] + } + } + `); + + expect(renderPropArrayV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.Array>', + ); + }); + + it('can evaluate an "Array" of "Link" with "linkContentType" validation', () => { + const field = JSON.parse(` + { + "id": "extraSections", + "name": "Extra sections (optional)", + "type": "Array", + "localized": false, + "required": false, + "validations": [ + ], + "disabled": false, + "omitted": false, + "items": { + "type": "Link", + "validations": [ + { + "linkContentType": [ + "componentCta", + "componentFaq", + "wrapperImage", + "wrapperVideo" + ] + } + ], + "linkType": "Entry" + } + } + `); + + expect(renderPropArrayV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.Array>', + ); + }); + + it('can evaluate a "Array" of "ResourceLink"', () => { + const field = JSON.parse(` + { + "id": "components", + "name": "Components", + "type": "Array", + "localized": false, + "required": true, + "validations": [], + "disabled": false, + "omitted": false, + "items": { + "type": "ResourceLink", + "validations": [] + }, + "allowedResources": [ + { + "type": "Contentful:Entry", + "source": "crn:contentful:::content:spaces/spaceId", + "contentTypes": [ + "componentCta", + "componentFaq", + "wrapperImage", + "wrapperVideo" + ] + } + ] + } + `); + + expect(renderPropArrayV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.Array>', + ); + }); +}); diff --git a/test/renderer/field/render-prop-link.test.ts b/test/renderer/field/render-prop-link.test.ts index 01ad9b2..88137c5 100644 --- a/test/renderer/field/render-prop-link.test.ts +++ b/test/renderer/field/render-prop-link.test.ts @@ -1,4 +1,9 @@ -import { createDefaultContext, renderPropLink } from '../../../src'; +import { + createDefaultContext, + createV10Context, + renderPropLink, + renderPropLinkV10, +} from '../../../src'; describe('A renderPropLink function', () => { it('can evaluate a "Link" type', () => { @@ -43,3 +48,78 @@ describe('A renderPropLink function', () => { expect(renderPropLink(field, createDefaultContext())).toEqual('Entry>'); }); }); + +describe('A renderPropLinkV10 function', () => { + it('can evaluate a "Link" type', () => { + const field = JSON.parse(` + { + "id": "category", + "name": "Category", + "type": "Link", + "localized": false, + "required": true, + "validations": [ + { + "linkContentType": [ + "topicCategory" + ] + } + ], + "disabled": false, + "omitted": false, + "linkType": "Entry" + } + `); + + expect(renderPropLinkV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.EntryLink', + ); + }); + + it('can evaluate a "Link" type with multiple linked content types', () => { + const field = JSON.parse(` + { + "id": "category", + "name": "Category", + "type": "Link", + "localized": false, + "required": true, + "validations": [ + { + "linkContentType": [ + "topicCategoryA", + "topicCategoryB" + ] + } + ], + "disabled": false, + "omitted": false, + "linkType": "Entry" + } + `); + + expect(renderPropLinkV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.EntryLink', + ); + }); + + it('can evaluate a "Link" type with no validations', () => { + const field = JSON.parse(` + { + "id": "components", + "name": "Components", + "type": "Link", + "localized": false, + "required": true, + "validations": [], + "disabled": false, + "omitted": false, + "linkType": "Entry" + } + `); + + expect(renderPropLinkV10(field, createV10Context())).toEqual( + 'EntryFieldTypes.EntryLink', + ); + }); +}); diff --git a/test/renderer/field/render-prop-resource-link.test.ts b/test/renderer/field/render-prop-resource-link.test.ts index bb7f119..e189057 100644 --- a/test/renderer/field/render-prop-resource-link.test.ts +++ b/test/renderer/field/render-prop-resource-link.test.ts @@ -1,4 +1,8 @@ -import { createDefaultContext, renderPropResourceLink } from '../../../src'; +import { + createDefaultContext, + renderPropResourceLink, + renderPropResourceLinkV10, +} from '../../../src'; describe('A renderPropResourceLink function', () => { it('can evaluate a "ResourceLink" type', () => { @@ -57,3 +61,60 @@ describe('A renderPropResourceLink function', () => { ); }); }); +describe('A renderPropResourceLinkV10 function', () => { + it('can evaluate a "ResourceLink" type', () => { + const field = JSON.parse(` + { + "id": "category", + "name": "Category", + "type": "ResourceLink", + "localized": false, + "required": true, + "validations": [], + "disabled": false, + "omitted": false, + "allowedResources": [ + { + "type": "Contentful:Entry", + "source": "crn:contentful:::content:spaces/spaceId", + "contentTypes": [ + "topicCategory" + ] + } + ] + } + `); + + expect(renderPropResourceLinkV10(field, createDefaultContext())).toEqual( + 'EntryFieldTypes.EntryResourceLink', + ); + }); + + it('rejects a "ResourceLink" with an unknown resource type', () => { + const field = JSON.parse(` + { + "id": "category", + "name": "Category", + "type": "ResourceLink", + "localized": false, + "required": true, + "validations": [], + "disabled": false, + "omitted": false, + "allowedResources": [ + { + "type": "Contentful:UnknownEntity", + "source": "crn:contentful:::content:spaces/spaceId", + "contentTypes": [ + "topicCategory" + ] + } + ] + } + `); + + expect(() => renderPropResourceLinkV10(field, createDefaultContext())).toThrow( + 'Unknown type "Contentful:UnknownEntity"', + ); + }); +}); diff --git a/test/renderer/field/render-prop-richtext.test.ts b/test/renderer/field/render-prop-richtext.test.ts index 40687d5..889b50d 100644 --- a/test/renderer/field/render-prop-richtext.test.ts +++ b/test/renderer/field/render-prop-richtext.test.ts @@ -1,4 +1,4 @@ -import { createDefaultContext, renderRichText } from '../../../src'; +import { createDefaultContext, renderRichText, renderRichTextV10 } from '../../../src'; describe('A renderPropRichText function', () => { it('can evaluate a "RichText" type', () => { @@ -29,3 +29,33 @@ describe('A renderPropRichText function', () => { ]); }); }); + +describe('A renderPropRichTextV10 function', () => { + it('can evaluate a "RichText" type', () => { + const field = JSON.parse(` + { + "id": "info", + "name": "Info", + "type": "RichText", + "localized": false, + "required": true, + "validations": [], + "disabled": false, + "omitted": false + } + `); + + const context = createDefaultContext(); + const result = renderRichTextV10(field, context); + + expect(result).toEqual('EntryFieldTypes.RichText'); + + expect([...context.imports.values()]).toEqual([ + { + moduleSpecifier: 'contentful', + namedImports: ['EntryFieldTypes'], + isTypeOnly: true, + }, + ]); + }); +}); diff --git a/test/renderer/type/content-type-renfderer.test.ts b/test/renderer/type/content-type-renderer.test.ts similarity index 99% rename from test/renderer/type/content-type-renfderer.test.ts rename to test/renderer/type/content-type-renderer.test.ts index d5027f8..1440d06 100644 --- a/test/renderer/type/content-type-renfderer.test.ts +++ b/test/renderer/type/content-type-renderer.test.ts @@ -8,6 +8,7 @@ import { FieldRenderer, moduleFieldsName, moduleName, + moduleSkeletonName, RenderContext, renderTypeGeneric, } from '../../../src'; @@ -38,6 +39,7 @@ describe('A derived content type renderer class', () => { return { moduleName, moduleFieldsName, + moduleSkeletonName, getFieldRenderer: (fieldType: FType) => { if (fieldType === 'Symbol') { return symbolTypeRenderer as FieldRenderer; diff --git a/test/renderer/type/js-doc-renderer.test.ts b/test/renderer/type/js-doc-renderer.test.ts index 9b3b911..2744fbf 100644 --- a/test/renderer/type/js-doc-renderer.test.ts +++ b/test/renderer/type/js-doc-renderer.test.ts @@ -1,5 +1,10 @@ import { Project, ScriptTarget, SourceFile } from 'ts-morph'; -import { CFContentType, DefaultContentTypeRenderer, JsDocRenderer } from '../../../src'; +import { + CFContentType, + DefaultContentTypeRenderer, + JsDocRenderer, + V10ContentTypeRenderer, +} from '../../../src'; import { defaultJsDocRenderOptions, JSDocRenderOptions, @@ -82,6 +87,49 @@ describe('A JSDoc content type renderer class', () => { ); }); + it('renders JSDocs with v10 flag', () => { + const v10Renderer = new V10ContentTypeRenderer(); + v10Renderer.setup(project); + v10Renderer.render(mockContentType, testFile); + + const docsRenderer = new JsDocRenderer(); + docsRenderer.render(mockContentType, testFile); + + expect('\n' + testFile.getFullText()).toEqual( + stripIndent(` + import type { ChainModifiers, Entry, EntryFieldTypes, EntrySkeletonType, LocaleCode } from "contentful"; + + /** + * Fields type definition for content type 'TypeAnimal' + * @name TypeAnimalFields + * @type {TypeAnimalFields} + * @memberof TypeAnimal + */ + export interface TypeAnimalFields { + /** + * Field type definition for field 'bread' (Bread) + * @name Bread + * @localized false + */ + bread: EntryFieldTypes.Symbol; + } + + /** + * Entry skeleton type definition for content type 'animal' (Animal) + * @name TypeAnimalSkeleton + * @type {TypeAnimalSkeleton} + */ + export type TypeAnimalSkeleton = EntrySkeletonType; + /** + * Entry type definition for content type 'animal' (Animal) + * @name TypeAnimal + * @type {TypeAnimal} + */ + export type TypeAnimal = Entry; + `), + ); + }); + it('renders optional Entry @author tag', () => { const defaultRenderer = new DefaultContentTypeRenderer(); defaultRenderer.setup(project); diff --git a/test/renderer/type/v10-content-type-renderer.test.ts b/test/renderer/type/v10-content-type-renderer.test.ts new file mode 100644 index 0000000..896f197 --- /dev/null +++ b/test/renderer/type/v10-content-type-renderer.test.ts @@ -0,0 +1,212 @@ +import { ContentTypeField, ContentTypeFieldType } from 'contentful'; + +import { Project, ScriptTarget, SourceFile } from 'ts-morph'; +import { + CFContentType, + V10ContentTypeRenderer, + defaultRenderers, + FieldRenderer, + moduleFieldsName, + moduleName, + moduleSkeletonName, + RenderContext, + renderTypeGeneric, +} from '../../../src'; +import stripIndent = require('strip-indent'); + +describe('A derived content type renderer class', () => { + let project: Project; + let testFile: SourceFile; + + beforeEach(() => { + project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { + target: ScriptTarget.ES5, + declaration: true, + }, + }); + testFile = project.createSourceFile('test.ts'); + }); + + it('can return a custom field type renderer', () => { + const symbolTypeRenderer = () => { + return 'Test.Symbol'; + }; + + class DerivedContentTypeRenderer extends V10ContentTypeRenderer { + public createContext(): RenderContext { + return { + moduleName, + moduleFieldsName, + moduleSkeletonName, + getFieldRenderer: (fieldType: FType) => { + if (fieldType === 'Symbol') { + return symbolTypeRenderer as FieldRenderer; + } + return defaultRenderers[fieldType] as FieldRenderer; + }, + imports: new Set(), + }; + } + } + + const renderer = new DerivedContentTypeRenderer(); + + const contentType: CFContentType = { + name: 'unused-name', + sys: { + id: 'test', + type: 'Symbol', + }, + fields: [ + { + id: 'field_id', + name: 'field_name', + disabled: false, + localized: false, + required: true, + type: 'Symbol', + omitted: false, + validations: [], + }, + ], + }; + + renderer.render(contentType, testFile); + + expect('\n' + testFile.getFullText()).toEqual( + stripIndent(` + import type { ChainModifiers, Entry, EntrySkeletonType, LocaleCode } from "contentful"; + + export interface TypeTestFields { + field_id: Test.Symbol; + } + + export type TypeTestSkeleton = EntrySkeletonType; + export type TypeTest = Entry; + `), + ); + }); + + it('can return a custom field renderer with docs support', () => { + class DerivedContentTypeRenderer extends V10ContentTypeRenderer { + protected renderField(field: ContentTypeField, context: RenderContext) { + return { + docs: [{ description: `Field of type "${field.type}"` }], + name: field.id, + hasQuestionToken: field.omitted || !field.required, + type: super.renderFieldType(field, context), + }; + } + + protected renderEntry(contentType: CFContentType, context: RenderContext) { + return { + docs: [ + { + description: `content type "${contentType.name}" with id: ${contentType.sys.id}`, + }, + ], + name: context.moduleName(contentType.sys.id), + isExported: true, + type: super.renderEntryType(contentType, context), + }; + } + } + + const renderer = new DerivedContentTypeRenderer(); + + const contentType: CFContentType = { + name: 'display name', + sys: { + id: 'test', + type: 'Symbol', + }, + fields: [ + { + id: 'field_id', + name: 'field_name', + disabled: false, + localized: false, + required: true, + type: 'Symbol', + omitted: false, + validations: [], + }, + ], + }; + + renderer.render(contentType, testFile); + + expect('\n' + testFile.getFullText()).toEqual( + stripIndent(` + import type { Entry, EntryFieldTypes, EntrySkeletonType } from "contentful"; + + export interface TypeTestFields { + /** Field of type "Symbol" */ + field_id: EntryFieldTypes.Symbol; + } + + export type TypeTestSkeleton = EntrySkeletonType; + /** content type "display name" with id: test */ + export type TypeTest = Entry; + `), + ); + }); + + it('can render custom entries', () => { + class DerivedContentTypeRenderer extends V10ContentTypeRenderer { + protected renderEntryType(contentType: CFContentType, context: RenderContext): string { + context.imports.add({ + moduleSpecifier: '@custom', + namedImports: ['CustomEntry'], + isTypeOnly: true, + }); + return renderTypeGeneric( + 'CustomEntry', + context.moduleSkeletonName(contentType.sys.id), + 'Modifiers', + 'Locales', + ); + } + } + + const renderer = new DerivedContentTypeRenderer(); + + const contentType: CFContentType = { + name: 'display name', + sys: { + id: 'test', + type: 'Symbol', + }, + fields: [ + { + id: 'field_id', + name: 'field_name', + disabled: false, + localized: false, + required: true, + type: 'Symbol', + omitted: false, + validations: [], + }, + ], + }; + + renderer.render(contentType, testFile); + + expect('\n' + testFile.getFullText()).toEqual( + stripIndent(` + import type { CustomEntry } from "@custom"; + import type { EntryFieldTypes, EntrySkeletonType } from "contentful"; + + export interface TypeTestFields { + field_id: EntryFieldTypes.Symbol; + } + + export type TypeTestSkeleton = EntrySkeletonType; + export type TypeTest = CustomEntry; + `), + ); + }); +});