From 7663c0e78e9853754a739118debf3aafd6c4c882 Mon Sep 17 00:00:00 2001 From: Shaun Stanworth Date: Fri, 30 Jan 2026 13:00:35 +0000 Subject: [PATCH] Prototype: Metaobject type generation --- packages/app/src/cli/models/app/app.ts | 4 + .../type-generation/metaobject-types.test.ts | 251 ++++++++++++++++++ .../dev/type-generation/metaobject-types.ts | 107 ++++++++ 3 files changed, 362 insertions(+) create mode 100644 packages/app/src/cli/services/dev/type-generation/metaobject-types.test.ts create mode 100644 packages/app/src/cli/services/dev/type-generation/metaobject-types.ts diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index ead77d1047..1c14542c06 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -14,6 +14,7 @@ import {AppAccessSpecIdentifier} from '../extensions/specifications/app_config_a import {WebhookSubscriptionSchema} from '../extensions/specifications/app_config_webhook_schemas/webhook_subscription_schema.js' import {configurationFileNames} from '../../constants.js' import {ApplicationURLs} from '../../services/dev/urls.js' +import {generateMetaobjectTypes} from '../../services/dev/type-generation/metaobject-types.js' import {patchAppHiddenConfigFile} from '../../services/app/patch-app-configuration-file.js' import {joinPath} from '@shopify/cli-kit/node/path' import {ZodObjectOf, zod} from '@shopify/cli-kit/node/schema' @@ -583,6 +584,9 @@ export class App< } writeFileSync(typeFilePath, typeContent) }) + + // Generate metaobject types from app configuration + await generateMetaobjectTypes(this.configuration, this.directory) } get includeConfigOnDeploy() { diff --git a/packages/app/src/cli/services/dev/type-generation/metaobject-types.test.ts b/packages/app/src/cli/services/dev/type-generation/metaobject-types.test.ts new file mode 100644 index 0000000000..d3d2eb7b71 --- /dev/null +++ b/packages/app/src/cli/services/dev/type-generation/metaobject-types.test.ts @@ -0,0 +1,251 @@ +import { + mapFieldTypeToTypeScript, + extractMetaobjectsConfig, + generateMetaobjectTypeDefinitions, + generateMetaobjectTypes, +} from './metaobject-types.js' +import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' +import * as fs from '@shopify/cli-kit/node/fs' + +vi.mock('@shopify/cli-kit/node/fs') + +describe('metaobject-types', () => { + describe('mapFieldTypeToTypeScript', () => { + test('maps single_line_text_field to string', () => { + expect(mapFieldTypeToTypeScript('single_line_text_field')).toBe('string') + }) + + test('maps multi_line_text_field to string', () => { + expect(mapFieldTypeToTypeScript('multi_line_text_field')).toBe('string') + }) + + test('maps metaobject_reference types to string', () => { + expect(mapFieldTypeToTypeScript('metaobject_reference<$app:author>')).toBe('string') + }) + + test('maps unknown types to any', () => { + expect(mapFieldTypeToTypeScript('unknown_type')).toBe('any') + expect(mapFieldTypeToTypeScript('number_field')).toBe('any') + }) + }) + + describe('extractMetaobjectsConfig', () => { + test('extracts metaobjects from configuration', () => { + const config = { + metaobjects: { + app: { + author: { + fields: { + name: 'single_line_text_field', + }, + }, + }, + }, + } + + const result = extractMetaobjectsConfig(config) + + expect(result).toEqual(config.metaobjects) + }) + + test('returns undefined when no metaobjects', () => { + const config = {name: 'My App'} + + const result = extractMetaobjectsConfig(config) + + expect(result).toBeUndefined() + }) + }) + + describe('generateMetaobjectTypeDefinitions', () => { + test('returns undefined when metaobjects is undefined', () => { + expect(generateMetaobjectTypeDefinitions(undefined)).toBeUndefined() + }) + + test('returns undefined when metaobjects.app is undefined', () => { + expect(generateMetaobjectTypeDefinitions({})).toBeUndefined() + }) + + test('returns undefined when metaobjects.app is empty', () => { + expect(generateMetaobjectTypeDefinitions({app: {}})).toBeUndefined() + }) + + test('generates types for short-form fields', () => { + const metaobjects = { + app: { + author: { + fields: { + name: 'single_line_text_field', + bio: 'multi_line_text_field', + }, + }, + }, + } + + const result = generateMetaobjectTypeDefinitions(metaobjects) + + expect(result).toContain('"$app:author"') + expect(result).toContain('name: string') + expect(result).toContain('bio: string') + }) + + test('generates types for long-form fields', () => { + const metaobjects = { + app: { + post: { + fields: { + title: {type: 'single_line_text_field'}, + author: {type: 'metaobject_reference<$app:author>'}, + }, + }, + }, + } + + const result = generateMetaobjectTypeDefinitions(metaobjects) + + expect(result).toContain('"$app:post"') + expect(result).toContain('title: string') + expect(result).toContain('author: string') + }) + + test('generates types for multiple metaobject types', () => { + const metaobjects = { + app: { + author: { + fields: { + name: 'single_line_text_field', + }, + }, + post: { + fields: { + title: 'single_line_text_field', + }, + }, + }, + } + + const result = generateMetaobjectTypeDefinitions(metaobjects) + + expect(result).toContain('"$app:author"') + expect(result).toContain('"$app:post"') + }) + + test('maps unknown field types to any', () => { + const metaobjects = { + app: { + item: { + fields: { + count: 'number_field', + }, + }, + }, + } + + const result = generateMetaobjectTypeDefinitions(metaobjects) + + expect(result).toContain('count: any') + }) + + test('generates correct TypeScript structure', () => { + const metaobjects = { + app: { + author: { + fields: { + name: 'single_line_text_field', + }, + }, + }, + } + + const result = generateMetaobjectTypeDefinitions(metaobjects) + + expect(result).toContain('declare global {') + expect(result).toContain('interface ShopifyGlobalOverrides {') + expect(result).toContain('metaobjectTypes: {') + expect(result).toContain('export {}') + }) + }) + + describe('generateMetaobjectTypes', () => { + beforeEach(() => { + vi.mocked(fs.fileExistsSync).mockReturnValue(false) + vi.mocked(fs.writeFileSync).mockImplementation(() => {}) + vi.mocked(fs.removeFileSync).mockImplementation(() => {}) + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from('')) + }) + + afterEach(() => { + vi.resetAllMocks() + }) + + test('writes type file when metaobjects are defined', async () => { + const config = { + metaobjects: { + app: { + author: { + fields: { + name: 'single_line_text_field', + }, + }, + }, + }, + } + + await generateMetaobjectTypes(config, '/app') + + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/app/app-bridge.d.ts', + expect.stringContaining('"$app:author"'), + ) + }) + + test('removes type file when no metaobjects and file exists', async () => { + vi.mocked(fs.fileExistsSync).mockReturnValue(true) + const config = {name: 'My App'} + + await generateMetaobjectTypes(config, '/app') + + expect(fs.removeFileSync).toHaveBeenCalledWith('/app/app-bridge.d.ts') + }) + + test('does nothing when no metaobjects and file does not exist', async () => { + vi.mocked(fs.fileExistsSync).mockReturnValue(false) + const config = {name: 'My App'} + + await generateMetaobjectTypes(config, '/app') + + expect(fs.removeFileSync).not.toHaveBeenCalled() + expect(fs.writeFileSync).not.toHaveBeenCalled() + }) + + test('does not write if content is unchanged', async () => { + const config = { + metaobjects: { + app: { + author: { + fields: { + name: 'single_line_text_field', + }, + }, + }, + }, + } + + const expectedContent = `declare global { + interface ShopifyGlobalOverrides { + metaobjectTypes: { + "$app:author": { name: string }; + } + } +} +export {} +` + vi.mocked(fs.fileExistsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(Buffer.from(expectedContent)) + + await generateMetaobjectTypes(config, '/app') + + expect(fs.writeFileSync).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/app/src/cli/services/dev/type-generation/metaobject-types.ts b/packages/app/src/cli/services/dev/type-generation/metaobject-types.ts new file mode 100644 index 0000000000..dea0916e11 --- /dev/null +++ b/packages/app/src/cli/services/dev/type-generation/metaobject-types.ts @@ -0,0 +1,107 @@ +import {fileExistsSync, readFileSync, removeFileSync, writeFileSync} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' + +const TYPE_FILE_NAME = 'app-bridge.d.ts' + +interface MetaobjectField { + type: string +} + +interface MetaobjectDefinition { + fields: Record +} + +interface MetaobjectsConfig { + app?: Record +} + +interface AppConfiguration { + metaobjects?: MetaobjectsConfig +} + +/** + * Maps a TOML field type to its TypeScript equivalent + */ +export function mapFieldTypeToTypeScript(fieldType: string): string { + if (fieldType === 'single_line_text_field' || fieldType === 'multi_line_text_field') { + return 'string' + } + if (fieldType.startsWith('metaobject_reference<')) { + return 'string' + } + return 'any' +} + +/** + * Extracts metaobjects configuration from the app configuration + */ +export function extractMetaobjectsConfig(configuration: object): MetaobjectsConfig | undefined { + const config = configuration as AppConfiguration + return config.metaobjects +} + +/** + * Generates TypeScript type definitions from metaobjects configuration + * Returns undefined if there are no metaobjects defined + */ +export function generateMetaobjectTypeDefinitions(metaobjects: MetaobjectsConfig | undefined): string | undefined { + if (!metaobjects?.app) { + return undefined + } + + const appMetaobjects = metaobjects.app + const typeNames = Object.keys(appMetaobjects) + + if (typeNames.length === 0) { + return undefined + } + + const typeEntries = typeNames.map((typeName) => { + const definition = appMetaobjects[typeName]! + const fields = definition.fields + const fieldEntries = Object.entries(fields).map(([fieldName, fieldConfig]) => { + const fieldType = typeof fieldConfig === 'string' ? fieldConfig : fieldConfig.type + const tsType = mapFieldTypeToTypeScript(fieldType) + return `${fieldName}: ${tsType}` + }) + return ` "$app:${typeName}": { ${fieldEntries.join('; ')} }` + }) + + return `declare global { + interface ShopifyGlobalOverrides { + metaobjectTypes: { +${typeEntries.join(';\n')}; + } + } +} +export {} +` +} + +/** + * Main entry point - handles everything: extraction, generation, file writing + * app.ts just calls this with raw config and directory + */ +export async function generateMetaobjectTypes(configuration: object, appDirectory: string): Promise { + const typeFilePath = joinPath(appDirectory, TYPE_FILE_NAME) + const metaobjects = extractMetaobjectsConfig(configuration) + const typeContent = generateMetaobjectTypeDefinitions(metaobjects) + + // No metaobjects defined - remove the file if it exists + if (typeContent === undefined) { + if (fileExistsSync(typeFilePath)) { + removeFileSync(typeFilePath) + } + return + } + + // Check if content has changed before writing + if (fileExistsSync(typeFilePath)) { + const existingContent = readFileSync(typeFilePath).toString() + if (existingContent === typeContent) { + return + } + } + + writeFileSync(typeFilePath, typeContent) +}