From 72b4cd7eed372b998222309126203998640be24e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:24:44 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20microCMS=E5=85=AC=E5=BC=8F=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=81=9F=E5=9E=8B?= =?UTF-8?q?=E7=94=9F=E6=88=90=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - セレクトフィールドの型を常にstring[]に修正(microCMSは単一選択でも常に配列を返す) - selectItemsがある場合は文字列リテラルユニオン型の配列を生成 - relationListフィールドで参照先エンドポイントのContent型を使用するよう修正 - Management APIのselectItemsパースを追加(文字列配列/オブジェクト配列の両形式対応) - 対応するテストケースを追加 Co-authored-by: Ryusei Sugita --- .../types/__tests__/management-api.test.ts | 37 ++++++++++ .../types/__tests__/type-generator.test.ts | 67 +++++++++++++++++++ src/commands/generate/types/management-api.ts | 21 ++++++ src/commands/generate/types/type-generator.ts | 13 +++- src/commands/generate/types/types.ts | 1 + 5 files changed, 136 insertions(+), 3 deletions(-) diff --git a/src/commands/generate/types/__tests__/management-api.test.ts b/src/commands/generate/types/__tests__/management-api.test.ts index 338c2ae..3ff1c21 100644 --- a/src/commands/generate/types/__tests__/management-api.test.ts +++ b/src/commands/generate/types/__tests__/management-api.test.ts @@ -126,6 +126,7 @@ describe('management-api', () => { referencedApiEndpoint: undefined, customFieldCreatedAt: undefined, customFieldCreatedAtList: undefined, + selectItems: undefined, }, { fieldId: 'related', @@ -135,6 +136,7 @@ describe('management-api', () => { referencedApiEndpoint: 'articles', customFieldCreatedAt: undefined, customFieldCreatedAtList: undefined, + selectItems: undefined, }, ]); expect(schema.customFields).toEqual([ @@ -150,12 +152,47 @@ describe('management-api', () => { referencedApiEndpoint: undefined, customFieldCreatedAt: undefined, customFieldCreatedAtList: undefined, + selectItems: undefined, }, ], }, ]); }); + it('fetchApiSchema は selectItems を文字列配列/オブジェクト配列の両形式でパースする', async () => { + setMockFetch(async () => { + return new Response( + JSON.stringify({ + apiEndpoint: 'product', + apiType: 'LIST', + apiFields: [ + { + fieldId: 'color', + kind: 'select', + required: true, + multipleSelect: false, + selectItems: ['Red', 'Blue', 'Green'], + }, + { + fieldId: 'size', + kind: 'select', + required: false, + multipleSelect: true, + selectItems: [{ value: 'S' }, { value: 'M' }, { value: 'L' }], + }, + ], + customFields: [], + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }); + + const schema = await fetchApiSchema(FIXTURE_CONFIG, 'product'); + + expect(schema.apiFields[0]?.selectItems).toEqual(['Red', 'Blue', 'Green']); + expect(schema.apiFields[1]?.selectItems).toEqual(['S', 'M', 'L']); + }); + it('401 応答時は API キー向けの専用エラーメッセージを返す', async () => { setMockFetch(async () => new Response('invalid key', { status: 401, statusText: 'Unauthorized' })); diff --git a/src/commands/generate/types/__tests__/type-generator.test.ts b/src/commands/generate/types/__tests__/type-generator.test.ts index f737a68..75f062a 100644 --- a/src/commands/generate/types/__tests__/type-generator.test.ts +++ b/src/commands/generate/types/__tests__/type-generator.test.ts @@ -57,6 +57,73 @@ describe('renderDefinitionsFile', () => { expect(source).not.toContain('SettingsListResponse'); }); + it('select フィールドは multipleSelect に関わらず常に string[] を生成する', () => { + const source = renderDefinitionsFile([ + createTarget('product', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { fieldId: 'category', kind: 'select', required: true, multipleSelect: false }, + { fieldId: 'tags', kind: 'select', required: false, multipleSelect: true }, + ], + }), + }), + ]); + + expect(source).toContain('category: string[];'); + expect(source).toContain('tags?: string[];'); + expect(source).not.toMatch(/category: string;/); + }); + + it('select フィールドに selectItems がある場合、文字列リテラルユニオンの配列型を生成する', () => { + const source = renderDefinitionsFile([ + createTarget('product', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { + fieldId: 'size', + kind: 'select', + required: true, + multipleSelect: false, + selectItems: ['S', 'M', 'L', 'XL'], + }, + ], + }), + }), + ]); + + expect(source).toContain('size: ("S" | "M" | "L" | "XL")[];'); + }); + + it('relationList フィールドは参照先エンドポイントの Content 型を配列で生成する', () => { + const source = renderDefinitionsFile([ + createTarget('article', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { + fieldId: 'relatedPosts', + kind: 'relationList', + required: false, + multipleSelect: false, + referencedApiEndpoint: 'blog', + }, + { + fieldId: 'authors', + kind: 'relationList', + required: true, + multipleSelect: false, + }, + ], + }), + }), + ]); + + expect(source).toContain('relatedPosts?: BlogContent[];'); + expect(source).toContain('authors: MicroCMSContentId[];'); + }); + it('custom/repeater の不正・未知データを安全にフォールバックして型生成する', () => { const source = renderDefinitionsFile([ createTarget('page', { diff --git a/src/commands/generate/types/management-api.ts b/src/commands/generate/types/management-api.ts index 88a91d1..1ef53e6 100644 --- a/src/commands/generate/types/management-api.ts +++ b/src/commands/generate/types/management-api.ts @@ -40,6 +40,26 @@ async function fetchFromManagementApi(url: string, apiKey: string): Promise { + if (typeof item === 'string') { + return item; + } + if (isRecord(item) && typeof item.value === 'string') { + return item.value; + } + return undefined; + }) + .filter((item): item is string => item !== undefined); + + return values.length > 0 ? values : undefined; +} + function parseApiField(rawField: unknown): ManagementApiField | null { if (!isRecord(rawField)) { return null; @@ -59,6 +79,7 @@ function parseApiField(rawField: unknown): ManagementApiField | null { referencedApiEndpoint: toStringValue(rawField.referencedApiEndpoint), customFieldCreatedAt: toStringValue(rawField.customFieldCreatedAt), customFieldCreatedAtList: toStringArray(rawField.customFieldCreatedAtList), + selectItems: parseSelectItems(rawField.selectItems), }; } diff --git a/src/commands/generate/types/type-generator.ts b/src/commands/generate/types/type-generator.ts index 87ced8f..9a8bf4b 100644 --- a/src/commands/generate/types/type-generator.ts +++ b/src/commands/generate/types/type-generator.ts @@ -111,9 +111,16 @@ function resolveFieldType(field: ManagementApiField, context: GenerationContext) ? `${toPascalCase(field.referencedApiEndpoint)}Content` : 'MicroCMSContentId'; case 'relationList': - return 'MicroCMSContentId[]'; - case 'select': - return field.multipleSelect ? 'string[]' : 'string'; + return field.referencedApiEndpoint + ? `${toPascalCase(field.referencedApiEndpoint)}Content[]` + : 'MicroCMSContentId[]'; + case 'select': { + if (field.selectItems && field.selectItems.length > 0) { + const union = field.selectItems.map((item) => JSON.stringify(item)).join(' | '); + return `(${union})[]`; + } + return 'string[]'; + } case 'iframe': case 'extension': return 'Record'; diff --git a/src/commands/generate/types/types.ts b/src/commands/generate/types/types.ts index 17f824b..20b1c3c 100644 --- a/src/commands/generate/types/types.ts +++ b/src/commands/generate/types/types.ts @@ -24,6 +24,7 @@ export interface ManagementApiField { referencedApiEndpoint?: string; customFieldCreatedAt?: string; customFieldCreatedAtList?: string[]; + selectItems?: string[]; } export interface ManagementCustomField { From be5b848006ab17d5662f1d5df85cb52acb99ed3b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 23 Feb 2026 05:31:04 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20relation=20=E3=83=95=E3=82=A3?= =?UTF-8?q?=E3=83=BC=E3=83=AB=E3=83=89=E3=81=AE=E5=9E=8B=E3=81=AB=20|=20nu?= =?UTF-8?q?ll=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit microCMS Content API の公式仕様では、コンテンツ参照(relation)フィールドは 空値の場合に null を返す。この挙動を型定義に正しく反映させる。 - relation フィールドの型を XxxContent | null に変更 - 全フィールドタイプの型マッピング網羅テストを追加 - 共通型の microcms-js-sdk 互換性テストを追加 Co-authored-by: Ryusei Sugita --- .../types/__tests__/type-generator.test.ts | 194 ++++++++++++++++++ src/commands/generate/types/type-generator.ts | 6 +- 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/src/commands/generate/types/__tests__/type-generator.test.ts b/src/commands/generate/types/__tests__/type-generator.test.ts index 75f062a..a6ea931 100644 --- a/src/commands/generate/types/__tests__/type-generator.test.ts +++ b/src/commands/generate/types/__tests__/type-generator.test.ts @@ -124,6 +124,200 @@ describe('renderDefinitionsFile', () => { expect(source).toContain('authors: MicroCMSContentId[];'); }); + it('全フィールドタイプの型マッピングを正しく生成する', () => { + const source = renderDefinitionsFile([ + createTarget('full-test', { + apiType: 'LIST', + schema: createSchema({ + apiFields: [ + { fieldId: 'title', kind: 'text', required: true, multipleSelect: false }, + { fieldId: 'body', kind: 'textArea', required: false, multipleSelect: false }, + { fieldId: 'content', kind: 'richEditor', required: false, multipleSelect: false }, + { fieldId: 'contentV2', kind: 'richEditorV2', required: false, multipleSelect: false }, + { fieldId: 'contentOld', kind: 'richEditorOld', required: false, multipleSelect: false }, + { fieldId: 'publishDate', kind: 'date', required: false, multipleSelect: false }, + { fieldId: 'order', kind: 'number', required: false, multipleSelect: false }, + { fieldId: 'isPublished', kind: 'boolean', required: true, multipleSelect: false }, + { fieldId: 'thumbnail', kind: 'media', required: false, multipleSelect: false }, + { fieldId: 'gallery', kind: 'mediaList', required: false, multipleSelect: false }, + { fieldId: 'document', kind: 'file', required: false, multipleSelect: false }, + { + fieldId: 'category', + kind: 'relation', + required: false, + multipleSelect: false, + referencedApiEndpoint: 'categories', + }, + { + fieldId: 'relatedPosts', + kind: 'relationList', + required: false, + multipleSelect: false, + referencedApiEndpoint: 'blog', + }, + { + fieldId: 'status', + kind: 'select', + required: true, + multipleSelect: false, + selectItems: ['draft', 'published', 'archived'], + }, + { fieldId: 'tags', kind: 'select', required: false, multipleSelect: true }, + { fieldId: 'widget', kind: 'iframe', required: false, multipleSelect: false }, + { fieldId: 'plugin', kind: 'extension', required: false, multipleSelect: false }, + { + fieldId: 'hero', + kind: 'custom', + required: false, + multipleSelect: false, + customFieldCreatedAt: 'cf-hero', + }, + { + fieldId: 'sections', + kind: 'repeater', + required: false, + multipleSelect: false, + customFieldCreatedAtList: ['cf-text-block', 'cf-image-block'], + }, + { + fieldId: 'orphanRelation', + kind: 'relation', + required: false, + multipleSelect: false, + }, + { + fieldId: 'orphanRelationList', + kind: 'relationList', + required: false, + multipleSelect: false, + }, + { fieldId: 'unknownKind', kind: 'futureType', required: false, multipleSelect: false }, + ], + customFields: [ + { + createdAt: 'cf-hero', + fieldId: 'hero-banner', + fields: [ + { fieldId: 'image', kind: 'media', required: true, multipleSelect: false }, + { fieldId: 'caption', kind: 'text', required: false, multipleSelect: false }, + ], + }, + { + createdAt: 'cf-text-block', + fieldId: 'text-block', + fields: [ + { fieldId: 'heading', kind: 'text', required: true, multipleSelect: false }, + { fieldId: 'paragraph', kind: 'richEditor', required: false, multipleSelect: false }, + ], + }, + { + createdAt: 'cf-image-block', + fieldId: 'image-block', + fields: [ + { fieldId: 'image', kind: 'media', required: true, multipleSelect: false }, + { fieldId: 'alt', kind: 'text', required: false, multipleSelect: false }, + ], + }, + ], + }), + }), + ]); + + // text → string + expect(source).toContain('title: string;'); + // textArea → string (optional) + expect(source).toContain('body?: string;'); + // richEditor → string + expect(source).toContain('content?: string;'); + // richEditorV2 → string + expect(source).toContain('contentV2?: string;'); + // richEditorOld → string + expect(source).toContain('contentOld?: string;'); + // date → string + expect(source).toContain('publishDate?: string;'); + // number → number + expect(source).toContain('order?: number;'); + // boolean → boolean + expect(source).toContain('isPublished: boolean;'); + // media → MicroCMSImage + expect(source).toContain('thumbnail?: MicroCMSImage;'); + // mediaList → MicroCMSImage[] + expect(source).toContain('gallery?: MicroCMSImage[];'); + // file → MicroCMSFile + expect(source).toContain('document?: MicroCMSFile;'); + // relation (with endpoint) → XxxContent | null + expect(source).toContain('category?: CategoriesContent | null;'); + // relationList (with endpoint) → XxxContent[] + expect(source).toContain('relatedPosts?: BlogContent[];'); + // select (with selectItems) → literal union array + expect(source).toContain('status: ("draft" | "published" | "archived")[];'); + // select (without selectItems) → string[] + expect(source).toContain('tags?: string[];'); + // iframe → Record + expect(source).toContain('widget?: Record;'); + // extension → Record + expect(source).toContain('plugin?: Record;'); + // custom → custom type + expect(source).toContain('hero?: FullTestHeroBannerCustomField;'); + // repeater → Array + expect(source).toMatch(/sections\?: Array<.*TextBlock.*ImageBlock/); + // relation (without endpoint) → MicroCMSContentId | null + expect(source).toContain('orphanRelation?: MicroCMSContentId | null;'); + // relationList (without endpoint) → MicroCMSContentId[] + expect(source).toContain('orphanRelationList?: MicroCMSContentId[];'); + // unknown kind → unknown + expect(source).toContain('unknownKind?: unknown;'); + + // custom field types + expect(source).toContain('export type FullTestHeroBannerCustomField = {'); + expect(source).toContain('export type FullTestTextBlockCustomField = {'); + expect(source).toContain('export type FullTestImageBlockCustomField = {'); + + // LIST API generates ListResponse + expect(source).toContain( + 'export type FullTestListResponse = MicroCMSListResponse;', + ); + // LIST API uses MicroCMSListContent + expect(source).toContain( + 'export type FullTestContent = FullTestSchema & MicroCMSListContent;', + ); + }); + + it('共通型が microcms-js-sdk の型定義と一致する', () => { + const source = renderDefinitionsFile([createTarget('dummy', { apiType: 'LIST' })]); + + // MicroCMSContentId + expect(source).toContain('export interface MicroCMSContentId {'); + expect(source).toContain(' id: string;'); + + // MicroCMSDate + expect(source).toContain('export interface MicroCMSDate {'); + expect(source).toContain(' createdAt: string;'); + expect(source).toContain(' updatedAt: string;'); + expect(source).toContain(' publishedAt?: string;'); + expect(source).toContain(' revisedAt?: string;'); + + // MicroCMSImage + expect(source).toContain('export interface MicroCMSImage {'); + expect(source).toContain(' url: string;'); + expect(source).toContain(' width?: number;'); + expect(source).toContain(' height?: number;'); + expect(source).toContain(' alt?: string;'); + + // MicroCMSListResponse + expect(source).toContain('export interface MicroCMSListResponse {'); + expect(source).toContain(' contents: (T & MicroCMSListContent)[];'); + expect(source).toContain(' totalCount: number;'); + expect(source).toContain(' limit: number;'); + expect(source).toContain(' offset: number;'); + + // MicroCMSListContent / MicroCMSObjectContent + expect(source).toContain( + 'export type MicroCMSListContent = MicroCMSContentId & MicroCMSDate;', + ); + expect(source).toContain('export type MicroCMSObjectContent = MicroCMSDate;'); + }); + it('custom/repeater の不正・未知データを安全にフォールバックして型生成する', () => { const source = renderDefinitionsFile([ createTarget('page', { diff --git a/src/commands/generate/types/type-generator.ts b/src/commands/generate/types/type-generator.ts index 9a8bf4b..27827ab 100644 --- a/src/commands/generate/types/type-generator.ts +++ b/src/commands/generate/types/type-generator.ts @@ -106,10 +106,12 @@ function resolveFieldType(field: ManagementApiField, context: GenerationContext) return 'MicroCMSImage[]'; case 'file': return 'MicroCMSFile'; - case 'relation': - return field.referencedApiEndpoint + case 'relation': { + const refType = field.referencedApiEndpoint ? `${toPascalCase(field.referencedApiEndpoint)}Content` : 'MicroCMSContentId'; + return `${refType} | null`; + } case 'relationList': return field.referencedApiEndpoint ? `${toPascalCase(field.referencedApiEndpoint)}Content[]`