Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/app/src/cli/models/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -583,6 +584,9 @@ export class App<
}
writeFileSync(typeFilePath, typeContent)
})

// Generate metaobject types from app configuration
await generateMetaobjectTypes(this.configuration, this.directory)
}

get includeConfigOnDeploy() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
})
})
})
Original file line number Diff line number Diff line change
@@ -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<string, string | MetaobjectField>
}

interface MetaobjectsConfig {
app?: Record<string, MetaobjectDefinition>
}

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<void> {
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)
}
Loading